Skip to content

feat(frontend): send imported EVM balances to the user's own wallet - #13650

Draft
sbpublic wants to merge 5 commits into
feat/frontend/ic-seed-phrase-import-chainsfrom
feat/frontend/ic-seed-phrase-import-evm
Draft

feat(frontend): send imported EVM balances to the user's own wallet#13650
sbpublic wants to merge 5 commits into
feat/frontend/ic-seed-phrase-import-chainsfrom
feat/frontend/ic-seed-phrase-import-evm

Conversation

@sbpublic

@sbpublic sbpublic commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Motivation

PR2 moves the ICP and ICRC balances of an imported wallet. This does the same for Ethereum and the other EVM networks — the first chain where OISY cannot sign for itself.

Those addresses are threshold keys rooted in the source wallet's own helper canister. On the management canister, ecdsa_public_key takes a canister_id argument but sign_with_ecdsa does not — it derives from the caller. So the addresses are readable by anyone (which is how PR1 derives them offline) and signable only by that canister. We ask it for a signature and broadcast the result ourselves.

Stacked on #13649; review that first. This PR targets its branch.

Note

Draft, and this is the first PR in the stack to request a real signature from that canister. eth_address confirmed it accepts outside callers, but no signing method has been exercised. Worth one small real send before this merges — that single test retires the main risk for the BTC and SOL PRs too.

Changes

  • Add the EVM send path: fee data, nonce, signature from the helper canister, broadcast through OISY's provider.
  • Route EVM rows on the import page to it, with a reason shown when a row cannot move.
  • Update PRODUCT.md and the spec; the PR3-approach decision is now marked resolved.
  • After a real-wallet test: handle ERC-4626 vault tokens. They were reported showing the account's ETH balance in every vault row (the balance dispatch had no erc4626 branch, so vaults fell through to native, formatted with each vault's own decimals). ERC-4626 is a superset of ERC-20, so the fix treats them alike via one isPlugEvmContractToken predicate — read via balanceOf, send via send_erc20, gate on native gas. getErc20FeeData is widened to accept either shape (it only reads address/symbol/name).

Gas shapes the whole thing. It is paid in the network's native coin out of the imported account:

  • A native send can only move balance - gas. Sending the full balance always fails.
  • A token send needs native coin sitting in that same account — a common gap for someone who only ever received tokens. The row is blocked with the coin named, not just greyed out.
  • Both are enforced in the send path against live fee data, not trusted from the UI, because the fee moves between the moment a row renders and the moment the user confirms.
  • An absent fee ceiling stops the send rather than defaulting: signing a transaction whose gas price is unknown risks spending the whole balance on gas.

Reuse over rebuild: fee data comes from getEthFeeDataWithProvider, which already applies the per-chain floors (BSC minimums) — reading getFeeData raw would have silently dropped those. The nonce comes from OISY's own provider rather than the canister's transaction_count, keeping a third party off the path for everything but the signature.

Two bugs the EVM case exposed in the rows PR2 shipped, fixed here:

  • A row's identity is network plus symbol. Symbol alone is not unique and neither is the address — the same token enabled on several EVM networks yields rows sharing both. Keying the in-flight send on symbol would have spun two rows at once. There is a regression test for it.
  • The confirmation copy was wrong for tokens. It said the fee is deducted "from this token", true for ICRC and native EVM but not for an ERC20, where gas is paid in the native coin. Token transfers now name that coin.

Also: the EVM destination is the user's own OISY EVM address, not their principal — unlike the IC sweep, where the principal is the destination.

Tests

  • plug-evm.services.spec.ts (7 cases): gas is reserved out of a native amount; the signed transaction is broadcast and its hash returned; a balance that cannot cover its own gas is refused without signing; an absent fee ceiling refuses without signing; a token sends its full balance; a token with no native coin is refused without signing; and a token never takes the native signing method.
  • PlugImportAccount.spec.ts (14 cases, up from 6): native EVM and funded-ERC20 rows offer an action; an ERC20 with no gas is blocked and names the coin; plus the cross-network symbol-collision regression.

The "without signing" assertions are the ones worth having — a refusal that still asked the canister to sign would burn its cycles and could leave a signed transaction in flight.

Local gates, all green:

Gate Result
npm run format clean
npm run lint -- --max-warnings 0 (whole repo) clean
npm run check 5892 files, 0 errors
npm run check:tests 7121 files, 0 errors
targeted vitest 82 passed (whole feature)

Not run locally: the full suite (MenuThemeSelector fails 8 cases on unmodified main — Node's localStorage clobbers jsdom) and test-coverage. compare-sizes will fail as it does across this stack; the increase is known and accepted.


🤖 Generated with Claude Code

sbpublic and others added 5 commits August 5, 2026 09:53
The signature comes from the source wallet's helper canister — those keys
are threshold keys it alone can sign for — and OISY broadcasts the result
through its own provider, so we see the transaction hash and any RPC error
directly.

Gas is paid in the network's native coin out of the *imported* account,
which shapes everything here. A native send can only move balance minus
gas; sending the full balance always fails. A token send needs native coin
sitting in that same account, a common gap for someone who only ever
received tokens.

Both are enforced here against live fee data rather than trusted from the
caller, because the fee moves between the moment a row renders and the
moment the user confirms. An absent fee ceiling stops the send rather than
defaulting: signing a transaction whose gas price is unknown risks spending
the whole balance on gas.

Fee data comes from getEthFeeDataWithProvider, which already applies the
per-chain floors, and the nonce from OISY's own provider rather than the
canister's transaction_count — keeping a third party off the path for
everything but the signature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Routes EVM rows to the new send path and reports why a blocked one cannot
move. An unsupported chain, a missing gas balance and a balance below its
own fee are three different problems with three different remedies, so each
says so rather than sharing a greyed-out button.

Fixes two things the EVM case exposed in the existing rows:

A row's identity is network plus symbol. Symbol alone is not unique and
neither is the address — the same token enabled on several EVM networks
yields rows sharing both — so keying the in-flight send on symbol would
have spun two rows at once.

The confirmation said the fee is deducted "from this token", which is true
for ICRC and native EVM but wrong for an ERC20, where gas is paid in the
network's native coin. Token transfers now name that coin instead.

The EVM destination is the user's own OISY EVM address, not their principal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marks the approach decision resolved — sign at the helper canister,
broadcast from OISY — and records why the reverse is impossible: the
management canister's public-key methods take a canister_id while the
signing methods derive from the caller, so these addresses are readable by
anyone and signable only by that canister.

Also notes the gas arithmetic, that a row's identity is network plus
symbol, and that BTC and SOL remain send-from-source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from a real wallet: three vault tokens on Base showed identical-
looking balances that were actually the account's ETH balance, formatted
with each vault's own decimals (bAutopilot_USDC at 8 decimals and
bAutopilot_wETH at 20 both render the same 0.0002 ETH as 2000000 and
0.000002).

Cause: the balance dispatch had a branch for erc20 and then a catch-all
native-EVM branch, but none for erc4626, so every vault row fell through
to native and reported ETH.

ERC-4626 is a superset of ERC-20 — the vault share implements balanceOf
and transfer on its own contract — so the fix is to treat the two alike
everywhere via a single isPlugEvmContractToken predicate: read via
balanceOf, send via send_erc20, gate on native gas, and never count a
vault as the native coin. getErc20FeeData is widened to accept either
shape; it only reads address/symbol/name, which both provide.

Co-Authored-By: Claude Opus 5 <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