From 8a81acf001bec8553a844a2b31c6174281c36ef9 Mon Sep 17 00:00:00 2001 From: Pranish Nepal Date: Fri, 4 Sep 2026 10:24:49 -0400 Subject: [PATCH] fix: derive safe child key for spend sharing Safe-minted wallets have pub-only children (owner material stays on the root), so the share path's scan for an encrypted user key wrongly treated them as cold and sent skipKeychain=true This PR fixes the issue to derive the key correctly and account for safe shared wallets. Ticket: WCN-2429 --- .../sdk-core/src/bitgo/wallet/safeKeychain.ts | 126 ++++++- modules/sdk-core/src/bitgo/wallet/wallet.ts | 62 +++- .../test/unit/bitgo/wallet/safeShareWallet.ts | 324 ++++++++++++++++++ 3 files changed, 483 insertions(+), 29 deletions(-) create mode 100644 modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts diff --git a/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts b/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts index 3a10536ca0..717efe9bab 100644 --- a/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts +++ b/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts @@ -5,6 +5,7 @@ import { BitGoBase } from '../bitgoBase'; import { decryptKeychainPrivateKey, IKeychains, Keychain, KeychainWithEncryptedPrv } from '../keychain'; import { deriveSafeChildHardenedFromXprv, parseDerivedFromParentWithHardenedPath } from '../safe/safeDerivation'; import { IncorrectPasswordError } from '../errors'; +import type { DecryptedKeychainData } from './iWallet'; export class InvalidRootKeychainSourceError extends Error { constructor(id: string, source: string | undefined) { @@ -35,6 +36,14 @@ export class SafeOwnerSigningNotImplementedError extends Error { } } +/** Thrown when wallet sharing is not implemented for this safe slot (TSS, ed25519 multisig, …). */ +export class SafeShareNotImplementedError extends Error { + constructor(walletId: string, detail: string) { + super(`Safe wallet ${walletId}: ${detail}`); + this.name = 'SafeShareNotImplementedError'; + } +} + /** ed25519 onchain multisig (slot ④). Needs SLIP-0010, not secp256k1 BIP32. */ const ED25519_ONCHAIN_FAMILIES = new Set(['algo', 'xlm', 'hbar']); @@ -70,7 +79,11 @@ export async function fetchRootKeychainForSafeChild( return root as KeychainWithEncryptedPrv; } -export interface ResolveSafeOwnerSigningPrvParams { +/** + * Shared params for resolving safe-owner key material (owner signing and wallet sharing). + * Both resolvers extend this so the precondition/derivation path cannot diverge. + */ +export interface SafeKeyMaterialBaseParams { bitgo: BitGoBase; keychains: IKeychains; walletId: string; @@ -83,31 +96,41 @@ export interface ResolveSafeOwnerSigningPrvParams { rootKeychain?: KeychainWithEncryptedPrv; } +export type ResolveSafeOwnerSigningPrvParams = SafeKeyMaterialBaseParams; + +type SafeKeyMaterialSlot = 'tss' | 'ed25519'; + +type ResolveSafeKeyMaterialParams = SafeKeyMaterialBaseParams & { + /** Constructs the not-implemented error for the current resolver (signing vs sharing). */ + makeNotImplementedError: (slot: SafeKeyMaterialSlot, walletId: string) => Error; +}; + /** - * Resolve signing material for a safe owner (child key has no encryptedPrv). + * Shared core that resolves safe key material for a pub-only safe child. Returns the CHILD + * `{prv, pub}` only — never the root — so the root can never leak into a share document. * * Onchain secp256k1: decrypt root, hardened-derive at `derivedFromParentWithHardenedPath` - * (`m/'`), and verify the registered pub. - * TSS and ed25519 onchain: throw — do not return root material or BIP32-derive the wrong curve. - * - * Do not use for wallet sharing — that must not receive root key material. - * Call only when `isSafeChildPublicOnlyKeychain` is true. + * (`m/'`), and verify the registered pub. TSS and ed25519 onchain throw via + * `makeNotImplementedError` — the caller constructs its own error class + message, so the + * guard set stays shared while signing/sharing report their own errors. */ -export async function resolveSafeOwnerSigningPrv(params: ResolveSafeOwnerSigningPrvParams): Promise { - const { bitgo, keychains, walletId, multisigType, coinFamily, childKeychain, walletPassphrase } = params; +async function resolveSafeKeyMaterial(params: ResolveSafeKeyMaterialParams): Promise<{ prv: string; pub: string }> { + const { + bitgo, + keychains, + walletId, + multisigType, + coinFamily, + childKeychain, + walletPassphrase, + makeNotImplementedError, + } = params; if (multisigType !== 'onchain') { - throw new SafeOwnerSigningNotImplementedError( - walletId, - 'TSS owner signing from the root keyshare is not implemented. ' + - 'Returning the root private key would expose material that can derive every child in this slot.' - ); + throw makeNotImplementedError('tss', walletId); } if (ED25519_ONCHAIN_FAMILIES.has(coinFamily)) { - throw new SafeOwnerSigningNotImplementedError( - walletId, - `ed25519 multisig owner derivation (${coinFamily}) is not implemented; BIP32 would produce the wrong child key.` - ); + throw makeNotImplementedError('ed25519', walletId); } const rootKeychain = params.rootKeychain ?? (await fetchRootKeychainForSafeChild(keychains, childKeychain)); @@ -130,5 +153,70 @@ export async function resolveSafeOwnerSigningPrv(params: ResolveSafeOwnerSigning if (derived.pub !== childKeychain.pub) { throw new SafeDerivedPublicKeyMismatchError(walletId, childKeychain.pub, derived.pub); } - return derived.prv; + + if (derived.pub === rootKeychain.pub) { + throw new Error(`Safe wallet ${walletId}: derived child pub unexpectedly equals the root pub`); + } + return { prv: derived.prv, pub: derived.pub }; +} + +/** + * Resolve signing material for a safe owner (child key has no encryptedPrv). + * + * Onchain secp256k1: decrypt root, hardened-derive at `derivedFromParentWithHardenedPath` + * (`m/'`), and verify the registered pub. + * TSS and ed25519 onchain: throw — do not return root material or BIP32-derive the wrong curve. + * + * Do not use for wallet sharing — that must not receive root key material. + * Call only when `isSafeChildPublicOnlyKeychain` is true. + */ +export async function resolveSafeOwnerSigningPrv(params: ResolveSafeOwnerSigningPrvParams): Promise { + const { prv } = await resolveSafeKeyMaterial({ + ...params, + makeNotImplementedError: (slot, walletId) => + slot === 'ed25519' + ? new SafeOwnerSigningNotImplementedError( + walletId, + `ed25519 multisig owner derivation (${params.coinFamily}) is not implemented; BIP32 would produce the wrong child key.` + ) + : new SafeOwnerSigningNotImplementedError( + walletId, + 'TSS owner signing from the root keyshare is not implemented. ' + + 'Returning the root private key would expose material that can derive every child in this slot.' + ), + }); + return prv; +} + +export interface ResolveSafeChildPrvForSharingParams extends SafeKeyMaterialBaseParams { + mpcAlgorithm?: 'ecdsa' | 'eddsa'; +} + +/** Slot-named not-implemented detail for wallet sharing. */ +function safeShareSlotDetail(slot: SafeKeyMaterialSlot, params: ResolveSafeChildPrvForSharingParams): string { + if (slot === 'ed25519') { + return `ed25519 multisig safe sharing (${params.coinFamily}) is not implemented; BIP32 would derive the wrong child.`; + } + return params.mpcAlgorithm === 'eddsa' + ? 'eddsaMpc safe sharing is not implemented (needs the EdDSA derive ceremony).' + : 'ecdsaMpc safe sharing is not implemented (needs the DKLS derive ceremony).'; +} + +/** + * Resolve sharing material for a safe owner (child key has no encryptedPrv). + * + * Onchain secp256k1: decrypt root, hardened-derive at `derivedFromParentWithHardenedPath` + * (`m/'`), verify the registered pub, and return the CHILD `{prv, pub}` — never the root. + * TSS and ed25519 onchain: throw `SafeShareNotImplementedError` naming the slot + blocker. + * + * Call only when `isSafeChildPublicOnlyKeychain` is true. + */ +export async function resolveSafeChildPrvForSharing( + params: ResolveSafeChildPrvForSharingParams +): Promise { + return resolveSafeKeyMaterial({ + ...params, + makeNotImplementedError: (slot, walletId) => + new SafeShareNotImplementedError(walletId, safeShareSlotDetail(slot, params)), + }); } diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 9e2ad82d55..cb268388f5 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -61,6 +61,7 @@ import { buildParamKeys, BuildParams } from './BuildParams'; import { fetchRootKeychainForSafeChild, isSafeChildPublicOnlyKeychain, + resolveSafeChildPrvForSharing, resolveSafeOwnerSigningPrv, } from './safeKeychain'; import { @@ -1760,6 +1761,18 @@ export class Wallet implements IWallet { return tryKeyChain(0); } + private async getSafeOwnerChildKeychain(): Promise<(Keychain & { parent: string }) | undefined> { + if (!this.safeId()) { + return undefined; + } + const userKeyId = this._wallet.keys?.[KeyIndices.USER]; + if (!userKeyId) { + return undefined; + } + const keychain = await this.baseCoin.keychains().get({ id: userKeyId }); + return isSafeChildPublicOnlyKeychain(this.safeId(), keychain) ? keychain : undefined; + } + /** * Gets the unencrypted private key for this wallet (be careful!) * Requires wallet passphrase @@ -1866,11 +1879,8 @@ export class Wallet implements IWallet { try { decryptedKeychain = await this.getDecryptedKeychainForSharing(params.walletPassphrase); } catch (e) { - if (e instanceof MissingEncryptedKeychainError) { - decryptedKeychain = undefined; - } else { - throw e; - } + this.rethrowUnlessColdWalletShare(e); + decryptedKeychain = undefined; } } @@ -1961,6 +1971,28 @@ export class Wallet implements IWallet { async getDecryptedKeychainForSharing( walletPassphrase: string | undefined ): Promise { + /** + * For Safe owners: detect child safes first and derive the child private key from the root keychain if present. + * Skip `lnbtc` as it uses the user auth key instead + */ + if (this.baseCoin.getFamily() !== 'lnbtc') { + const safeChildKeychain = await this.getSafeOwnerChildKeychain(); + if (safeChildKeychain) { + if (!walletPassphrase) { + throw new Error('Missing walletPassphrase argument'); + } + return resolveSafeChildPrvForSharing({ + bitgo: this.bitgo, + keychains: this.baseCoin.keychains(), + walletId: this._wallet.id, + multisigType: this._wallet.multisigType, + coinFamily: this.baseCoin.getFamily(), + childKeychain: safeChildKeychain, + walletPassphrase, + }); + } + } + const keychain = await this.getEncryptedWalletKeychainForWalletSharing(); if (!keychain.encryptedPrv) { @@ -2031,6 +2063,18 @@ export class Wallet implements IWallet { return keychain; } + private rethrowUnlessColdWalletShare(e: unknown): void { + if (!(e instanceof MissingEncryptedKeychainError)) { + throw e; + } + if (this.safeId()) { + throw new MissingEncryptedKeychainError( + `Safe wallet ${this._wallet.id}: no keychain with an encryptedPrv and the safe child ` + + `could not be resolved; refusing to create a spend share without key material.` + ); + } + } + /** * Prepares a keychain for sharing with another user. * Fetches the wallet keychain, decrypts it, and encrypts it for the recipient. @@ -2056,11 +2100,9 @@ export class Wallet implements IWallet { } return await this.encryptPrvForUser(keychain.prv, keychain.pub, pubkey, path, encryptionVersion); } catch (e) { - if (e instanceof MissingEncryptedKeychainError) { - // ignore this error because this looks like a cold wallet - return {}; - } - throw e; + this.rethrowUnlessColdWalletShare(e); + // ignore this error because this looks like a cold wallet + return {}; } } diff --git a/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts b/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts new file mode 100644 index 0000000000..cf25a59fc0 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts @@ -0,0 +1,324 @@ +/** + * @prettier + * + * Safe-wallet SPEND sharing. Mirrors test/unit/bitgo/wallet/safeGetUserPrv.ts (sinon-stubbed, + * no nock) for the sharing analogue: a safe owner's children are pub-only, so the share path + * must re-derive the child prv from the root — never the root material. + */ +import 'should'; +import * as sinon from 'sinon'; +import { + IncorrectPasswordError, + SafeDerivedPublicKeyMismatchError, + SafeShareNotImplementedError, + Wallet, + deriveSafeChildHardenedFromXprv, +} from '../../../../src'; +import { BaseCoin } from '../../../../src/bitgo/baseCoin'; +import { getSharedSecret } from '../../../../src/bitgo/ecdh'; +import { makeRandomKey } from '../../../../src/bitgo/bitcoin'; + +require('should-sinon'); + +describe('Safe wallet spend sharing', function () { + const prv = + 'xprv9s21ZrQH143K3hekyNj7TciR4XNYe1kMj68W2ipjJGNHETWP7o42AjDnSPgKhdZ4x8NBAvaL72RrXjuXNdmkMqLERZza73oYugGtbLFXG8g'; + const hardened = deriveSafeChildHardenedFromXprv(prv, '123'); + const passphrase = 'test-passphrase'; + const rootKeyId = 'root-key-id'; + const SHAREE_PUB = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + + let mockBitGo: any; + let mockBaseCoin: any; + let keychainsGetStub: sinon.SinonStub; + let encryptStub: sinon.SinonStub; + let decryptStub: sinon.SinonStub; + let createShareStub: sinon.SinonStub; + let createBulkKeySharesStub: sinon.SinonStub; + + const baseWalletData = { + id: 'wallet-id', + coin: 'tbtc', + keys: ['user-key', 'backup-key', 'bitgo-key'], + type: 'hot', + multisigType: 'onchain', + enterprise: 'ent-id', + }; + + const childKeychain = (id: string) => ({ + id, + pub: hardened.pub, + type: 'independent' as const, + parent: rootKeyId, + derivedFromParentWithHardenedPath: "m/123'", + }); + const publicOnlyKeychain = (id: string) => ({ id, pub: 'pub-' + id, type: 'independent' as const }); + const rootKeychain = { + id: rootKeyId, + source: 'user' as const, + encryptedPrv: `enc:${prv}`, + type: 'independent' as const, + pub: 'root-pub', + }; + + beforeEach(function () { + keychainsGetStub = sinon.stub(); + encryptStub = sinon.stub(); + decryptStub = sinon.stub(); + + mockBitGo = { + encrypt: encryptStub, + decrypt: decryptStub, + url: sinon.stub().returns('https://test.bitgo.com/'), + setRequestTracer: sinon.stub(), + getSharingKey: sinon.stub().resolves({ userId: 'sharee-id', pubkey: SHAREE_PUB, path: 'm/0' }), + }; + + mockBaseCoin = { + getChain: sinon.stub().returns('tbtc'), + getFamily: sinon.stub().returns('btc'), + getFullName: sinon.stub().returns('Test Bitcoin'), + keychains: sinon.stub().returns({ + get: keychainsGetStub, + getKeysForSigning: sinon.stub().resolves([]), + }), + deriveKeyWithSeed: sinon.stub(), + url: sinon.stub().callsFake((path: string) => `https://test.bitgo.com/api/v2/tbtc${path}`), + supportsStaking: sinon.stub().returns(false), + supportsTss: sinon.stub().returns(false), + getMPCAlgorithm: sinon.stub(), + keyIdsForSigning: sinon.stub().returns([0, 1, 2]), + }; + + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve(id === rootKeyId ? rootKeychain : id === 'user-key' ? childKeychain(id) : publicOnlyKeychain(id)) + ); + + // Reversible encrypt: `shared::` so tests can verify the ECDH secret and + // that the payload encrypts the CHILD prv, not the root. + encryptStub.callsFake(({ input, password }: { input: string; password: string }) => + Promise.resolve(`shared:${password}:${input}`) + ); + + decryptStub.callsFake(({ input, password }: { input: string; password: string }) => { + if (password !== passphrase) return null; + if (typeof input === 'string' && input.startsWith('enc:')) return input.slice(4); + return null; + }); + + createShareStub = sinon.stub(Wallet.prototype, 'createShare').resolves({}); + createBulkKeySharesStub = sinon.stub(Wallet.prototype, 'createBulkKeyShares').resolves({ shares: [] }); + }); + + afterEach(function () { + sinon.restore(); + }); + + function makeWallet(overrides: Record = {}): Wallet { + return new Wallet(mockBitGo, mockBaseCoin as unknown as BaseCoin, { + ...baseWalletData, + ...overrides, + }); + } + + async function getShareOptions(wallet: Wallet, params: Record = {}) { + await wallet.shareWallet({ + email: 'shareto@test.com', + permissions: 'spend', + walletPassphrase: passphrase, + ...params, + }); + return createShareStub.firstCall.args[0]; + } + + describe('hot safe wallet, spend share', function () { + it('derives child material, uses the registered child pub, and never ships root material', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + const options = await getShareOptions(wallet); + + options.skipKeychain.should.equal(false); + options.keychain.should.be.ok(); + options.keychain.pub.should.equal(hardened.pub); + + const json = JSON.stringify(options.keychain); + json.should.not.containEql(prv); // root xprv never leaves + json.should.not.containEql('root-pub'); // root pub never leaves + }); + + it('the encrypted prv ECDH-decrypts (sharee side) to the CHILD prv', async function () { + const shareeKey = makeRandomKey(); + const shareePub = shareeKey.publicKey.toString('hex'); + mockBitGo.getSharingKey = sinon.stub().resolves({ userId: 'sharee-id', pubkey: shareePub, path: 'm/0' }); + + const wallet = makeWallet({ safe: 'safe-id-1' }); + const options = await getShareOptions(wallet); + + const shareeSecret = getSharedSecret(shareeKey, Buffer.from(options.keychain.fromPubKey, 'hex')).toString('hex'); + options.keychain.encryptedPrv.should.equal(`shared:${shareeSecret}:${hardened.prv}`); + }); + + it('a view-only share needs no keychain and does not fetch the root', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + await wallet.shareWallet({ email: 'shareto@test.com', permissions: 'view', walletPassphrase: passphrase }); + + createShareStub.firstCall.args[0].skipKeychain.should.equal(true); + createShareStub.firstCall.args[0].should.have.property('keychain', undefined); + keychainsGetStub.notCalled.should.equal(true); + }); + + it('a wrong passphrase rejects with IncorrectPasswordError and posts nothing', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: 'wrong-passphrase' }) + .should.be.rejectedWith(IncorrectPasswordError); + createShareStub.notCalled.should.equal(true); + }); + + it('a missing passphrase throws instead of silently skipKeychain', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend' }) + .should.be.rejectedWith(/Missing walletPassphrase argument/); + createShareStub.notCalled.should.equal(true); + }); + + it('fails closed when the derived pub does not match the registered child pub', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve( + id === rootKeyId + ? rootKeychain + : id === 'user-key' + ? { ...childKeychain(id), pub: 'wrong-child-pub', derivedFromParentWithHardenedPath: "m/123'" } + : publicOnlyKeychain(id) + ) + ); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: passphrase }) + .should.be.rejectedWith(SafeDerivedPublicKeyMismatchError); + }); + }); + + describe('unsupported safe slots', function () { + it('TSS safe wallet throws SafeShareNotImplementedError without fetching the root', async function () { + const wallet = makeWallet({ safe: 'safe-id-1', multisigType: 'tss' }); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: passphrase }) + .should.be.rejectedWith(SafeShareNotImplementedError); + // child fetched (safe branch), root never fetched (guard fires first) + keychainsGetStub.calledOnce.should.equal(true); + keychainsGetStub.firstCall.args[0].should.deepEqual({ id: 'user-key' }); + }); + + it('ed25519 onchain safe wallet throws SafeShareNotImplementedError', async function () { + const wallet = makeWallet({ safe: 'safe-id-1', coin: 'txlm' }); + mockBaseCoin.getFamily.returns('xlm'); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: passphrase }) + .should.be.rejectedWith(SafeShareNotImplementedError); + keychainsGetStub.calledOnce.should.equal(true); + }); + }); + + describe('regression: non-safe and sharee paths', function () { + it('a genuine cold wallet still yields skipKeychain', async function () { + const wallet = makeWallet({ type: 'cold' }); + const options = await getShareOptions(wallet); + options.skipKeychain.should.equal(true); + options.should.have.property('keychain', undefined); + }); + + it('a sharee re-sharing (child has encryptedPrv) takes the ordinary path and never fetches the root', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + const shareeChild = { + id: 'user-key', + pub: 'sharee-pub', + type: 'independent' as const, + encryptedPrv: `enc:sharee-prv`, + }; + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve(id === 'user-key' ? shareeChild : publicOnlyKeychain(id)) + ); + const options = await getShareOptions(wallet); + options.skipKeychain.should.equal(false); + options.keychain.pub.should.equal('sharee-pub'); + // root (safe-owner detour) must never be reached for a sharee with an encrypted child prv + keychainsGetStub + .getCalls() + .filter((c) => c.args?.[0]?.id === rootKeyId) + .length.should.equal(0); + }); + + it('a malformed safe wallet (child with no parent) fails closed instead of cold-skipping', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve(id === 'user-key' ? publicOnlyKeychain(id) : publicOnlyKeychain(id)) + ); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: passphrase }) + .should.be.rejectedWith(/safe child could not be resolved/); + createShareStub.notCalled.should.equal(true); + }); + + it('a non-safe hot wallet spend share is unchanged', async function () { + const wallet = makeWallet({}); + const userKeychain = { + id: 'user-key', + pub: 'hot-pub', + type: 'independent' as const, + encryptedPrv: `enc:hot-prv`, + }; + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve(id === 'user-key' ? userKeychain : publicOnlyKeychain(id)) + ); + const options = await getShareOptions(wallet); + options.skipKeychain.should.equal(false); + options.keychain.pub.should.equal('hot-pub'); + }); + + it('an lnbtc wallet takes the userAuth path and never enters the safe branch', async function () { + const wallet = makeWallet({ safe: 'safe-id-1', coin: 'lbtc' }); + mockBaseCoin.getFamily.returns('lnbtc'); + const safeChildSpy = sinon.spy(wallet as any, 'getSafeOwnerChildKeychain'); + // stubbing the private keychain fetch so lnbtc resolves a keychain without full lightning mocks + sinon.stub(wallet as any, 'getEncryptedWalletKeychainForWalletSharing').resolves({ + id: 'user-key', + pub: 'ln-pub', + encryptedPrv: `enc:ln-prv`, + type: 'independent', + }); + const options = await getShareOptions(wallet); + options.keychain.pub.should.equal('ln-pub'); + safeChildSpy.notCalled.should.equal(true); + }); + }); + + describe('createBulkWalletShare on a safe wallet', function () { + const bulkParams = { + walletPassphrase: passphrase, + keyShareOptions: [ + { userId: 'u1', pubKey: SHAREE_PUB, path: 'm/0', permissions: 'spend' }, + { userId: 'u2', pubKey: SHAREE_PUB, path: 'm/0', permissions: 'spend' }, + ], + } as any; + + it('derives the child once (one root decrypt) and fans it out per user', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + await wallet.createBulkWalletShare(bulkParams); + + createBulkKeySharesStub.calledOnce.should.equal(true); + const options = createBulkKeySharesStub.firstCall.args[0]; + options.length.should.equal(2); + options.forEach((o: any) => o.keychain.pub.should.equal(hardened.pub)); + // child + root exactly once (single root decrypt) + const rootCalls = keychainsGetStub.getCalls().filter((c) => c.args?.[0]?.id === rootKeyId); + rootCalls.length.should.equal(1); + }); + + it('the real error propagates instead of shareOptions cannot be empty', async function () { + const wallet = makeWallet({ safe: 'safe-id-1', multisigType: 'tss' }); + await wallet.createBulkWalletShare(bulkParams).should.be.rejectedWith(SafeShareNotImplementedError); + createBulkKeySharesStub.notCalled.should.equal(true); + }); + }); +});