Skip to content

Ownerless same-chain intent settlement via IntentSettlerV1

September 2026

This document describes IntentSettlerV1, the current AgentSwap Intents Protocol. Every mechanism below is verified against the deployed contracts and the facts recorded in the protocol source tree. An earlier generation — the clone-v1 stack (IntentFactory + IntentClone) — is retired; its signatures, order shape, and settlement contracts are incompatible with IntentSettlerV1 by design.


AgentSwap Intents is an on-chain intent protocol in which a user signs a declarative order: swap amountIn of tokenIn for at least a floor of tokenOut, the floor decaying from a start price to a reservation price over a time window, with the output delivered to a named recipient. An open, permissionless set of solvers competes to fill it.

The live protocol has no owner, no admin key, no allowlist, no pause switch, and no oracle. Safety is achieved by construction: every fill either delivers the signed floor to recipient or reverts atomically. One immutable IntentSettlerV1 contract per chain acts as the Permit2 spender; funds are pulled through Permit2 only at fill time; solvers source liquidity inside a flash callback, so filling requires no principal from the settler.

Authorization is a Permit2 witness signature over a 12-field IntentOrder. Settlement is same-chain only. A companion read contract, IntentLens, reports screening facts; an ERC-7683 resolver, IntentSettlerResolver, translates orders for cross-protocol discovery. Neither holds funds or changes settlement rules.


Two converging trends motivate the design.

Intents over transactions. Users increasingly express outcomes (“I want at least X of token B for my token A”) rather than execution paths (pool hops, router calldata). Signature-based intent models let solvers compete on execution while the user signs a single authorization.

Minimal on-chain trust. Existing intent systems often retain at least one of: an owner-controlled filler or router allowlist, an upgradable reactor, an off-chain order-flow auction with privileged participants, or a trusted quoting oracle. Each is an admin surface — a censorship point and a liability for automated traders that cannot judge counterparty honesty.

AgentSwap Intents removes all of them. The design question the protocol answers is:

What is the minimal on-chain mechanism such that an arbitrary, unvetted counterparty can execute a user’s order and the recipient provably cannot receive less than the user signed for?

The answer — a hard floor enforced at settlement, combined with revert-or-deliver atomicity — needs no allowlist, no owner, and no oracle. Discovery and routing are off-chain concerns; the contract enforces only what the signature authorizes.

The retired clone-v1 stack deployed a per-intent minimal proxy via IntentFactory. Each intent had its own contract address, a 9-field order (no recipient, no decayEndTime, no appData), and the clone as Permit2 spender. That model remains deployed and fillable until existing orders expire, but new orders use IntentSettlerV1.

IntentSettlerV1 consolidates settlement into one constant address per chain. The order gained recipient (output need not return to the signer), decayEndTime (the floor rests after decay instead of existing for a single instant at expiry), and appData (opaque attribution bound by the signature). The witness type, order encoding, and Permit2 spender all changed. A clone-v1 signature cannot verify against IntentSettlerV1 and vice versa; this is intended, not a compatibility break to patch.


  1. Privilege-less by construction. The settler has no owner, no upgrade path, no pause switch, and no allowlist. The only privileged action on an order belongs to its owner: cancellation. Removing protocol-level power is strictly stronger than guarding it.

  2. The signed floor is the sole safety root. There is no price oracle. The user’s signature is the price policy; the contract’s only job is to enforce it atomically.

  3. Revert-or-deliver. No partial state exists. Either the recipient’s balance increases by at least the floor, or the transaction reverts and the owner’s funds never moved.

  4. Zero custody, zero standing approvals to the protocol. Funds are pulled through Permit2 with a witness signature at fill time. An unfilled intent expires with its signature; nothing needs to be withdrawn because nothing was ever deposited into the settler. The owner must hold a live tokenIn allowance to Permit2 covering amountIn at fill time, however it was granted — that allowance gates whether a fill can pull funds.

  5. The event log is the discovery feed. announce emits the complete order and signature for indexers. It is optional, builds no on-chain index, and does not make the feed trustworthy on its own. Solvers must dry-run fill before spending gas.

  6. Immutability is a requirement, not a preference. Permit2 verifies the signer but lets the signed spender choose SignatureTransferDetails.to at execution time. An upgradeable settler could redirect funds that outstanding signatures already authorized, against different bytecode. New semantics ship as IntentSettlerV2 at a new address.


