Organization
- @coinbase
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Medium Risk
1 findings
1 fixed
0 acknowledged
Low Risk
3 findings
2 fixed
1 acknowledged
Informational
8 findings
6 fixed
2 acknowledged
Medium Risk1 finding
Constant DEPOSIT_WITNESS_TYPE_STRING violates EIP-712 alphabetical ordering
State
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Low Submitted by
Sujith S
Description
Permit2DepositCollector.soldefines:string public constant DEPOSIT_WITNESS_TYPE_STRING = "DepositWitness witness)TokenPermissions(address token,uint256 amount)DepositWitness(bytes32 channelId)";Permit2 prepends its stub PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline, to this string and hashes the concatenation to derive the typehash used in permitWitnessTransferFrom.
The resulting on-chain encoded type is:
PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,DepositWitness witness)TokenPermissions(address token,uint256 amount)DepositWitness(bytes32 channelId)EIP-712's definition of encodeType requires that the set of referenced struct types be "collected, sorted by name and appended to the encoding." The two referenced types here are DepositWitness and TokenPermissions. Lexicographically, D (0x44) < T (0x54), so the spec-mandated order is
DepositWitnessfirst, thenTokenPermissions.Spec-compliant signing libraries (ethers signTypedData, viem signTypedData, MetaMask eth_signTypedData_v4, Coinbase Wallet, hardware wallets) accept a JSON types object and reconstruct the encoded type by sorting referenced types alphabetically.
They will sign a digest derived from:
PermitWitnessTransferFrom(...,DepositWitness witness)DepositWitness(bytes32 channelId)TokenPermissions(address token,uint256 amount)Because the two type strings differ, the resulting typehashes and therefore the EIP-712 digests differ. Permit2 will compute a different message hash than the one the wallet signed,
_signatureBased.verify()returns false, and permitWitnessTransferFrom reverts with InvalidSigner. No deposit through a normal wallet flow can succeed via this collector.The existing
x402ExactPermit2Proxyandx402UptoPermit2Proxyare unaffected because their witness struct is named Witness (W = 0x57 > T = 0x54), so TokenPermissions already precedes Witness alphabetically. The bug is specific to choosing a witness struct name that sorts beforeTokenPermissions, whichDepositWitnessdoes.Recommendation
Reorder the witness type string so the referenced struct definitions appear in alphabetical order:
string public constant DEPOSIT_WITNESS_TYPE_STRING = "DepositWitness witness)DepositWitness(bytes32 channelId)TokenPermissions(address token,uint256 amount)";
Low Risk3 findings
USDC/USDT blacklist of payer or receiver permanently traps escrow
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Cryptara
Description
The contract escrows USDC/USDT for a payer and receiver. Every payout path (
settleto the receiver,finalizeWithdrawandrefundto the payer) sends tokens to addresses fixed inChannelConfigat channel creation.USDC and USDT both maintain blacklists. A
transferto a blacklisted address reverts. There's no admin, no rescue function, no way to redirect a payout, and nosetReceiverorclaimTo. The receiver and payer addresses are hashed intochannelId, so they can't be migrated either.receivers[r][token]is keyed by address, so every channel sharing a blacklisted receiver freezes at once.Once either party gets blacklisted, the funds in that channel are stuck. The honest counterparty has no path to recover their share.
Worth flagging: Coinbase's own audit checklist (SOL-AM-DOSA-3) calls out this exact scenario, citing Real Wagmi 2 as precedent. USDC is the documented default asset for this contract.
Proof of concept
Two ways this plays out:
Receiver blacklisted after claiming. Receiver calls
claim(), which bumpsreceivers[R][USDC].totalClaimed. ThenRgets blacklisted. Anyone callingsettle(R, USDC)reverts insideUSDC.transfer. The claimed funds sit in the contract with no way out.Payer blacklisted during the withdraw window. Payer deposits, then calls
initiateWithdraw(). Payer gets blacklisted before the delay elapses.finalizeWithdraw(),refund(), andrefundWithSignature()all revert because they pay the blacklisted address. Escrow is stranded.Remediation
I see 3 options:
- Add
settleTo(receiver, token, recipient, sig)and equivalents for withdraw and refund. The relevant authorizer signs an EIP-712 redirect to a fresh address. No admin, trust model intact. - A bilateral-consent rescue path that requires signatures from both
payerAuthorizerandreceiverAuthorizer. Either side can block griefing. - If neither is acceptable, at minimum document the blacklist exposure in NatSpec and the README so integrators know what they're signing up for.
Function initiateWithdraw missing nonReentrant modifier
State
Severity
- Severity: Low
Submitted by
Sujith S
Description
Function
initiateWithdraw()is declared as external without the nonReentrant modifier, while every other state-changing entrypoint in the contract (deposit, claim, settle, finalizeWithdraw, refund, etc.) is guarded.Although the function itself performs no external calls and is unlikely to be exploitable in isolation, the missing guard breaks the consistent reentrancy posture of the contract. If future changes introduce a token hook, callback, or cross-function path that reaches initiateWithdraw during another guarded call's execution, the inconsistency could become exploitable (for example, opening a withdrawal under stale balance/totalClaimed state observed mid-claim).
Recommendation
Add the
nonReentrantmodifier toinitiateWithdraw()so the withdrawal flow has uniform reentrancy protection alongsidefinalizeWithdraw()andrefund():- function initiateWithdraw(ChannelConfig calldata config, uint128 amount) external {+ function initiateWithdraw(ChannelConfig calldata config, uint128 amount) external nonReentrant {Event ChannelCreated can be re-emitted for the same channelId
State
Severity
- Severity: Low
Submitted by
Sujith S
Description
The
deposit()function detects the "first deposit" on a channel usingch.balance == amount && ch.totalClaimed == 0. This heuristic assumes the channel state is freshly initialized, but both fields can legitimately return to zero after creation: if the payer deposits, then fully refunds before any claim or settlement occurs,ch.balanceis decremented back to 0 andch.totalClaimedremains 0.A subsequent deposit on the same channelId then satisfies the predicate again and re-emits
ChannelCreated(channelId, config).Function
finalizeWithdraw()produces the same shape (full withdrawal of an unclaimed channel resets balance to 0 with totalClaimed == 0).The contract state is unaffected, but indexers, subgraphs, and off-chain monitors that treat
ChannelCreatedas a one-shot lifecycle event (e.g., to bootstrap channel records, dedupe creation, or trigger notifications) can double-count or overwrite metadata. It also weakens any invariant downstream consumers might rely on, that one channelId maps to exactly one ChannelCreated.Recommendation
Stop inferring creation from balances and just track it explicitly. A single bool on
ChannelStateis enough:ChannelState storage ch = channels[channelId];... if (!ch.created) { ch.created = true; emit ChannelCreated(channelId, config);}Now the event fires exactly once per channelId, no matter how many refund/withdraw/redeposit cycles the channel goes through.
Coinbase: Fixed in 10b35d3. Instead of gating
ChannelCreatedbehind a stored flag, we added a matchingChannelClosedevent that fires when arefund()orfinalizeWithdraw()leaves the channel withbalance == 0 && totalClaimed == 0. This makes the lifecycle explicit on-chain: every re-emission ofChannelCreatedis now preceded by aChannelClosedfor the samechannelId, so indexers can pair them up and treat each create/close cycle as a distinct epoch rather than a duplicate.Cantina: Verified fix.
Informational8 findings
Claim-vs-finalizeWithdraw race under censorship
State
Severity
- Severity: Informational
Submitted by
Cryptara
Description
claim()andfinalizeWithdraw()are independent entry points but both read and writech.balance/ch.totalClaimed. Since vouchers live off-chain until claimed, the contract can't see outstanding receiver entitlement when the payer initiates withdraw,totalClaimedreflects only what's been claimed onchain so far.That opens a race during the withdraw window:
- A payer can sign a voucher, hand it to the receiver, and call
initiateWithdraw()in the same block. The available withdraw amount is computed against the currenttotalClaimed, not against any voucher the receiver is holding. - If the receiver gets
claim()onchain beforefinalizeWithdraw,totalClaimedrises and the payer's withdraw is capped to whatever remains. Receiver wins. - If the receiver can't transact for the full window, RPC outage, sanctioned address, builder censorship, infra failure,
finalizeWithdrawlands first, drains the balance, and the receiver's laterclaimreverts withClaimExceedsBalance. The voucher becomes worthless.
Proof of concept
- Channel open with
withdrawDelay = 900. Payer deposits $100. - Payer signs voucher for $80, receiver delivers the service.
- Payer calls
initiateWithdraw()immediately. Contract seestotalClaimed = 0, so available looks like $100. - Receiver's claim infra is offline / censored for the full 15 minutes.
- Payer calls
finalizeWithdraw(). Balance drained. - Receiver comes back online, calls
claim(voucher)→ revertsClaimExceedsBalance. - Payer kept the service and the money.
Remediation
Mostly documentation and tooling:
- Make the receiver's claim-watching responsibility explicit in NatSpec and integrator docs. Ship a reference claim-watcher in the SDK. Recommend
withdrawDelaywell above the 900s floor for high-value channels (hours or days). - Optional: add a
commitVoucher(channelId, maxAmount, sig)primitive. Cheap storage write, no transfer.finalizeWithdrawthen caps tobalance - max(totalClaimed, voucherCommitment). Lets the receiver reserve a voucher's value in one tx without doing the full claim, neutralizes the race even under temporary censorship.
Spec/code drift on channelId derivation
Description
Spec (
specs/schemes/deferred/scheme_batch_settlement_evm.mdL7, 23, 86, 332, 403) defineschannelId = keccak256(abi.encode(channelConfig)). The contract (x402BatchSettlement.sol:403-407) uses_hashTypedDataV4(CHANNEL_CONFIG_TYPEHASH, ...). The contract is correct, EIP-712 binds the ID to chain + verifying contract, which is what INV-006 asserts.specId = keccak256(abi.encode(channelConfig))///contractId = _hashTypedDataV4(keccak256(abi.encode(CHANNEL_CONFIG_TYPEHASH, ...)))Remediation
Docs and test only, no contract change:
- Update spec (L7, 23, 86, 332, 403) to use the EIP-712 form.
- Fix contract NatSpec L18.
- Fix fork test L86 to match contract derivation.
Partial refund does not clear pending withdrawal
Description
The specification and the implementation diverge on how refunds interact with pending timed withdrawals.
Spec (
scheme_batch_settlement_evm.mdL449) states: "Any successful refund clears a pending timed withdrawal."The contract (
x402BatchSettlement.sol:518-526) only clears the pending withdrawal when the refund amount is greater than or equal topws.amount. Smaller refunds decrementpws.amountbut leave the withdrawal active and the timer running.Test cases at
x402BatchSettlement.t.sol:990,1009, and1034confirm this is the intended behavior. Implementing the spec literally would let a receiver grief the payer by submitting repeated 1-wei refunds, indefinitely resetting the payer's exit timer, a denial-of-service against the payer's unilateral liveness guarantee (INV-014). The contract's NatSpec at L501 already describes the behavior accurately ("reduces or clears").The code is correct; the specification is the source of the drift. The risk is integrator confusion: external implementers and auditors reading the spec will see a contradiction with onchain behavior.
Proof of concept
pendingWithdrawals[channelId].amount = 100receiver calls refund(channelId, 30) Spec: pending withdrawal cleared, timer resetContract: pws.amount = 70, timer unchangedFollowing the spec literally produces a griefing primitive: the receiver repeatedly calls
refund(channelId, 1), resetting the payer's withdrawal each time and preventing exit indefinitely.Remediation
Specification update only; no contract changes required.
- Revise spec L449 to: "A refund reduces a pending timed withdrawal by the refunded amount. The withdrawal is cleared only when fully refunded."
- Optional: emit distinct
WithdrawReducedandWithdrawCancelledevents so integrators can distinguish the two outcomes without inspecting storage deltas.
Voucher signatures have no expiry; remain valid across top-ups on the same channelId
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Cryptara
Description
The
Voucherstruct (x402BatchSettlement.sol:62-65) contains only(channelId, maxClaimableAmount). There is no deadline, nonce, or epoch counter._processVoucherClaim(L470-499) enforces only the cumulative ceiling and monotonicity oftotalClaimed. As a result, any voucher signed by the payer remains valid for the full lifetime of the associatedchannelId.The spec (
scheme_batch_settlement_evm.md:444-450) describes the channel lifecycle as long-lived: deposits and top-ups accumulate into the samechannel.balanceunder the samechannelId, and the only documented way to "reset" a channel is to fully withdraw and migrate to a newChannelConfig(differentsaltor other field). There is no on-chain primitive for invalidating outstanding vouchers without that migration.This produces two exposures:
Voucher unused ceiling persists across top-ups. Because
totalClaimedis cumulative perchannelId, any unused headroom on a previously-signed voucher carries forward into later top-ups. A voucher signed formax=200against which only 50 has been claimed still authorizes 150 more — even after the payer has withdrawn the remaining balance and later topped up for an unrelated purpose. The receiver can sweep the new escrow without the payer signing again.Unbounded blast radius on payer key compromise. A stolen payer signing key produces a voucher signed with
maxClaimableAmount = type(uint128).maxthat is valid forever, against any current or future escrow in the affectedchannelId. The contract has no on-chain revocation primitive: no per-channel epoch, no voucher expiry, no allow/deny list. Recovery requires fully draining the channel and migrating to a newChannelConfig.Severity: LOW. The cumulative-claim model is intentional and replay-safe within a single voucher's lifetime. The intended usage pattern (tight
maxClaimableAmount, channel migration when intent changes) avoids the issue. The risk surfaces under long-lived channel reuse, loose ceiling signing, and key rotation.Proof of concept
// Initial depositChannelConfig memory cfg = ChannelConfig({ payer: P, payerAuthorizer: address(0), receiver: R, receiverAuthorizer: address(0), token: USDC, withdrawDelay: 900, salt: SALT});bytes32 chId = settlement.getChannelId(cfg);settlement.deposit(cfg, 100e6, collector, ""); // Payer signs a generous voucher for the ongoing relationshipbytes memory sig = sign(P, voucherDigest(chId, 200e6)); // Receiver claims only 50e6, settlessettlement.claim(VoucherClaim({voucher: Voucher(cfg, 200e6), signature: sig, totalClaimed: 50e6}));settlement.settle(R, USDC); // Payer withdraws the remaining unclaimed balancesettlement.initiateWithdraw(chId, 50e6);// ... withdrawDelay elapses ...settlement.finalizeWithdraw(chId); // Later: payer tops up the same channel for a new purposesettlement.deposit(cfg, 100e6, collector, ""); // The original voucher is still valid. Receiver claims another 150e6 against the new escrow.settlement.claim(VoucherClaim({voucher: Voucher(cfg, 200e6), signature: sig, totalClaimed: 200e6}));The receiver captures the new escrow against a previously-signed voucher whose unused ceiling carried forward across the withdraw and top-up. The same primitive applies to any voucher signed by a compromised key: it remains valid against all future escrow on the affected
channelId.Remediation
Three options, in order of preference:
- Add a
deadlinefield toVoucher. Revert in_processVoucherClaimwhenblock.timestamp > voucher.deadline. Bounds the time window for any signed voucher and limits the blast radius of a key compromise. Minimal protocol change. - Per-channel epoch counter. Increment an epoch on
finalizeWithdraw(or expose an explicitrotateEpochcall) and bind vouchers to the epoch at signing time. Reverts on mismatch. More invasive but avoids clock-based reasoning. - Document explicitly in NatSpec and the integrator guide if neither is acceptable. Specifically: sign vouchers with the tightest feasible
maxClaimableAmount, treat top-ups as an extension of the same authorization context (do not assume voucher hygiene resets), and treat any payer-key compromise as requiring full migration to a newchannelId— the contract provides no on-chain revocation path.
Deposit events emitted before token-receipt verification
Severity
- Severity: Informational
Submitted by
Cryptara
Description
In
deposit()(x402BatchSettlement.sol:209-218), theChannelCreatedandDepositedevents are emitted before thebalanceOfdelta check that verifies the collector actually pulled the tokens:ch.balance += amount; if (ch.balance == amount && ch.totalClaimed == 0) { emit ChannelCreated(channelId, config);}emit Deposited(channelId, msg.sender, amount, ch.balance); uint256 balBefore = IERC20(config.token).balanceOf(address(this));IDepositCollector(collector).collect(...);uint256 balAfter = IERC20(config.token).balanceOf(address(this));if (balAfter != balBefore + amount) revert DepositCollectionFailed();If the collector fails to deliver the expected amount, the transaction reverts and the events are not persisted on-chain. State is safe.
The risk is off-chain: pending-tx parsers, mempool indexers, and some debugger/trace tools surface emitted events from a transaction's execution trace before the final revert is observed. Integrators reading these intermediate signals may show a deposit confirmation that never lands, leading to UI/accounting drift between off-chain views and final on-chain state.
Remediation
Move the event emissions after the post-pull balance check:
ch.balance += amount; uint256 balBefore = IERC20(config.token).balanceOf(address(this));IDepositCollector(collector).collect(config.payer, config.token, amount, channelId, msg.sender, collectorData);uint256 balAfter = IERC20(config.token).balanceOf(address(this));if (balAfter != balBefore + amount) revert DepositCollectionFailed(); if (ch.balance == amount && ch.totalClaimed == 0) { emit ChannelCreated(channelId, config);}emit Deposited(channelId, msg.sender, amount, ch.balance);Events now only emit on successful, fully-verified deposits.
Function refund() invalidates pre-signed refundWithSignature() authorizations
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Sujith S
Description
Function
_executeRefund()unconditionally incrementsrefundNonceon every non-reverting entry. Bothrefund()and
refundWithSignature()flow through it. As a result, a receiver (or receiverAuthorizer) calling plainrefund(config, 1)bumps the channel's refund nonce and silently invalidates any in-flight refundWithSignature digest the receiverAuthorizer had pre-signed for that channelId at the prior nonce. The signed authorization becomes unrelayable and must be re-issued.In practice both callers sit on the receiver side of the trust boundary, so this is internal griefing between the receiver and its delegated authorizer (or two relayers racing) rather than a cross-party attack. It still produces foot-gun behavior: a relay-friendly signed refund can be cancelled by an unrelated direct refund, wasting authorizer effort and potentially stranding a relayer's gas.
Recommendation
Only consume the refund nonce on the signature-gated path. Move the
refundNonce[channelId]++out of_executeRefund()and intorefundWithSignature()immediately after the nonce/signature checks pass, so direct refund calls do not invalidate outstanding signed authorizations. The replay-prevention property the comment cites is still preserved because only the signed path reads or writes the nonce.Alternatively, consider documenting this behavior.
Coinbase: Acknowledged. Documented this behavior in e13ee4f
Cantina: Verified fix.
Empty-batch branch in getClaimBatchDigest() is dead code on the on-chain path
State
Severity
- Severity: Informational
Submitted by
Sujith S
Description
In
getClaimBatchDigest()function, there exist an explicit branch for the empty batch case:if (n == 0) { entriesRoot = keccak256(""); } else { ...}Both functions
claimWithSignature()andclaim()revert withEmptyBatchwhenvoucherClaims.length == 0before the digest is ever computed. So no on-chain caller can produce a digest over an empty batch, and theentriesRoot = keccak256("")arm cannot influence any state transition.The branch is reachable when
getClaimBatchDigest()is called as a view directly (e.g., off-chain signing tools or client SDKs computing what the authorizer would sign). That is the only path it serves today, and it is reachable by external callers only, not by the contract's own logic.Recommendation
Consider adding a natspec line stating that the empty-batch digest is defined for off-chain use only and is never accepted by the two on-chain functions.
Param caller in IDepositCollector.collect is unused
State
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The function
collect()inIDepositCollectorinterface declares acallerargument, populated byx402BatchSettlement.depositwithmsg.sender.However, both the deposit collector implementations (
ERC3009DepositCollectorandPermit2DepositCollector) leave the slot named, making the parameter redundant.Recommendation
Consider removing the
callerparameter to improve code quality and gas efficiency.