Skip to content

feat: enable Safe recovery for ALGO - #10012

Merged
pranishnepal merged 1 commit into
masterfrom
WCN-2898
Oct 22, 2026
Merged

pranishnepal merged 1 commit into
masterfrom
WCN-2898

Conversation

@pranishnepal

@pranishnepal pranishnepal commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

What

Add the missing mapper arm so Safe ALGO wallets recover end-to-end:

  • sdk-lib-safes: add algoMultisigAddress to compute the 2-of-3 Algorand multisig address from raw child pubs.
  • sdk-core: add the ed25519Multisig arm to buildSafeRecoverKeyParams, producing ALGO-encoded keys + rootAddress and, for signed mode, a lazy externalBackupSigner closure over the seedless backup child's (kL, kR). The request type is now a discriminated union on slot.
  • sdk-core: move the lazy-signer shape to ExternalEd25519Signer, living next to ExternalEd25519Signature, with a shared assertValidExternalEd25519Signer and a signWithExternalEd25519Signer helper that fails fast unless the signature is the required 64 bytes.
  • sdk-coin-algo: Algo.recover() takes only externalBackupSigner (the pre-made externalBackupSignature hook is gone), signs the tx bytes lazily, and appends the backup signature; the seed path is unchanged.

Why recover() changes

The backup child is a soft BIP32-Ed25519 key with no seed, and its signature must cover tx.bytesToSign(), which depends on the on-chain balance and firstRound — both fetched from the node inside recover(). The keycard → buildSafeRecoverKeyParams mapper runs fully offline, before any of that exists, so a pre-made signature cannot be produced at map time. Hence the lazy signer: the mapper hands recover() a closure over the private extended-key halves, and recover() invokes it once the transaction is built.

XLM and HBAR hit the same wall (their signatures cover Horizon sequence/balance/reserve/fee and bodyBytes/startTime respectively); they still use the pre-made-signature hook and will cut over to ExternalEd25519Signer in their own tickets.

Ticket: WCN-2898

@pranishnepal
pranishnepal force-pushed the WCN-2898 branch 3 times, most recently from 8d5d10d to b851c51 Compare October 19, 2026 22:07
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ Unit tests are failing on Node 26.x (Current release line, non-blocking). This is not an LTS version yet, so it does not block merge, but it signals an incompatibility to fix before Node 26.x becomes LTS.

View run

@pranishnepal
pranishnepal marked this pull request as ready for review October 19, 2026 22:37
@pranishnepal
pranishnepal requested review from a team as code owners October 19, 2026 22:37
s84krish
s84krish previously approved these changes Oct 20, 2026

@davidkaplanbitgo davidkaplanbitgo left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the lazy signer is the right call. externalBackupSignature from #9984 can't work for the Safe flow, because the sig has to cover bytesToSign() and that depends on the balance and firstRound, which recover() fetches from the node. the mapper runs offline before any of that exists. can you add that to the description? right now it reads like scope creep, since WCN-2898 doesn't mention touching recover().

my main concern is that this leaves two hooks that do the same job. a pre-made signature is just a signer that ignores its input. and hbar and xlm will hit the same wall:

  • hbar signs bodyBytes, which depend on the balance and on startTime (defaults to now at build time)
  • xlm signs tx.hash(), which depends on the horizon sequence, balance, reserve and base fee

so externalBackupSignature has no real caller on the safe path in any coin, and we'd end up with three copies of this validate/sign/inject block.

suggestion, keeping it scoped to this PR:

  1. move the signer type into sdk-core next to ExternalEd25519Signature, with a shared assertValidExternalEd25519Signer and a small helper that calls sign() and checks for 64 bytes. replaces SafeBackupExternalSigner in safe/recovery.ts.
  2. use that in algo, and drop externalBackupSignature from algo (clean cutover instead of two hooks).
  3. leave hbar/xlm on the old hook for now. their tickets can cut over with ~15 lines each.

the shared validator also fixes the pub check in algo: Buffer.from(pub, 'hex').length === 32 accepts 'f1'.repeat(32) + 'zz' because node stops at the first bad char. the existing assertValidExternalEd25519Signature already uses a strict regex, so reuse that.

