Skip to content

Commit d594ecf

Browse files
feat(sdk-core): update wallet sharing for multi-user-key
TICKET: WP-6790
1 parent b9fdf1d commit d594ecf

File tree

2 files changed

+263
-78
lines changed

2 files changed

+263
-78
lines changed

modules/bitgo/test/v2/unit/wallet.ts

Lines changed: 255 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -2072,9 +2072,11 @@ describe('V2 Wallet:', function () {
20722072

20732073
describe('OFC Multi-User-Key Wallet Sharing', function () {
20742074
const userId = '123';
2075+
const email = '[email protected]';
20752076
const permissions = 'view,spend';
20762077
let ofcWallet: Wallet;
20772078
let ofcMultiUserKeyWallet: Wallet;
2079+
let nonMultiUserKeyWallet: Wallet;
20782080

20792081
before(function () {
20802082
const ofcCoin = bitgo.coin('ofc');
@@ -2110,105 +2112,281 @@ describe('V2 Wallet:', function () {
21102112
type: 'hot',
21112113
} as any;
21122114
ofcMultiUserKeyWallet = new Wallet(bitgo, ofcCoin, multiUserKeyWalletData);
2115+
2116+
// Non-multi-user-key wallet for backwards compatibility tests
2117+
const nonMultiUserKeyWalletData = {
2118+
id: '5b34252f1bf349930e34020a00000003',
2119+
coin: 'ofc',
2120+
keys: ['5b3424f91bf349930e34017500000002'],
2121+
coinSpecific: {
2122+
features: [],
2123+
},
2124+
type: 'hot',
2125+
};
2126+
nonMultiUserKeyWallet = new Wallet(bitgo, ofcCoin, nonMultiUserKeyWalletData);
21132127
});
21142128

21152129
afterEach(function () {
21162130
sinon.restore();
21172131
nock.cleanAll();
21182132
});
21192133

2120-
it('should exclude keychain property for multi-user-key wallets in createShare', async function () {
2121-
const createShareParams = {
2122-
user: userId,
2123-
permissions,
2124-
};
2134+
describe('createShare method', function () {
2135+
describe('multi-user-key wallets', function () {
2136+
it('should exclude keychain property from API request', async function () {
2137+
const createShareParams = {
2138+
user: userId,
2139+
permissions,
2140+
};
21252141

2126-
const createShareNock = nock(bgUrl)
2127-
.post(`/api/v2/ofc/wallet/${ofcMultiUserKeyWallet.id()}/share`, (body) => {
2128-
// Verify that keychain is not included in the request
2129-
body.should.not.have.property('keychain');
2130-
body.user.should.equal(userId);
2131-
body.permissions.should.equal(permissions);
2132-
return true;
2133-
})
2134-
.reply(200, {});
2142+
const createShareNock = nock(bgUrl)
2143+
.post(`/api/v2/ofc/wallet/${ofcMultiUserKeyWallet.id()}/share`, (body) => {
2144+
body.should.not.have.property('keychain');
2145+
body.user.should.equal(userId);
2146+
body.permissions.should.equal(permissions);
2147+
return true;
2148+
})
2149+
.reply(200, {});
21352150

2136-
await ofcMultiUserKeyWallet.createShare(createShareParams);
2151+
await ofcMultiUserKeyWallet.createShare(createShareParams);
21372152

2138-
createShareNock.isDone().should.be.True();
2139-
});
2153+
createShareNock.isDone().should.be.True();
2154+
});
21402155

2141-
it('should throw error when keychain is provided for multi-user-key wallets in createShare', async function () {
2142-
const createShareParams = {
2143-
user: userId,
2144-
permissions,
2145-
keychain: {
2146-
pub: 'xpub661MyMwAqRbcFXDcWD2vxuebcT1ZpTF4Vke6qmMW8yzddwNYpAPjvYEEL5jLfyYXW2fuxtAxY8TgjPUJLcf1C8qz9N6VgZxArKX4EwB8rH5',
2147-
encryptedPrv: 'encrypted',
2148-
fromPubKey: 'fromPub',
2149-
toPubKey: 'toPub',
2150-
path: 'm/999999/1/1',
2151-
},
2152-
};
2156+
it('should throw error when non-empty keychain is provided', async function () {
2157+
const createShareParams = {
2158+
user: userId,
2159+
permissions,
2160+
keychain: {
2161+
pub: 'xpub661MyMwAqRbcFXDcWD2vxuebcT1ZpTF4Vke6qmMW8yzddwNYpAPjvYEEL5jLfyYXW2fuxtAxY8TgjPUJLcf1C8qz9N6VgZxArKX4EwB8rH5',
2162+
encryptedPrv: 'encrypted',
2163+
fromPubKey: 'fromPub',
2164+
toPubKey: 'toPub',
2165+
path: 'm/999999/1/1',
2166+
},
2167+
};
2168+
2169+
await ofcMultiUserKeyWallet
2170+
.createShare(createShareParams)
2171+
.should.be.rejectedWith('keychain property must not be provided for multi-user-key wallets');
2172+
});
21532173

2154-
await ofcMultiUserKeyWallet
2155-
.createShare(createShareParams)
2156-
.should.be.rejectedWith('keychain property must not be provided for multi-user-key wallets');
2174+
it('should omit keychain from request even when empty object is provided', async function () {
2175+
const createShareParams = {
2176+
user: userId,
2177+
permissions,
2178+
keychain: {},
2179+
};
2180+
2181+
const createShareNock = nock(bgUrl)
2182+
.post(`/api/v2/ofc/wallet/${ofcMultiUserKeyWallet.id()}/share`, (body) => {
2183+
body.should.not.have.property('keychain');
2184+
body.user.should.equal(userId);
2185+
body.permissions.should.equal(permissions);
2186+
return true;
2187+
})
2188+
.reply(200, {});
2189+
2190+
await ofcMultiUserKeyWallet.createShare(createShareParams);
2191+
2192+
createShareNock.isDone().should.be.True();
2193+
});
2194+
});
2195+
2196+
describe('non-multi-user-key wallets', function () {
2197+
it('should include keychain property in API request when provided', async function () {
2198+
const createShareParams = {
2199+
user: userId,
2200+
permissions,
2201+
keychain: {
2202+
pub: 'xpub661MyMwAqRbcFXDcWD2vxuebcT1ZpTF4Vke6qmMW8yzddwNYpAPjvYEEL5jLfyYXW2fuxtAxY8TgjPUJLcf1C8qz9N6VgZxArKX4EwB8rH5',
2203+
encryptedPrv: 'encrypted',
2204+
fromPubKey: 'fromPub',
2205+
toPubKey: 'toPub',
2206+
path: 'm/999999/1/1',
2207+
},
2208+
};
2209+
2210+
const createShareNock = nock(bgUrl)
2211+
.post(`/api/v2/ofc/wallet/${ofcWallet.id()}/share`, (body) => {
2212+
body.should.have.property('keychain');
2213+
body.keychain.should.have.property('pub');
2214+
body.keychain.should.have.property('encryptedPrv');
2215+
body.keychain.should.have.property('fromPubKey');
2216+
body.keychain.should.have.property('toPubKey');
2217+
body.keychain.should.have.property('path');
2218+
body.user.should.equal(userId);
2219+
body.permissions.should.equal(permissions);
2220+
return true;
2221+
})
2222+
.reply(200, {});
2223+
2224+
await ofcWallet.createShare(createShareParams);
2225+
2226+
createShareNock.isDone().should.be.True();
2227+
});
2228+
});
21572229
});
21582230

2159-
it('should include keychain property for non-multi-user-key OFC wallets', async function () {
2160-
const createShareParams = {
2161-
user: userId,
2162-
permissions,
2163-
keychain: {
2164-
pub: 'xpub661MyMwAqRbcFXDcWD2vxuebcT1ZpTF4Vke6qmMW8yzddwNYpAPjvYEEL5jLfyYXW2fuxtAxY8TgjPUJLcf1C8qz9N6VgZxArKX4EwB8rH5',
2165-
encryptedPrv: 'encrypted',
2166-
fromPubKey: 'fromPub',
2167-
toPubKey: 'toPub',
2168-
path: 'm/999999/1/1',
2169-
},
2170-
};
2231+
describe('shareWallet method', function () {
2232+
describe('multi-user-key wallets', function () {
2233+
it('should skip keychain preparation and set skipKeychain=true in API request', async function () {
2234+
const getSharingKeyNock = nock(bgUrl)
2235+
.post('/api/v1/user/sharingkey', { email })
2236+
.reply(200, { userId, pubkey: 'testpubkey', path: 'm/999999/1/1' });
2237+
2238+
const createShareNock = nock(bgUrl)
2239+
.post(`/api/v2/ofc/wallet/${ofcMultiUserKeyWallet.id()}/share`, function (body) {
2240+
body.should.have.property('skipKeychain', true);
2241+
body.should.not.have.property('keychain');
2242+
body.should.have.property('user', userId);
2243+
body.should.have.property('permissions', permissions);
2244+
return true;
2245+
})
2246+
.reply(200, {});
21712247

2172-
const createShareNock = nock(bgUrl)
2173-
.post(`/api/v2/ofc/wallet/${ofcWallet.id()}/share`, (body) => {
2174-
// Verify that keychain IS included in the request for regular wallets
2175-
body.should.have.property('keychain');
2176-
body.keychain.should.have.property('pub');
2177-
body.keychain.should.have.property('encryptedPrv');
2178-
body.keychain.should.have.property('fromPubKey');
2179-
body.keychain.should.have.property('toPubKey');
2180-
body.keychain.should.have.property('path');
2181-
body.user.should.equal(userId);
2182-
body.permissions.should.equal(permissions);
2183-
return true;
2184-
})
2185-
.reply(200, {});
2248+
// Stub prepareSharedKeychain to ensure it's not called
2249+
const prepareSharedKeychainStub = sinon.stub(ofcMultiUserKeyWallet, 'prepareSharedKeychain').resolves({});
2250+
2251+
// Stub getEncryptedUserKeychain to prevent any keychain fetching
2252+
const getEncryptedUserKeychainStub = sinon.stub(ofcMultiUserKeyWallet, 'getEncryptedUserKeychain');
2253+
getEncryptedUserKeychainStub.rejects(new Error('getEncryptedUserKeychain should not be called'));
21862254

2187-
await ofcWallet.createShare(createShareParams);
2255+
await ofcMultiUserKeyWallet.shareWallet({ email, permissions });
21882256

2189-
createShareNock.isDone().should.be.True();
2257+
// Verify keychain preparation methods are not called
2258+
prepareSharedKeychainStub.called.should.be.false();
2259+
getEncryptedUserKeychainStub.called.should.be.false();
2260+
getSharingKeyNock.isDone().should.be.True();
2261+
createShareNock.isDone().should.be.True();
2262+
});
2263+
2264+
it('should pass skipKeychain=true and undefined keychain to createShare', async function () {
2265+
const getSharingKeyNock = nock(bgUrl)
2266+
.post('/api/v1/user/sharingkey', { email })
2267+
.reply(200, { userId, pubkey: 'testpubkey', path: 'm/999999/1/1' });
2268+
2269+
// Stub getEncryptedUserKeychain to prevent any keychain fetching
2270+
const getEncryptedUserKeychainStub = sinon.stub(ofcMultiUserKeyWallet, 'getEncryptedUserKeychain');
2271+
getEncryptedUserKeychainStub.rejects(new Error('getEncryptedUserKeychain should not be called'));
2272+
2273+
const createShareStub = sinon.stub(ofcMultiUserKeyWallet, 'createShare').callsFake(async (options) => {
2274+
options!.skipKeychain!.should.equal(true);
2275+
should(options!.keychain).be.undefined();
2276+
return undefined;
2277+
});
2278+
2279+
await ofcMultiUserKeyWallet.shareWallet({ email, permissions });
2280+
2281+
getEncryptedUserKeychainStub.called.should.be.false();
2282+
createShareStub.calledOnce.should.be.true();
2283+
getSharingKeyNock.isDone().should.be.True();
2284+
});
2285+
});
2286+
2287+
describe('non-multi-user-key wallets', function () {
2288+
it('should include keychain when wallet passphrase is provided', async function () {
2289+
const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
2290+
const path = 'm/999999/1/1';
2291+
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');
2292+
const walletPassphrase = 'bitgo1234';
2293+
const pub = 'Zo1ggzTUKMY5bYnDvT5mtVeZxzf2FaLTbKkmvGUhUQk';
2294+
2295+
const getSharingKeyNock = nock(bgUrl)
2296+
.post('/api/v1/user/sharingkey', { email })
2297+
.reply(200, { userId, pubkey, path });
2298+
2299+
const getKeyNock = nock(bgUrl)
2300+
.get(`/api/v2/ofc/key/${nonMultiUserKeyWallet.keyIds()[0]}`)
2301+
.reply(200, {
2302+
id: nonMultiUserKeyWallet.keyIds()[0],
2303+
pub,
2304+
source: 'user',
2305+
encryptedPrv: bitgo.encrypt({ input: 'xprv1', password: walletPassphrase }),
2306+
coinSpecific: {},
2307+
});
2308+
2309+
const createShareStub = sinon.stub(nonMultiUserKeyWallet, 'createShare').callsFake(async (options) => {
2310+
// For non-multi-user-key wallets, keychain should be present when spend permissions are included
2311+
options!.keychain!.should.not.be.undefined();
2312+
options!.keychain!.pub!.should.equal(pub);
2313+
return undefined;
2314+
});
2315+
2316+
await nonMultiUserKeyWallet.shareWallet({ email, permissions, walletPassphrase });
2317+
2318+
createShareStub.calledOnce.should.be.true();
2319+
getSharingKeyNock.isDone().should.be.True();
2320+
getKeyNock.isDone().should.be.True();
2321+
});
2322+
});
21902323
});
21912324

2192-
it('should handle empty keychain object for multi-user-key wallets', async function () {
2193-
const createShareParams = {
2194-
user: userId,
2195-
permissions,
2196-
keychain: {},
2197-
};
2325+
describe('multi-user-key detection', function () {
2326+
it('should detect multi-user-key wallet via coinSpecific.features array', async function () {
2327+
const ofcCoin: any = bitgo.coin('ofc');
2328+
const walletWithMultiUserKeyFeature = new Wallet(bitgo, ofcCoin, {
2329+
id: '5b34252f1bf349930e34020a00000004',
2330+
coin: 'ofc',
2331+
keys: ['5b3424f91bf349930e34017500000003'],
2332+
coinSpecific: {
2333+
features: ['multi-user-key'],
2334+
},
2335+
type: 'hot',
2336+
});
21982337

2199-
const createShareNock = nock(bgUrl)
2200-
.post(`/api/v2/ofc/wallet/${ofcMultiUserKeyWallet.id()}/share`, (body) => {
2201-
// Verify that keychain is not included in the request even if passed as empty
2202-
body.should.not.have.property('keychain');
2203-
body.user.should.equal(userId);
2204-
body.permissions.should.equal(permissions);
2205-
return true;
2206-
})
2207-
.reply(200, {});
2338+
const walletWithoutFeature = new Wallet(bitgo, ofcCoin, {
2339+
id: '5b34252f1bf349930e34020a00000005',
2340+
coin: 'ofc',
2341+
keys: ['5b3424f91bf349930e34017500000004'],
2342+
coinSpecific: {
2343+
features: ['some-other-feature'],
2344+
},
2345+
type: 'hot',
2346+
});
2347+
2348+
const walletWithoutCoinSpecific = new Wallet(bitgo, ofcCoin, {
2349+
id: '5b34252f1bf349930e34020a00000006',
2350+
coin: 'ofc',
2351+
keys: ['5b3424f91bf349930e34017500000005'],
2352+
coinSpecific: {},
2353+
type: 'hot',
2354+
});
2355+
2356+
const getSharingKeyNock = nock(bgUrl)
2357+
.post('/api/v1/user/sharingkey', { email })
2358+
.times(3)
2359+
.reply(200, { userId, pubkey: 'testpubkey', path: 'm/999999/1/1' });
2360+
2361+
// Multi-user-key wallet should skip keychain
2362+
const getEncryptedUserKeychainStub1 = sinon.stub(walletWithMultiUserKeyFeature, 'getEncryptedUserKeychain');
2363+
getEncryptedUserKeychainStub1.rejects(new Error('getEncryptedUserKeychain should not be called'));
2364+
const createShareStub1 = sinon
2365+
.stub(walletWithMultiUserKeyFeature, 'createShare')
2366+
.callsFake(async (options) => {
2367+
options!.skipKeychain!.should.equal(true);
2368+
return undefined;
2369+
});
2370+
await walletWithMultiUserKeyFeature.shareWallet({ email, permissions });
2371+
getEncryptedUserKeychainStub1.called.should.be.false();
2372+
createShareStub1.calledOnce.should.be.true();
2373+
2374+
// Wallet without multi-user-key feature should respect skipKeychain flag
2375+
const createShareStub2 = sinon.stub(walletWithoutFeature, 'createShare').callsFake(async (options) => {
2376+
return undefined;
2377+
});
2378+
await walletWithoutFeature.shareWallet({ email, permissions, skipKeychain: true });
2379+
createShareStub2.calledOnce.should.be.true();
22082380

2209-
await ofcMultiUserKeyWallet.createShare(createShareParams);
2381+
// Wallet without coinSpecific should not be detected as multi-user-key
2382+
const createShareStub3 = sinon.stub(walletWithoutCoinSpecific, 'createShare').callsFake(async (options) => {
2383+
return undefined;
2384+
});
2385+
await walletWithoutCoinSpecific.shareWallet({ email, permissions, skipKeychain: true });
2386+
createShareStub3.calledOnce.should.be.true();
22102387

2211-
createShareNock.isDone().should.be.True();
2388+
getSharingKeyNock.isDone().should.be.True();
2389+
});
22122390
});
22132391
});
22142392
});

modules/sdk-core/src/bitgo/wallet/wallet.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1810,7 +1810,14 @@ export class Wallet implements IWallet {
18101810
if (params.skipKeychain !== undefined && !_.isBoolean(params.skipKeychain)) {
18111811
throw new Error('Expected skipKeychain to be a boolean. ');
18121812
}
1813-
const needsKeychain = !params.skipKeychain && params.permissions && params.permissions.indexOf('spend') !== -1;
1813+
1814+
// Check if this is a multi-user-key OFC wallet
1815+
// For multi-user-key wallets, skip keychain preparation regardless of other conditions
1816+
const needsKeychain =
1817+
!this.isMultiUserKeyWallet() &&
1818+
!params.skipKeychain &&
1819+
params.permissions &&
1820+
params.permissions.indexOf('spend') !== -1;
18141821

18151822
if (params.disableEmail !== undefined && !_.isBoolean(params.disableEmail)) {
18161823
throw new Error('Expected disableEmail to be a boolean.');

0 commit comments

Comments
 (0)