Skip to content

Commit 6468395

Browse files
barathcjclaude
authored andcommitted
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 <noreply@anthropic.com>
1 parent 468eb53 commit 6468395

5 files changed

Lines changed: 124 additions & 3 deletions

File tree

modules/sdk-coin-sol/src/sol.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,47 @@ export class Sol extends BaseCoin {
10291029
return response.body.result.value;
10301030
}
10311031

1032+
/**
1033+
* Compute the maximum spendable balance for a SOL wallet address.
1034+
*
1035+
* The Solana fee payer pays fees on top of the transfer amount, so the true
1036+
* maximum sendable amount is: balance - transactionFee.
1037+
*
1038+
* We build a representative transfer transaction, ask the Solana node for its
1039+
* fee, then subtract that fee from the on-chain balance. This is the same
1040+
* approach used in the self-custody `recover()` flow.
1041+
*
1042+
* @param walletAddress - the base58-encoded Solana account public key
1043+
* @param apiKey - optional Alchemy API key for node requests
1044+
* @returns maximum spendable amount in lamports
1045+
*/
1046+
async getMaximumSpendable(walletAddress: string, apiKey?: string): Promise<number> {
1047+
const balance = await this.getAccountBalance(walletAddress, apiKey);
1048+
if (balance === 0) {
1049+
return 0;
1050+
}
1051+
1052+
const blockhash = await this.getBlockhash(apiKey);
1053+
const factory = this.getBuilder();
1054+
1055+
// Build a representative transfer so we can ask the node for its fee.
1056+
// The exact destination and amount do not affect the fee for a simple
1057+
// SOL transfer, so we use the sender as a placeholder destination.
1058+
const txBuilder = factory
1059+
.getTransferBuilder()
1060+
.nonce(blockhash)
1061+
.sender(walletAddress)
1062+
.send({ address: walletAddress, amount: balance.toString() })
1063+
.feePayer(walletAddress);
1064+
1065+
const unsignedTx = (await txBuilder.build()) as Transaction;
1066+
const serializedMessage = unsignedTx.solTransaction.serializeMessage().toString('base64');
1067+
const fee = await this.getFeeForMessage(serializedMessage, apiKey);
1068+
1069+
const maximumSpendable = balance - fee;
1070+
return maximumSpendable > 0 ? maximumSpendable : 0;
1071+
}
1072+
10321073
protected async getAccountInfo(pubKey: string, apiKey?: string): Promise<SolDurableNonceFromNode> {
10331074
const response = await this.getDataFromNode(
10341075
{

modules/sdk-coin-sol/test/unit/sol.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3851,4 +3851,46 @@ describe('SOL:', function () {
38513851
address.should.equal(expectedAddress);
38523852
});
38533853
});
3854+
3855+
describe('getMaximumSpendable', () => {
3856+
const sandBox = sinon.createSandbox();
3857+
const walletAddress = testData.accountInfo.bs58EncodedPublicKey;
3858+
// balance: 1_000_000_000 lamports (1 SOL)
3859+
const balance = testData.SolResponses.getAccountBalanceResponse.body.result.value;
3860+
// fee: 5_000 lamports (one signature)
3861+
const fee = testData.SolResponses.getFeesForMessageResponse.body.result.value;
3862+
3863+
beforeEach(() => {
3864+
sandBox.stub(Sol.prototype as any, 'getAccountBalance').callsFake(async (...args: unknown[]) => {
3865+
const pubKey = args[0] as string;
3866+
if (pubKey === testData.accountInfo.bs58EncodedPublicKeyNoFunds) return 0;
3867+
return balance;
3868+
});
3869+
3870+
sandBox.stub(Sol.prototype as any, 'getBlockhash').resolves('GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi');
3871+
3872+
sandBox.stub(Sol.prototype as any, 'getFeeForMessage').resolves(fee);
3873+
});
3874+
3875+
afterEach(() => {
3876+
sandBox.restore();
3877+
});
3878+
3879+
it('should return balance minus transaction fee', async () => {
3880+
const result = await basecoin.getMaximumSpendable(walletAddress);
3881+
result.should.equal(balance - fee);
3882+
});
3883+
3884+
it('should return 0 when wallet has no funds', async () => {
3885+
const result = await basecoin.getMaximumSpendable(testData.accountInfo.bs58EncodedPublicKeyNoFunds);
3886+
result.should.equal(0);
3887+
});
3888+
3889+
it('should return 0 when balance is less than fee', async () => {
3890+
(Sol.prototype as any).getAccountBalance.restore();
3891+
sandBox.stub(Sol.prototype as any, 'getAccountBalance').resolves(fee - 1);
3892+
const result = await basecoin.getMaximumSpendable(walletAddress);
3893+
result.should.equal(0);
3894+
});
3895+
});
38543896
});

modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,22 @@ export abstract class BaseCoin implements IBaseCoin {
408408
return Promise.resolve(walletParams);
409409
}
410410

411+
/**
412+
* Compute the maximum spendable amount for a wallet address, coin-side.
413+
*
414+
* Coins that need to deduct chain-specific fees (e.g. Solana, where the fee
415+
* payer pays on top of the transfer amount) should override this method.
416+
* Return `undefined` to signal that the server-side /maximumSpendable API
417+
* result should be used instead.
418+
*
419+
* @param _walletAddress - the address whose balance to inspect
420+
* @returns maximum spendable amount in base units, or undefined
421+
*/
422+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
423+
getMaximumSpendable(_walletAddress: string): Promise<number | undefined> {
424+
return Promise.resolve(undefined);
425+
}
426+
411427
/**
412428
* Get extra parameters for prebuilding a tx. Add things like hop transaction params
413429
*/

modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,7 @@ export interface IBaseCoin {
613613
supportsMessageSigning(): boolean;
614614
supportsSigningTypedData(): boolean;
615615
supplementGenerateWallet(walletParams: SupplementGenerateWalletOptions, keychains: KeychainsTriplet): Promise<any>;
616+
getMaximumSpendable(walletAddress: string): Promise<number | undefined>;
616617
getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions): Promise<Record<string, unknown>>;
617618
postProcessPrebuild(prebuildResponse: TransactionPrebuild): Promise<TransactionPrebuild>;
618619
presignTransaction(params: PresignTransactionOptions): Promise<PresignTransactionOptions>;

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,9 +1007,30 @@ export class Wallet implements IWallet {
10071007
'cannot sweep when unconfirmed funds exist on the wallet, please wait until all inbound transactions confirm'
10081008
);
10091009
}
1010-
const value = await this.bitgo.get(this.url('/maximumSpendable')).result();
1011-
const maximumSpendable = new BigNumber(value.maximumSpendable);
1012-
if (value === undefined || maximumSpendable.isZero()) {
1010+
1011+
// Some coins (e.g. Solana) need to compute the maximum spendable amount
1012+
// locally because the transaction fee is charged on top of the transfer
1013+
// amount, so the server-side /maximumSpendable value is too large by
1014+
// exactly one transaction fee. If the coin provides its own calculation
1015+
// we use that; otherwise we fall back to the BitGo API.
1016+
const walletAddress = this.coinSpecific()?.rootAddress || this.receiveAddress();
1017+
const coinMaximumSpendable =
1018+
walletAddress !== undefined
1019+
? await this.baseCoin.getMaximumSpendable(walletAddress)
1020+
: undefined;
1021+
1022+
let maximumSpendable: BigNumber;
1023+
if (coinMaximumSpendable !== undefined) {
1024+
maximumSpendable = new BigNumber(coinMaximumSpendable);
1025+
} else {
1026+
const value = await this.bitgo.get(this.url('/maximumSpendable')).result();
1027+
maximumSpendable = new BigNumber(value.maximumSpendable);
1028+
if (value === undefined || maximumSpendable.isZero()) {
1029+
throw new Error('no funds to sweep');
1030+
}
1031+
}
1032+
1033+
if (maximumSpendable.isZero()) {
10131034
throw new Error('no funds to sweep');
10141035
}
10151036

0 commit comments

Comments
 (0)