Skip to content

feat(frontend): add access to the imported wallet's signing canister - #13649

Draft
sbpublic wants to merge 3 commits into
feat/frontend/ic-seed-phrase-import-sweepfrom
feat/frontend/ic-seed-phrase-import-chains
Draft

feat(frontend): add access to the imported wallet's signing canister#13649
sbpublic wants to merge 3 commits into
feat/frontend/ic-seed-phrase-import-sweepfrom
feat/frontend/ic-seed-phrase-import-chains

Conversation

@sbpublic

@sbpublic sbpublic commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Motivation

PR1 displays what an imported wallet holds; PR2 moves the ICP and ICRC balances. The remaining chains — BTC, EVM, SOL — cannot be moved the same way, because their keys are not derived from the seed phrase at all.

They are chain-key (threshold) keys rooted in the source wallet's own helper canister. On the management canister, ecdsa_public_key and schnorr_public_key accept a canister_id argument, but sign_with_ecdsa and sign_with_schnorr do not — the key is derived from the caller. So those addresses are readable by anyone (which is how PR1 derives them offline) and signable only by that canister.

This PR adds the access layer for asking it to sign. No user-visible change; the per-chain send flows follow in their own PRs, one per chain.

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

Changes

  • Vendor the helper canister interface following the existing third-party pattern (xtc_ledger, icrc3, …): a build script produces the .did, dfx.json points at it, generate.sh runs it, and the bindings are generated rather than hand-written.
  • Add the canister wrapper and api layer for the four signing methods the remaining chains need: send_eth, send_erc20, send_btc, sign_sol.

Four decisions worth review attention:

  • The candid is fetched from the canister, not downloaded from a repo. Every other script here downloads from a public URL; this interface is published nowhere. The canister exposes it via __get_candid_interface_tmp_hack, which makes the deployed canister the only authoritative source — and fetching from it keeps the checked-in bindings honest about what is live, rather than a snapshot transcribed from the extension's minified bundle. The fetch fails the build if the reply does not declare a service, so a truncated response cannot silently produce bindings for an empty interface.
  • { Sign: null } everywhere, never Send. The canister will broadcast for us; we don't let it. Broadcasting ourselves means we see the transaction hash and the RPC error directly, and a third party stays off the critical path for everything but the signature.
  • Not routed through CanisterApi. That cache holds one actor per principal in a module-level map that is never evicted, which would pin the imported identity — and the key derived from the seed phrase — for the lifetime of the page, outliving both the reset button and the send itself. Sends are rare enough that building the actor per call costs nothing worth keeping a secret around for.
  • plug_helper added to the did.delete.types allowlist. That script deletes any generated folder not named in a hardcoded list; without the entry the bindings are removed at the end of the pipeline.

This touches dfx.json and scripts/, which AGENTS.md marks as restricted — done with explicit approval, and following the established pattern rather than inventing one.

Tests

New plug-helper.canister.spec.ts (9 cases): each method returns its payload, throws the canister's error text on Err, and — for eth and erc20 — is asserted to pass arguments in the canister's exact positional order and to request { Sign: null } rather than Send. The positional assertions are the valuable ones: the interface is all unnamed nat parameters, so a transposed gas and nonce would type-check and fail only on chain.

Local gates, all green:

Gate Result
npm run format clean
npm run lint -- --max-warnings 0 (whole repo) clean
npm run check 5891 files, 0 errors
npm run check:tests 7119 files, 0 errors
targeted vitest 9 passed

Regenerating exposed one wrinkle worth recording: did.update.types.mjs rewrites 15 unrelated *.factory.certified.did.js files with a cosmetic trailing comma, which the pipeline's final npm run format cleans up. Running the steps by hand without that final format would have committed 15 files of churn; the diff here touches none of them.

Not verified against the live canister. eth_address confirmed the canister accepts callers other than the source wallet, but no signing method has been called by an outside caller. That is the main risk in this stack and is tested in the EVM PR, which is the first to exercise a real signature.


🤖 Generated with Claude Code

sbpublic and others added 2 commits August 5, 2026 09:27
PR3 needs to ask the source wallet's helper canister to sign BTC / EVM /
SOL transactions, since those keys are threshold keys rooted in that
canister and cannot be derived from the seed phrase.

Follows the existing third-party-canister pattern (xtc_ledger, icrc3, …):
a build script produces the .did, dfx.json points at it, generate.sh runs
it, and the bindings are generated rather than hand-written.

One deviation, deliberate: every other script downloads the candid from a
public repository, and this interface is published nowhere. The deployed
canister exposes it via __get_candid_interface_tmp_hack, which makes the
canister the only authoritative source — and fetching from it keeps the
checked-in bindings honest about what is actually live, rather than a
snapshot transcribed from the extension's minified bundle.

The fetch fails the build if the reply does not declare a service, so a
truncated response cannot silently yield bindings for an empty interface.

Adds plug_helper to the did.delete.types allowlist; without it the
generated folder is removed at the end of the pipeline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wraps the four signing methods the remaining chains need — eth, erc20, btc
and sol — following the existing canister/api layering.

Every call passes `{ Sign: null }`. The canister also offers a `Send` mode
that broadcasts for us, deliberately unused: keeping broadcast on our side
means we see the transaction hash and the RPC error directly, and a third
party stays off the critical path for everything but the signature.

Not routed through CanisterApi. That cache holds one actor per principal in
a module-level map that is never evicted, which would pin the imported
identity — and the key derived from the seed phrase — for the lifetime of
the page, outliving both the reset button and the send itself. Sends are
rare enough that building the actor per call costs nothing worth keeping a
secret around for.

The canister reports failures as a plain text variant with no structured
error type, so the text is surfaced as-is rather than mapped.

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