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..d7bd5f02ac 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -1007,9 +1007,28 @@ 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'); }