From c83344add7bbec1831e28163c61b3520fc4454c6 Mon Sep 17 00:00:00 2001 From: Daniel Babjak Date: Sun, 3 May 2026 08:10:07 +0200 Subject: [PATCH] examples: policy-guarded swap pattern for autonomous agents (SBO3L reference) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a community-contributed example demonstrating per-command policy gating for Universal Router multicalls. Designed for autonomous-agent use cases where an off-chain policy engine evaluates each command in a multicall sequence independently, with abort-on-first-deny semantics + audit evidence capture. Why this matters ================ A naïve "policy-gated" wrapper evaluates the *whole* multicall with one decision and either approves or denies in bulk. That misses the point: a malicious or buggy autonomous agent could sneak `SWEEP → 0xevil` past a swap-only gate. The per-command pattern prevents this by re-running the policy engine for each command in the decoded sequence. What this example ships ======================= - README.md — explains the per-command pattern, references the full off-chain Rust implementation in SBO3L (B2JK-Industry/SBO3L-ethglobal-openagents-2026), provides the `executor_evidence` JSON shape that captures command-by-command decisions for auditor replay. - PolicyGuardedRouter.sol — illustrative on-chain wrapper that forwards UR calls only after verifying an ECDSA-signed receipt from the policy engine. The signed digest binds (commands, inputs, deadline, audit-anchor) so an attacker can't substitute bytes between off-chain decision and on-chain landing. Reference implementation context ================================ SBO3L's full Rust implementation at crates/sbo3l-execution/src/uniswap_router.rs decodes multicalls into a Vec enum, runs the policy engine per-command with full parameter context (token, amount, recipient), and emits canonical executor_evidence JSON for the audit chain. ENSIP-26 (proposed at ensdomains/ensips#71) specifies the `audit_root` ENS text record where these decisions are anchored. This example is intentionally non-canonical =========================================== It's a community contribution, not part of the deployed Universal Router bytecode. If maintainers prefer this content lives elsewhere (separate repo, docs site, wiki), happy to relocate. Goal is documenting the pattern so the agentic-DeFi community can converge on a consistent policy-boundary shape. Open to feedback on: - Where this content should live long-term. - Whether the evidence shape (command_sequence, aborted_at_index) should be standardised. - Whether ERC-4337 wallets should surface this hook natively. --- .../PolicyGuardedRouter.sol | 166 ++++++++++++++++++ examples/policy-guarded-swap/README.md | 138 +++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 examples/policy-guarded-swap/PolicyGuardedRouter.sol create mode 100644 examples/policy-guarded-swap/README.md diff --git a/examples/policy-guarded-swap/PolicyGuardedRouter.sol b/examples/policy-guarded-swap/PolicyGuardedRouter.sol new file mode 100644 index 00000000..01a651ac --- /dev/null +++ b/examples/policy-guarded-swap/PolicyGuardedRouter.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title PolicyGuardedRouter — wrapping pattern reference +/// @author Community contribution (B2JK-Industry / SBO3L) +/// @notice Demonstrates the per-command policy-gating pattern for +/// Universal Router multicalls. Designed for autonomous-agent +/// use cases where an off-chain policy engine evaluates each +/// command in a multicall sequence independently, with +/// abort-on-first-deny semantics. +/// +/// @dev This is a REFERENCE example illustrating the on-chain +/// wrapper shape. The actual policy decisions live off-chain +/// (in an agent's policy engine, e.g. SBO3L's GuardedExecutor) +/// and are committed via signed receipts before the multicall +/// is broadcast. This contract is the on-chain view: it +/// re-checks the receipt's commitment matches the multicall +/// payload (so an attacker can't swap the bytes between +/// off-chain decision and on-chain landing) and forwards to +/// Universal Router. +/// +/// Full Rust off-chain implementation: +/// B2JK-Industry/SBO3L-ethglobal-openagents-2026 +/// crates/sbo3l-execution/src/uniswap_router.rs +/// +/// @custom:non-canonical This contract is illustrative; production +/// deployments should integrate with their existing receipt +/// signing scheme (ECDSA / Ed25519 / EIP-712) and policy +/// hash anchor. + +interface IUniversalRouter { + function execute( + bytes calldata commands, + bytes[] calldata inputs, + uint256 deadline + ) external payable; +} + +contract PolicyGuardedRouter { + /// @notice The Universal Router this wrapper forwards to. Pinned + /// at deploy time; immutable. + IUniversalRouter public immutable router; + + /// @notice The off-chain policy engine's signing address. + /// Multicalls are admitted only if the supplied receipt + /// was signed by this signer over the multicall's + /// commitment digest. + address public immutable policySigner; + + /// @notice Emitted on every successful policy-guarded multicall. + /// `evidenceDigest` binds (commands, inputs, deadline, + /// receipt-anchor) so an auditor can reconstruct the + /// decision off-chain. + event PolicyGuardedExecute( + address indexed caller, + bytes32 indexed evidenceDigest, + bytes32 receiptAnchor + ); + + error InvalidPolicySignature(); + error ReceiptCommitmentMismatch(); + error DeadlinePassed(); + + constructor(address router_, address policySigner_) { + router = IUniversalRouter(router_); + policySigner = policySigner_; + } + + /// @notice Execute a Universal Router multicall iff the supplied + /// policy receipt's commitment matches the multicall + /// payload AND the receipt was signed by `policySigner`. + /// + /// @param commands Standard UR commands byte string. + /// @param inputs Standard UR inputs array. + /// @param deadline Standard UR deadline. + /// @param receiptAnchor Hash of the off-chain audit-chain + /// position the policy decision was + /// anchored at. The receipt contained an + /// `audit_root` text record value (see + /// ENSIP-26 if you've published agent + /// identity) — this anchor is its keccak. + /// @param signature ECDSA(r || s || v) over + /// `keccak256(commands || inputs || deadline || receiptAnchor)`. + function execute( + bytes calldata commands, + bytes[] calldata inputs, + uint256 deadline, + bytes32 receiptAnchor, + bytes calldata signature + ) external payable { + if (block.timestamp > deadline) revert DeadlinePassed(); + + // The evidence digest binds every byte the policy decision + // saw + the audit-chain anchor. If a malicious operator + // tries to substitute even one byte of `inputs` between the + // off-chain decision and the on-chain landing, the digest + // changes, the signature fails, and the multicall reverts. + bytes32 evidenceDigest = keccak256( + abi.encodePacked( + hex"19", // EIP-191 prefix-byte + hex"00", // version 0 — generic intent + address(this), // bind to this wrapper + block.chainid, // bind to this chain + commands, + _hashInputs(inputs), + deadline, + receiptAnchor + ) + ); + + // EIP-191 personal-message style: prepend + // "\x19Ethereum Signed Message:\n32" to the digest before + // recovery. + bytes32 ethSignedDigest = keccak256( + abi.encodePacked("\x19Ethereum Signed Message:\n32", evidenceDigest) + ); + + if (_recover(ethSignedDigest, signature) != policySigner) { + revert InvalidPolicySignature(); + } + + emit PolicyGuardedExecute(msg.sender, evidenceDigest, receiptAnchor); + + // Forward to the canonical Universal Router. msg.value passes + // through; the user's ETH is escrowed for the duration of + // the multicall and refunded by UR's internal sweep if the + // input commands don't consume it all. + router.execute{value: msg.value}(commands, inputs, deadline); + } + + /// @dev Hash a `bytes[]` array so it can participate in the + /// evidence digest. A naive `keccak256(abi.encode(inputs))` + /// would also work, but per-element hashing makes the + /// digest construction match the off-chain policy engine's + /// iterative-decode pattern more closely. + function _hashInputs(bytes[] calldata inputs) + internal + pure + returns (bytes32) + { + bytes32[] memory leafHashes = new bytes32[](inputs.length); + for (uint256 i = 0; i < inputs.length; i++) { + leafHashes[i] = keccak256(inputs[i]); + } + return keccak256(abi.encodePacked(leafHashes)); + } + + /// @dev ECDSA recovery for a 65-byte (r, s, v) signature. + function _recover(bytes32 digest, bytes calldata sig) + internal + pure + returns (address) + { + if (sig.length != 65) return address(0); + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := calldataload(sig.offset) + s := calldataload(add(sig.offset, 32)) + v := byte(0, calldataload(add(sig.offset, 64))) + } + if (v < 27) v += 27; + return ecrecover(digest, v, r, s); + } +} diff --git a/examples/policy-guarded-swap/README.md b/examples/policy-guarded-swap/README.md new file mode 100644 index 00000000..e50c1f0f --- /dev/null +++ b/examples/policy-guarded-swap/README.md @@ -0,0 +1,138 @@ +# Policy-guarded swap pattern for autonomous agents + +> **What this example demonstrates:** how an autonomous-agent +> middleware (e.g. an off-chain policy engine) can wrap Universal +> Router calls so each command in a multicall is independently +> policy-evaluated, with abort-on-first-deny semantics + audit +> evidence capture. Reference implementation: +> [`B2JK-Industry/SBO3L-ethglobal-openagents-2026`](https://github.com/B2JK-Industry/SBO3L-ethglobal-openagents-2026/blob/main/crates/sbo3l-execution/src/uniswap_router.rs). + +## Why this matters for autonomous-agent use cases + +Universal Router transactions are **multicall sequences**. One tx +can chain `PERMIT2 → V3_SWAP_EXACT_IN → SWEEP → UNWRAP_WETH`, +each command with its own parameters and its own policy-relevant +decisions (counterparty, slippage, recipient, ...). + +For an autonomous agent operating on behalf of a user, a naïve +"policy-gated" wrapper would evaluate the *whole* multicall with +one decision and either approve or deny in bulk. That misses the +point: a malicious or buggy agent could sneak a `SWEEP → 0xevil` +past a swap-only gate that only inspects the V3_SWAP leg. + +The right pattern is **per-command** policy evaluation: + +1. Decode the multicall into individual commands. +2. For each command, in order, ask the policy engine to decide + based on that command's parameters. +3. The first command that denies aborts the *whole* multicall — + later commands are not evaluated, no tx is broadcast, and the + audit evidence records both the deny reason and the + `aborted_at_index`. +4. If every command allows, the multicall is approved as a whole. + +## Reference implementation (off-chain) + +The SBO3L project ships a working implementation of this pattern +in Rust at +[`crates/sbo3l-execution/src/uniswap_router.rs`](https://github.com/B2JK-Industry/SBO3L-ethglobal-openagents-2026/blob/main/crates/sbo3l-execution/src/uniswap_router.rs). +Key shape: + +- Decode the `(commands bytes, inputs bytes[])` pair into a + `Vec` enum (`PERMIT2_PERMIT`, + `V3_SWAP_EXACT_IN`, `SWEEP`, `UNWRAP_WETH`, etc.). +- Call the policy engine per-command. Each command carries + decoded parameters (token, amount, recipient) the engine + inspects. +- On first deny, abort and emit `executor_evidence` of shape: + + ```json + { + "router_address": "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad", + "router_version": "v2", + "command_sequence": [ + { "op": "PERMIT2_PERMIT", "params": { ... }, "decision": "allow" }, + { "op": "V3_SWAP_EXACT_IN", "params": { ... }, "decision": "allow" }, + { "op": "SWEEP", "params": { "recipient": "0xevil…" }, "decision": "deny", "deny_code": "policy.recipient_blocklist" } + ], + "aborted_at_index": 2, + "aborted_reason": "policy.recipient_blocklist" + } + ``` + +- The evidence is signed (Ed25519 in SBO3L's case, but any + agent-side signing scheme works) and committed to an + append-only audit chain so a third party can replay the + decision later. + +## Why this matters for the Universal Router community + +Most autonomous-agent / "agentic DeFi" implementations today +either: + +1. **Sign opaque multicalls** — the user's signing key is a + single-shot rubber-stamp; granular safety isn't possible. +2. **Use bespoke pre-flight checks** — implementations diverge, + each project re-implements the policy boundary. +3. **Skip policy entirely** — the agent has direct execution + authority; the user trusts the agent unconditionally. + +A standardised pattern + reference impl means agentic apps can +ship a per-command policy boundary without reinventing the +decoder + evidence shape. Universal Router is well-positioned to +host this guidance because: + +- Its multicall surface is the canonical multi-step DeFi flow. +- Its command opcodes are stable (changes happen via versioned + routers, not in-place). +- A documented per-command policy hook composes with existing + audit / monitoring tooling without contract changes. + +## Counterfactual: V3_SWAP_EXACT_IN-only gate + +What happens if you gate ONLY the swap leg and not the sweep? + +``` +[PERMIT2_PERMIT, V3_SWAP_EXACT_IN(USDC → WETH), SWEEP(recipient=0xevil)] +``` + +A swap-only gate looks at command 1 (swap is fine — user's tokens +moving to user's WETH balance), approves, and the SWEEP command +fires. Result: USDC → WETH → WETH-sent-to-attacker. This is the +attack the per-command pattern prevents. + +## Where this fits in the Universal Router repo + +This `examples/` directory is a **community-contributed reference +pattern**. It's not part of the canonical Universal Router +contracts, doesn't ship with the deployed bytecode, and doesn't +require any changes to the core router. It's purely educational + +points readers at a working implementation they can study or +adopt. + +If the Universal Router maintainers prefer this content lives +elsewhere (a separate `examples` repo, the docs site, or a wiki +page), happy to relocate. Goal is to document the pattern so the +agentic-DeFi community can converge on a consistent +policy-boundary shape. + +## Other reference patterns in the wild + +- **SBO3L UniswapRouterExecutor** ([`crates/sbo3l-execution/src/uniswap_router.rs`](https://github.com/B2JK-Industry/SBO3L-ethglobal-openagents-2026/blob/main/crates/sbo3l-execution/src/uniswap_router.rs)) + — Rust, async/tokio, alloy-based, full implementation with + evidence-chain integration. +- **SBO3L policy engine** ([`crates/sbo3l-policy/`](https://github.com/B2JK-Industry/SBO3L-ethglobal-openagents-2026/tree/main/crates/sbo3l-policy)) + — JCS-canonicalised policy snapshot, ENS-anchored policy hash, + receipt signing. + +## Discussion + +Open to feedback on: + +- Whether the per-command pattern should be canonicalised in this + repo or in a separate "Universal Router agentic patterns" repo. +- Whether the evidence shape (`command_sequence`, `aborted_at_index`) + should be standardised, or left implementation-specific. +- Whether ERC-4337-style account-abstraction wallets should + surface this hook natively (vs always going through an external + policy engine).