Skip to content
Merged
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
9 changes: 4 additions & 5 deletions modules/sdk-coin-apt/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ export interface RecipientsValidationResult {
* ```
*
* @remarks
* - The `abi` field is required to ensure type safety
* - Invalid ABI will cause transaction building to fail
* - The `abi` field is optional but provides type validation when present
* - ABI must match the exact function signature of the target entry function
*/
export interface CustomTransactionParams {
Expand Down Expand Up @@ -105,9 +104,9 @@ export interface CustomTransactionParams {
functionArguments?: Array<EntryFunctionArgumentTypes | SimpleEntryFunctionArgumentTypes>;

/**
* Entry function ABI for type validation and safety (required)
* Entry function ABI for type validation and safety (optional)
*
* Provides:
* When provided:
* - Validates argument count matches expected parameters
* - Performs type checking during transaction building
* - Improves error messages for invalid calls
Expand All @@ -116,5 +115,5 @@ export interface CustomTransactionParams {
* - Providing incorrect ABI will cause transaction building to fail
* - Must match the exact function signature of the target entry function
*/
abi: EntryFunctionABI;
abi?: EntryFunctionABI;
}
201 changes: 143 additions & 58 deletions modules/sdk-coin-apt/src/lib/transaction/customTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
import { Transaction } from './transaction';
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { TransactionType } from '@bitgo/sdk-core';
import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core';
import {
EntryFunctionABI,
EntryFunctionArgumentTypes,
InputGenerateTransactionPayloadData,
SimpleEntryFunctionArgumentTypes,
InputGenerateTransactionPayloadData,
TransactionPayload,
TransactionPayloadEntryFunction,
AccountAddress,
TypeTagAddress,
TypeTagBool,
TypeTagU8,
TypeTagU16,
TypeTagU32,
TypeTagU64,
TypeTagU128,
TypeTagU256,
} from '@aptos-labs/ts-sdk';
import { CustomTransactionParams } from '../iface';
import { validateModuleName, validateFunctionName } from '../utils/validation';

/**
* Transaction class for custom Aptos transactions with entry function payloads.
* Transaction class for custom Aptos transactions.
*/
export class CustomTransaction extends Transaction {
private _moduleName: string;
private _functionName: string;
private _typeArguments: string[] = [];
private _functionArguments: Array<EntryFunctionArgumentTypes | SimpleEntryFunctionArgumentTypes> = [];
private _entryFunctionAbi: EntryFunctionABI;
private _entryFunctionAbi?: EntryFunctionABI;

constructor(coinConfig: Readonly<CoinConfig>) {
super(coinConfig);
Expand All @@ -29,13 +38,10 @@ export class CustomTransaction extends Transaction {

/**
* Set the custom transaction parameters
*
* @param {CustomTransactionParams} params - Custom transaction parameters
*/
setCustomTransactionParams(params: CustomTransactionParams): void {
validateModuleName(params.moduleName);
validateFunctionName(params.functionName);
this.validateAbi(params.abi);

this._moduleName = params.moduleName;
this._functionName = params.functionName;
Expand All @@ -46,17 +52,13 @@ export class CustomTransaction extends Transaction {

/**
* Set the entry function ABI
*
* @param {EntryFunctionABI} abi - The ABI definition for the entry function
*/
setEntryFunctionAbi(abi: EntryFunctionABI): void {
this._entryFunctionAbi = abi;
}

/**
* Get the full function name in the format moduleName::functionName
*
* @returns {string} The full function name
* Get the full function name
*/
get fullFunctionName(): string {
if (!this._moduleName || !this._functionName) {
Expand All @@ -72,7 +74,7 @@ export class CustomTransaction extends Transaction {
*/
protected parseTransactionPayload(payload: TransactionPayload): void {
if (!(payload instanceof TransactionPayloadEntryFunction)) {
throw new Error('Expected entry function payload for custom transaction');
throw new InvalidTransactionError('Expected entry function payload for custom transaction');
}

const entryFunction = payload.entryFunction;
Expand All @@ -84,40 +86,151 @@ export class CustomTransaction extends Transaction {

const moduleName = `${moduleAddress}::${moduleIdentifier}`;

// Validate the extracted names using our existing validation
// Validate
validateModuleName(moduleName);
validateFunctionName(functionIdentifier);

this._moduleName = moduleName;
this._functionName = functionIdentifier;

// Extract type arguments and function arguments
this._typeArguments = entryFunction.type_args.map((typeArg) => typeArg.toString());
this._functionArguments = entryFunction.args as Array<
EntryFunctionArgumentTypes | SimpleEntryFunctionArgumentTypes
>;

this._functionArguments = entryFunction.args.map((arg: any) => {
if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') {
return arg;
}
if (arg && typeof arg === 'object' && 'data' in arg && arg.data) {
const bytes = Array.from(arg.data) as number[];
return '0x' + bytes.map((b: number) => b.toString(16).padStart(2, '0')).join('');
}
return arg;
});
}

/**
* Generate the transaction payload data for the custom transaction
*
* @returns {InputGenerateTransactionPayloadData} The transaction payload data
* Generate transaction payload data
*/
protected getTransactionPayloadData(): InputGenerateTransactionPayloadData {
const functionName = this.getValidatedFullFunctionName();

// Convert arguments based on ABI information if available
const processedArguments = this._functionArguments.map((arg: any, index: number) => {
// Use ABI to identify the expected type for this argument
const paramType = this._entryFunctionAbi?.parameters?.[index];
if (paramType) {
return this.convertArgumentByABI(arg, paramType);
}

// Fallback: basic conversion for common cases
if (typeof arg === 'string' && arg.startsWith('0x') && arg.length === 66) {
try {
return AccountAddress.fromString(arg);
} catch {
return arg;
}
}
return arg;
});

return {
function: functionName,
typeArguments: this._typeArguments,
functionArguments: this._functionArguments,
functionArguments: processedArguments,
abi: this._entryFunctionAbi,
} as InputGenerateTransactionPayloadData;
}

/**
* Convert argument based on ABI type information
*/
private convertArgumentByABI(arg: any, paramType: any): any {
// Helper function to convert bytes to hex string
const bytesToHex = (bytes: number[]): string => {
return '0x' + bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
};

// Helper function to try converting a hex string to an AccountAddress
const tryToAddress = (hexStr: string): any => {
try {
return AccountAddress.fromString(hexStr);
} catch {
return hexStr;
}
};

// Handle primitive values (string, number, boolean)
if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') {
// Address conversion for hex strings
if (paramType instanceof TypeTagAddress && typeof arg === 'string' && arg.startsWith('0x')) {
return tryToAddress(arg);
}

// Type conversions based on parameter type
if (paramType instanceof TypeTagBool) return Boolean(arg);
if (paramType instanceof TypeTagU8 || paramType instanceof TypeTagU16 || paramType instanceof TypeTagU32)
return Number(arg);
if (paramType instanceof TypeTagU64 || paramType instanceof TypeTagU128 || paramType instanceof TypeTagU256)
return String(arg);

return arg;
}

// Handle BCS-encoded data with 'data' property
if (arg && typeof arg === 'object' && 'data' in arg && arg.data) {
const bytes = Array.from(arg.data) as number[];
const hexString = bytesToHex(bytes);

return paramType instanceof TypeTagAddress ? tryToAddress(hexString) : hexString;
}

// Handle nested BCS structures with 'value' property
if (arg && typeof arg === 'object' && 'value' in arg && arg.value) {
// Simple value wrapper
if (!('value' in arg.value) || typeof arg.value.value !== 'object') {
return this.convertArgumentByABI(arg.value, paramType);
}

// Double nested structure with numeric keys
const bytesObj = arg.value.value;
const keys = Object.keys(bytesObj)
.filter((k) => !isNaN(parseInt(k, 10)))
.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));

if (keys.length === 0) return arg;

const bytes = keys.map((k) => bytesObj[k]);
let extractedValue: any;

// Convert bytes based on parameter type
if (
paramType instanceof TypeTagAddress ||
paramType instanceof TypeTagU64 ||
paramType instanceof TypeTagU128 ||
paramType instanceof TypeTagU256
) {
extractedValue = bytesToHex(bytes);
} else if (paramType instanceof TypeTagBool) {
extractedValue = bytes[0] === 1;
} else if (paramType instanceof TypeTagU8 || paramType instanceof TypeTagU16 || paramType instanceof TypeTagU32) {
// Convert little-endian bytes to number using the original algorithm
// to ensure consistent behavior with large numbers
let result = 0;
for (let i = bytes.length - 1; i >= 0; i--) {
result = result * 256 + bytes[i];
}
extractedValue = result;
} else {
extractedValue = bytesToHex(bytes);
}

return this.convertArgumentByABI(extractedValue, paramType);
}

// For anything else, return as-is
return arg;
}

/**
* Get the custom transaction parameters
*
* @returns {CustomTransactionParams} The custom transaction parameters
* Get custom transaction parameters
*/
getCustomTransactionParams(): CustomTransactionParams {
return {
Expand All @@ -130,27 +243,20 @@ export class CustomTransaction extends Transaction {
}

/**
* Override the deprecated recipient getter to handle custom transactions gracefully
* Custom transactions may not have traditional recipients
*
* @deprecated - use `recipients()`
* Override recipient getter for custom transactions
*/
get recipient(): any {
// For custom transactions, return a placeholder recipient if no recipients exist
if (this._recipients.length === 0) {
return {
address: '', // Empty address for custom transactions
address: '',
amount: '0',
};
}
return this._recipients[0];
}

/**
* Get validated full function name with runtime format checking
*
* @returns {string} The validated full function name
* @throws {Error} If function name format is invalid
* Get validated full function name
*/
private getValidatedFullFunctionName(): `${string}::${string}::${string}` {
if (!this._moduleName || !this._functionName) {
Expand All @@ -159,8 +265,7 @@ export class CustomTransaction extends Transaction {

const fullName = `${this._moduleName}::${this._functionName}`;

// Runtime validation of the expected format
// Supports both hex addresses (SHORT/LONG) and named addresses
// Basic validation
const fullFunctionPattern =
/^(0x[a-fA-F0-9]{1,64}|[a-zA-Z_][a-zA-Z0-9_]*)::[a-zA-Z_][a-zA-Z0-9_]*::[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!fullFunctionPattern.test(fullName)) {
Expand All @@ -169,24 +274,4 @@ export class CustomTransaction extends Transaction {

return fullName as `${string}::${string}::${string}`;
}

/**
* Validate ABI structure and provide helpful error messages
*
* @param {EntryFunctionABI} abi - The ABI to validate
* @throws {Error} If ABI format is invalid
*/
private validateAbi(abi: EntryFunctionABI): void {
if (!abi || typeof abi !== 'object') {
throw new Error('ABI must be a valid EntryFunctionABI object');
}

if (!Array.isArray(abi.typeParameters)) {
throw new Error('ABI must have a typeParameters array. Use [] if the function has no type parameters');
}

if (!Array.isArray(abi.parameters)) {
throw new Error('ABI must have a parameters array containing TypeTag objects for each function parameter');
}
}
}
5 changes: 4 additions & 1 deletion modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
}

/** @inheritdoc */
from(signedRawTxn: string): TransactionBuilder {
from(signedRawTxn: string, abi?: any): TransactionBuilder {
try {
const signedTxn = Transaction.deserializeSignedTransaction(signedRawTxn);
const txnType = this.getTransactionTypeFromSignedTxn(signedTxn);
Expand All @@ -39,6 +39,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
return this.getDigitalAssetTransactionBuilder(digitalAssetTransferTx);
case TransactionType.CustomTx:
const customTx = new CustomTransaction(this._coinConfig);
if (abi) {
customTx.setEntryFunctionAbi(abi);
}
customTx.fromDeserializedSignedTransaction(signedTxn);
return this.getCustomTransactionBuilder(customTx);
default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -588,36 +588,6 @@ describe('Apt Custom Transaction Builder', () => {
await should(txBuilder.build()).be.rejected();
});

it('should provide helpful error for invalid ABI structure', async function () {
const transaction = new CustomTransaction(coins.get('tapt'));
const txBuilder = factory.getCustomTransactionBuilder(transaction);

should(() => {
txBuilder.customTransaction({
moduleName: '0x1::aptos_account',
functionName: 'transfer_coins',
typeArguments: ['0x1::aptos_coin::AptosCoin'],
functionArguments: [testData.recipients[0].address, testData.recipients[0].amount],
abi: {} as any, // Invalid empty object
});
}).throw(/typeParameters array/);
});

it('should provide helpful error for completely wrong ABI', async function () {
const transaction = new CustomTransaction(coins.get('tapt'));
const txBuilder = factory.getCustomTransactionBuilder(transaction);

should(() => {
txBuilder.customTransaction({
moduleName: '0x1::aptos_account',
functionName: 'transfer_coins',
typeArguments: ['0x1::aptos_coin::AptosCoin'],
functionArguments: [testData.recipients[0].address, testData.recipients[0].amount],
abi: 'not an object' as any, // Completely wrong type
});
}).throw(/valid EntryFunctionABI object/);
});

it('should build regulated token initialize with correct ABI', async function () {
const regulatedTokenInitializeAbi = {
typeParameters: [],
Expand Down