diff --git a/modules/sdk-coin-xtz/src/lib/iface.ts b/modules/sdk-coin-xtz/src/lib/iface.ts index 8682a682b3f..5901d938522 100644 --- a/modules/sdk-coin-xtz/src/lib/iface.ts +++ b/modules/sdk-coin-xtz/src/lib/iface.ts @@ -140,6 +140,7 @@ export interface IndexedSignature extends IndexedData { export type RecoverOptions = { userKey: string; backupKey: string; + bitgoKey?: string; walletPassphrase?: string; walletContractAddress: string; // use this as walletBaseAddress for TSS recoveryDestination: string; diff --git a/modules/sdk-coin-xtz/src/lib/multisigUtils.ts b/modules/sdk-coin-xtz/src/lib/multisigUtils.ts index f069bb4e923..d8d2631893b 100644 --- a/modules/sdk-coin-xtz/src/lib/multisigUtils.ts +++ b/modules/sdk-coin-xtz/src/lib/multisigUtils.ts @@ -1,6 +1,13 @@ import * as _ from 'lodash'; import { IndexedSignature, OriginationData, OriginationOp, RevealOp, TransactionOp, TransferData } from './iface'; -import { DEFAULT_FEE, DEFAULT_GAS_LIMIT, DEFAULT_STORAGE_LIMIT, hashTypes, isValidKey } from './utils'; +import { + DEFAULT_FEE, + DEFAULT_GAS_LIMIT, + DEFAULT_STORAGE_LIMIT, + hashTypes, + isValidKey, + isValidPublicKey, +} from './utils'; // Default n of m for multisig wallets const DEFAULT_N = 2; @@ -526,6 +533,55 @@ export function getOwnersPublicKeys(operation: OriginationOp): string[] { return ownersArgs.map((o) => o.string); } +/** + * Upper bound on the number of owners we will accept from contract storage. The BitGo generic + * multisig wallet contract has exactly three owners; the bound guards against a compromised or + * misbehaving explorer returning an unbounded list. + */ +const MAX_CONTRACT_OWNERS = 100; + +export interface ContractStorage { + ownerKeys: string[]; + storedCounter: string; +} + +/** + * Extract the owner list and stored counter from the TZKT normalized storage returned by + * `/v1/contracts/{address}/storage`. TZKT returns storage keyed by its Michelson field + * annotations, so the generic multisig storage type + * `pair (nat %stored_counter) (pair (nat %threshold) (list %keys key))` yields a flat + * `{ stored_counter, threshold, keys: ["sppk…", …] }` object whose `keys` array holds base58 + * public key strings and `stored_counter` the contract's next counter. Returns undefined for + * any shape that does not match, so the caller fails closed instead of guessing. + * + * @param storage the normalized storage JSON from TZKT + * @returns the owner public keys and stored counter, or undefined if the shape is unexpected + */ +export function extractContractStorage(storage: unknown): ContractStorage | undefined { + if (!storage || typeof storage !== 'object' || Array.isArray(storage)) { + return undefined; + } + if (!('keys' in storage) || !('stored_counter' in storage)) { + return undefined; + } + const keys = storage.keys; + if (!Array.isArray(keys) || keys.length === 0 || keys.length > MAX_CONTRACT_OWNERS) { + return undefined; + } + const ownerKeys: string[] = []; + for (const key of keys) { + if (typeof key !== 'string' || !isValidPublicKey(key)) { + return undefined; + } + ownerKeys.push(key); + } + const storedCounter = storage.stored_counter; + if (typeof storedCounter !== 'string' || !/^\d+$/.test(storedCounter)) { + return undefined; + } + return { ownerKeys, storedCounter }; +} + /** * Generic Multisig contract from https://github.com/murbard/smart-contracts/blob/master/multisig/michelson/generic.tz */ diff --git a/modules/sdk-coin-xtz/src/lib/transactionBuilder.ts b/modules/sdk-coin-xtz/src/lib/transactionBuilder.ts index 25913383f96..432e4551826 100644 --- a/modules/sdk-coin-xtz/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-xtz/src/lib/transactionBuilder.ts @@ -139,6 +139,25 @@ export class TransactionBuilder extends BaseTransactionBuilder { return this.transaction; } + /** + * Register a key pair to sign the multisig contract payload at a given owner index. + * Unlike sign(), this registers contract signers directly and does not route by source + * address, so the backup key (which is also the fee payer / transaction source) can emit a + * contract signature. Used by Safe recovery, where the 2-of-3 contract requires owner + * signatures indexed per the on-chain storage keys. + * + * @param {KeyPair} keyPair - the owner key pair to sign with + * @param {number} index - the owner index in the contract storage keys list + */ + addContractSigner(keyPair: KeyPair, index: number): void { + if (index >= DEFAULT_M) { + throw new BuildTransactionError( + 'Custom index cannot be greater than the wallet total number of signers (owners)' + ); + } + this._multisigSignerKeyPairs.push({ key: keyPair, index }); + } + /** @inheritdoc */ protected async buildImplementation(): Promise { // If the from() method was called, use the serialized transaction as a base diff --git a/modules/sdk-coin-xtz/src/xtz.ts b/modules/sdk-coin-xtz/src/xtz.ts index 17f16c59232..05562157c66 100644 --- a/modules/sdk-coin-xtz/src/xtz.ts +++ b/modules/sdk-coin-xtz/src/xtz.ts @@ -6,6 +6,7 @@ import { MethodNotImplementedError, ParsedTransaction, ParseTransactionOptions, + RecoveryKeyMismatchError, RecoveryProviderError, KeyPair as SdkCoreKeyPair, SignedTransaction, @@ -16,12 +17,17 @@ import { multisigTypes, AuditDecryptedKeyParams, common, + getIsUnsignedSweep, + isValidPrv, + isValidXprv, + isValidXpub, TransactionType, } from '@bitgo/sdk-core'; import { bip32 } from '@bitgo/secp256k1'; import { CoinFamily, coins, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; import BigNumber from 'bignumber.js'; import { Interface, KeyPair, TransactionBuilder, Utils } from './lib'; +import { extractContractStorage, type ContractStorage } from './lib/multisigUtils'; import { RecoverOptions } from './lib/iface'; import { generateDataToSign, @@ -247,9 +253,10 @@ export class Xtz extends BaseCoin { /** * Method to validate recovery params * @param {RecoverOptions} params + * @param {boolean} isUnsignedSweep whether the caller is building an unsigned sweep * @returns {void} */ - validateRecoveryParams(params: RecoverOptions): void { + validateRecoveryParams(params: RecoverOptions, isUnsignedSweep: boolean): void { if (params.userKey === undefined) { throw new Error('missing userKey'); } @@ -258,7 +265,17 @@ export class Xtz extends BaseCoin { throw new Error('missing backupKey'); } - if (!params.isUnsignedSweep && params.walletPassphrase === undefined && !params.userKey.startsWith('xpub')) { + // A passphrase is only required for keys that need decryption. Raw xprv/hex/spsk private keys + // and xpub public keys are used as-is; only v1/v2 encrypted envelopes need the passphrase. + const requiresPassphrase = (key: string): boolean => { + const cleaned = key.replace(/\s/g, ''); + return ( + !cleaned.startsWith('xpub') && + !(isValidXprv(cleaned) || isValidPrv(cleaned) || Utils.isValidKey(cleaned, Utils.hashTypes.spsk)) + ); + }; + const keysToResolve = [params.userKey, params.backupKey].filter((key): key is string => key !== undefined); + if (!isUnsignedSweep && params.walletPassphrase === undefined && keysToResolve.some(requiresPassphrase)) { throw new Error('missing wallet passphrase'); } @@ -271,6 +288,94 @@ export class Xtz extends BaseCoin { } } + /** + * Resolve a recovery key into a KeyPair. Mirrors exactly the formats `KeyPair.recordKeysFromPrivateKey` + * accepts (keyPair.ts:56-67): an xpub in unsigned mode, a raw xprv / hex / spsk private key directly, + * or a decrypted v1/v2 SJCL envelope. Consolidates the user/backup key resolution so the raw-vs-decrypt + * decision (and its error wording) lives in exactly one place. + * + * @param {string} key the (whitespace-cleaned) recovery key + * @param {string} walletPassphrase the wallet passphrase for encrypted envelopes + * @param {boolean} isUnsignedSweep whether this is an unsigned sweep (pub-backed keys) + * @param {string} descriptor 'user' or 'backup', for error messages + * @returns {Promise} + */ + private async resolveKeyPair( + key: string, + walletPassphrase: string | undefined, + isUnsignedSweep: boolean, + descriptor: string + ): Promise { + if (isUnsignedSweep) { + return new KeyPair({ pub: key }); + } + if (isValidXprv(key) || isValidPrv(key) || Utils.isValidKey(key, Utils.hashTypes.spsk)) { + return new KeyPair({ prv: key }); + } + let prv; + try { + prv = await this.bitgo.decrypt({ input: key, password: walletPassphrase }); + } catch (e) { + throw new Error(`Error decrypting ${descriptor} keychain: ${e.message}`); + } + return new KeyPair({ prv }); + } + + /** + * Fails closed with RecoveryKeyMismatchError when any of the user, backup, or bitgo keys is not + * an owner of the wallet contract (KT1), then returns the contract storage (owner order and + * stored counter) the caller needs to build a chain-valid contract transfer. Runs before the + * fee-account balance check or any signing step so Safe per-address scanning can distinguish + * "these keys do not control this address" from "nothing to recover". Safe recovery only: + * legacy callers (no bitgoKey) bypass by design. + * + * @param {string} walletContractAddress the KT1 wallet contract to verify against + * @param {KeyPair} userKeyPair resolved user key pair + * @param {KeyPair} backupKeyPair resolved backup key pair + * @param {string} bitgoKey the BitGo xpub from the Safe key params + * @param {string} apiKey optional explorer API key + * @returns {Promise} the owner keys (in on-chain order) and stored counter + */ + private async assertRecoveryKeysAreContractOwners( + walletContractAddress: string, + userKeyPair: KeyPair, + backupKeyPair: KeyPair, + bitgoKey: string, + apiKey?: string + ): Promise { + let storage; + try { + storage = await this.recoveryBlockchainExplorerQuery( + { actionPath: 'contracts', address: walletContractAddress, action: 'storage', timeout: 30000 }, + apiKey + ); + } catch { + // Static message, no raw cause: the underlying explorer error embeds the request URL, + // which can include the caller's apiKey. + throw new RecoveryProviderError(`unable to fetch contract storage for ${walletContractAddress}`); + } + + const contractStorage = extractContractStorage(storage); + if (!contractStorage) { + throw new RecoveryProviderError(`unable to parse contract storage for ${walletContractAddress}`); + } + + const ownerKeyPairs = [ + { name: 'user', keyPair: userKeyPair }, + { name: 'backup', keyPair: backupKeyPair }, + { name: 'bitgo', keyPair: new KeyPair({ pub: bitgoKey }) }, + ] as const; + for (const { name, keyPair } of ownerKeyPairs) { + const pub = keyPair.getKeys().pub; + if (!contractStorage.ownerKeys.includes(pub)) { + throw new RecoveryKeyMismatchError( + `${name} key ${pub} is missing from the wallet contract owners of ${walletContractAddress}` + ); + } + } + return contractStorage; + } + /** * Make a query to blockchain explorer for information such as balance, token balance, solidity calls * @param query {Object} key-value pairs of parameters to append after /api @@ -282,16 +387,20 @@ export class Xtz extends BaseCoin { actionPath: string; address?: string; action?: string; + /** Optional superagent response timeout in ms. Omitted for legacy recovery calls so the + * legacy path (and its existing test stub on request.get) is untouched. */ + timeout?: number; }, apiKey?: string ): Promise { let response; try { - response = await request.get( + const req = request.get( `${common.Environments[this.bitgo.getEnv()].xtzExplorerBaseUrl}/v1/${params.actionPath}${ params.address ? '/' + params.address : '' }${params.action ? '/' + params.action : ''}${apiKey ? `?apikey=${apiKey}` : ''}` ); + response = await (params.timeout ? req.timeout({ response: params.timeout }) : req); } catch (e) { throw new RecoveryProviderError('could not reach TZKT', e); } @@ -393,11 +502,25 @@ export class Xtz extends BaseCoin { * @param params */ async recover(params: RecoverOptions): Promise { - const isUnsignedSweep = params.isUnsignedSweep; - this.validateRecoveryParams(params); + // Clean up whitespace before inference/validation so a whitespace-padded xpub is still + // detected as an unsigned sweep. Fall back to '' so a missing key surfaces as the documented + // 'missing userKey'/'missing backupKey' Error from validateRecoveryParams, not a TypeError. + const userKey = (params.userKey ?? '').replace(/\s/g, ''); + const backupKey = (params.backupKey ?? '').replace(/\s/g, ''); + + // Safe key params (from buildSafeRecoverKeyParams) always carry bitgoKey and omit the + // isUnsignedSweep flag; legacy callers never pass bitgoKey. Infer unsigned mode for Safe + // calls only, so accidental xpub/xprv inputs to legacy recovery keep failing loudly as before. + const isUnsignedSweep = Boolean( + params.isUnsignedSweep ?? (params.bitgoKey !== undefined && getIsUnsignedSweep({ userKey, backupKey })) + ); + this.validateRecoveryParams(params, isUnsignedSweep); - // Clean up whitespace from entered values - const backupKey = params.backupKey.replace(/\s/g, ''); + // Validate the Safe marker before any network call so a malformed (e.g. private) bitgoKey is + // rejected with no explorer round-trip and without echoing its value. + if (params.bitgoKey !== undefined && !isValidXpub(params.bitgoKey)) { + throw new Error('invalid bitgo key for Safe recovery'); + } const userAddressDetails = await this.getAddressDetails(params.walletContractAddress, params.apiKey); @@ -405,27 +528,34 @@ export class Xtz extends BaseCoin { throw new Error('Unable to fetch user address details'); } - // Decrypt backup private key and get address - let backupPrv; - let keyPair; - let backupSigningKey; - - if (isUnsignedSweep) { - keyPair = new KeyPair({ pub: backupKey }); - } else { - try { - backupPrv = await this.bitgo.decrypt({ - input: backupKey, - password: params.walletPassphrase, - }); - } catch (e) { - throw new Error(`Error decrypting backup keychain: ${e.message}`); - } - keyPair = new KeyPair({ prv: backupPrv }); - backupSigningKey = keyPair.getKeys().prv; - if (!backupSigningKey) { - throw new Error('no private key'); + // Resolve the backup key into a KeyPair and, in signed mode, its signing private key + const keyPair = await this.resolveKeyPair(backupKey, params.walletPassphrase, isUnsignedSweep, 'backup'); + const backupSigningKey = keyPair.getKeys().prv; + if (!isUnsignedSweep && !backupSigningKey) { + throw new Error('no private key'); + } + + // Safe recovery (slot-1): the derived keys must be owners of the wallet contract. Gated on + // bitgoKey, which buildSafeRecoverKeyParams always emits and legacy calls never pass, so + // legacy recovery makes no additional network calls and stays byte-for-byte unchanged. + let safeUserKeyPair: KeyPair | undefined; + let safeContractStorage: ContractStorage | undefined; + if (params.bitgoKey !== undefined) { + // The user key may be an xpub/sppk in unsigned Safe params, or a public key passed directly + // in signed mode; only resolve/decrypt private formats (xprv/hex/spsk/envelope). + safeUserKeyPair = Utils.isValidPublicKey(userKey) + ? new KeyPair({ pub: userKey }) + : await this.resolveKeyPair(userKey, params.walletPassphrase, false, 'user'); + if (!isUnsignedSweep && !safeUserKeyPair.getKeys().prv) { + throw new Error('user key is a public key; a private user key is required for a signed Safe recovery'); } + safeContractStorage = await this.assertRecoveryKeysAreContractOwners( + params.walletContractAddress, + safeUserKeyPair, + keyPair, + params.bitgoKey, + params.apiKey + ); } const backupKeyAddress = keyPair.getAddress(); @@ -497,9 +627,14 @@ export class Xtz extends BaseCoin { txBuilder.counter(backupKeyNonce.toString()); + // Safe recovery builds the contract payload with the contract's actual stored counter, so the + // transfer is accepted on-chain. Legacy recovery keeps the fee-account nonce in the payload + // exactly as before. + const contractCounter = safeContractStorage ? safeContractStorage.storedCounter : backupKeyNonce.toString(); + const packedDataToSign = await this.packDataToSign( params.walletContractAddress, - backupKeyNonce.toString(), + contractCounter, params.recoveryDestination, txAmount?.toString() ); @@ -508,7 +643,7 @@ export class Xtz extends BaseCoin { .transfer(txAmount?.toString()) .from(params.walletContractAddress) .to(params.recoveryDestination) - .counter(backupKeyNonce.toString()) + .counter(contractCounter) .fee(TRANSACTION_FEE.TRANSFER.toString()) .storageLimit(TRANSACTION_STORAGE_LIMIT.TRANSFER.toString()) .gasLimit(gasLimit.toString()) @@ -525,10 +660,25 @@ export class Xtz extends BaseCoin { dataToSign: packedDataToSign, feeInfo, sourceCounter: backupKeyNonce.toString(), - transferCounters: [backupKeyNonce.toString()], + transferCounters: safeContractStorage ? [contractCounter] : [backupKeyNonce.toString()], }; } + if (safeContractStorage && safeUserKeyPair) { + // Place the user and backup contract signatures at their on-chain owner indexes so the + // 2-of-3 contract accepts the transfer. The backup key additionally signs the outer + // operation as the fee-payer source below. + const userPub = safeUserKeyPair.getKeys().pub; + const backupPub = keyPair.getKeys().pub; + const userIndex = safeContractStorage.ownerKeys.indexOf(userPub); + const backupIndex = safeContractStorage.ownerKeys.indexOf(backupPub); + if (userIndex < 0 || backupIndex < 0) { + throw new RecoveryKeyMismatchError(`recovery keys are not owners of ${params.walletContractAddress}`); + } + txBuilder.addContractSigner(safeUserKeyPair, userIndex); + txBuilder.addContractSigner(keyPair, backupIndex); + } + txBuilder.sign({ key: backupSigningKey }); const signedTx = await txBuilder.build(); diff --git a/modules/sdk-coin-xtz/test/unit/xtzSafeRecovery.ts b/modules/sdk-coin-xtz/test/unit/xtzSafeRecovery.ts new file mode 100644 index 00000000000..78f335d6e56 --- /dev/null +++ b/modules/sdk-coin-xtz/test/unit/xtzSafeRecovery.ts @@ -0,0 +1,574 @@ +import should from 'should'; +import nock from 'nock'; +import Sinon from 'sinon'; +import { + common, + buildSafeRecoverKeyParams, + deriveSafeWalletKeys, + RecoveryKeyMismatchError, + RecoveryProviderError, + type DecryptedSafeRoots, + type Secp256k1MultisigChildKeys, +} from '@bitgo/sdk-core'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { coins } from '@bitgo/statics'; +import { Txtz, XtzLib } from '../../src'; +import { chainheadData, packDataToSign } from '../fixtures'; + +const walletContractAddress = 'KT1RsZPTQ9BCmL6RMDgJuPdVPwAEERSoqmJi'; +const recoveryDestination = 'tz2RtnvELVAW5dUTtsBNG6cbSA2eQFiYt4Rp'; +const baseUrl = common.Environments.test.xtzExplorerBaseUrl as string; +const rpcUrl = common.Environments.test.xtzRpcUrl as string; + +/** + * End-to-end Safe XTZ recovery: derive slot-1 (secp256k1Multisig) children from real roots, + * map them with buildSafeRecoverKeyParams, and prove `Xtz.recover()` accepts them while (a) verifying + * the user/backup/bitgo keys are the KT1 contract owners in both signed and unsigned modes, and + * (b) leaving the legacy path (no bitgoKey) untouched. + * + * The storage mock matches the REAL TZKT normalized shape: `/v1/contracts/{addr}/storage` returns + * storage keyed by its Michelson field annotations, so the generic multisig type + * `pair (nat %stored_counter) (pair (nat %threshold) (list %keys key))` yields + * `{ stored_counter, threshold, keys: ["sppk...", ...] }`. Verified against live TZKT: the endpoint + * returns normalized JSON, not micheline. + */ +describe('Safe XTZ recovery end-to-end', function () { + let bitgo: TestBitGoAPI; + let basecoin: Txtz; + const sandbox = Sinon.createSandbox(); + + afterEach(function () { + nock.cleanAll(); + sandbox.restore(); + }); + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.safeRegister('txtz', Txtz.createInstance); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('txtz') as Txtz; + }); + + // Distinct secp256k1 root triplets so user, backup and bitgo children are different keys. + function secp256k1Roots(): DecryptedSafeRoots { + const user = new XtzLib.KeyPair({ seed: Buffer.alloc(32, 1) }).getExtendedKeys(); + const backup = new XtzLib.KeyPair({ seed: Buffer.alloc(32, 2) }).getExtendedKeys(); + const bitgo = new XtzLib.KeyPair({ seed: Buffer.alloc(32, 3) }).getExtendedKeys(); + if (!user.xprv || !backup.xprv) { + throw new Error('expected private root keys'); + } + return { + secp256k1Multisig: { user: user.xprv, backup: backup.xprv, bitgo: bitgo.xpub }, + ed25519Multisig: { user: 'u', backup: { prv: 'b', chainCode: 'cc' }, bitgo: 'g' }, + ecdsaMpc: { + user: { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) }, + backup: { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) }, + bitgo: 'g', + }, + eddsaMpc: { + user: { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) }, + backup: { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) }, + bitgo: 'g', + }, + }; + } + + interface SafeDerived { + keys: Secp256k1MultisigChildKeys; + userPub: string; + backupPub: string; + bitgoPub: string; + feeAddress: string; + } + + async function deriveSafe(): Promise { + const derived = await deriveSafeWalletKeys({ + coin: 'txtz', + slot: 'secp256k1Multisig', + account: 0, + roots: secp256k1Roots(), + }); + if (derived.slot !== 'secp256k1Multisig') { + throw new Error('expected secp256k1Multisig slot'); + } + return { + keys: derived.keys, + userPub: new XtzLib.KeyPair({ prv: derived.keys.user.prv }).getKeys().pub, + backupPub: new XtzLib.KeyPair({ prv: derived.keys.backup.prv }).getKeys().pub, + bitgoPub: new XtzLib.KeyPair({ pub: derived.keys.bitgo.pub }).getKeys().pub, + feeAddress: new XtzLib.KeyPair({ prv: derived.keys.backup.prv }).getAddress(), + }; + } + + // TZKT-normalized generic multisig storage: `{ stored_counter, threshold, keys: ["sppk...", ...] }`. + function ownerStorage(keys: string[], storedCounter = '0') { + return { + stored_counter: storedCounter, + threshold: keys.length.toString(), + keys, + }; + } + + function stubWalletAccount() { + nock(baseUrl).get(`/v1/accounts/${walletContractAddress}`).reply(200, { + address: walletContractAddress, + type: 'contract', + kind: 'smart_contract', + balance: 10000000, + }); + } + + function stubCoreExplorer() { + stubWalletAccount(); + nock(baseUrl).get('/v1/head').reply(200, chainheadData); + nock(rpcUrl) + .post('/chains/main/blocks/head/helpers/scripts/pack_data', () => true) + .reply(200, packDataToSign); + } + + function stubFeeAccount(feeAddress: string) { + nock(baseUrl).get(`/v1/accounts/${feeAddress}`).reply(200, { + address: feeAddress, + type: 'user', + balance: 1000000, + counter: 100, + revealed: true, + }); + } + + function stubContractStorage(keys: string[], storedCounter = '0') { + nock(baseUrl).get(`/v1/contracts/${walletContractAddress}/storage`).reply(200, ownerStorage(keys, storedCounter)); + } + + function pendingStorageCount(): number { + return nock.pendingMocks().filter((m) => m.includes('/storage')).length; + } + + // Decode a signed/unsigned recovered transaction back into its parsed JSON so we can assert + // on the multisig contract operation (owner signatures, contract counter, manager counter). + async function decodeTx(txHex: string): Promise { + const builder = new XtzLib.TransactionBuilder(coins.get('txtz') as any); + builder.from(txHex); + const decoded = await builder.build(); + return decoded.toJson(); + } + + function findMultisigOp(txJson: any): any { + return txJson.contents.find( + (c: any) => c.kind === 'transaction' && c.parameters && c.destination === walletContractAddress + ); + } + + // Contract signature indexes present in the %sigs list (Some entries), in ascending order. + function contractSignatureIndexes(multisigOp: any): number[] { + const sigs = multisigOp.parameters.value.args[1] as any[]; + return sigs.map((s: any, i: number) => (s.prim === 'Some' ? i : -1)).filter((i: number) => i >= 0); + } + + it('recovers a Safe XTZ wallet in signed mode after verifying all three owners', async function () { + const safe = await deriveSafe(); + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txtz', + slot: 'secp256k1Multisig', + keys: safe.keys, + mode: 'signed', + }); + if (keyParams.slot !== 'secp256k1Multisig') { + throw new Error('expected secp256k1Multisig key params'); + } + + stubCoreExplorer(); + stubFeeAccount(safe.feeAddress); + // Contract stored counter (7) deliberately differs from the fee-account nonce (101) so the + // recovery must keep them separate for the contract to accept the transfer. + stubContractStorage([safe.userPub, safe.bitgoPub, safe.backupPub], '7'); + + const recovery: any = await basecoin.recover({ + userKey: keyParams.userKey, + backupKey: keyParams.backupKey, + bitgoKey: keyParams.bitgoKey, + walletPassphrase: keyParams.walletPassphrase, + walletContractAddress, + recoveryDestination, + }); + + should.exist(recovery); + recovery.id.should.be.a.String(); + recovery.tx.should.be.a.String(); + // The owner check ran and consumed the storage mock: the positive gate is pinned. + pendingStorageCount().should.equal(0); + + // The signed Safe transaction must be chain-executable: 2-of-2 owner signatures over the + // contract payload at their on-chain indexes, the contract's stored_counter in the payload, + // and the fee-account nonce as the source (manager) counter. + const decoded = await decodeTx(recovery.tx); + const multisigOp = findMultisigOp(decoded); + should.exist(multisigOp); + multisigOp.counter.should.equal('101'); // manager counter = fee account counter + 1 + multisigOp.parameters.value.args[0].args[0].int.should.equal('7'); // contract stored_counter + contractSignatureIndexes(multisigOp).should.deepEqual([0, 2]); // user at 0, backup at 2 + }); + + it('recovers a Safe XTZ wallet in unsigned mode (xpubs, no bitgo.decrypt) after verifying owners', async function () { + const safe = await deriveSafe(); + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txtz', + slot: 'secp256k1Multisig', + keys: safe.keys, + mode: 'unsigned', + }); + + stubCoreExplorer(); + stubFeeAccount(safe.feeAddress); + stubContractStorage([safe.userPub, safe.bitgoPub, safe.backupPub]); + + const decryptSpy = sandbox.spy(bitgo, 'decrypt'); + const recovery: any = await basecoin.recover({ + userKey: keyParams.userKey, + backupKey: keyParams.backupKey, + bitgoKey: keyParams.bitgoKey, + walletContractAddress, + recoveryDestination, + }); + + Sinon.assert.notCalled(decryptSpy); + should.exist(recovery); + recovery.txHex.should.be.a.String(); + recovery.source.should.equal(walletContractAddress); + pendingStorageCount().should.equal(0); + // Contract counter (stored_counter) stays separate from the manager counter (fee nonce). + recovery.sourceCounter.should.equal('101'); + recovery.transferCounters.should.deepEqual(['0']); + const decodedUnsigned = await decodeTx(recovery.txHex); + const multisigOpUnsigned = findMultisigOp(decodedUnsigned); + multisigOpUnsigned.counter.should.equal('101'); + multisigOpUnsigned.parameters.value.args[0].args[0].int.should.equal('0'); + contractSignatureIndexes(multisigOpUnsigned).should.deepEqual([]); + }); + + it('raw xprv user and backup keys work in signed mode without a passphrase', async function () { + const safe = await deriveSafe(); + const derived = await deriveSafeWalletKeys({ + coin: 'txtz', + slot: 'secp256k1Multisig', + account: 0, + roots: secp256k1Roots(), + }); + if (derived.slot !== 'secp256k1Multisig') { + throw new Error('expected secp256k1Multisig slot'); + } + + stubCoreExplorer(); + stubFeeAccount(safe.feeAddress); + stubContractStorage([safe.userPub, safe.bitgoPub, safe.backupPub]); + + const decryptSpy = sandbox.spy(bitgo, 'decrypt'); + const recovery: any = await basecoin.recover({ + userKey: derived.keys.user.prv, + backupKey: derived.keys.backup.prv, + bitgoKey: derived.keys.bitgo.pub, + walletContractAddress, + recoveryDestination, + }); + + should.exist(recovery); + recovery.tx.should.be.a.String(); + Sinon.assert.notCalled(decryptSpy); + pendingStorageCount().should.equal(0); + // Raw keys still produce the 2-of-2 contract signatures Safe recovery requires. + const decodedRaw = await decodeTx(recovery.tx); + contractSignatureIndexes(findMultisigOp(decodedRaw)).should.deepEqual([0, 2]); + }); + + it('signed Safe mode requires a private user key and never tries to decrypt an xpub user key', async function () { + const safe = await deriveSafe(); + const derived = await deriveSafeWalletKeys({ + coin: 'txtz', + slot: 'secp256k1Multisig', + account: 0, + roots: secp256k1Roots(), + }); + if (derived.slot !== 'secp256k1Multisig') { + throw new Error('expected secp256k1Multisig slot'); + } + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txtz', + slot: 'secp256k1Multisig', + keys: safe.keys, + mode: 'signed', + }); + + stubCoreExplorer(); + stubFeeAccount(safe.feeAddress); + stubContractStorage([safe.userPub, safe.bitgoPub, safe.backupPub]); + + const decryptSpy = sandbox.spy(bitgo, 'decrypt'); + let caughtError: unknown; + try { + await basecoin.recover({ + userKey: derived.keys.user.pub, + backupKey: keyParams.backupKey, + bitgoKey: keyParams.bitgoKey, + walletPassphrase: keyParams.walletPassphrase, + walletContractAddress, + recoveryDestination, + }); + } catch (e) { + caughtError = e; + } + (caughtError as Error).message.should.match(/private user key is required for a signed Safe recovery/); + // The xpub user key is never routed into bitgo.decrypt; only the encrypted backup envelope would be. + decryptSpy + .getCalls() + .filter((c) => (c.args[0]?.input as string)?.startsWith('xpub')) + .should.have.length(0); + }); + + it('reports a missing userKey as a validation error, not a TypeError', async function () { + let caughtError: unknown; + try { + await (basecoin as any).recover({ + backupKey: 'backup-key-placeholder', + walletContractAddress, + recoveryDestination, + }); + } catch (e) { + caughtError = e; + } + (caughtError as Error).should.be.instanceOf(Error); + (caughtError as Error).should.not.be.instanceOf(TypeError); + (caughtError as Error).message.should.match(/missing userKey/); + }); + + it('legacy signed recovery (no bitgoKey) never queries contract storage', async function () { + const safe = await deriveSafe(); + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txtz', + slot: 'secp256k1Multisig', + keys: safe.keys, + mode: 'signed', + }); + + stubCoreExplorer(); + stubFeeAccount(safe.feeAddress); + // No storage mock is registered: any storage query would fail the test with a nock error. + + const storageSpy = sandbox.spy(basecoin, 'recoveryBlockchainExplorerQuery'); + const recovery: any = await basecoin.recover({ + userKey: keyParams.userKey, + backupKey: keyParams.backupKey, + walletPassphrase: keyParams.walletPassphrase, + walletContractAddress, + recoveryDestination, + }); + + should.exist(recovery); + recovery.tx.should.be.a.String(); + const storageCalls = storageSpy + .getCalls() + .filter((c) => c.args[0]?.actionPath === 'contracts' && c.args[0]?.action === 'storage'); + storageCalls.should.have.length(0); + }); + + it('fails closed with RecoveryKeyMismatchError before the fee-account query when keys are not owners', async function () { + const safe = await deriveSafe(); + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txtz', + slot: 'secp256k1Multisig', + keys: safe.keys, + mode: 'signed', + }); + + const otherPub = (n: number) => new XtzLib.KeyPair({ seed: Buffer.alloc(32, n) }).getKeys().pub; + stubWalletAccount(); + stubContractStorage([otherPub(11), otherPub(12), otherPub(13)]); + stubFeeAccount(safe.feeAddress); + + let caughtError: unknown; + try { + await basecoin.recover({ + userKey: keyParams.userKey, + backupKey: keyParams.backupKey, + bitgoKey: keyParams.bitgoKey, + walletPassphrase: keyParams.walletPassphrase, + walletContractAddress, + recoveryDestination, + }); + } catch (e) { + caughtError = e; + } + (caughtError as Error).should.be.instanceOf(RecoveryKeyMismatchError); + (caughtError as any).code.should.equal('recovery_key_mismatch'); + // The fee-account nock must still be pending: the mismatch fires before any fee/balance check. + const pendingFee = nock.pendingMocks().filter((m) => m.includes(`/v1/accounts/${safe.feeAddress}`)); + pendingFee.should.have.length(1); + }); + + it('fails closed with RecoveryProviderError when the storage endpoint fails', async function () { + const safe = await deriveSafe(); + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txtz', + slot: 'secp256k1Multisig', + keys: safe.keys, + mode: 'signed', + }); + if (keyParams.slot !== 'secp256k1Multisig') { + throw new Error('expected secp256k1Multisig key params'); + } + + stubWalletAccount(); + nock(baseUrl).get(`/v1/contracts/${walletContractAddress}/storage`).replyWithError(new Error('boom')); + + let caughtError: unknown; + try { + await basecoin.recover({ + userKey: keyParams.userKey, + backupKey: keyParams.backupKey, + bitgoKey: keyParams.bitgoKey, + walletPassphrase: keyParams.walletPassphrase, + walletContractAddress, + recoveryDestination, + }); + } catch (e) { + caughtError = e; + } + (caughtError as Error).should.be.instanceOf(RecoveryProviderError); + }); + + it('fails closed with RecoveryProviderError on an unexpected storage shape', async function () { + const safe = await deriveSafe(); + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txtz', + slot: 'secp256k1Multisig', + keys: safe.keys, + mode: 'signed', + }); + if (keyParams.slot !== 'secp256k1Multisig') { + throw new Error('expected secp256k1Multisig key params'); + } + + stubWalletAccount(); + nock(baseUrl) + .get(`/v1/contracts/${walletContractAddress}/storage`) + .reply(200, { stored_counter: '0', threshold: '2', keys: ['not-a-public-key', safe.backupPub] }); + + let caughtError: unknown; + try { + await basecoin.recover({ + userKey: keyParams.userKey, + backupKey: keyParams.backupKey, + bitgoKey: keyParams.bitgoKey, + walletPassphrase: keyParams.walletPassphrase, + walletContractAddress, + recoveryDestination, + }); + } catch (e) { + caughtError = e; + } + (caughtError as Error).should.be.instanceOf(RecoveryProviderError); + }); + + it('rejects a malformed (private) bitgoKey before any explorer call, without echoing it', async function () { + const safe = await deriveSafe(); + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txtz', + slot: 'secp256k1Multisig', + keys: safe.keys, + mode: 'signed', + }); + if (keyParams.slot !== 'secp256k1Multisig') { + throw new Error('expected secp256k1Multisig key params'); + } + + const explorerSpy = sandbox.spy(basecoin, 'recoveryBlockchainExplorerQuery'); + let caughtError: unknown; + try { + await basecoin.recover({ + userKey: keyParams.userKey, + backupKey: keyParams.backupKey, + bitgoKey: safe.keys.user.prv, + walletPassphrase: keyParams.walletPassphrase, + walletContractAddress, + recoveryDestination, + }); + } catch (e) { + caughtError = e; + } + (caughtError as Error).message.should.match(/invalid bitgo key for Safe recovery/); + (caughtError as Error).message.should.not.containEql('xprv'); + Sinon.assert.notCalled(explorerSpy); + }); + + it('fails closed with RecoveryProviderError when storage reports too many owners', async function () { + const safe = await deriveSafe(); + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txtz', + slot: 'secp256k1Multisig', + keys: safe.keys, + mode: 'signed', + }); + if (keyParams.slot !== 'secp256k1Multisig') { + throw new Error('expected secp256k1Multisig key params'); + } + + stubWalletAccount(); + stubContractStorage(Array(101).fill(safe.userPub)); + + let caughtError: unknown; + try { + await basecoin.recover({ + userKey: keyParams.userKey, + backupKey: keyParams.backupKey, + bitgoKey: keyParams.bitgoKey, + walletPassphrase: keyParams.walletPassphrase, + walletContractAddress, + recoveryDestination, + }); + } catch (e) { + caughtError = e; + } + (caughtError as Error).should.be.instanceOf(RecoveryProviderError); + }); + + it('unsigned mode fails closed with RecoveryKeyMismatchError when the bitgo key is not an owner', async function () { + const safe = await deriveSafe(); + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txtz', + slot: 'secp256k1Multisig', + keys: safe.keys, + mode: 'unsigned', + }); + + const otherPub = (n: number) => new XtzLib.KeyPair({ seed: Buffer.alloc(32, n) }).getKeys().pub; + stubCoreExplorer(); + stubFeeAccount(safe.feeAddress); + stubContractStorage([safe.userPub, otherPub(11), safe.backupPub]); + + const decryptSpy = sandbox.spy(bitgo, 'decrypt'); + let caughtError: unknown; + try { + await basecoin.recover({ + userKey: keyParams.userKey, + backupKey: keyParams.backupKey, + bitgoKey: keyParams.bitgoKey, + walletContractAddress, + recoveryDestination, + }); + } catch (e) { + caughtError = e; + } + (caughtError as Error).should.be.instanceOf(RecoveryKeyMismatchError); + Sinon.assert.notCalled(decryptSpy); + }); +});