Skip to content

Commit ce0e0af

Browse files
authored
Merge pull request #7034 from BitGo/COIN-5733-aptos-customtx
feat(sdk-coin-apt): modify deserialization for custom tx, make abi optional
2 parents 820dbda + 1284e6f commit ce0e0af

File tree

4 files changed

+151
-94
lines changed

4 files changed

+151
-94
lines changed

modules/sdk-coin-apt/src/lib/iface.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ export interface RecipientsValidationResult {
6363
* ```
6464
*
6565
* @remarks
66-
* - The `abi` field is required to ensure type safety
67-
* - Invalid ABI will cause transaction building to fail
66+
* - The `abi` field is optional but provides type validation when present
6867
* - ABI must match the exact function signature of the target entry function
6968
*/
7069
export interface CustomTransactionParams {
@@ -105,9 +104,9 @@ export interface CustomTransactionParams {
105104
functionArguments?: Array<EntryFunctionArgumentTypes | SimpleEntryFunctionArgumentTypes>;
106105

107106
/**
108-
* Entry function ABI for type validation and safety (required)
107+
* Entry function ABI for type validation and safety (optional)
109108
*
110-
* Provides:
109+
* When provided:
111110
* - Validates argument count matches expected parameters
112111
* - Performs type checking during transaction building
113112
* - Improves error messages for invalid calls
@@ -116,5 +115,5 @@ export interface CustomTransactionParams {
116115
* - Providing incorrect ABI will cause transaction building to fail
117116
* - Must match the exact function signature of the target entry function
118117
*/
119-
abi: EntryFunctionABI;
118+
abi?: EntryFunctionABI;
120119
}
Lines changed: 143 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,35 @@
11
import { Transaction } from './transaction';
22
import { BaseCoin as CoinConfig } from '@bitgo/statics';
3-
import { TransactionType } from '@bitgo/sdk-core';
3+
import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core';
44
import {
55
EntryFunctionABI,
66
EntryFunctionArgumentTypes,
7-
InputGenerateTransactionPayloadData,
87
SimpleEntryFunctionArgumentTypes,
8+
InputGenerateTransactionPayloadData,
99
TransactionPayload,
1010
TransactionPayloadEntryFunction,
11+
AccountAddress,
12+
TypeTagAddress,
13+
TypeTagBool,
14+
TypeTagU8,
15+
TypeTagU16,
16+
TypeTagU32,
17+
TypeTagU64,
18+
TypeTagU128,
19+
TypeTagU256,
1120
} from '@aptos-labs/ts-sdk';
1221
import { CustomTransactionParams } from '../iface';
1322
import { validateModuleName, validateFunctionName } from '../utils/validation';
1423

1524
/**
16-
* Transaction class for custom Aptos transactions with entry function payloads.
25+
* Transaction class for custom Aptos transactions.
1726
*/
1827
export class CustomTransaction extends Transaction {
1928
private _moduleName: string;
2029
private _functionName: string;
2130
private _typeArguments: string[] = [];
2231
private _functionArguments: Array<EntryFunctionArgumentTypes | SimpleEntryFunctionArgumentTypes> = [];
23-
private _entryFunctionAbi: EntryFunctionABI;
32+
private _entryFunctionAbi?: EntryFunctionABI;
2433

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

3039
/**
3140
* Set the custom transaction parameters
32-
*
33-
* @param {CustomTransactionParams} params - Custom transaction parameters
3441
*/
3542
setCustomTransactionParams(params: CustomTransactionParams): void {
3643
validateModuleName(params.moduleName);
3744
validateFunctionName(params.functionName);
38-
this.validateAbi(params.abi);
3945

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

4753
/**
4854
* Set the entry function ABI
49-
*
50-
* @param {EntryFunctionABI} abi - The ABI definition for the entry function
5155
*/
5256
setEntryFunctionAbi(abi: EntryFunctionABI): void {
5357
this._entryFunctionAbi = abi;
5458
}
5559

5660
/**
57-
* Get the full function name in the format moduleName::functionName
58-
*
59-
* @returns {string} The full function name
61+
* Get the full function name
6062
*/
6163
get fullFunctionName(): string {
6264
if (!this._moduleName || !this._functionName) {
@@ -72,7 +74,7 @@ export class CustomTransaction extends Transaction {
7274
*/
7375
protected parseTransactionPayload(payload: TransactionPayload): void {
7476
if (!(payload instanceof TransactionPayloadEntryFunction)) {
75-
throw new Error('Expected entry function payload for custom transaction');
77+
throw new InvalidTransactionError('Expected entry function payload for custom transaction');
7678
}
7779

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

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

87-
// Validate the extracted names using our existing validation
89+
// Validate
8890
validateModuleName(moduleName);
8991
validateFunctionName(functionIdentifier);
9092

9193
this._moduleName = moduleName;
9294
this._functionName = functionIdentifier;
93-
94-
// Extract type arguments and function arguments
9595
this._typeArguments = entryFunction.type_args.map((typeArg) => typeArg.toString());
96-
this._functionArguments = entryFunction.args as Array<
97-
EntryFunctionArgumentTypes | SimpleEntryFunctionArgumentTypes
98-
>;
96+
97+
this._functionArguments = entryFunction.args.map((arg: any) => {
98+
if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') {
99+
return arg;
100+
}
101+
if (arg && typeof arg === 'object' && 'data' in arg && arg.data) {
102+
const bytes = Array.from(arg.data) as number[];
103+
return '0x' + bytes.map((b: number) => b.toString(16).padStart(2, '0')).join('');
104+
}
105+
return arg;
106+
});
99107
}
100108

101109
/**
102-
* Generate the transaction payload data for the custom transaction
103-
*
104-
* @returns {InputGenerateTransactionPayloadData} The transaction payload data
110+
* Generate transaction payload data
105111
*/
106112
protected getTransactionPayloadData(): InputGenerateTransactionPayloadData {
107113
const functionName = this.getValidatedFullFunctionName();
108114

115+
// Convert arguments based on ABI information if available
116+
const processedArguments = this._functionArguments.map((arg: any, index: number) => {
117+
// Use ABI to identify the expected type for this argument
118+
const paramType = this._entryFunctionAbi?.parameters?.[index];
119+
if (paramType) {
120+
return this.convertArgumentByABI(arg, paramType);
121+
}
122+
123+
// Fallback: basic conversion for common cases
124+
if (typeof arg === 'string' && arg.startsWith('0x') && arg.length === 66) {
125+
try {
126+
return AccountAddress.fromString(arg);
127+
} catch {
128+
return arg;
129+
}
130+
}
131+
return arg;
132+
});
133+
109134
return {
110135
function: functionName,
111136
typeArguments: this._typeArguments,
112-
functionArguments: this._functionArguments,
137+
functionArguments: processedArguments,
113138
abi: this._entryFunctionAbi,
139+
} as InputGenerateTransactionPayloadData;
140+
}
141+
142+
/**
143+
* Convert argument based on ABI type information
144+
*/
145+
private convertArgumentByABI(arg: any, paramType: any): any {
146+
// Helper function to convert bytes to hex string
147+
const bytesToHex = (bytes: number[]): string => {
148+
return '0x' + bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
114149
};
150+
151+
// Helper function to try converting a hex string to an AccountAddress
152+
const tryToAddress = (hexStr: string): any => {
153+
try {
154+
return AccountAddress.fromString(hexStr);
155+
} catch {
156+
return hexStr;
157+
}
158+
};
159+
160+
// Handle primitive values (string, number, boolean)
161+
if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') {
162+
// Address conversion for hex strings
163+
if (paramType instanceof TypeTagAddress && typeof arg === 'string' && arg.startsWith('0x')) {
164+
return tryToAddress(arg);
165+
}
166+
167+
// Type conversions based on parameter type
168+
if (paramType instanceof TypeTagBool) return Boolean(arg);
169+
if (paramType instanceof TypeTagU8 || paramType instanceof TypeTagU16 || paramType instanceof TypeTagU32)
170+
return Number(arg);
171+
if (paramType instanceof TypeTagU64 || paramType instanceof TypeTagU128 || paramType instanceof TypeTagU256)
172+
return String(arg);
173+
174+
return arg;
175+
}
176+
177+
// Handle BCS-encoded data with 'data' property
178+
if (arg && typeof arg === 'object' && 'data' in arg && arg.data) {
179+
const bytes = Array.from(arg.data) as number[];
180+
const hexString = bytesToHex(bytes);
181+
182+
return paramType instanceof TypeTagAddress ? tryToAddress(hexString) : hexString;
183+
}
184+
185+
// Handle nested BCS structures with 'value' property
186+
if (arg && typeof arg === 'object' && 'value' in arg && arg.value) {
187+
// Simple value wrapper
188+
if (!('value' in arg.value) || typeof arg.value.value !== 'object') {
189+
return this.convertArgumentByABI(arg.value, paramType);
190+
}
191+
192+
// Double nested structure with numeric keys
193+
const bytesObj = arg.value.value;
194+
const keys = Object.keys(bytesObj)
195+
.filter((k) => !isNaN(parseInt(k, 10)))
196+
.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
197+
198+
if (keys.length === 0) return arg;
199+
200+
const bytes = keys.map((k) => bytesObj[k]);
201+
let extractedValue: any;
202+
203+
// Convert bytes based on parameter type
204+
if (
205+
paramType instanceof TypeTagAddress ||
206+
paramType instanceof TypeTagU64 ||
207+
paramType instanceof TypeTagU128 ||
208+
paramType instanceof TypeTagU256
209+
) {
210+
extractedValue = bytesToHex(bytes);
211+
} else if (paramType instanceof TypeTagBool) {
212+
extractedValue = bytes[0] === 1;
213+
} else if (paramType instanceof TypeTagU8 || paramType instanceof TypeTagU16 || paramType instanceof TypeTagU32) {
214+
// Convert little-endian bytes to number using the original algorithm
215+
// to ensure consistent behavior with large numbers
216+
let result = 0;
217+
for (let i = bytes.length - 1; i >= 0; i--) {
218+
result = result * 256 + bytes[i];
219+
}
220+
extractedValue = result;
221+
} else {
222+
extractedValue = bytesToHex(bytes);
223+
}
224+
225+
return this.convertArgumentByABI(extractedValue, paramType);
226+
}
227+
228+
// For anything else, return as-is
229+
return arg;
115230
}
116231

117232
/**
118-
* Get the custom transaction parameters
119-
*
120-
* @returns {CustomTransactionParams} The custom transaction parameters
233+
* Get custom transaction parameters
121234
*/
122235
getCustomTransactionParams(): CustomTransactionParams {
123236
return {
@@ -130,27 +243,20 @@ export class CustomTransaction extends Transaction {
130243
}
131244

132245
/**
133-
* Override the deprecated recipient getter to handle custom transactions gracefully
134-
* Custom transactions may not have traditional recipients
135-
*
136-
* @deprecated - use `recipients()`
246+
* Override recipient getter for custom transactions
137247
*/
138248
get recipient(): any {
139-
// For custom transactions, return a placeholder recipient if no recipients exist
140249
if (this._recipients.length === 0) {
141250
return {
142-
address: '', // Empty address for custom transactions
251+
address: '',
143252
amount: '0',
144253
};
145254
}
146255
return this._recipients[0];
147256
}
148257

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

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

162-
// Runtime validation of the expected format
163-
// Supports both hex addresses (SHORT/LONG) and named addresses
268+
// Basic validation
164269
const fullFunctionPattern =
165270
/^(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_]*$/;
166271
if (!fullFunctionPattern.test(fullName)) {
@@ -169,24 +274,4 @@ export class CustomTransaction extends Transaction {
169274

170275
return fullName as `${string}::${string}::${string}`;
171276
}
172-
173-
/**
174-
* Validate ABI structure and provide helpful error messages
175-
*
176-
* @param {EntryFunctionABI} abi - The ABI to validate
177-
* @throws {Error} If ABI format is invalid
178-
*/
179-
private validateAbi(abi: EntryFunctionABI): void {
180-
if (!abi || typeof abi !== 'object') {
181-
throw new Error('ABI must be a valid EntryFunctionABI object');
182-
}
183-
184-
if (!Array.isArray(abi.typeParameters)) {
185-
throw new Error('ABI must have a typeParameters array. Use [] if the function has no type parameters');
186-
}
187-
188-
if (!Array.isArray(abi.parameters)) {
189-
throw new Error('ABI must have a parameters array containing TypeTag objects for each function parameter');
190-
}
191-
}
192277
}

modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
1919
}
2020

2121
/** @inheritdoc */
22-
from(signedRawTxn: string): TransactionBuilder {
22+
from(signedRawTxn: string, abi?: any): TransactionBuilder {
2323
try {
2424
const signedTxn = Transaction.deserializeSignedTransaction(signedRawTxn);
2525
const txnType = this.getTransactionTypeFromSignedTxn(signedTxn);
@@ -39,6 +39,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
3939
return this.getDigitalAssetTransactionBuilder(digitalAssetTransferTx);
4040
case TransactionType.CustomTx:
4141
const customTx = new CustomTransaction(this._coinConfig);
42+
if (abi) {
43+
customTx.setEntryFunctionAbi(abi);
44+
}
4245
customTx.fromDeserializedSignedTransaction(signedTxn);
4346
return this.getCustomTransactionBuilder(customTx);
4447
default:

modules/sdk-coin-apt/test/unit/transactionBuilder/customTransactionBuilder.ts

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -588,36 +588,6 @@ describe('Apt Custom Transaction Builder', () => {
588588
await should(txBuilder.build()).be.rejected();
589589
});
590590

591-
it('should provide helpful error for invalid ABI structure', async function () {
592-
const transaction = new CustomTransaction(coins.get('tapt'));
593-
const txBuilder = factory.getCustomTransactionBuilder(transaction);
594-
595-
should(() => {
596-
txBuilder.customTransaction({
597-
moduleName: '0x1::aptos_account',
598-
functionName: 'transfer_coins',
599-
typeArguments: ['0x1::aptos_coin::AptosCoin'],
600-
functionArguments: [testData.recipients[0].address, testData.recipients[0].amount],
601-
abi: {} as any, // Invalid empty object
602-
});
603-
}).throw(/typeParameters array/);
604-
});
605-
606-
it('should provide helpful error for completely wrong ABI', async function () {
607-
const transaction = new CustomTransaction(coins.get('tapt'));
608-
const txBuilder = factory.getCustomTransactionBuilder(transaction);
609-
610-
should(() => {
611-
txBuilder.customTransaction({
612-
moduleName: '0x1::aptos_account',
613-
functionName: 'transfer_coins',
614-
typeArguments: ['0x1::aptos_coin::AptosCoin'],
615-
functionArguments: [testData.recipients[0].address, testData.recipients[0].amount],
616-
abi: 'not an object' as any, // Completely wrong type
617-
});
618-
}).throw(/valid EntryFunctionABI object/);
619-
});
620-
621591
it('should build regulated token initialize with correct ABI', async function () {
622592
const regulatedTokenInitializeAbi = {
623593
typeParameters: [],

0 commit comments

Comments
 (0)