Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions modules/sdk-coin-sol/src/sol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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<SolDurableNonceFromNode> {
const response = await this.getDataFromNode(
{
Expand Down
42 changes: 42 additions & 0 deletions modules/sdk-coin-sol/test/unit/sol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
16 changes: 16 additions & 0 deletions modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | undefined> {
return Promise.resolve(undefined);
}

/**
* Get extra parameters for prebuilding a tx. Add things like hop transaction params
*/
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ export interface IBaseCoin {
supportsMessageSigning(): boolean;
supportsSigningTypedData(): boolean;
supplementGenerateWallet(walletParams: SupplementGenerateWalletOptions, keychains: KeychainsTriplet): Promise<any>;
getMaximumSpendable(walletAddress: string): Promise<number | undefined>;
getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions): Promise<Record<string, unknown>>;
postProcessPrebuild(prebuildResponse: TransactionPrebuild): Promise<TransactionPrebuild>;
presignTransaction(params: PresignTransactionOptions): Promise<PresignTransactionOptions>;
Expand Down
25 changes: 22 additions & 3 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand Down
Loading