Skip to content

fix(rpc): look up dst decimals on destination chain by destination address - #1520

Draft
ultrasecreth wants to merge 1 commit into
ithacaxyz:mainfrom
contango-xyz:fix/source-funds-dst-decimals-lookup
Draft

fix(rpc): look up dst decimals on destination chain by destination address#1520
ultrasecreth wants to merge 1 commit into
ithacaxyz:mainfrom
contango-xyz:fix/source-funds-dst-decimals-lookup

Conversation

@ultrasecreth

Copy link
Copy Markdown

Summary

Relay::source_funds silently returns None whenever a requiredFunds-driven multichain bundle would need to source an ERC-20 from a chain where that asset has a different address than on the destination chain (i.e. most canonical ERC-20s — USDC, USDT, …). The user gets a single-chain quote with a deficit instead of the expected multichain bundle, and wallet_sendPreparedCalls then fails with quote has asset deficits and is expected to fail.

The cause is a wrong address in one lookup; one-line fix.

The bug

In src/rpc/relay.rs, inside source_funds's funding-chain loop:

let Some(dst_decimals) = self
    .inner
    .chains
    .asset(destination_chain_id, mapped.address)   // ← wrong address
    .map(|(_, desc)| desc.decimals)
else {
    return true;
};

mapped is the source-chain descriptor (it came from map_interop_asset(destination_chain_id, chain, asset.address()), whose final step looks the asset up on the source chain). So mapped.address is the source-chain address. Querying that address against destination_chain_id returns None whenever the two chains use different addresses for the same asset.

When the lookup fails, retain returns true, the asset stays in remaining, taken_assets is empty, no FundSource is pushed, and source_funds returns None. build_quotes then takes the silent fallback at src/rpc/relay.rs#L2058-L2062 and produces a single-chain quote with a deficit.

Reproduction against rpc.ithaca.xyz

  • EOA: 2.86 USDC on Base, 0 USDC on Optimism, delegated to current Porto accountImplementation v0.5.10 on Base.
  • wallet_prepareCalls on chain 0xa (OP) with a 0.1 USDC transfer call and capabilities.requiredFunds: [{ address: <USDC_OP>, value: 1000000 }].

Expected: multiChainRoot set, quotes.length === 2 (Base source leg + OP destination leg), no deficits.

Observed (before fix): multiChainRoot: null, quotes.length === 1, quotes[0].assetDeficits = [{ address: <USDC_OP>, deficit: 100001, required: 100001 }].

With diagnostic logging inserted into source_funds, the trace was unambiguous:

source_funds entered  assets_keys=[10, 8453]  remaining={<USDC_OP>: 1000001}  destination_chain_id=10
kept as source        chain=8453  asset_addr=<USDC_BASE>  balance=2860697
processing funding source  chain=8453  escrow_cost=7
retain: no dst decimals lookup  chain=8453  mapped_src=<USDC_BASE>      ← lookup fails here
returning None — remaining not satisfied  remaining={<USDC_OP>: 1000001}  plan_len=0

After the fix, the same call returns a two-leg multichain quote (Base source + OP destination), non-null multiChainRoot, no deficits.

The fix

Use the destination address (the original asset key being retained on) when looking up the destination decimals:

.asset(destination_chain_id, asset.address())

asset is the key from remaining, which originates in requested_assets — i.e. the asset address on the destination chain. That's the address whose decimals we want.

Why existing tests don't catch this

The cross-chain e2e tests in tests/e2e/cases/multichain_usdt_transfer.rs deploy the test ERC-20 separately on each anvil chain via the same deployer nonce, so the contract lands at the same address on every chain. In that case mapped.address == asset.address() and the wrong lookup happens to resolve. The bug only manifests when source and destination addresses differ — which is the production reality for canonical USDC/USDT/etc.

A targeted regression test would require extending the test harness to deploy ERC-20s at distinct per-chain addresses while registering them under a shared AssetUid. Happy to add that here, or in a follow-up, depending on what the maintainers prefer — flagging now so I don't sit on the fix.

Test plan

  • cargo build --bin relay (clean)
  • Manual repro against a locally-run relay configured for Base + Optimism mainnet with LayerZero interop: before the fix → single-chain quote with deficit; after the fix → two-leg multichain quote with non-null multiChainRoot.
  • Existing unit/e2e suite
  • Add regression test with differently-addressed source/destination ERC-20s (would appreciate guidance on whether you want this in this PR or a follow-up)

🤖 Generated with Claude Code

…dress

In `Relay::source_funds`, when iterating funding sources, the destination
decimals were being looked up via:

    chains.asset(destination_chain_id, mapped.address)

`mapped` is the source-chain descriptor (returned by
`map_interop_asset(dst, src, asset)`), so `mapped.address` is the
asset's address on the *source* chain. Querying that address against the
*destination* chain returns `None` whenever the asset has a different
address on the two chains (which is the common case for canonical
ERC-20s such as USDC, USDT, etc.).

When the lookup fails, `retain` returns `true` and the asset stays in
`remaining`; no funds are taken; `source_funds` returns `None`; and
`build_quotes` silently falls back to a single-chain quote with a
deficit. The user ends up with a quote they cannot submit
("quote has asset deficits and is expected to fail" on send).

Use the destination address (the original `asset` key being retained)
when looking up the destination decimals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant