Skip to content

feat(sdk-coin-xtz): enable Safe wallet recovery - #10071

Merged
pranishnepal merged 2 commits into
masterfrom
WCN-2883
Oct 27, 2026
Merged

pranishnepal merged 2 commits into
masterfrom
WCN-2883

Conversation

@pranishnepal

Copy link
Copy Markdown
Contributor

What

Additively extend Xtz.recover() to accept Safe-derived keys from buildSafeRecoverKeyParams (slot-1 secp256k1Multisig) in both signed and unsigned modes, and fail closed with RecoveryKeyMismatchError when the user, backup, or bitgo key is not an owner of the KT1 wallet contract. Legacy recovery stays byte-for-byte unchanged.

  • infer unsigned-sweep mode for Safe calls (bitgoKey-gated) so xpub Safe params no longer hit bitgo.decrypt
  • resolve raw xprv/hex/spsk keys directly; decrypt v1/v2 SJCL envelopes otherwise (consolidated resolveKeyPair)
  • verify contract ownership against the TZKT normalized /storage shape ({ stored_counter, threshold, keys }) before any fee-account balance check or signing, in both signed and unsigned modes
  • add bitgoKey to RecoverOptions and verify the bitgo key is an owner; reject malformed bitgo keys without echoing the value
  • bound and validate the explorer-provided owner list; fail closed on fetch or parse failures without chaining the apiKey-bearing cause
  • Safe E2E tests: signed, unsigned, raw-xprv, xpub-user, owner mismatch, provider failures, and the legacy no-storage path

TICKET: WCN-2883

@linear-code

linear-code Bot commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

WCN-2883

@github-actions

github-actions Bot commented Oct 26, 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 26, 2026 22:17
@pranishnepal
pranishnepal requested a review from a team as a code owner October 26, 2026 22:17

@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.

Additively extend Xtz.recover() to accept Safe-derived keys from
buildSafeRecoverKeyParams (slot-1 secp256k1Multisig) in both signed and
unsigned modes, and fail closed with RecoveryKeyMismatchError when the
user, backup, or bitgo key is not an owner of the KT1 wallet contract.
Legacy recovery stays byte-for-byte unchanged.

- infer unsigned-sweep mode for Safe calls (bitgoKey-gated) so xpub
  Safe params no longer hit bitgo.decrypt
- resolve raw xprv/hex/spsk keys directly; decrypt v1/v2 SJCL envelopes
  otherwise (consolidated resolveKeyPair)
- verify contract ownership against the TZKT normalized /storage shape
  ({ stored_counter, threshold, keys }) before any fee-account balance
  check or signing, in both signed and unsigned modes
- add bitgoKey to RecoverOptions and verify the bitgo key is an owner;
  reject malformed bitgo keys without echoing the value
- bound and validate the explorer-provided owner list; fail closed on
  fetch or parse failures without chaining the apiKey-bearing cause
- Safe E2E tests: signed, unsigned, raw-xprv, xpub-user, owner mismatch,
  provider failures, and the legacy no-storage path.

TICKET: WCN-2883
danielpeng1
danielpeng1 previously approved these changes Oct 27, 2026

@dpkjnr dpkjnr 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.

Reviewed commit 8d11d422f6d83a6ddd5f478ec100af0de4dfa916. I found two execution blockers in the newly enabled Safe path and one raw-key validation issue.

1. [P1] Signed Safe recovery contains no contract-owner signatures

Location: modules/sdk-coin-xtz/src/xtz.ts:535–544

The resolved user key is used only for ownership validation. Later, txBuilder.sign({ key: backupSigningKey }) signs only the outer operation because the backup address is also the transaction source.

I decoded the returned signed transaction: its contract signature list is [None, None, None]. A normal 2-of-3 wallet will reject it. The Safe path needs user and backup signatures over the contract payload, positioned according to the on-chain owner order, followed by the outer fee-account signature. Please extend the signed-recovery test to verify the contract signatures rather than only asserting that a transaction string exists.

2. [P1] Safe recovery discards the contract counter and substitutes the fee-account nonce

Location: modules/sdk-coin-xtz/src/xtz.ts:340–350

The new storage query retrieves stored_counter but retains only the owner keys. Both packDataToSign() and the contract transfer still use backupKeyNonce.

With storage counter 7 and fee-account counter 100, both signed and unsigned recovery produce contract counter 101, rather than 7. The contract rejects this independently of the missing signatures. Please retain and validate stored_counter, and use it for the contract payload while keeping the manager counter separate. A test with deliberately different counters would catch this.

