From 6468395cb6c99355d99fce4e06b698bfd5fa1cb8 Mon Sep 17 00:00:00 2001 From: Barath Jawahar Date: Mon, 4 May 2026 12:33:13 +0000 Subject: [PATCH 1/2] fix(sdk-coin-sol): account for tx fee in max spendable calculation The Solana fee payer is charged the transaction fee on top of the transfer amount. The server-side maximumSpendable API was returning balance minus the rent-exempt reserve without subtracting the transaction fee, so submitting that amount left an unsendable dust residue equal to approximately one transaction fee. Add Sol.getMaximumSpendable() which queries the Solana node for the current balance, builds a representative transfer, fetches the fee via getFeeForMessage, and returns balance - fee. Add an optional getMaximumSpendable hook on BaseCoin/IBaseCoin so any coin can supply its own computation. Update wallet.ts sweep() to use the coin-level result when available, falling back to the BitGo API. Ticket: COIN-88 Co-Authored-By: Claude Opus 4.7 --- modules/sdk-coin-sol/src/sol.ts | 41 ++++++++++++++++++ modules/sdk-coin-sol/test/unit/sol.ts | 42 +++++++++++++++++++ .../sdk-core/src/bitgo/baseCoin/baseCoin.ts | 16 +++++++ .../sdk-core/src/bitgo/baseCoin/iBaseCoin.ts | 1 + modules/sdk-core/src/bitgo/wallet/wallet.ts | 27 ++++++++++-- 5 files changed, 124 insertions(+), 3 deletions(-) diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 8cb1651010..3698a17674 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -1029,6 +1029,47 @@ export class Sol extends BaseCoin { return response.body.result.value; } + /** + * Compute the maximum spendable balance for a SOL wallet address. + * + * The Solana fee payer pays fees on top of the transfer amount, so the true + * maximum sendable amount is: balance - transactionFee. + * + * We build a representative transfer transaction, ask the Solana node for its + * fee, then subtract that fee from the on-chain balance. This is the same + * approach used in the self-custody `recover()` flow. + * + * @param walletAddress - the base58-encoded Solana account public key + * @param apiKey - optional Alchemy API key for node requests + * @returns maximum spendable amount in lamports + */ + async getMaximumSpendable(walletAddress: string, apiKey?: string): Promise { + const balance = await this.getAccountBalance(walletAddress, apiKey); + if (balance === 0) { + return 0; + } + + const blockhash = await this.getBlockhash(apiKey); + const factory = this.getBuilder(); + + // Build a representative transfer so we can ask the node for its fee. + // The exact destination and amount do not affect the fee for a simple + // SOL transfer, so we use the sender as a placeholder destination. + const txBuilder = factory + .getTransferBuilder() + .nonce(blockhash) + .sender(walletAddress) + .send({ address: walletAddress, amount: balance.toString() }) + .feePayer(walletAddress); + + const unsignedTx = (await txBuilder.build()) as Transaction; + const serializedMessage = unsignedTx.solTransaction.serializeMessage().toString('base64'); + const fee = await this.getFeeForMessage(serializedMessage, apiKey); + + const maximumSpendable = balance - fee; + return maximumSpendable > 0 ? maximumSpendable : 0; + } + protected async getAccountInfo(pubKey: string, apiKey?: string): Promise { const response = await this.getDataFromNode( { diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index 8c793efad2..528c6c468b 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -3851,4 +3851,46 @@ describe('SOL:', function () { address.should.equal(expectedAddress); }); }); + + describe('getMaximumSpendable', () => { + const sandBox = sinon.createSandbox(); + const walletAddress = testData.accountInfo.bs58EncodedPublicKey; + // balance: 1_000_000_000 lamports (1 SOL) + const balance = testData.SolResponses.getAccountBalanceResponse.body.result.value; + // fee: 5_000 lamports (one signature) + const fee = testData.SolResponses.getFeesForMessageResponse.body.result.value; + + beforeEach(() => { + sandBox.stub(Sol.prototype as any, 'getAccountBalance').callsFake(async (...args: unknown[]) => { + const pubKey = args[0] as string; + if (pubKey === testData.accountInfo.bs58EncodedPublicKeyNoFunds) return 0; + return balance; + }); + + sandBox.stub(Sol.prototype as any, 'getBlockhash').resolves('GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'); + + sandBox.stub(Sol.prototype as any, 'getFeeForMessage').resolves(fee); + }); + + afterEach(() => { + sandBox.restore(); + }); + + it('should return balance minus transaction fee', async () => { + const result = await basecoin.getMaximumSpendable(walletAddress); + result.should.equal(balance - fee); + }); + + it('should return 0 when wallet has no funds', async () => { + const result = await basecoin.getMaximumSpendable(testData.accountInfo.bs58EncodedPublicKeyNoFunds); + result.should.equal(0); + }); + + it('should return 0 when balance is less than fee', async () => { + (Sol.prototype as any).getAccountBalance.restore(); + sandBox.stub(Sol.prototype as any, 'getAccountBalance').resolves(fee - 1); + const result = await basecoin.getMaximumSpendable(walletAddress); + result.should.equal(0); + }); + }); }); diff --git a/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts index 737024125c..9b3d594cd3 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts @@ -408,6 +408,22 @@ export abstract class BaseCoin implements IBaseCoin { return Promise.resolve(walletParams); } + /** + * Compute the maximum spendable amount for a wallet address, coin-side. + * + * Coins that need to deduct chain-specific fees (e.g. Solana, where the fee + * payer pays on top of the transfer amount) should override this method. + * Return `undefined` to signal that the server-side /maximumSpendable API + * result should be used instead. + * + * @param _walletAddress - the address whose balance to inspect + * @returns maximum spendable amount in base units, or undefined + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getMaximumSpendable(_walletAddress: string): Promise { + return Promise.resolve(undefined); + } + /** * Get extra parameters for prebuilding a tx. Add things like hop transaction params */ diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 145972207d..b81e1e3d86 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -613,6 +613,7 @@ export interface IBaseCoin { supportsMessageSigning(): boolean; supportsSigningTypedData(): boolean; supplementGenerateWallet(walletParams: SupplementGenerateWalletOptions, keychains: KeychainsTriplet): Promise; + getMaximumSpendable(walletAddress: string): Promise; getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions): Promise>; postProcessPrebuild(prebuildResponse: TransactionPrebuild): Promise; presignTransaction(params: PresignTransactionOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 8bc607b891..07593ea974 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -1007,9 +1007,30 @@ export class Wallet implements IWallet { 'cannot sweep when unconfirmed funds exist on the wallet, please wait until all inbound transactions confirm' ); } - const value = await this.bitgo.get(this.url('/maximumSpendable')).result(); - const maximumSpendable = new BigNumber(value.maximumSpendable); - if (value === undefined || maximumSpendable.isZero()) { + + // Some coins (e.g. Solana) need to compute the maximum spendable amount + // locally because the transaction fee is charged on top of the transfer + // amount, so the server-side /maximumSpendable value is too large by + // exactly one transaction fee. If the coin provides its own calculation + // we use that; otherwise we fall back to the BitGo API. + const walletAddress = this.coinSpecific()?.rootAddress || this.receiveAddress(); + const coinMaximumSpendable = + walletAddress !== undefined + ? await this.baseCoin.getMaximumSpendable(walletAddress) + : undefined; + + let maximumSpendable: BigNumber; + if (coinMaximumSpendable !== undefined) { + maximumSpendable = new BigNumber(coinMaximumSpendable); + } else { + const value = await this.bitgo.get(this.url('/maximumSpendable')).result(); + maximumSpendable = new BigNumber(value.maximumSpendable); + if (value === undefined || maximumSpendable.isZero()) { + throw new Error('no funds to sweep'); + } + } + + if (maximumSpendable.isZero()) { throw new Error('no funds to sweep'); } From 2409f3f9b0fba76457b92820f11fea8fae049594 Mon Sep 17 00:00:00 2001 From: Barath Jawahar Date: Mon, 4 May 2026 13:08:23 +0000 Subject: [PATCH 2/2] style(sdk-core): fix prettier formatting on ternary expression Ticket: COIN-88 --- modules/sdk-core/src/bitgo/wallet/wallet.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 07593ea974..d7bd5f02ac 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -1015,9 +1015,7 @@ export class Wallet implements IWallet { // we use that; otherwise we fall back to the BitGo API. const walletAddress = this.coinSpecific()?.rootAddress || this.receiveAddress(); const coinMaximumSpendable = - walletAddress !== undefined - ? await this.baseCoin.getMaximumSpendable(walletAddress) - : undefined; + walletAddress !== undefined ? await this.baseCoin.getMaximumSpendable(walletAddress) : undefined; let maximumSpendable: BigNumber; if (coinMaximumSpendable !== undefined) {