Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions examples/policy-guarded-swap/PolicyGuardedRouter.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
138 changes: 138 additions & 0 deletions examples/policy-guarded-swap/README.md
Original file line number Diff line number Diff line change
@@ -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<UniversalRouterCommand>` 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).