@@ -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 ( / ^ s h a r e \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 ( / ^ s h a r e \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 ( ) {
0 commit comments