Search projects, hackathons, sponsors and tech

    ← All projects

    Mercury

    A USDC wallet built around identity, not addresses. Any chain, no gas, ~5 seconds.

    Demo

    About

    Mercury is a self-custodial iOS wallet (Expo / React Native, Rust signing core) that makes paying in USDC feel like sending a message: you pay a name, not an address; you never bridge and claim; and you never buy a gas token first. Circle Gateway and Arc are what make that literally true rather than a tagline. One balance, no bridge. USDC is held as a single Circle Gateway balance rather than a dozen per-chain pots. You deposit once — approve, deposit into GatewayWallet, on whichever chain you already hold funds — and it is spendable on every Gateway chain in the wallet's registry (sixteen Circle domains: ten testnet, six mainnet). There is no bridge, no claim, no wrapped asset and no per-transfer wait, because the finality was paid for at deposit time. A send is an EIP-712 burn intent naming the destination and recipient, signed on the device, attested by Circle, and minted on the destination by our relayer. Measured end to end: attested in 1.2s, minted in 1.0s, with the recipient holding no gas token on the destination chain. Settlement on Arc. Arc is the settlement chain, and USDC is its native gas token — so a balance there pays its own fees and the wallet never has to tell anyone to go buy ETH before they can spend what they were just paid. It is also the fastest door into the balance: Arc reaches finality in about half a second, against 13–19 minutes on the Ethereum-finality chains (Base, Arbitrum, OP, Unichain, World Chain), which is why every testnet deposit routes through Arc rather than through the "obvious" L2. Spending is instant regardless of where the money was deposited, so the only thing the deposit chain changes is how long the top-up takes to land — and we route on that number instead of on familiarity. On mainnet, Gateway lists no Arc domain yet, so mainnet settles through Polygon: 8s finality and the cheapest domain to spend from, ~$0.0015 of gas per transfer against $1.00 on Ethereum. Money is never stranded mid-flight. Handing an attestation to a relayer is safe by construction — Circle signs it and names the recipient inside the signature, so the relayer cannot redirect, alter or skim it; its only power is whether to submit. What it can do is fail to submit, so the app checks deliverability before it signs: destinations the relayer cannot pay out on are refused, not offered, because a burn cannot be recalled once Circle accepts it. The signed claim is written to disk before the relay is attempted, so a dropped mint is retried rather than lost. We also shipped the unglamorous half of the door — withdrawing from Gateway back onto one chain takes seven days, and readiness is never guessed from block heights (on Arbitrum an in-contract block.number is an L1 approximation while eth_blockNumber is L2 — a bug that would be days wrong on exactly one chain). We eth_call the withdrawal instead: it reverts until the contract's own clock says it is ready. Identity. Every user gets name.mercurywallet.eth — real ENS, not a database row. We wrote and deployed MercuryNameRegistry.sol (Sepolia, 0xe6967ac719caa21ee46d62ceafa9969f96e9252d) and set it as the resolver on our parent name, so every subname is answered on-chain through ENSIP-10 wildcard resolution. One record holds the owner's EVM, Solana and Bitcoin addresses plus a preferred chain — the routing hint that tells a Gateway send where to land — so a single name routes payments on all three. Claiming is sponsored by our hub, so a brand-new wallet with a zero balance can get a name before it has ever received money. No server sits behind it: any ENS-aware wallet resolves these names, and they keep resolving if every machine we run disappears. Data: The Graph. Balances and history come from The Graph — the Token API for the major EVM mainnets, and our own subgraph for Arc, which indexes native USDC and EURC transfers because nothing else indexes Arc at all. Both are proxied through our hub, so no provider key ships in the app bundle. Also in the app. Per-payment stealth addresses (ERC-5564) for private receiving — and Arc is the first chain where that closes end to end, because a note there pays its own gas out, so the consolidating sweep that would tie every note back to one identity is never needed. Cross-chain atomic swaps (Garden on testnet including Bitcoin testnet4; Flashnet Orchestra on mainnet). Uniswap V2 to turn a non-USDC balance into a depositable one. A dApp browser with WalletConnect. A spoof-resistant token registry where a curated seed always beats a downloaded list. Multi-account BIP-32 wallets across EVM, Solana and Bitcoin. Custody. All keys and all signing live in a Rust core on the phone, behind iOS keychain classes that delete the recovery phrase if the passcode is removed and require biometric auth at the OS level rather than in app code. The hub is one small Node service with exactly three jobs — relay Gateway mints, sponsor ENS names, proxy The Graph — holding a gas-only key bounded by an on-disk spend ledger denominated in ETH with a gas-price ceiling. It never sees a user key and never holds user funds; kill it and the wallet still holds keys, shows balances and sends transactions.

    How it's made

    The stack. Expo / React Native (TypeScript, expo-router) for the iOS app; a Rust signing core exposed as a React Native turbo module for every key and every signature; Solidity for MercuryNameRegistry; a small Hono/Node service ("the hub") in Docker; an AssemblyScript subgraph on The Graph. The app talks to chains over raw JSON-RPC through one hand-rolled eth layer (ethCall / sendCall / waitForReceipt) rather than a full client library — the whole EVM surface this wallet needs is about six methods, and hand-encoding calldata kept the bundle small and the failure modes visible. viem is used on the hub, where bundle size does not matter. There is no stub for the signing core: without the native module linked, the app refuses to bundle. A wallet that starts without its signer would be worse than one that will not start. Arc Arc is the settlement chain, and building on it produced most of the interesting engineering in this project, because USDC being the native gas coin changes things that are normally invariants. Everything below we observed against the live chain; it is all written up in FEEDBACK.md with the commands that produced it. USDC is the native coin AND an ERC-20, at two different scales. eth_getBalance returns 18dp; balanceOf(0x3600…) returns 6dp; they are the same money. Measured at one block: 10.191319965 native against 10.191319 ERC-20. A naive wallet shows the user two balances for one pot — and a wallet that sweeps "all your USDC" as an ERC-20 has just spent its own gas. Two Transfer emitters, and the native one is a superset. A plain value send emits one log from 0xffff…fffe. An ERC-20 transfer() emits from BOTH 0xffff…fffe and 0x3600…. We built the subgraph against the ERC-20 contract first and lost every native send; indexing both double-counts. The fix — index the native emitter alone and scale 18dp → 6dp in the handler — we only found by sending one of each and counting logs. It is the highest-risk thing about indexing Arc and it is documented nowhere. Gas is cheap enough to stop designing around. Measured gas price 21.2 gwei in USDC: a transfer is 0.00045 USDC, an approve 0.00127. So we approve the exact amount being spent instead of the customary unlimited allowance — a trade nobody makes on a chain where approvals cost real money. Because gas IS the asset, we reserve 0.25 USDC (about fifty transactions) and enforce it at the point of sweeping, not by asking the user to remember. There is no second token to fall back on. UniswapV2Router02.WETH() on the Arc deployment points at an address with no code, so every ETH-path helper reverts with nothing useful. We use swapExactTokensForTokens exclusively. Arc is also the reason private receiving works at all. A per-payment ERC-5564 stealth address elsewhere is money that cannot move without a gas top-up that deanonymises the recipient. On Arc the note pays its own way out, so we never sweep, and the consolidating transaction that would link every note back to one identity is never created. Circle Gateway sits on top: one unified USDC balance, spendable across sixteen Circle domains with no bridge and nothing to claim. Three hacks worth naming. (a) The transfer fee is not knowable up front — maxFee must be inside the signed intent, and Circle only prices it in answer to one. So we submit once with a deliberately low fee to READ the quote out of the rejection ("expected at least 0.0035, got 0.000001"), then re-sign at that exact price: one extra round trip, ~280ms, and an exact fee instead of a guess. (b) burnIntentExpirationHeight from /v1/info is the current height and is already stale by the time you have signed; we add a 1000-block buffer. (c) The Gateway API and the GatewayWallet contract lag in OPPOSITE directions — the contract sees a fresh deposit the block it mines but still counts an attested burn; the API is the reverse. Measured simultaneously they disagreed by $3.00 on $18.72, and both were right about different things. So the contract answers "what do I own", the API answers "what can I spend", and burns our own client issued are discounted locally until the contract catches up. Withdrawal readiness is never computed either: withdrawalDelay() is in the chain's own blocks, and on Arbitrum an in-contract block.number is an L1 approximation while eth_blockNumber is L2, so we eth_call the withdrawal and let it revert until the contract's own clock says yes. The Graph Two Graph products composed into one portfolio, over live data, because neither one covers this wallet alone. Token API for the major EVM mainnets — balances and transfers, eight networks. Our own subgraph for Arc, because the Token API rejects network=arc-testnet outright, Arc is Studio-only on the decentralized network (support level basic, issuanceRewards: false, no Substreams), and Arc has no Blockscout instance either. A subgraph is the only way to see Arc history at all, so we wrote one: two data sources (the native USDC emitter, and EURC as an ordinary 6dp ERC-20, which the native emitter does not mirror), an immutable Transfer entity keyed txHash-logIndex so the indexer can skip load-before-write, and a mutable Account entity carrying running sent/received/txCount totals so history questions do not have to page every transfer. Amounts are normalised to 6dp minor units in the handler regardless of which emitter produced them. Two field notes that cost us time: on the free tier a Token API limit above 10 does not clamp, it returns ZERO rows — so an activity feed asking for 25 renders as "no transactions" rather than erroring, the worst possible failure mode. We page at 10. And the server_-prefixed key authenticates with X-Api-Key, not Authorization: Bearer, contrary to the docs we were reading; the JWT does use Bearer. Both products are proxied through the hub's /index route rather than called from the app. EXPO_PUBLIC_* is inlined into the bundle at build time and recoverable from the app package, so a provider key there is handed to everyone who downloads the app and billed to us — ERC-7677 gives exactly this advice for paymaster keys and it applies unchanged here. The proxy forwards a fixed set of shapes with validated parameters; one that passed arbitrary paths through with our key attached would have moved the credential rather than protected it. ENS Every user gets name.mercurywallet.eth as a real ENS name. We deployed MercuryNameRegistry.sol to Sepolia (0xe6967ac719caa21ee46d62ceafa9969f96e9252d) and set it as the RESOLVER on our parent name, so under ENSv2 every subname beneath it arrives as resolve(name, data) via ENSIP-10 wildcard resolution — no per-name registry entry, no subregistry, records living in this contract's storage. One record holds the owner, the 0x address (coinType 60, which covers Arc, Base, Arbitrum and every other EVM chain at once from one secp256k1 key), a Solana address and a Bitcoin address as ENSIP-9 addr(node, coinType) records, and a preferred-chain hint that tells a Gateway send where to land. Lookups go through ENSv2's UpgradableUniversalResolverProxy (0xeEeE…EeEe, the same address on mainnet and Sepolia, so it needs no per-environment config), with DNS wire-format names encoded by hand. We implemented EIP-3668 CCIP-Read ourselves, including the x-batch-gateway:true pseudo-URL — the Universal Resolver offers its own hosted batch gateway, and we chose to unpack the batch and fetch each inner gateway ourselves instead. That removes a third party from the path of every lookup and stops leaking which names our users type into someone else's server. The deliberate trade: on-chain records cost one transaction per name, where offchain CCIP-Read names are free but put an HTTP service on the critical path of every lookup forever. We took the transaction, and then removed it from the user — the hub sponsors registration, so a zero-balance wallet can claim a name before it has ever received money. The sponsor is the payer and never the owner: the caller signs a message naming the label and every address in it, the registration is made out to the RECOVERED signer rather than to any address in the request body, and we only sponsor a name pointing at the signer's own address (paying to publish an address nobody proved they hold is how you fund a phishing campaign). One sponsored name per address, plus a daily ceiling, both bounded by an on-disk ETH-denominated spend ledger with a gas-price ceiling — counting operations does not bound cost. Honorable mention: Uniswap Uniswap V2 is what turns a non-USDC balance into a depositable one: if you hold something else on the chain you want to fund from, we quote and swap to USDC on that same chain first, then deposit. Same-chain by design — Circle credits a deposit made on any domain to the one balance, so there is never a reason to move funds across chains before depositing. The V2 router on Arc is also where the WETH()-with-no-code finding above came from; our FEEDBACK.md carries it. The rest of the hacky bits The app checks deliverability before it signs a burn, because a burn cannot be recalled once Circle accepts it. Destinations the relayer cannot pay out on are refused rather than offered, and the signed claim is written to disk before the relay is attempted so a dropped mint is retried rather than lost. Block-explorer history has a keyless Blockscout first path and an Etherscan V2 fallback — of 22 supported testnets only 5 had a reachable history source when we checked. The fallback learns which chains the key's plan covers from the upstream's own refusals ("Free API access is not supported for this chain") rather than hard-coding a free-tier list that is wrong for anyone who pays and stale the day Etherscan moves a chain between tiers. The token registry ships a curated seed that a downloaded list can never overwrite. Against CoinGecko's list the guard drops a "UNI" that is really UNICORN and an "ETH" that is really The Infinite Garden — an ERC-20 wearing the native coin's ticker. The recovery phrase lives under iOS's WHEN_PASSCODE_SET_THIS_DEVICE_ONLY with requireAuthentication, so it is excluded from every backup and device-to-device restore, cannot be written on a device with no passcode, and is DELETED if the passcode is removed. The biometric gate is the keychain refusing to return bytes, not a JavaScript if that can fail open. One hub process, not two: the mint relayer and the name sponsor spend the same key against the same ledger, with in-flight reservations held in memory so concurrent requests cannot each pass a check none of them could afford together. Two containers would mean two ledgers that cannot see each other. It runs as non-root with a read-only root filesystem, tini as PID 1, and /app/data as the only writable path.