Actor Role Trust required
Owner Signs the Permit2 witness; the only account whose tokenIn moves None — self-custody throughout
Recipient Receives the floor of tokenOut; may differ from owner None — named in the signed order
Solver / filler Anyone; watches for orders and fills profitable ones Zero — permissionless
Relayer (optional) Broadcasts announce so the owner pays no gas Zero — cannot alter a signed order
Protocol admin Does not exist
sign ──► announce (optional) ──► open ──► fill ──► settled
│ │
└── relay may broadcast └── floor → recipient
surplus stays with solver
(by never sending it)
Step On-chain function Who
Publish announce(order, permit2Sig) Anyone — owner self-submits, or a relay broadcasts
Settle fill(order, permit2Sig, solver, routerData) Anyone
Cancel (own gas) cancel(order) owner only
Cancel (gasless) cancelSigned(order, deadline, ownerSig) Anyone broadcasts the owner’s signature
Cancel (backstop) Permit2.invalidateUnorderedNonces(…) owner, directly on Permit2
  1. Sign. The owner signs one Permit2 witness signature binding the order to a one-time Permit2 unordered nonce. One wallet interaction; no funds move.

  2. Announce (optional). Anyone calls announce, which verifies the signature against the digest Permit2 itself will check and emits IntentAnnounced with the full encoded order and signature. This is the discovery feed, not a settlement step.

  3. Open. Solvers observe announcements (or obtain orders elsewhere) and price the order as the floor decays.

  4. Fill. A solver for whom the order clears calls fill. In one transaction: the owner’s tokenIn is pulled via Permit2 to the settler, flash-transferred to the solver, the solver sources tokenOut in a callback, the settler verifies the floor, and delivers everything received to recipient.

  5. Settled — or the transaction reverted and nothing happened.

Cancellation does not front-run a fill already in the mempool. Whichever valid transaction lands first wins.


struct IntentOrder {
address owner; // the Permit2 signer; the only account whose funds move
address recipient; // where output is delivered — not necessarily the owner
address tokenIn;
uint256 amountIn;
address tokenOut;
uint256 startAmountOut; // the curve's value at startTime
uint256 endAmountOut; // the hard floor
uint256 startTime; // decay begins
uint256 decayEndTime; // decay reaches endAmountOut here; floor rests until endTime
uint256 endTime; // the order dies; also the Permit2 deadline
bytes32 appData; // opaque attribution tag, bound by signature, ignored by settlement
uint256 nonce; // Permit2 unordered nonce
}

The struct is frozen at deploy. IntentSettlerV1 is immutable and Permit2 binds this struct into every signature, so no field can be added, removed, or reordered. New semantics ship as IntentSettlerV2 at a new address.

# Field Meaning
1 owner Permit2 signer; sole source of tokenIn
2 recipient Output delivery target
3 tokenIn Input ERC-20
4 amountIn Input amount
5 tokenOut Output ERC-20
6 startAmountOut Curve value at startTime
7 endAmountOut Hard floor
8 startTime Decay begins
9 decayEndTime Decay completes; floor rests until endTime
10 endTime Order expiry; Permit2 deadline
11 appData Opaque attribution; ignored by settlement
12 nonce Permit2 unordered nonce
  • startAmountOut == endAmountOut encodes a fixed-price limit order.
  • startAmountOut > endAmountOut encodes a descending Dutch auction.

Order id = keccak256(abi.encode(order)), returned by orderHash. Clients read it from the chain via eth_call.

The Permit2 witness binds 11 fields; nonce is omitted because Permit2 signs it itself. The (order, permit2Sig) pair is therefore self-describing.

startAmountOut ╲
╲________
╲______________________ endAmountOut
├──── decay ────┤──────── floor rests ────────┤✗ expires
startTime decayEndTime endTime

At time t, requiredOut is:

t <= startTime -> startAmountOut
t >= decayEndTime -> endAmountOut (rests until endTime)
otherwise -> startAmountOut - mulDiv(startAmountOut - endAmountOut,
t - startTime,
decayEndTime - startTime)

Two segments: linear decay, then flat. The resting stretch is why decayEndTime is separate from endTime. With a single deadline field, the floor existed for one instant at expiry; a short decay window would leave no time to fill at the floor. requiredOut is defined past endTime; callers gate expiry themselves.

Fills are accepted on the closed window [startTime, endTime].

Enforced by IntentLib.validate at announce, fill, and resolve alike:

Check Revert
owner != 0, recipient != 0 InvalidOrder
startTime < decayEndTime <= endTime InvalidOrder
startAmountOut >= endAmountOut, both non-zero InvalidOrder
amountIn != 0 InvalidOrder
neither token is 0x0 or 0xEeee…EEeE (native sentinel) NativeNotSupportedInOrder
tokenIn != tokenOut SameToken

startTime < decayEndTime is strict so the divisor cannot be zero. decayEndTime == endTime is allowed and reproduces single-deadline behaviour. ERC-20 only; no native ETH on either leg.


5.1 IntentSettlerV1 — one contract per chain

Section titled “5.1 IntentSettlerV1 — one contract per chain”

Each chain runs one immutable IntentSettlerV1 at a deterministic CREATE2 address. The contract exports:

Function Purpose
announce(order, permit2Sig) Discovery feed; verifies signature
fill(order, permit2Sig, solver, routerData) Permissionless settlement
cancel(order) Owner-paid cancellation
cancelSigned(order, deadline, ownerSig) Gasless cancellation
orderHash(order) Order id
permitDigest(order) Exact digest Permit2 will verify
cancelled(id) Cancellation flag lookup

The only persistent state is mapping(bytes32 => bool) cancelled. Permit2’s unordered nonce already makes a second fill impossible, so replay needs no storage; only gasless cancellation does.

Reentrancy is blocked by an EIP-1153 transient guard, which requires transient storage support on each deployment chain.

5.2 Signature binding: the Permit2 witness

Section titled “5.2 Signature binding: the Permit2 witness”

Funds move via canonical Permit2 (0x000000000022D473030F116dDEE9F6B43aC78BA3) permitWitnessTransferFrom, only at fill time.

Parameter Value
Primary type PermitWitnessTransferFrom
Domain Permit2’s own (name: "Permit2", verifyingContract = Permit2 singleton)
Witness type IntentWitness
spender IntentSettlerV1 address
deadline order.endTime
permitted TokenPermissions(tokenIn, amountIn)

Witness type string (byte-exact):

IntentWitness witness)IntentWitness(address owner,address recipient,address tokenIn,uint256 amountIn,address tokenOut,uint256 startAmountOut,uint256 endAmountOut,uint256 startTime,uint256 decayEndTime,uint256 endTime,bytes32 appData)TokenPermissions(address token,uint256 amount)

The settler reads PERMIT2.DOMAIN_SEPARATOR() rather than mirroring it, so the two cannot drift. permitDigest(order) returns the exact digest Permit2 will verify; clients sign it; relays check it before spending gas.

Prerequisite: tokenIn.approve(Permit2, …). IntentLens reports ownerPermit2Allowance for exactly this reason.

Cancellation uses a separate EIP-712 domain on the settler itself: EIP712("AgentSwap IntentSettler", "1"), with type CancelIntent(bytes32 orderHash,uint256 deadline).

Because every economic field is inside the signed data, neither a relayer nor a solver can execute the owner’s permission under altered terms. Nonces are Permit2 unordered nonces (a per-owner bitmap), so any number of intents can be open concurrently and cancelled independently.

fill(order, permit2Sig, solver, routerData) is permissionless and executes under the transient reentrancy guard:

1. validate IntentLib.validate(o); reject if cancelled, before startTime,
past endTime, or solver == 0
2. price required = IntentLib.requiredOut(o, block.timestamp)
3. pull Permit2.permitWitnessTransferFrom:
owner's amountIn → settler (transferDetails.to hardcoded to address(this);
Permit2 lets the spender choose the recipient, so that choice must never be
caller-influenced). Consumes the nonce.
4. exact-in pull must land exactly amountIn, or InexactInputReceived
5. flash transfer amountIn of tokenIn → solver
6. callback IIntentFiller(solver).fillIntentCallback(o, required, routerData)
7. floor receivedOut by balance delta; < required → SlippageExceeded
8. deliver transfer everything received to o.recipient;
delivered < required → InsufficientRecipientDelivery;
delivered != receivedOut → InexactOutputDelivered
9. conserve settler must end holding exactly what it started with on both tokens,
or SettlerBalanceChanged

Step 5 is safe because tokenIn != tokenOut is enforced. The solver needs no principal: the flashed amountIn funds the callback. The callback is shaped like the equivalent hooks in other intent systems — OIF’s IInputCallback and UniswapX’s reactorCallback — but it is its own interface, and a filler implements fillIntentCallback against this signature.

Surplus belongs to the filler by construction, not by a payout rule. The solver keeps its margin by never sending it: fillIntentCallback is expected to return exactly requiredOut. Anything that does arrive above the floor is the solver overpaying, and the settler pays it on to o.recipient — it is never refunded to the solver. IntentFilled.aboveFloor reports that amount and is normally zero.