Scope clarification: the first two defects are inherited from the legacy builder flow, not regressions in legacy recovery introduced by this PR. They nevertheless prevent the newly advertised Safe recovery from producing an executable transaction.

3. [P2] Raw private keys still require an unused passphrase

Location: modules/sdk-coin-xtz/src/xtz.ts:268–273

Although resolveKeyPair() now supports raw xprv/hex/spsk inputs, validation rejects them without walletPassphrase before reaching that resolver. I reproduced raw-xprv Safe recovery failing with missing wallet passphrase.

The new test masks this by supplying 'unused-passphrase'. Please require a passphrase only for inputs that need decryption, and test raw keys with the passphrase omitted.

Validation: all 130 XTZ unit tests passed on Node 24.22.1 using existing workspace dependencies. Additional checks decoded the signed and unsigned Safe transactions and reproduced the signature, counter, and raw-key validation issues described above. No transaction was broadcast.

Safe recovery now produces a transaction the 2-of-3 wallet contract will
accept, additively in the Safe branch (bitgoKey present) while the
legacy path stays byte-for-byte unchanged:

- sign the contract payload with the user and backup keys at their
  on-chain owner indexes (new TransactionBuilder.addContractSigner,
  which bypasses source routing so the fee-payer backup key can also
  emit a contract signature); the backup key still signs the outer
  source operation
- use the contract's stored_counter (from TZKT storage) for the contract
  payload in packDataToSign and the transfer, keeping the fee-account
  nonce as the source/manager counter; unsigned mode reports the split
  via sourceCounter/transferCounters
- return ownerKeys + storedCounter from extractContractStorage so the
  caller can place signatures and counters correctly
- require a private user key for signed Safe recovery (clear error for
  xpub-only input) instead of silently producing an invalid tx
- only require walletPassphrase when a key needs decryption, so raw
  xprv/hex/spsk keys work without one

Tests decode the recovered transaction and verify 2-of-2 contract
signatures at the right indexes, the contract counter (7) vs the manager
counter (101), and raw-key flows with no passphrase. 130 passing, legacy
fixtures byte-identical. All changes gated on the Safe marker; legacy
recovery unchanged.

TICKET: WCN-2883
@pranishnepal

Copy link
Copy Markdown
Contributor Author

Reviewed commit 8d11d422f6d83a6ddd5f478ec100af0de4dfa916. I found two execution blockers in the newly enabled Safe path and one raw-key validation issue.

1. [P1] Signed Safe recovery contains no contract-owner signatures

Location: modules/sdk-coin-xtz/src/xtz.ts:535–544

The resolved user key is used only for ownership validation. Later, txBuilder.sign({ key: backupSigningKey }) signs only the outer operation because the backup address is also the transaction source.

I decoded the returned signed transaction: its contract signature list is [None, None, None]. A normal 2-of-3 wallet will reject it. The Safe path needs user and backup signatures over the contract payload, positioned according to the on-chain owner order, followed by the outer fee-account signature. Please extend the signed-recovery test to verify the contract signatures rather than only asserting that a transaction string exists.

2. [P1] Safe recovery discards the contract counter and substitutes the fee-account nonce

Location: modules/sdk-coin-xtz/src/xtz.ts:340–350

The new storage query retrieves stored_counter but retains only the owner keys. Both packDataToSign() and the contract transfer still use backupKeyNonce.

With storage counter 7 and fee-account counter 100, both signed and unsigned recovery produce contract counter 101, rather than 7. The contract rejects this independently of the missing signatures. Please retain and validate stored_counter, and use it for the contract payload while keeping the manager counter separate. A test with deliberately different counters would catch this.

Scope clarification: the first two defects are inherited from the legacy builder flow, not regressions in legacy recovery introduced by this PR. They nevertheless prevent the newly advertised Safe recovery from producing an executable transaction.

3. [P2] Raw private keys still require an unused passphrase

Location: modules/sdk-coin-xtz/src/xtz.ts:268–273

Although resolveKeyPair() now supports raw xprv/hex/spsk inputs, validation rejects them without walletPassphrase before reaching that resolver. I reproduced raw-xprv Safe recovery failing with missing wallet passphrase.

The new test masks this by supplying 'unused-passphrase'. Please require a passphrase only for inputs that need decryption, and test raw keys with the passphrase omitted.

Validation: all 130 XTZ unit tests passed on Node 24.22.1 using existing workspace dependencies. Additional checks decoded the signed and unsigned Safe transactions and reproduced the signature, counter, and raw-key validation issues described above. No transaction was broadcast.

Addressed, thank you

@pranishnepal
pranishnepal merged commit 8fbb338 into master Oct 27, 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.

3 participants