From 42f087bbedea29604e29ae1813296dccc96e83b7 Mon Sep 17 00:00:00 2001 From: Support Bot Date: Wed, 9 Sep 2026 20:52:48 +0000 Subject: [PATCH] fix(apt): estimate gas during opted-in builds Use Aptos SDK simulation with a conservative 200,000-unit limit, then rebuild with ceil(gas_used * 1.2) while retaining a 20,000-unit minimum fallback. Explicit gasData remains authoritative and simulation is opt-in to avoid adding a network dependency to existing builds. Refs: CECHO-2022 Session-Id: 9c40532a-e51d-4a1e-a703-ccd15303c96b Task-Id: e62c1d9a-6785-4fac-a98b-400668b68cd6 --- modules/sdk-coin-apt/src/lib/constants.ts | 12 +++ .../src/lib/transaction/transaction.ts | 82 +++++++++++++++---- .../transactionBuilder/transactionBuilder.ts | 9 ++ modules/sdk-coin-apt/test/unit/apt.ts | 21 +++++ .../transactionBuilder/transferBuilder.ts | 65 +++++++++++++++ 5 files changed, 175 insertions(+), 14 deletions(-) diff --git a/modules/sdk-coin-apt/src/lib/constants.ts b/modules/sdk-coin-apt/src/lib/constants.ts index 43080bf6d0..d60964619f 100644 --- a/modules/sdk-coin-apt/src/lib/constants.ts +++ b/modules/sdk-coin-apt/src/lib/constants.ts @@ -4,6 +4,18 @@ export const APT_BLOCK_ID_LENGTH = 64; export const APT_SIGNATURE_LENGTH = 128; export const UNAVAILABLE_TEXT = 'UNAVAILABLE'; export const DEFAULT_GAS_UNIT_PRICE = 100; +/** + * Aptos validators currently reject transactions whose max gas amount is below + * the network minimum. Keep this conservative value independent of the SDK's + * lower default so every transaction type (including delegation) is safe. + * + * Dynamic estimation is opt-in because simulation adds a network request and + * can fail for offline or newly-created accounts; callers always retain this + * safe fallback. + */ +export const DEFAULT_MAX_GAS_AMOUNT = 20_000; +export const SIMULATION_MAX_GAS_AMOUNT = 200_000; +export const SIMULATION_GAS_BUFFER = 1.2; export const SECONDS_PER_WEEK = 7 * 24 * 60 * 60; // Days * Hours * Minutes * Seconds export const ADDRESS_BYTES_LENGTH = 32; export const AMOUNT_BYTES_LENGTH = 8; diff --git a/modules/sdk-coin-apt/src/lib/transaction/transaction.ts b/modules/sdk-coin-apt/src/lib/transaction/transaction.ts index 692a7e5d08..7192b4b3b2 100644 --- a/modules/sdk-coin-apt/src/lib/transaction/transaction.ts +++ b/modules/sdk-coin-apt/src/lib/transaction/transaction.ts @@ -17,7 +17,6 @@ import { AccountAuthenticatorNoAccountAuthenticator, Aptos, AptosConfig, - DEFAULT_MAX_GAS_AMOUNT, Ed25519PublicKey, Ed25519Signature, FeePayerRawTransaction, @@ -34,12 +33,25 @@ import { TransactionAuthenticatorFeePayer, TransactionPayload, } from '@aptos-labs/ts-sdk'; -import { DEFAULT_GAS_UNIT_PRICE, UNAVAILABLE_TEXT } from '../constants'; +import { + DEFAULT_GAS_UNIT_PRICE, + DEFAULT_MAX_GAS_AMOUNT, + SIMULATION_GAS_BUFFER, + SIMULATION_MAX_GAS_AMOUNT, + UNAVAILABLE_TEXT, +} from '../constants'; import utils from '../utils'; import BigNumber from 'bignumber.js'; import { AptTransactionExplanation, TxData } from '../iface'; import assert from 'assert'; +export function calculateDynamicMaxGasAmount(gasUsed: number): number { + if (!Number.isFinite(gasUsed) || gasUsed < 0) { + throw new Error('Invalid gas estimate'); + } + return Math.max(DEFAULT_MAX_GAS_AMOUNT, Math.ceil(gasUsed * SIMULATION_GAS_BUFFER)); +} + export type InputsAndOutputs = { /** Used for this.inputs */ inputs: Entry[]; @@ -63,6 +75,8 @@ export abstract class Transaction extends BaseTransaction { protected _feePayerAddress: string; protected _assetId: string; protected _isSimulateTxn: boolean; + protected _dynamicGasEstimation: boolean; + protected _gasDataProvided: boolean; static EMPTY_PUBLIC_KEY = Buffer.alloc(32); static EMPTY_SIGNATURE = Buffer.alloc(64); @@ -78,6 +92,8 @@ export abstract class Transaction extends BaseTransaction { this._recipients = []; this._assetId = AccountAddress.ZERO.toString(); this._isSimulateTxn = false; + this._dynamicGasEstimation = false; + this._gasDataProvided = false; this._senderSignature = { publicKey: { pub: Hex.fromHexInput(Transaction.EMPTY_PUBLIC_KEY).toString(), @@ -194,6 +210,18 @@ export abstract class Transaction extends BaseTransaction { this._isSimulateTxn = value; } + get dynamicGasEstimation(): boolean { + return this._dynamicGasEstimation; + } + + set dynamicGasEstimation(value: boolean) { + this._dynamicGasEstimation = value; + } + + markGasDataProvided(): void { + this._gasDataProvided = true; + } + protected abstract getTransactionPayloadData(): InputGenerateTransactionPayloadData; protected abstract parseTransactionPayload(payload: TransactionPayload): void; @@ -373,22 +401,48 @@ export abstract class Transaction extends BaseTransaction { }; } + public createAptos(network: Network): Aptos { + return new Aptos(new AptosConfig({ network })); + } + protected async buildRawTransaction(): Promise { const network: Network = this._coinConfig.network.type === NetworkType.MAINNET ? Network.MAINNET : Network.TESTNET; - const aptos = new Aptos(new AptosConfig({ network })); + const aptos = this.createAptos(network); const senderAddress = AccountAddress.fromString(this._sender); + const data = this.getTransactionPayloadData() as InputGenerateTransactionPayloadData; + const maxGasAmount = this._dynamicGasEstimation && !this._gasDataProvided + ? SIMULATION_MAX_GAS_AMOUNT + : this.maxGasAmount; + + const build = (gas: number) => + aptos.transaction.build.simple({ + sender: senderAddress, + data, + options: { + maxGasAmount: gas, + gasUnitPrice: this.gasUnitPrice, + expireTimestamp: this.expirationTime, + accountSequenceNumber: this.sequenceNumber, + }, + }); + + const simpleTxn = await build(maxGasAmount); + if (!this._dynamicGasEstimation || this._gasDataProvided) { + this._rawTransaction = simpleTxn.rawTransaction; + return; + } - const simpleTxn = await aptos.transaction.build.simple({ - sender: senderAddress, - data: this.getTransactionPayloadData() as InputGenerateTransactionPayloadData, - options: { - maxGasAmount: this.maxGasAmount, - gasUnitPrice: this.gasUnitPrice, - expireTimestamp: this.expirationTime, - accountSequenceNumber: this.sequenceNumber, - }, - }); - this._rawTransaction = simpleTxn.rawTransaction; + try { + const [simulation] = await aptos.transaction.simulate.simple({ transaction: simpleTxn }); + const gasUsed = Number(simulation?.gas_used); + this._maxGasAmount = calculateDynamicMaxGasAmount(gasUsed); + const estimatedTxn = await build(this._maxGasAmount); + this._rawTransaction = estimatedTxn.rawTransaction; + } catch { + this._maxGasAmount = DEFAULT_MAX_GAS_AMOUNT; + const fallbackTxn = await build(this._maxGasAmount); + this._rawTransaction = fallbackTxn.rawTransaction; + } } private getSignablePayloadWithFeePayer(): Buffer { diff --git a/modules/sdk-coin-apt/src/lib/transactionBuilder/transactionBuilder.ts b/modules/sdk-coin-apt/src/lib/transactionBuilder/transactionBuilder.ts index 2c70ea1d53..1ef1c306be 100644 --- a/modules/sdk-coin-apt/src/lib/transactionBuilder/transactionBuilder.ts +++ b/modules/sdk-coin-apt/src/lib/transactionBuilder/transactionBuilder.ts @@ -92,6 +92,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this.transaction.maxGasAmount = gasData.maxGasAmount; this.transaction.gasUnitPrice = gasData.gasUnitPrice; this.transaction.gasUsed = gasData.gasUsed ?? 0; + this.transaction.markGasDataProvided(); return this; } @@ -125,6 +126,14 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this.transaction.isSimulateTxn = value; } + /** + * Opt in to Aptos REST transaction simulation for a gas limit estimate. + * Explicit gasData always takes precedence over this option. + */ + setDynamicGasEstimation(value = true): void { + this.transaction.dynamicGasEstimation = value; + } + /** @inheritdoc */ protected fromImplementation(rawTransaction: string): Transaction { this.transaction.fromRawTransaction(rawTransaction); diff --git a/modules/sdk-coin-apt/test/unit/apt.ts b/modules/sdk-coin-apt/test/unit/apt.ts index 03f07a96dd..ffe839f712 100644 --- a/modules/sdk-coin-apt/test/unit/apt.ts +++ b/modules/sdk-coin-apt/test/unit/apt.ts @@ -20,6 +20,7 @@ import { import utils from '../../src/lib/utils'; import { AptCoin, coins, GasTankAccountCoin } from '@bitgo/statics'; import { DelegationPoolAddStakeTransaction } from '../../src/lib/transaction/delegationPoolAddStakeTransaction'; +import { calculateDynamicMaxGasAmount } from '../../src/lib/transaction/transaction'; describe('APT:', function () { let bitgo: TestBitGoAPI; @@ -340,6 +341,26 @@ describe('APT:', function () { }); }); + describe('Gas configuration', () => { + it('rounds simulated gas up with a 20 percent safety buffer', function () { + calculateDynamicMaxGasAmount(16667).should.equal(20001); + calculateDynamicMaxGasAmount(1).should.equal(20000); + }); + + it('rejects invalid simulation results', function () { + (() => calculateDynamicMaxGasAmount(Number.NaN)).should.throw('Invalid gas estimate'); + (() => calculateDynamicMaxGasAmount(-1)).should.throw('Invalid gas estimate'); + }); + + it('uses a validator-safe max gas amount for every transaction type', function () { + const transfer = new TransferTransaction(coins.get('tapt')); + const delegation = new DelegationPoolAddStakeTransaction(coins.get('tapt')); + + transfer.maxGasAmount.should.equal(20000); + delegation.maxGasAmount.should.equal(20000); + }); + }); + describe('ID Validation', () => { it('check id', async function () { const network: Network = Network.TESTNET; diff --git a/modules/sdk-coin-apt/test/unit/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-apt/test/unit/transactionBuilder/transferBuilder.ts index f2bf624f27..d471c12b76 100644 --- a/modules/sdk-coin-apt/test/unit/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-apt/test/unit/transactionBuilder/transferBuilder.ts @@ -2,12 +2,77 @@ import { coins } from '@bitgo/statics'; import { TransactionBuilderFactory, TransferTransaction } from '../../../src'; import * as testData from '../../resources/apt'; import { TransactionType } from '@bitgo/sdk-core'; +import { Aptos, RawTransaction } from '@aptos-labs/ts-sdk'; import should from 'should'; +import sinon from 'sinon'; describe('Apt Transfer Transaction', () => { const factory = new TransactionBuilderFactory(coins.get('tapt')); describe('Aptos Coin Transfer Transaction', () => { + describe('Dynamic gas estimation', () => { + function setup(simulation: Promise | undefined) { + const transaction = new TransferTransaction(coins.get('tapt')); + const txBuilder = factory.getTransferBuilder(transaction); + txBuilder.sender(testData.sender2.address); + txBuilder.recipients(testData.recipients); + txBuilder.sequenceNumber(14); + txBuilder.expirationTime(1736246155); + + const build = sinon.stub().callsFake(async (args: { options: { maxGasAmount: number } }) => ({ + rawTransaction: {} as RawTransaction, + options: args.options, + })); + const simulate = sinon.stub(); + if (simulation) { + simulate.returns(simulation); + } + const aptos = { + transaction: { build: { simple: build }, simulate: { simple: simulate } }, + }; + sinon.stub(transaction, 'createAptos').returns(aptos as unknown as Aptos); + sinon.stub(transaction as unknown as { generateTxnId: () => void }, 'generateTxnId'); + return { transaction, txBuilder, build, simulate }; + } + + it('uses one build and no simulation when the flag is off', async function () { + const { txBuilder, build, simulate } = setup(undefined); + await txBuilder.build(); + build.callCount.should.equal(1); + simulate.called.should.equal(false); + }); + + it('simulates with a high limit and rebuilds using the buffered estimate', async function () { + const { transaction, txBuilder, build, simulate } = setup(Promise.resolve([{ gas_used: '16667' }])); + txBuilder.setDynamicGasEstimation(); + await txBuilder.build(); + build.firstCall.args[0].options.maxGasAmount.should.equal(200000); + build.secondCall.args[0].options.maxGasAmount.should.equal(20001); + simulate.calledOnce.should.equal(true); + simulate.firstCall.args[0].transaction.should.equal(await build.firstCall.returnValue); + transaction.maxGasAmount.should.equal(20001); + }); + + it('falls back to the safe default when simulation rejects', async function () { + const { txBuilder, build, simulate } = setup(Promise.reject(new Error('offline'))); + txBuilder.setDynamicGasEstimation(); + await txBuilder.build(); + build.firstCall.args[0].options.maxGasAmount.should.equal(200000); + build.secondCall.args[0].options.maxGasAmount.should.equal(20000); + simulate.calledOnce.should.equal(true); + }); + + it('does not simulate when explicit gas data is provided', async function () { + const { txBuilder, build, simulate } = setup(Promise.resolve([{ gas_used: '16667' }])); + txBuilder.setDynamicGasEstimation(); + txBuilder.gasData({ maxGasAmount: 12345, gasUnitPrice: 100 }); + await txBuilder.build(); + build.callCount.should.equal(1); + build.firstCall.args[0].options.maxGasAmount.should.equal(12345); + simulate.called.should.equal(false); + }); + }); + describe('Succeed', () => { it('should build a transfer tx', async function () { const transaction = new TransferTransaction(coins.get('tapt'));