Skip to content

Commit b22bb38

Browse files
authored
feat(sdk-core): add retry logic for bulk accept share requests
2 parents 0de7dc1 + 87357ac commit b22bb38

File tree

2 files changed

+341
-6
lines changed

2 files changed

+341
-6
lines changed

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

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
makeRandomKey,
2323
getSharedSecret,
2424
BulkWalletShareOptions,
25+
AcceptShareOptionsRequest,
2526
KeychainWithEncryptedPrv,
2627
WalletWithKeychains,
2728
multisigTypes,
@@ -1872,6 +1873,283 @@ describe('V2 Wallets:', function () {
18721873
],
18731874
});
18741875
});
1876+
1877+
it('should handle 413 payload too large error with smart retry', async () => {
1878+
const walletPassphrase = 'bitgo1234';
1879+
const fromUserPrv = Math.random();
1880+
const keychainTest: OptionalKeychainEncryptedKey = {
1881+
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
1882+
};
1883+
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
1884+
if (!userPrv) {
1885+
throw new Error('Unable to decrypt user keychain');
1886+
}
1887+
1888+
const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
1889+
const path = 'm/999999/1/1';
1890+
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');
1891+
1892+
const eckey = makeRandomKey();
1893+
const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex');
1894+
// Pad the private key with additional data to make it larger before encrypting
1895+
const newEncryptedPrv = bitgo.encrypt({ password: secret, input: userPrv });
1896+
const keychain = {
1897+
path: path,
1898+
fromPubKey: eckey.publicKey.toString('hex'),
1899+
encryptedPrv: newEncryptedPrv,
1900+
toPubKey: pubkey,
1901+
pub: pubkey,
1902+
};
1903+
const shareIds = Array.from({ length: 20 }, (_, i) => `share${i + 1}`);
1904+
1905+
// Mock listSharesV2 to return 25 shares
1906+
const shares = shareIds.map((id, index) => ({
1907+
id,
1908+
coin: 'tsol',
1909+
walletLabel: `testing${index}`,
1910+
fromUser: 'dummyFromUser',
1911+
toUser: 'dummyToUser',
1912+
wallet: `wallet${index}`,
1913+
permissions: ['spend'],
1914+
state: 'active' as const,
1915+
keychain: keychain,
1916+
}));
1917+
1918+
sinon.stub(Wallets.prototype, 'listSharesV2').resolves({
1919+
incoming: shares,
1920+
outgoing: [],
1921+
});
1922+
1923+
const myEcdhKeychain = await bitgo.keychains().create();
1924+
sinon.stub(bitgo, 'getECDHKeychain').resolves({
1925+
encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
1926+
});
1927+
1928+
const prvKey = bitgo.decrypt({
1929+
password: walletPassphrase,
1930+
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
1931+
});
1932+
sinon.stub(bitgo, 'decrypt').returns(prvKey);
1933+
sinon.stub(bitgo, 'encrypt').returns(userPrv + 'X'.repeat(100000));
1934+
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
1935+
1936+
// Mock bulkAcceptShareRequestWithRetry to track batch sizes
1937+
const batchSizes: number[] = [];
1938+
1939+
nock(bgUrl)
1940+
.persist() // This ensures the interceptor remains active for multiple requests
1941+
.put('/api/v2/walletshares/accept')
1942+
.reply(function (_, requestBody, cb) {
1943+
const params = requestBody['keysForWalletShares'] as AcceptShareOptionsRequest[];
1944+
batchSizes.push(params.length);
1945+
if (Buffer.byteLength(JSON.stringify(requestBody), 'utf8') > 950000) {
1946+
// Simulate 413 error
1947+
return cb(null, [413, { error: 'Request Entity Too Large' }]);
1948+
}
1949+
// Return success for smaller batches
1950+
return cb(null, [
1951+
200,
1952+
{
1953+
acceptedWalletShares: params.map((param) => ({
1954+
walletShareId: param.walletShareId,
1955+
})),
1956+
},
1957+
]);
1958+
});
1959+
1960+
const result = await wallets.bulkAcceptShare({
1961+
walletShareIds: shareIds,
1962+
userLoginPassword: walletPassphrase,
1963+
});
1964+
1965+
// Should have tried with 20 (initial batch size for 25 items), then retried with smaller batches
1966+
batchSizes.length.should.be.greaterThan(1);
1967+
batchSizes.should.deepEqual([9, 9, 2]); // Initial batch size// Retry batches should be smaller
1968+
1969+
result.should.have.property('acceptedWalletShares');
1970+
result.acceptedWalletShares.should.be.an.Array();
1971+
result.acceptedWalletShares.length.should.equal(20);
1972+
result.acceptedWalletShares.forEach((share) => {
1973+
share.should.have.property('walletShareId');
1974+
share.walletShareId.should.match(/^share\d+$/);
1975+
});
1976+
});
1977+
1978+
it('should retry with progressively smaller batch sizes on 413 errors', async () => {
1979+
const walletPassphrase = 'bitgo1234';
1980+
const fromUserPrv = Math.random();
1981+
const keychainTest: OptionalKeychainEncryptedKey = {
1982+
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
1983+
};
1984+
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
1985+
if (!userPrv) {
1986+
throw new Error('Unable to decrypt user keychain');
1987+
}
1988+
1989+
const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
1990+
const path = 'm/999999/1/1';
1991+
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');
1992+
1993+
const eckey = makeRandomKey();
1994+
const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex');
1995+
const newEncryptedPrv = bitgo.encrypt({ password: secret, input: userPrv });
1996+
const keychain = {
1997+
path: path,
1998+
fromPubKey: eckey.publicKey.toString('hex'),
1999+
encryptedPrv: newEncryptedPrv,
2000+
toPubKey: pubkey,
2001+
pub: pubkey,
2002+
};
2003+
const shareIds = Array.from({ length: 20 }, (_, i) => `share${i + 1}`);
2004+
2005+
// Mock listSharesV2
2006+
const shares = shareIds.map((id, index) => ({
2007+
id,
2008+
coin: 'tsol',
2009+
walletLabel: `testing${index}`,
2010+
fromUser: 'dummyFromUser',
2011+
toUser: 'dummyToUser',
2012+
wallet: `wallet${index}`,
2013+
permissions: ['spend'],
2014+
state: 'active' as const,
2015+
keychain: keychain,
2016+
}));
2017+
2018+
sinon.stub(Wallets.prototype, 'listSharesV2').resolves({
2019+
incoming: shares,
2020+
outgoing: [],
2021+
});
2022+
2023+
const myEcdhKeychain = await bitgo.keychains().create();
2024+
sinon.stub(bitgo, 'getECDHKeychain').resolves({
2025+
encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
2026+
});
2027+
2028+
const prvKey = bitgo.decrypt({
2029+
password: walletPassphrase,
2030+
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
2031+
});
2032+
sinon.stub(bitgo, 'decrypt').returns(prvKey);
2033+
sinon.stub(bitgo, 'encrypt').returns(userPrv + 'X'.repeat(100000));
2034+
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
2035+
2036+
// Track the sequence of batch sizes attempted
2037+
const batchSizeAttempts: number[] = [];
2038+
2039+
nock(bgUrl)
2040+
.persist() // This ensures the interceptor remains active for multiple requests
2041+
.put('/api/v2/walletshares/accept')
2042+
.reply(function (_, requestBody: any, cb) {
2043+
const params = requestBody['keysForWalletShares'] as AcceptShareOptionsRequest[];
2044+
batchSizeAttempts.push(params.length);
2045+
2046+
// Simulate 413 for batches > 5, success for batches <= 5
2047+
if (Buffer.byteLength(JSON.stringify(requestBody), 'utf8') > 600000) {
2048+
// Simulate 413 error
2049+
return cb(null, [413, { error: 'Request Entity Too Large' }]);
2050+
}
2051+
2052+
// Return success for smaller batches
2053+
return cb(null, [
2054+
200,
2055+
{
2056+
acceptedWalletShares: params.map((param) => ({
2057+
walletShareId: param.walletShareId || 'test',
2058+
})),
2059+
},
2060+
]);
2061+
});
2062+
2063+
const result = await wallets.bulkAcceptShare({
2064+
walletShareIds: shareIds,
2065+
userLoginPassword: walletPassphrase,
2066+
});
2067+
2068+
// Should see progressive batch size reduction: 20 -> 10 -> 5 (success)
2069+
batchSizeAttempts.should.containDeep([9, 4, 4, 4, 4, 4]);
2070+
2071+
result.should.have.property('acceptedWalletShares');
2072+
result.acceptedWalletShares.should.be.an.Array();
2073+
result.acceptedWalletShares.length.should.equal(20);
2074+
result.acceptedWalletShares.forEach((share) => {
2075+
share.should.have.property('walletShareId');
2076+
share.walletShareId.should.match(/^share\d+$/);
2077+
});
2078+
});
2079+
2080+
it('should throw error when batch size cannot be reduced further', async () => {
2081+
const walletPassphrase = 'bitgo1234';
2082+
const fromUserPrv = Math.random();
2083+
const keychainTest: OptionalKeychainEncryptedKey = {
2084+
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
2085+
};
2086+
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
2087+
if (!userPrv) {
2088+
throw new Error('Unable to decrypt user keychain');
2089+
}
2090+
2091+
const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
2092+
const path = 'm/999999/1/1';
2093+
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');
2094+
2095+
const eckey = makeRandomKey();
2096+
const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex');
2097+
const newEncryptedPrv = bitgo.encrypt({ password: secret, input: userPrv });
2098+
const keychain = {
2099+
path: path,
2100+
fromPubKey: eckey.publicKey.toString('hex'),
2101+
encryptedPrv: newEncryptedPrv,
2102+
toPubKey: pubkey,
2103+
pub: pubkey,
2104+
};
2105+
const shareIds = ['share1'];
2106+
2107+
// Mock listSharesV2
2108+
sinon.stub(Wallets.prototype, 'listSharesV2').resolves({
2109+
incoming: [
2110+
{
2111+
id: 'share1',
2112+
coin: 'tsol',
2113+
walletLabel: 'testing',
2114+
fromUser: 'dummyFromUser',
2115+
toUser: 'dummyToUser',
2116+
wallet: 'wallet1',
2117+
permissions: ['spend'],
2118+
state: 'active',
2119+
keychain: keychain,
2120+
},
2121+
],
2122+
outgoing: [],
2123+
});
2124+
2125+
const myEcdhKeychain = await bitgo.keychains().create();
2126+
sinon.stub(bitgo, 'getECDHKeychain').resolves({
2127+
encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
2128+
});
2129+
2130+
const prvKey = bitgo.decrypt({
2131+
password: walletPassphrase,
2132+
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
2133+
});
2134+
sinon.stub(bitgo, 'decrypt').returns(prvKey);
2135+
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
2136+
2137+
// Always throw 413 error, even for batch size 1
2138+
nock(bgUrl)
2139+
.persist()
2140+
.put('/api/v2/walletshares/accept')
2141+
.reply(function (_, _requestBody, cb) {
2142+
// Always respond with 413 error
2143+
return cb(null, [413, { error: 'Request Entity Too Large' }]);
2144+
});
2145+
2146+
await wallets
2147+
.bulkAcceptShare({
2148+
walletShareIds: shareIds,
2149+
userLoginPassword: walletPassphrase,
2150+
})
2151+
.should.be.rejectedWith('Request Entity Too Large');
2152+
});
18752153
});
18762154

