Wrap & Unwrap the Native Coin¶
Trade ▸ Wrap turns any supported network's coin into its canonical wrapped ERC-20 form, and
back again. It is the plainest money-moving surface in the app, because the operation itself is
plain: a WETH9-shaped contract mints exactly what you send it on deposit() and burns exactly what
you name on withdraw(wad).
- View:
frontend/src/components/wallet/WrapView.jsx - Engine:
frontend/src/hooks/useWrapNative.js - Picker options + balances:
frontend/src/hooks/useWrapCoinOptions.js - Address resolution + candidate list:
frontend/src/config/wrappedNative.js - ABI:
frontend/src/abis/WNative.js(already used byDexContextfor its wrapped-balance read) - Tests:
frontend/src/test/wallet/{wrappedNative,useWrapNative,useWrapCoinOptions,WrapView}.test.*
The asset is the entry point (spec 108)¶
The view is no longer pinned to the connected chain. A trading-view-style picker
(UniversalAssetSelect, spec 064) lists every cohort chain's base coin that has a configured
wrapper — listWrappableCoins() lives beside the resolver in config/wrappedNative.js, so the file
that answers "what is the wrapped form here?" also owns "which coins are offered" — with the
member's balance beside each, read per chain through readBalancesSettled + getReadProvider with
failure isolation (an unreadable chain's row shows —, never 0, and stays selectable).
The network is a property of the selection, resolved at submit time per rail
(useWrapNative({ chainId }), default: the wallet's chain — every pre-108 caller is unchanged):
- Classic signer, other chain → switch-then-settle first (the spec-102 /
useEarnSenddevice: awaitedswitchNetwork, then a 150 ms poll on a render-updated snapshot until the chain-scoped signer exists, 20 s deadline). A refusal names BOTH chains and sends nothing. - Passkey → the batch is chain-targeted by parameter (
sendCalls(calls, { chainId })), offered only whereisPasskeySupported(target); elsewhere the rail is stated as unavailable BEFORE the tap, with the support seam's own reason. - Vault / legacy / hardware → unchanged refusals; the picker pins to the acting account's chain (multichain custody actions belong to spec 102, not this surface).
Changing the coin clears the amount — a MAX quoted against one chain's balance and gas reserve is never carried to another. Sponsorship, the reserve, and the fee line all follow the target.
E2E: fast/48-wrap-multi-currency.cy.js (picker honesty, refused switch) and
full/45-wrap-cross-chain.cy.js (real cross-chain deposit on the local chain).
Members need the wrapped form for anything that only accepts an ERC-20 — a DEX pair, a liquidity
position, a contract that takes transferFrom. Previously the app could show a member their
wrapped balance (it is in the portfolio registry) with no way to obtain or redeem one in-app.
It is not a swap and not a bridge¶
Nothing here has a price, a quote, a slippage tolerance, a counterparty, or an expiry — and the view must never grow controls that imply otherwise. The rate is 1:1 in both directions, permanently, and the entire cost is the network fee. That is why:
- the confirm block states
1:1 — no price, no slippagerather than showing a rate row that could be mistaken for a market; - there is no route, no protocol attribution and no venue link — the contract is the network's own;
- the only disclosed cost is the fee line, which reads sponsored only when the passkey UserOp
genuinely is (spec 050), and
You pay the <COIN> network feein every other case.
One resolver for "what is the wrapped coin here?"¶
config/wrappedNative.js#getWrappedNative(chainId) is the single seam. It reads, in order:
NETWORKS[chainId].dex.wnative— what the swap surface pairs against;getContractAddressForChain('wmatic', chainId)— the synced deployment record. Despite the key's name this holds WETC on Ethereum Classic and Mordor; it is the DEX wrapper the contracts were configured with, not a Polygon token.
getPortfolioRegistry (config/assetTaxonomy.js) used to re-derive the same answer from the same
two sources inline. It now calls this resolver, so a chain cannot be wrappable on one surface and
missing from the portfolio on the other.
There is deliberately no curated fallback table. Wrapping sends a member's coin to whatever
address this returns, so an address that is not already in the app's own verified configuration is a
guess — and a guess here is unrecoverable. A network without one resolves to null and the Wrap view
says exactly that (Constitution III). Sepolia, Hoodi and an unconfigured Amoy are in that state
today; adding one means adding the address to that network's config, where
scripts/ops/verify-protocol-addresses.js covers it, not to a table in this feature.
symbol/name from the resolver are derived labels for the first render only. The hook then
asks the contract what it calls itself and prefers that answer — Polygon's wrapper answers WPOL
while NETWORKS[137].nativeCurrency still says MATIC, and the member should see the token's own
name.
Routing mirrors useTransfer¶
"Who is acting" is the same question it is for a send, so the answer is the same shape:
| Acting as | What happens |
|---|---|
| Custody vault (spec 043) | A threshold-gated proposal (useActiveAccount().submit) — nothing moves until the signers approve. Button reads Propose. |
| Recovered legacy account (spec 062) | Signed by the unlocked in-memory key through the same seam, never by the connected wallet. |
| Passkey smart account (specs 041/050) | One sendCalls batch, sponsored where the chain runs a paymaster. |
| Classic wallet | A plain transaction; the sender pays the fee. |
Both the vault and legacy branches re-check the network before signing, and a passkey op that is
submitted but never included is reported as pending — a userOpHash is not a transaction hash,
no explorer resolves one, and it is never rendered as one.
Two honesty rules the tests pin¶
An unread balance is not a zero balance. A failed read leaves the balance null, the view
renders —, and it says the value could not be read. Rendering 0 would be a false claim about the
member's money.
MAX never spends the fee. Wrapping is paid for in the very coin being wrapped, so maxWrappable
is the balance less a gas reserve quoted from the chain's own getFeeData() (doubled against a fee
tick between quote and signature), and the view discloses the hold-back. Unwrapping has no such
reserve — the fee comes out of the native balance, which is not the balance being spent. An amount
above the reserve but within the balance gets its own message ("leaves nothing for the network fee"),
because "exceeds balance" would not be true.
Where Activity went¶
The Transfer section no longer has an Activity tab. Transfer history belongs to the activity ledger
(spec 051) and My Account ▸ Activity renders it in full, for every class of entry; a
transfer-only copy of the same feed inside this section only created two places to look for one
answer. The ?view=activity id is gone with it, so a saved link falls back to Transfer rather than
opening an empty panel.
Wrap results are reported in place (a notification plus an explorer link for the confirmed transaction) rather than written to the ledger — a wrap has no counterparty and no value change, so a ledger entry would be a transfer-shaped record of something that is not a transfer.