Skip to content

Commit 236afa6

Browse files
feat(sdk-core): update wallet share acceptance for multi-user-key
TICKET: WP-6769
1 parent b9fdf1d commit 236afa6

File tree

4 files changed

+240
-0
lines changed

4 files changed

+240
-0
lines changed

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

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1728,6 +1728,198 @@ describe('V2 Wallets:', function () {
17281728
});
17291729
});
17301730

1731+
describe('Wallet share where userMultiKeyRotationRequired is set true', () => {
1732+
const sandbox = sinon.createSandbox();
1733+
1734+
afterEach(function () {
1735+
sandbox.verifyAndRestore();
1736+
});
1737+
1738+
it('should throw error when password not provided', async function () {
1739+
const shareId = 'test_multi_key_1';
1740+
1741+
const walletShareNock = nock(bgUrl)
1742+
.get(`/api/v2/tbtc/walletshare/${shareId}`)
1743+
.reply(200, {
1744+
userMultiKeyRotationRequired: true,
1745+
permissions: ['admin', 'spend', 'view'],
1746+
});
1747+
1748+
await wallets
1749+
.acceptShare({ walletShareId: shareId })
1750+
.should.be.rejectedWith('userPassword param must be provided to generate user keychain');
1751+
walletShareNock.done();
1752+
});
1753+
1754+
it('should successfully accept share with userMultiKeyRotationRequired and send pub and encryptedPrv', async function () {
1755+
const shareId = 'test_multi_key_2';
1756+
const userPassword = 'test_password_123';
1757+
const walletId = 'test_wallet_123';
1758+
1759+
// Mock wallet share response
1760+
const walletShareNock = nock(bgUrl)
1761+
.get(`/api/v2/tbtc/walletshare/${shareId}`)
1762+
.reply(200, {
1763+
userMultiKeyRotationRequired: true,
1764+
permissions: ['admin', 'spend', 'view'],
1765+
wallet: walletId,
1766+
});
1767+
1768+
// Mock keychain creation - baseCoin.keychains().create() returns { prv, pub }
1769+
// We need to create a proper keychain object with prv property
1770+
const testKeychain = bitgo.keychains().create();
1771+
const keychain = {
1772+
prv: testKeychain.xprv, // baseCoin.keychains().create() returns prv (not xprv), but for BTC it's the same
1773+
pub: testKeychain.xpub,
1774+
};
1775+
const encryptedPrv = bitgo.encrypt({ input: keychain.prv, password: userPassword });
1776+
1777+
// Mock the updateShare API call
1778+
const acceptShareNock = nock(bgUrl)
1779+
.post(`/api/v2/tbtc/walletshare/${shareId}`, (body: any) => {
1780+
// Verify that pub and encryptedPrv are included
1781+
if (body.walletShareId !== shareId || body.state !== 'accepted' || !body.pub || !body.encryptedPrv) {
1782+
return false;
1783+
}
1784+
// Verify pub is a valid format (xpub for BTC)
1785+
if (!body.pub.startsWith('xpub')) {
1786+
return false;
1787+
}
1788+
return true;
1789+
})
1790+
.reply(200, { changed: true, state: 'accepted' });
1791+
1792+
// Stub keychains().create() to return our test keychain
1793+
const keychainsStub = sandbox.stub(wallets.baseCoin.keychains(), 'create').resolves(keychain);
1794+
1795+
// Stub bitgo.encrypt to return our encrypted value
1796+
const encryptStub = sandbox.stub(bitgo, 'encrypt').returns(encryptedPrv);
1797+
1798+
const res = await wallets.acceptShare({ walletShareId: shareId, userPassword });
1799+
should.equal(res.changed, true);
1800+
should.equal(res.state, 'accepted');
1801+
1802+
// Verify keychain creation was called
1803+
should.equal(keychainsStub.calledOnce, true);
1804+
// Verify encrypt was called with the correct parameters
1805+
should.equal(encryptStub.calledWith({ input: keychain.prv, password: userPassword }), true);
1806+
1807+
walletShareNock.done();
1808+
acceptShareNock.done();
1809+
});
1810+
1811+
it('should NOT trigger reshare when userMultiKeyRotationRequired is true', async function () {
1812+
const shareId = 'test_multi_key_3';
1813+
const userPassword = 'test_password_123';
1814+
const walletId = 'test_wallet_123';
1815+
1816+
const walletShareNock = nock(bgUrl)
1817+
.get(`/api/v2/tbtc/walletshare/${shareId}`)
1818+
.reply(200, {
1819+
userMultiKeyRotationRequired: true,
1820+
permissions: ['admin', 'spend', 'view'],
1821+
wallet: walletId,
1822+
});
1823+
1824+
const testKeychain = bitgo.keychains().create();
1825+
const keychain = {
1826+
prv: testKeychain.xprv,
1827+
pub: testKeychain.xpub,
1828+
};
1829+
const encryptedPrv = bitgo.encrypt({ input: keychain.prv, password: userPassword });
1830+
1831+
const acceptShareNock = nock(bgUrl)
1832+
.post(`/api/v2/tbtc/walletshare/${shareId}`)
1833+
.reply(200, { changed: true, state: 'accepted' });
1834+
1835+
sandbox.stub(wallets.baseCoin.keychains(), 'create').resolves(keychain);
1836+
sandbox.stub(bitgo, 'encrypt').returns(encryptedPrv);
1837+
1838+
// Stub reshareWalletWithSpenders to verify it's NOT called
1839+
const reshareStub = sandbox.stub(Wallets.prototype, 'reshareWalletWithSpenders');
1840+
1841+
const res = await wallets.acceptShare({ walletShareId: shareId, userPassword });
1842+
should.equal(res.changed, true);
1843+
should.equal(res.state, 'accepted');
1844+
1845+
// Verify reshare was NOT called (unlike keychainOverrideRequired case)
1846+
should.equal(reshareStub.called, false);
1847+
1848+
walletShareNock.done();
1849+
acceptShareNock.done();
1850+
});
1851+
1852+
it('should handle bulk accept share with userMultiKeyRotationRequired', async function () {
1853+
const shareId = 'test_multi_key_bulk_1';
1854+
const userPassword = 'test_password_123';
1855+
1856+
// Mock listSharesV2 to return a share with userMultiKeyRotationRequired
1857+
// Note: userMultiKeyRotationRequired shares don't have a keychain, so they won't be filtered
1858+
// by the bulkAcceptShare filter, but processAcceptShare will handle them
1859+
sinon.stub(Wallets.prototype, 'listSharesV2').resolves({
1860+
incoming: [
1861+
{
1862+
id: shareId,
1863+
coin: 'tbtc',
1864+
walletLabel: 'test wallet',
1865+
fromUser: 'fromUser',
1866+
toUser: 'toUser',
1867+
wallet: 'wallet123',
1868+
permissions: ['admin', 'spend', 'view'],
1869+
state: 'active',
1870+
userMultiKeyRotationRequired: true,
1871+
// No keychain - this is the key difference for multi-user-key shares
1872+
},
1873+
],
1874+
outgoing: [],
1875+
});
1876+
1877+
const testKeychain = bitgo.keychains().create();
1878+
const keychain = {
1879+
prv: testKeychain.xprv,
1880+
pub: testKeychain.xpub,
1881+
};
1882+
const encryptedPrv = bitgo.encrypt({ input: keychain.prv, password: userPassword });
1883+
1884+
// Mock bulk update API - this is called via bulkUpdateWalletShare
1885+
nock(bgUrl)
1886+
.put('/api/v2/walletshares/update', (body: any) => {
1887+
if (!body.shares || body.shares.length !== 1) {
1888+
return false;
1889+
}
1890+
const share = body.shares[0];
1891+
return (
1892+
share.walletShareId === shareId &&
1893+
share.status === 'accept' &&
1894+
share.pub === keychain.pub &&
1895+
share.encryptedPrv === encryptedPrv
1896+
);
1897+
})
1898+
.reply(200, {
1899+
acceptedWalletShares: [{ walletShareId: shareId }],
1900+
rejectedWalletShares: [],
1901+
walletShareUpdateErrors: [],
1902+
});
1903+
1904+
const keychainsStub = sandbox.stub(wallets.baseCoin.keychains(), 'create').resolves(keychain);
1905+
const encryptStub = sandbox.stub(bitgo, 'encrypt').returns(encryptedPrv);
1906+
1907+
// Note: bulkAcceptShare filters for shares WITH keychains, but userMultiKeyRotationRequired
1908+
// shares don't have keychains, so they go through a different path
1909+
// We need to stub the processAcceptShare path or use bulkUpdateWalletShare directly
1910+
// For this test, we'll use bulkUpdateWalletShare which calls processAcceptShare internally
1911+
const result = await wallets.bulkUpdateWalletShare({
1912+
shares: [{ walletShareId: shareId, status: 'accept' }],
1913+
userLoginPassword: userPassword,
1914+
});
1915+
1916+
should.equal(result.acceptedWalletShares.length, 1);
1917+
should.deepEqual(result.acceptedWalletShares[0], { walletShareId: 'test_multi_key_bulk_1' });
1918+
should.equal(keychainsStub.calledOnce, true);
1919+
should.equal(encryptStub.calledWith({ input: keychain.prv, password: userPassword }), true);
1920+
});
1921+
});
1922+
17311923
it('should share a wallet to viewer', async function () {
17321924
const shareId = '12311';
17331925

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,7 @@ export interface WalletShare {
658658
message?: string;
659659
pendingApprovalId?: string;
660660
keychainOverrideRequired?: boolean;
661+
userMultiKeyRotationRequired?: boolean;
661662
isUMSInitiated?: boolean;
662663
keychain?: BulkWalletShareKeychain;
663664
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export interface UpdateShareOptions {
120120
keyId?: string;
121121
signature?: string;
122122
payload?: string;
123+
pub?: string;
123124
}
124125

125126
export interface AcceptShareOptions {
@@ -156,6 +157,7 @@ export interface BulkUpdateWalletShareOptionsRequest {
156157
keyId?: string;
157158
signature?: string;
158159
payload?: string;
160+
pub?: string;
159161
}
160162

161163
export interface BulkUpdateWalletShareResponse {

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,29 @@ export class Wallets implements IWallets {
927927
}
928928
return response;
929929
}
930+
// Multi-user-key case: requires user to provide their own public key in addition to the encrypted private key
931+
if (walletShare.userMultiKeyRotationRequired) {
932+
if (_.isUndefined(params.userPassword)) {
933+
throw new Error('userPassword param must be provided to generate user keychain');
934+
}
935+
936+
const walletKeychain = await this.baseCoin.keychains().create();
937+
const encryptedPrv = this.bitgo.encrypt({
938+
password: params.userPassword,
939+
input: walletKeychain.prv,
940+
});
941+
942+
const updateParams: UpdateShareOptions = {
943+
walletShareId: params.walletShareId,
944+
state: 'accepted',
945+
encryptedPrv: encryptedPrv,
946+
pub: walletKeychain.pub,
947+
};
948+
949+
// Note: Unlike keychainOverrideRequired, we do NOT reshare the wallet with spenders
950+
// This is a key difference - multi-user-key wallets don't require reshare
951+
return this.updateShare(updateParams);
952+
}
930953
// Return right away if there is no keychain to decrypt, or if explicit encryptedPrv was provided
931954
if (!walletShare.keychain || !walletShare.keychain.encryptedPrv || encryptedPrv) {
932955
return this.updateShare({
@@ -1292,6 +1315,28 @@ export class Wallets implements IWallets {
12921315
];
12931316
}
12941317

1318+
// Multi-user-key case: requires user to provide their own public key
1319+
if (walletShare.userMultiKeyRotationRequired) {
1320+
if (!userLoginPassword) {
1321+
throw new Error('userLoginPassword param must be provided to generate user keychain');
1322+
}
1323+
1324+
const walletKeychain = await this.baseCoin.keychains().create();
1325+
const encryptedPrv = this.bitgo.encrypt({
1326+
password: userLoginPassword,
1327+
input: walletKeychain.prv,
1328+
});
1329+
1330+
return [
1331+
{
1332+
walletShareId,
1333+
status: 'accept' as const,
1334+
encryptedPrv: encryptedPrv,
1335+
pub: walletKeychain.pub,
1336+
},
1337+
];
1338+
}
1339+
12951340
// Return right away if there is no keychain to decrypt
12961341
if (!walletShare.keychain || !walletShare.keychain.encryptedPrv) {
12971342
return [

0 commit comments

Comments
 (0)