Refunding surplus to the solver would be a round trip returning the solver its own money that could never reach the user, and it would add a second post-callback token interaction whose ordering hazard in a contract that cannot be patched. Paying it to the recipient makes the invariant one sentence: everything that came in goes to the party the order exists for.

announce verifies the signature against the digest Permit2 itself will check. That check is also the relay’s free pre-gas admission gate. It does not make the feed trustworthy: a valid signature can still name a spent nonce, an unapproved token, or a broken router, and an ERC-1271 owner’s signature is a snapshot a later wallet-config change can invalidate. Fillers must dry-run fill.

announce builds no index and is optional. It emits:

event IntentAnnounced(
bytes32 indexed id,
address indexed owner,
bytes32 indexed appData,
bytes order, // abi.encode(IntentOrder) — complete and self-describing
bytes permit2Sig
);

The three indexed topics are the three queries not answerable from the payload alone: one order, one user’s history, one integrator’s attribution. Individual fields are deliberately not duplicated in the log.

On fill, IntentFilled records id, owner, solver, recipient, caller, amountIn, requiredOut, receivedOut, and aboveFloor. On cancel, IntentCancelled records id, owner, and caller.

Three layers, in the order a user reaches for them — the most convenient first, the most durable last:

Method Mechanism Who broadcasts
cancel(order) Sets cancelled[id] owner only (NotOrderOwner)
cancelSigned(order, deadline, ownerSig) Sets cancelled[id] via owner signature Anyone
Permit2.invalidateUnorderedNonces(nonce >> 8, 1 << (nonce & 0xff)) Invalidates the nonce in Permit2’s bitmap owner, on Permit2 directly

cancelled[id] is the only state the contract keeps beyond what Permit2 enforces.

nonceSpent is not “filled”. Permit2’s bitmap is set by a fill and by the owner invalidating the nonce. Distinguishing them requires a matching IntentFilled log.

approve(Permit2, 0) is not a cancellation: re-approving reactivates an unspent order. Nonce invalidation is the durable backstop, but note its granularity: it clears one bit in the owner’s Permit2 bitmap, so it kills every signed order sharing that owner and nonce. cancel and cancelSigned are the per-order paths.

5.6 IntentLens — screening, not a verdict

Section titled “5.6 IntentLens — screening, not a verdict”

IntentLens is a replaceable read-only contract. It lives outside the settler because the settler is immutable and a screening surface is exactly the kind of thing whose shape changes. Replacing the lens needs no change to the settlement core.

preview(order) returns IntentView:

Field Meaning
id keccak256(abi.encode(order))
cancelled Settler cancellation flag
nonceSpent Permit2 nonce bitmap (fill or invalidation)
inWindow startTime <= now <= endTime
decayComplete now >= decayEndTime
floorNow requiredOut at observation time; 0 outside window
ownerBalance Owner’s tokenIn balance
ownerPermit2Allowance Owner’s tokenIn allowance to Permit2
observedAt / observedBlock Observation stamp

It deliberately returns no bool fillable. Balance, allowance, nonce state, wall-clock time, solver inventory, and venue liquidity all move between the call and inclusion, so any boolean would be a promise it cannot keep. The solver still must eth_call-dry-run fill.

IntentSettlerResolver adopts the ERC-7683 resolver surface for discovery and representation only:

Adopted Not adopted
resolve(bytes) — view-only, returns ResolvedOrder for a single same-chain fill() step OIF InputSettler / OutputSettler settlement surface
encodePayload for building resolver input ERC-7930 addresses in the discovery event

Assumption strings the resolver declares:

  • swap_solver.intent_settler
  • swap_solver.payment_recipient_is_step_caller
  • swap_solver.same_chain_only
  • swap_solver.intent_required_out

Same-chain only. There is no cross-chain path in V1.


Both legs must be balance-stable ERC-20s: no rebasing, no fee-on-transfer, no transfer hooks. Settlement accounts by balance delta, and a delta cannot tell “the solver paid me” from “a balance I already held grew”.

Leg measured Check
owner → settler Pull must arrive exactly amountIn (InexactInputReceived)
settler → recipient Credit must equal what was sent and must clear the floor
Settler balances Both tokens end exactly where they started (SettlerBalanceChanged)

Within that model, these hold no matter how a solver behaves:

  • The owner is never debited more than amountIn.
  • The recipient is never credited less than the floor.
  • No stray donation can subsidise a fill.

The contract does not claim that every exotic token fails closed. That statement is too strong.

A tokenOut taxed only on the solver → settler hop is invisible here by construction: the settler sees what it received, never what the counterparty sent, and settles safely. A tokenIn taxed on settler → solver shortchanges the solver. Neither is checked: the first cannot be, and the second would spend gas in an immutable contract to protect a sophisticated party that picks its own tokens and can measure them itself.