@pranishnepal
pranishnepal marked this pull request as draft October 22, 2026 13:12
@pranishnepal
pranishnepal force-pushed the WCN-2898 branch 3 times, most recently from 94b84b8 to a57bae5 Compare October 22, 2026 14:52
Comment thread modules/sdk-core/test/unit/bitgo/safe/recovery.ts Outdated
Comment thread modules/sdk-core/src/bitgo/safe/recovery.ts Outdated
@pranishnepal

Copy link
Copy Markdown
Contributor Author

the lazy signer is the right call. externalBackupSignature from #9984 can't work for the Safe flow, because the sig has to cover bytesToSign() and that depends on the balance and firstRound, which recover() fetches from the node. the mapper runs offline before any of that exists. can you add that to the description? right now it reads like scope creep, since WCN-2898 doesn't mention touching recover().

my main concern is that this leaves two hooks that do the same job. a pre-made signature is just a signer that ignores its input. and hbar and xlm will hit the same wall:

  • hbar signs bodyBytes, which depend on the balance and on startTime (defaults to now at build time)
  • xlm signs tx.hash(), which depends on the horizon sequence, balance, reserve and base fee

so externalBackupSignature has no real caller on the safe path in any coin, and we'd end up with three copies of this validate/sign/inject block.

suggestion, keeping it scoped to this PR:

  1. move the signer type into sdk-core next to ExternalEd25519Signature, with a shared assertValidExternalEd25519Signer and a small helper that calls sign() and checks for 64 bytes. replaces SafeBackupExternalSigner in safe/recovery.ts.
  2. use that in algo, and drop externalBackupSignature from algo (clean cutover instead of two hooks).
  3. leave hbar/xlm on the old hook for now. their tickets can cut over with ~15 lines each.

the shared validator also fixes the pub check in algo: Buffer.from(pub, 'hex').length === 32 accepts 'f1'.repeat(32) + 'zz' because node stops at the first bad char. the existing assertValidExternalEd25519Signature already uses a strict regex, so reuse that.

Makes sense - addressed and made a ticket to migrate the 2 coins: https://linear.app/bitgo/issue/WCN-3754

Add the missing mapper arm so Safe ALGO wallets recover end-to-end:

- sdk-lib-safes: add `algoMultisigAddress` to compute the 2-of-3
  Algorand multisig address from raw child pubs.
- sdk-core: add the `ed25519Multisig` arm to `buildSafeRecoverKeyParams`
  producing ALGO-encoded keys + `rootAddress` and, for signed mode, a
  lazy `externalBackupSigner` closure over the seedless backup child's
  (kL, kR). The request type is now a discriminated union on `slot`.
- sdk-core: move the lazy-signer shape to `ExternalEd25519Signer`,living
  next to `ExternalEd25519Signature`, with a shared
  `assertValidExternalEd25519Signer` and `signWithExternalEd25519Signer`
  helper that fails fast unless the signature is the required 64 bytes &
  verifies against the advertised public key.
- sdk-coin-algo: `Algo.recover()` takes only `externalBackupSigner` (the
  pre-made `externalBackupSignature` hook is gone), signs the tx bytes
  lazily, and appends the backup signature; the seed path is unchanged

Why `recover()` changes: the backup child is a soft BIP32-Ed25519 key
with no seed, and its signature must cover `tx.bytesToSign()`, which
depends on the on-chain balance and `firstRound` — both fetched from the
node inside `recover()`. The `buildSafeRecoverKeyParams` mapper
runs fully offline, before any of that exists, so a pre-made signature
cannot be produced at map time. Hence the lazy signer: the mapper hands
`recover()` a closure over the private extended-key halves, and
`recover()` invokes it once the transaction is built.

XLM and HBAR hit the same wall (their signatures cover Horizon
sequence/balance/reserve/fee and bodyBytes/startTime respectively); they
still use the pre-made-signature hook and will cut over to
`ExternalEd25519Signer` in their own tickets.

Ticket: WCN-2898
@pranishnepal
pranishnepal marked this pull request as ready for review October 22, 2026 15:37

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@davidkaplanbitgo

Copy link
Copy Markdown
Contributor

@claude were all of the comments resolved?

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

1 similar comment
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

@pranishnepal
pranishnepal merged commit 406d041 into master Oct 22, 2026
26 checks passed
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.

5 participants