Skip to content
Draft
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
12 changes: 12 additions & 0 deletions modules/sdk-coin-apt/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
82 changes: 68 additions & 14 deletions modules/sdk-coin-apt/src/lib/transaction/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
AccountAuthenticatorNoAccountAuthenticator,
Aptos,
AptosConfig,
DEFAULT_MAX_GAS_AMOUNT,
Ed25519PublicKey,
Ed25519Signature,
FeePayerRawTransaction,
Expand All @@ -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[];
Expand All @@ -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);
Expand All @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions modules/sdk-coin-apt/test/unit/apt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> | 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'));
Expand Down
Loading