Donations sent directly to the settler are permanently trapped. There is deliberately no sweep, because a recovery path would be authority over shared balances. Conservation is what keeps a donation from becoming a subsidy.


No owner, no admin, no pause, no upgrade path, no allowlist, no oracle, no escrow, no sweep, no custody. Safety is structural: a fill either delivers the signed floor atomically or reverts.

Immutability is a requirement, not a preference: Permit2 verifies the signer but lets the signed spender choose SignatureTransferDetails.to at execution time, so an upgradeable settler could redirect funds that outstanding signatures already authorized, against different bytecode.

External trust anchors:

Anchor Role
Canonical Permit2 Authorization and nonce consumption
ERC-20 behaviour within the enforced token model Balance deltas are meaningful
EVM atomicity and EIP-1153 support Transient reentrancy guard
User’s signed curve, floor, deadline, and token addresses Economic terms

Solvers, fillers, routers, relayers, frontends, and indexers are trusted with nothing.

Accepted residuals:

  • An order may simply go unfilled.
  • Announcement logs may carry signatures that later became invalid.
  • Donations are permanently trapped.
  • Unsupported token behaviour can make an order unfillable.
  • A user can sign an economically poor floor — the protocol enforces intent, not market quality.

Stated plainly:

Limitation Detail
ERC-20 → ERC-20 only Native ETH is rejected on either leg; wrap first
Same-chain only No cross-chain settlement path in V1
No partial fills An intent fills atomically in full or not at all
No oracle The signed floor is the only economic protection
Cancellation is not fill-atomic Whichever valid transaction lands first wins
announce does not guarantee fillability Dry-run fill before spending gas
nonceSpent ≠ filled Check IntentFilled logs to distinguish fill from nonce invalidation
approve(Permit2, 0) is not cancellation Re-approving reactivates an unspent order
Trapped donations No sweep on the settler
Exotic token behaviour May make an order unfillable; not all cases fail closed
Clone-v1 incompatibility Retired stack; different order, witness, and spender

Contracts are deployed at identical CREATE2 addresses on four chains. Byte sizes match across chains, consistent with deterministic CREATE2 deployment.

Contract Address Size
IntentSettlerV1 0x7eE5E32d16a90BD4ab3d048B3EE04A7D2d174c4E 10.5 KB
IntentLens 0xDa3407688b96AeC7e31Fc937978F951689C401D6 3.1 KB
IntentSettlerResolver 0xf0E4B200F6214aBb2BAC34e054aF072ec1E5CaEa 8.0 KB
Permit2 (Uniswap, canonical) 0x000000000022D473030F116dDEE9F6B43aC78BA3 8.9 KB

Chains: Base (8453), Arbitrum One (42161), BNB Smart Chain (56), Robinhood Chain (4663).

Clients pin the address, not the runtime codehash. A CREATE2 address match proves the initcode; the settler’s runtime codehash differs per chain because OpenZeppelin’s EIP712 caches the chain id.


Facts an integration must get exactly right:

  1. Order hash. orderHash = keccak256(abi.encode(order)) over all 12 fields. Either compute it locally or read it from the chain via orderHash(order) — AgentSwap’s own client does the latter and ships no keccak.

  2. Permit2 witness. Rebuild typed data from the witness type string in section 5.2. A clone-v1 witness (9 fields, clone as spender) cannot verify here.

  3. Spender. spender = IntentSettlerV1 address, not a per-order clone.

  4. Solver callback. Implement fillIntentCallback(IntentOrder calldata order, uint256 requiredOut, bytes calldata data). Return exactly requiredOut to the settler; surplus above the floor that reaches the settler goes to recipient, not back to the solver.

  5. Recipient. Output goes to order.recipient, not necessarily order.owner.

  6. Three timestamps. startTime, decayEndTime, and endTime are all required. The floor rests between decayEndTime and endTime.

  7. Dry-run discipline. Call permitDigest to verify signing, IntentLens.preview for facts, and eth_call on fill before submitting. announce verifying a signature does not prove the order is fillable.

  8. Nonce hygiene. Intent nonces share the owner’s Permit2 unordered-nonce space with any other Permit2 use.

  9. Cancellation backstop. Permit2.invalidateUnorderedNonces(nonce >> 8, 1 << (nonce & 0xff)) from the owner’s wallet is the durable revocation path.

  10. ERC-7683. Use IntentSettlerResolver.resolve for cross-protocol discovery; settlement still goes through IntentSettlerV1.fill.