Intent based bridging lets a user describe the outcome they want on the destination chain, then delegate execution to competing solvers. Instead of calling a specific bridge directly, the user signs a message that states the source chain, source token, amount in, destination chain, destination token, minimum destination amount, recipient, deadline, and a nonce. That signed intent is portable and verifiable. The settlement layer verifies the signature and the proof that funds arrived on the destination chain, then reimburses the solver.
This guide shows where value leaks, how assumptions fail in production, and what to change in code and architecture. It includes concrete Solidity examples, exploit paths, and safer patterns.
How an intent based bridge typically works
- The user submits or broadcasts a signed intent through a trusted interface or a private orderflow channel.
- Solvers observe the intent, source destination side liquidity, deliver funds to the recipient on the destination chain, and collect a receipt or proof of delivery.
- A settlement contract on the source chain verifies the user signature and timing rules, verifies the destination proof, applies quality checks, and releases reimbursement and any solver fee.
Common assumptions that fail in practice: the solver set behaves competitively, simulation reflects execution, signatures cannot be replayed across domains, partial fills behave linearly, and the settlement logic captures every constraint the user expects.
Failure class 1. Cross chain and cross domain replay
Bug. The signed digest excludes chain context, so the same intent can be replayed on a sister deployment.
// Buggy domain, missing chainId in the domain separator
bytes32 constant TYPEHASH_DOMAIN =
keccak256("EIP712Domain(string name,string version,address verifyingContract)");
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
TYPEHASH_DOMAIN,
keccak256(bytes("IntentSettlement")),
keccak256(bytes("1")),
address(this) // no chain id here
));
function _intentHash(Intent calldata it) internal view returns (bytes32) {
return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, _hashIntent(it)));
}
Exploit path. A dishonest solver replays the same signed intent on a different chain where the contract exists, draining assets again.
Fix. Include chain context in the domain, or also inside the struct.
bytes32 constant TYPEHASH_DOMAIN =
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
TYPEHASH_DOMAIN,
keccak256(bytes("IntentSettlement")),
keccak256(bytes("1")),
block.chainid,
address(this)
));
// Optional defense in depth. Bind chainId inside the struct and require equality.
struct Intent {
address from;
address toToken;
uint256 amountIn;
uint256 minAmountOut;
address recipient;
uint256 nonce;
uint256 validAfter;
uint256 validUntil;
uint256 chainId; // must equal block.chainid
}
Failure class 2. Nonce misuse and replay within the same domain
Bug. A single increasing counter per user creates real race and reorg risks. Two solvers can observe the same counter value in the mempool, both satisfy the nonce check, and both proceed. If the contract updates the counter only after external calls, the second fill can land before the first marks the nonce, so the same intent is consumed twice. A short chain reorganization can also undo the transaction that advanced the counter, restoring the old value and letting an already accepted intent be replayed.
mapping(address => uint256) public lastNonce;
function settle(Intent calldata it, bytes calldata sig) external {
// `sig` is the user's EIP 712 signature over `it`.
// Verify it before any state changes or external calls, for example:
// require(_verify(it, sig), "invalid signature");
require(it.nonce >= lastNonce[it.from], "nonce too small");
// race allows reuse of the same nonce by competing solvers
lastNonce[it.from] = it.nonce;
// proceed with transfer and calls
}
Fix. Use a sparse nonce set or bitmap. Mark each nonce as consumed atomically before any external call.
mapping(address => mapping(uint256 => bool)) public usedNonce;
function settle(Intent calldata it, bytes calldata sig) external {
// `sig` is the user's EIP 712 signature over `it`.
// Verify it before consuming the nonce or making external calls, for example:
// require(_verify(it, sig), "invalid signature");
require(!usedNonce[it.from][it.nonce], "nonce used");
usedNonce[it.from][it.nonce] = true;
// proceed after signature and constraint checks
}
Failure class 3. Partial fill ambiguity and rounding theft
Bug. Enforcement is proportional per fill, which lets a solver split a fill into many small parts and shave value via rounding or path choice.
// Buggy pro rata check that can leak value over many small fills
function onFill(bytes32 key, uint256 inAmt, uint256 outAmt) internal {
// Link the key to its per-intent state for clarity, even though this buggy check does not use it
FillState storage s = state[key];
// intent.minOut and intent.amountIn are the totals
require(outAmt * intent.amountIn >= intent.minOut * inAmt, "slippage");
}
Exploit path. A solver submits many small fills where each passes by a fraction, yet the total out falls short of the promised minimum.
Fix. Track cumulative progress and enforce the global minimum only when the intent is fully filled. If you support partial fills, validate the aggregate minimum at completion, not on each fill.
struct FillState {
uint256 filledIn;
uint256 totalOut;
bool closed;
}
mapping(bytes32 => FillState) public state;
function onFill(bytes32 key, uint256 inAmt, uint256 outAmt) internal {
FillState storage s = state[key];
require(!s.closed, "closed");
s.filledIn += inAmt;
s.totalOut += outAmt;
if (s.filledIn == intent.amountIn) {
require(s.totalOut >= intent.minAmountOut, "global slippage breach");
s.closed = true;
}
}
Failure class 4. Simulation leakage and solver capture
Pattern. Builders expose a public simulation endpoint that returns the best path and expected out. Adversaries query it, copy the route, and submit privately. Users get a worse price or never land their original intent.
Server side example
# Public endpoint that leaks price and path
@app.post("/simulate")
def simulate(intent):
route = best_route(intent) # uses internal price DB
quote = expected_out(route)
return {"route": route, "quote": str(quote)}
Fixes.
Gate simulation with authenticated solver keys and rate limits. Return obfuscated hints, not full paths. Use commit reveal for solver bids. Send intents over private orderflow and settle with bundles that avoid the public mempool.
Failure class 5. Objective function mismatch and self dealing
Pattern. The settlement checks only a minimum destination amount. A solver can pay through a destination venue they control at a worse rate, still meet the minimum, and keep the spread.
Scenario. The user intends to bridge 1,000 USDC from Ethereum to Arbitrum with a minimum destination amount of 995 USDC. The best executable outcome at that size is about 1,003 USDC on Arbitrum. A solver routes through a private venue and pays 996 USDC, which clears the minimum. The solver then arbitrages to 1,003 USDC and keeps the 7 USDC spread. The settlement objective allowed it, because it only enforced the user’s minimum.
Fixes.
Define and enforce a price quality rule onchain relative to a conservative reference quote. If you include a solver fee, pay it as a share of measured surplus over the reference. Maintain a quality score and a bond, and penalize solvers who consistently deliver outcomes below a moving benchmark.
Example bond and penalty
mapping(address => uint256) public solverBond;
uint256 public constant BOND = 10 ether;
function registerSolver() external payable {
require(msg.value >= BOND, "bond too small");
solverBond[msg.sender] += msg.value;
}
function penalize(address solver, uint256 amt) internal {
require(solverBond[solver] >= amt, "insufficient bond");
solverBond[solver] -= amt;
// send penalty to a burn or insurance module
}
Compute penalties offchain, submit a proof or attestation, and apply onchain through governance or a rule based module.
Failure class 6. Guardian like assumptions in solver permissions
If the solver set is permissioned, control of the registry can be abused to insert a malicious solver, rotate keys silently, or censor fills.
Fixes.
Require timelocks on solver list changes with public announcements and a review window. Separate roles for listing and slashing. Publish a registry event feed and surface changes in user interfaces.
A safer Intent struct and cross domain verification flow
This pattern binds signatures to the chain and the verifying contract, which prevents cross domain replay. It does not prevent same chain replay by itself. To stop intra chain replay, consume a user nonce or mark the signed digest as used before any external call.
struct Intent {
address from;
address tokenIn;
address tokenOut;
uint256 amountIn;
uint256 minAmountOut;
address recipient;
uint256 nonce;
uint256 validAfter;
uint256 validUntil;
// optional: bytes32 routeHash to pin a route, set zero to allow any approved route
}
bytes32 constant TYPEHASH_INTENT = keccak256(
"Intent(address from,address tokenIn,address tokenOut,uint256 amountIn,uint256 minAmountOut,address recipient,uint256 nonce,uint256 validAfter,uint256 validUntil)"
);
function _hashIntent(Intent calldata it) internal pure returns (bytes32) {
return keccak256(abi.encode(
TYPEHASH_INTENT,
it.from,
it.tokenIn,
it.tokenOut,
it.amountIn,
it.minAmountOut,
it.recipient,
it.nonce,
it.validAfter,
it.validUntil
));
}
function _digest(Intent calldata it) internal view returns (bytes32) {
return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, _hashIntent(it)));
}
// Verify EOAs via ECDSA and smart wallets via EIP 1271
function _verify(Intent calldata it, bytes calldata sig) internal view returns (bool) {
require(block.timestamp >= it.validAfter && block.timestamp <= it.validUntil, "expired");
require(it.recipient != address(0), "bad recipient");
bytes32 digest = _digest(it);
if (it.from.code.length == 0) {
return ECDSA.recover(digest, sig) == it.from;
} else {
// 0x1626ba7e = isValidSignature(bytes32,bytes)
(bool ok, bytes memory ret) = it.from.staticcall(
abi.encodeWithSelector(0x1626ba7e, digest, sig)
);
return ok && ret.length == 32 && bytes32(ret) == bytes32(0x1626ba7e);
}
}
Add nonce consumption to prevent same chain replay. If you support partial fills, validate the aggregate minimum at completion, not on each fill.
Checklist for builders using intent based bridging
- Bind signatures to the correct chain and contract through a proper EIP 712 domain.
- Consume a unique nonce for each intent.
- If partial fills are enabled, enforce the minimum only on the final cumulative amount.
- Gate or obfuscate simulation endpoints and prefer private orderflow for sensitive intents.
- Enforce a price quality rule against a protected cross chain reference, and pay solver fees only from measured surplus.
- Maintain a solver registry with timelocks, audit trails, and separate listing and slashing roles.
- Emit events that include route metadata, reference quotes, improvement in basis points, and per fill accounting.
- Add end to end tests and fuzzing for settlement, including reorg and replay scenarios.
Work with us
Spearbit audits intent systems with a focus on settlement correctness, replay resistance, solver incentives, and data leakage. Share your spec, contracts, and paths, and we will map the attack surface and validate the code. Start here.