18772155
describe('bulkUpdateWalletShare', function () {

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

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -639,12 +639,69 @@ export class Wallets implements IWallets {
639639
* @returns {Promise<BulkAcceptShareResponse>}
640640
*/
641641
async bulkAcceptShareRequest(params: AcceptShareOptionsRequest[]): Promise<BulkAcceptShareResponse> {
642-
return await this.bitgo
643-
.put(this.bitgo.url('/walletshares/accept', 2))
644-
.send({
645-
keysForWalletShares: params,
646-
})
647-
.result();
642+
return await this.bulkAcceptShareRequestWithRetry(params);
643+
}
644+
645+
private async bulkAcceptShareRequestWithRetry(params: AcceptShareOptionsRequest[]): Promise<BulkAcceptShareResponse> {
646+
// Server has a limit of approximately 1MB for payload size
647+
let MAX_PAYLOAD_SIZE = 950000; // ~950KB to leave some buffer
648+
649+
// Function to calculate the size of a payload
650+
const calculatePayloadSize = (items: AcceptShareOptionsRequest[]): number => {
651+
return Buffer.byteLength(JSON.stringify({ keysForWalletShares: items }), 'utf8');
652+
};
653+
654+
const results: any[] = [];
655+
const remainingParams = [...params];
656+
657+
while (remainingParams.length > 0) {
658+
// Build optimal batch by adding items until we reach size limit
659+
const batch: AcceptShareOptionsRequest[] = [];
660+
// Start with empty batch
661+
662+
// Add items one by one while monitoring payload size
663+
while (remainingParams.length > 0) {
664+
// Test adding the next item
665+
const testBatch = [...batch, remainingParams[0]];
666+
const testSize = calculatePayloadSize(testBatch);
667+
668+
// If adding this item would exceed the size limit, stop adding
669+
if (testSize > MAX_PAYLOAD_SIZE && batch.length > 0) {
670+
break;
671+
}
672+
673+
// Otherwise, add the item to the batch
674+
batch.push(remainingParams.shift()!);
675+
}
676+
677+
// Handle case where even a single item is too large
678+
if (batch.length === 0 && remainingParams.length > 0) {
679+
// Send just the first item even if it's oversized
680+
batch.push(remainingParams.shift()!);
681+
}
682+
683+
const payloadObj = { keysForWalletShares: batch };
684+
685+
try {
686+
const result = await this.bitgo.put(this.bitgo.url('/walletshares/accept', 2)).send(payloadObj).result();
687+
688+
if (result.acceptedWalletShares && Array.isArray(result.acceptedWalletShares)) {
689+
results.push(...result.acceptedWalletShares);
690+
}
691+
} catch (error: any) {
692+
if (error.status === 413 && batch.length > 1) {
693+
// If we still get 413 with multiple items, put them back and try with half the batch size
694+
remainingParams.unshift(...batch);
695+
MAX_PAYLOAD_SIZE = Math.floor(MAX_PAYLOAD_SIZE / 2); // Reduce size limit for next attempt
696+
continue;
697+
}
698+
throw error;
699+
}
700+
}
701+
702+
return {
703+
acceptedWalletShares: results,
704+
};
648705
}
649706

650707
async bulkUpdateWalletShareRequest(

0 commit comments

Comments
 (0)