diff --git a/modules/sdk-coin-apt/package.json b/modules/sdk-coin-apt/package.json index c7b0d1529f..d2252c007b 100644 --- a/modules/sdk-coin-apt/package.json +++ b/modules/sdk-coin-apt/package.json @@ -43,10 +43,12 @@ "@aptos-labs/ts-sdk": "^1.32.0", "@bitgo/sdk-core": "^28.19.0", "@bitgo/statics": "^50.18.0", - "bignumber.js": "^9.1.2" + "bignumber.js": "^9.1.2", + "lodash": "^4.17.21" }, "devDependencies": { "@bitgo/sdk-api": "^1.58.0", - "@bitgo/sdk-test": "^8.0.63" + "@bitgo/sdk-test": "^8.0.63", + "@bitgo/sdk-lib-mpc": "^10.1.0" } } diff --git a/modules/sdk-coin-apt/src/apt.ts b/modules/sdk-coin-apt/src/apt.ts index 752b55eb67..8642008278 100644 --- a/modules/sdk-coin-apt/src/apt.ts +++ b/modules/sdk-coin-apt/src/apt.ts @@ -1,5 +1,6 @@ import { BaseCoin, + BaseTransaction, BitGoBase, InvalidAddressError, KeyPair, @@ -11,9 +12,17 @@ import { VerifyAddressOptions, VerifyTransactionOptions, } from '@bitgo/sdk-core'; -import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; -import { KeyPair as AptKeyPair } from './lib'; +import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; +import { KeyPair as AptKeyPair, TransactionBuilderFactory, TransferTransaction } from './lib'; import utils from './lib/utils'; +import * as _ from 'lodash'; +import BigNumber from 'bignumber.js'; +import { ExplainTransactionOptions } from './lib/types'; +import { AptTransactionExplanation } from './lib/iface'; + +export interface AptParseTransactionOptions extends ParseTransactionOptions { + txHex: string; +} export class Apt extends BaseCoin { protected readonly _staticsCoin: Readonly; @@ -64,7 +73,40 @@ export class Apt extends BaseCoin { } async verifyTransaction(params: VerifyTransactionOptions): Promise { - throw new Error('Method not implemented.'); + const coinConfig = coins.get(this.getChain()); + const { txPrebuild: txPrebuild, txParams: txParams } = params; + const transaction = new TransferTransaction(coinConfig); + const rawTx = txPrebuild.txHex; + if (!rawTx) { + throw new Error('missing required tx prebuild property txHex'); + } + transaction.fromRawTransaction(rawTx); + const explainedTx = transaction.explainTransaction(); + if (txParams.recipients !== undefined) { + const filteredRecipients = txParams.recipients?.map((recipient) => { + return { + address: recipient.address, + amount: BigInt(recipient.amount), + }; + }); + const filteredOutputs = explainedTx.outputs.map((output) => { + return { + address: output.address, + amount: BigInt(output.amount), + }; + }); + if (!_.isEqual(filteredOutputs, filteredRecipients)) { + throw new Error('Tx outputs does not match with expected txParams recipients'); + } + let totalAmount = new BigNumber(0); + for (const recipients of txParams.recipients) { + totalAmount = totalAmount.plus(recipients.amount); + } + if (!totalAmount.isEqualTo(explainedTx.outputAmount)) { + throw new Error('Tx total amount does not match with expected total amount field'); + } + } + return true; } async isWalletAddress(params: VerifyAddressOptions): Promise { @@ -76,8 +118,41 @@ export class Apt extends BaseCoin { return true; } - parseTransaction(params: ParseTransactionOptions): Promise { - throw new Error('Method not implemented.'); + async parseTransaction(params: AptParseTransactionOptions): Promise { + const transactionExplanation = await this.explainTransaction({ txHex: params.txHex }); + if (!transactionExplanation) { + throw new Error('Invalid transaction'); + } + return { + inputs: [ + { + address: transactionExplanation.sender, + amount: transactionExplanation.outputAmount, + }, + ], + outputs: [ + { + address: transactionExplanation.outputs[0].address, + amount: transactionExplanation.outputs[0].amount, + }, + ], + }; + } + + /** + * Explain a Apt transaction + * @param params + */ + async explainTransaction(params: ExplainTransactionOptions): Promise { + const factory = this.getBuilder(); + let rebuiltTransaction: BaseTransaction; + try { + const transactionBuilder = factory.from(params.txHex); + rebuiltTransaction = await transactionBuilder.build(); + } catch { + return undefined; + } + return rebuiltTransaction.explainTransaction(); } generateKeyPair(seed?: Buffer): KeyPair { @@ -103,4 +178,8 @@ export class Apt extends BaseCoin { signTransaction(params: SignTransactionOptions): Promise { throw new Error('Method not implemented.'); } + + private getBuilder(): TransactionBuilderFactory { + return new TransactionBuilderFactory(coins.get(this.getChain())); + } } diff --git a/modules/sdk-coin-apt/src/lib/constants.ts b/modules/sdk-coin-apt/src/lib/constants.ts index 4cbd0c5c11..836a1b11b6 100644 --- a/modules/sdk-coin-apt/src/lib/constants.ts +++ b/modules/sdk-coin-apt/src/lib/constants.ts @@ -2,3 +2,6 @@ export const APT_ADDRESS_LENGTH = 64; export const APT_TRANSACTION_ID_LENGTH = 64; 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; +export const SECONDS_PER_WEEK = 7 * 24 * 60 * 60; // Days * Hours * Minutes * Seconds diff --git a/modules/sdk-coin-apt/src/lib/iface.ts b/modules/sdk-coin-apt/src/lib/iface.ts index 546ef821e8..13bfa249f7 100644 --- a/modules/sdk-coin-apt/src/lib/iface.ts +++ b/modules/sdk-coin-apt/src/lib/iface.ts @@ -1,6 +1,23 @@ +import { + TransactionExplanation as BaseTransactionExplanation, + TransactionRecipient, + TransactionType as BitGoTransactionType, +} from '@bitgo/sdk-core'; + +export interface AptTransactionExplanation extends BaseTransactionExplanation { + sender?: string; + type?: BitGoTransactionType; +} + /** * The transaction data returned from the toJson() function of a transaction */ -export interface TxData { +export interface TransferTxData { id: string; + sender: string; + recipient: TransactionRecipient; + sequenceNumber: number; + maxGasAmount: number; + gasUnitPrice: number; + expirationTime: number; } diff --git a/modules/sdk-coin-apt/src/lib/index.ts b/modules/sdk-coin-apt/src/lib/index.ts index aa5217eaac..e0d02046f3 100644 --- a/modules/sdk-coin-apt/src/lib/index.ts +++ b/modules/sdk-coin-apt/src/lib/index.ts @@ -2,7 +2,8 @@ import * as Utils from './utils'; import * as Interface from './iface'; export { KeyPair } from './keyPair'; -export { Transaction } from './transaction'; +export { Transaction } from './transaction/transaction'; +export { TransferTransaction } from './transaction/transferTransaction'; export { TransactionBuilder } from './transactionBuilder'; export { TransferBuilder } from './transferBuilder'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; diff --git a/modules/sdk-coin-apt/src/lib/transaction.ts b/modules/sdk-coin-apt/src/lib/transaction.ts deleted file mode 100644 index 431c397b49..0000000000 --- a/modules/sdk-coin-apt/src/lib/transaction.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { BaseKey, BaseTransaction } from '@bitgo/sdk-core'; -import { TxData } from './iface'; - -export class Transaction extends BaseTransaction { - canSign(key: BaseKey): boolean { - return false; - } - - toBroadcastFormat(): string { - throw new Error('Method not implemented.'); - } - - toJson(): TxData { - throw new Error('Method not implemented.'); - } -} diff --git a/modules/sdk-coin-apt/src/lib/transaction/transaction.ts b/modules/sdk-coin-apt/src/lib/transaction/transaction.ts new file mode 100644 index 0000000000..325397c1f1 --- /dev/null +++ b/modules/sdk-coin-apt/src/lib/transaction/transaction.ts @@ -0,0 +1,260 @@ +import { + BaseKey, + BaseTransaction, + InvalidTransactionError, + PublicKey, + Signature, + TransactionRecipient, + TransactionType, +} from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig, NetworkType } from '@bitgo/statics'; +import { + AccountAddress, + AccountAuthenticatorEd25519, + Aptos, + APTOS_COIN, + AptosConfig, + DEFAULT_MAX_GAS_AMOUNT, + Ed25519PublicKey, + Ed25519Signature, + generateUserTransactionHash, + Hex, + Network, + RawTransaction, + SignedTransaction, + SimpleTransaction, + TransactionAuthenticatorEd25519, +} from '@aptos-labs/ts-sdk'; +import { DEFAULT_GAS_UNIT_PRICE, SECONDS_PER_WEEK, UNAVAILABLE_TEXT } from '../constants'; +import utils from '../utils'; +import BigNumber from 'bignumber.js'; + +export abstract class Transaction extends BaseTransaction { + protected _rawTransaction: RawTransaction; + protected _signature: Signature; + protected _sender: string; + protected _recipient: TransactionRecipient; + protected _sequenceNumber: number; + protected _maxGasAmount: number; + protected _gasUnitPrice: number; + protected _gasUsed: number; + protected _expirationTime: number; + + static EMPTY_PUBLIC_KEY = Buffer.alloc(32); + static EMPTY_SIGNATURE = Buffer.alloc(64); + + constructor(coinConfig: Readonly) { + super(coinConfig); + this._maxGasAmount = DEFAULT_MAX_GAS_AMOUNT; + this._gasUnitPrice = DEFAULT_GAS_UNIT_PRICE; + this._gasUsed = 0; + this._expirationTime = Math.floor(Date.now() / 1e3) + SECONDS_PER_WEEK; + this._sequenceNumber = 0; + this._signature = { + publicKey: { + pub: Hex.fromHexInput(Transaction.EMPTY_PUBLIC_KEY).toString(), + }, + signature: Transaction.EMPTY_SIGNATURE, + }; + } + + /** @inheritDoc **/ + public get id(): string { + this.generateTxnId(); + return this._id ?? UNAVAILABLE_TEXT; + } + + get sender(): string { + return this._sender; + } + + set sender(value: string) { + this._sender = value; + } + + get recipient(): TransactionRecipient { + return this._recipient; + } + + set recipient(value: TransactionRecipient) { + this._recipient = value; + } + + get sequenceNumber(): number { + return this._sequenceNumber; + } + + set sequenceNumber(value: number) { + this._sequenceNumber = value; + } + + get maxGasAmount(): number { + return this._maxGasAmount; + } + + set maxGasAmount(value: number) { + this._maxGasAmount = value; + } + + get gasUnitPrice(): number { + return this._gasUnitPrice; + } + + set gasUnitPrice(value: number) { + this._gasUnitPrice = value; + } + + get gasUsed(): number { + return this._gasUsed; + } + + set gasUsed(value: number) { + this._gasUsed = value; + } + + get expirationTime(): number { + return this._expirationTime; + } + + set expirationTime(value: number) { + this._expirationTime = value; + } + + set transactionType(transactionType: TransactionType) { + this._type = transactionType; + } + + canSign(_key: BaseKey): boolean { + return false; + } + + toBroadcastFormat(): string { + if (!this._rawTransaction) { + throw new InvalidTransactionError('Empty transaction'); + } + return this.serialize(); + } + + serialize(): string { + const publicKeyBuffer = utils.getBufferFromHexString(this._signature.publicKey.pub); + const publicKey = new Ed25519PublicKey(publicKeyBuffer); + + const signature = new Ed25519Signature(this._signature.signature); + + const txnAuthenticator = new TransactionAuthenticatorEd25519(publicKey, signature); + const signedTxn = new SignedTransaction(this._rawTransaction, txnAuthenticator); + return signedTxn.toString(); + } + + addSignature(publicKey: PublicKey, signature: Buffer): void { + this._signatures = [signature.toString('hex')]; + this._signature = { publicKey, signature }; + } + + async build(): Promise { + await this.buildRawTransaction(); + this.generateTxnId(); + this.loadInputsAndOutputs(); + } + + loadInputsAndOutputs(): void { + this._inputs = [ + { + address: this.sender, + value: this.recipient.amount as string, + coin: this._coinConfig.name, + }, + ]; + this._outputs = [ + { + address: this.recipient.address, + value: this.recipient.amount as string, + coin: this._coinConfig.name, + }, + ]; + } + + fromRawTransaction(rawTransaction: string): void { + let signedTxn: SignedTransaction; + try { + signedTxn = utils.deserializeSignedTransaction(rawTransaction); + } catch (e) { + console.error('invalid raw transaction', e); + throw new Error('invalid raw transaction'); + } + this.fromDeserializedSignedTransaction(signedTxn); + } + + fromDeserializedSignedTransaction(signedTxn: SignedTransaction): void { + try { + const rawTxn = signedTxn.raw_txn; + this._sender = rawTxn.sender.toString(); + this._recipient = utils.getRecipientFromTransactionPayload(rawTxn.payload); + this._sequenceNumber = utils.castToNumber(rawTxn.sequence_number); + this._maxGasAmount = utils.castToNumber(rawTxn.max_gas_amount); + this._gasUnitPrice = utils.castToNumber(rawTxn.gas_unit_price); + this._expirationTime = utils.castToNumber(rawTxn.expiration_timestamp_secs); + this._rawTransaction = rawTxn; + + this.loadInputsAndOutputs(); + const authenticator = signedTxn.authenticator as TransactionAuthenticatorEd25519; + const signature = Buffer.from(authenticator.signature.toUint8Array()); + this.addSignature({ pub: authenticator.public_key.toString() }, signature); + } catch (e) { + console.error('invalid signed transaction', e); + throw new Error('invalid signed transaction'); + } + } + + /** + * Deserializes a signed transaction hex string + * @param {string} signedRawTransaction + * @returns {SignedTransaction} the aptos signed transaction + */ + static deserializeSignedTransaction(signedRawTransaction: string): SignedTransaction { + try { + return utils.deserializeSignedTransaction(signedRawTransaction); + } catch (e) { + console.error('invalid raw transaction', e); + throw new Error('invalid raw transaction'); + } + } + + protected async buildRawTransaction() { + const network: Network = this._coinConfig.network.type === NetworkType.MAINNET ? Network.MAINNET : Network.TESTNET; + const aptos = new Aptos(new AptosConfig({ network })); + const senderAddress = AccountAddress.fromString(this._sender); + const recipientAddress = AccountAddress.fromString(this._recipient.address); + + const simpleTxn = await aptos.transaction.build.simple({ + sender: senderAddress, + data: { + function: '0x1::coin::transfer', + typeArguments: [APTOS_COIN], + functionArguments: [recipientAddress, this.recipient.amount], + }, + options: { + maxGasAmount: this.maxGasAmount, + gasUnitPrice: this.gasUnitPrice, + expireTimestamp: this.expirationTime, + accountSequenceNumber: this.sequenceNumber, + }, + }); + this._rawTransaction = simpleTxn.rawTransaction; + } + + protected getFee(): string { + return new BigNumber(this.gasUsed).multipliedBy(this.gasUnitPrice).toString(); + } + + private generateTxnId() { + if (!this._signature || !this._signature.publicKey || !this._signature.signature) { + return; + } + const transaction = new SimpleTransaction(this._rawTransaction); + const publicKey = new Ed25519PublicKey(utils.getBufferFromHexString(this._signature.publicKey.pub)); + const signature = new Ed25519Signature(this._signature.signature); + const senderAuthenticator = new AccountAuthenticatorEd25519(publicKey, signature); + this._id = generateUserTransactionHash({ transaction, senderAuthenticator }); + } +} diff --git a/modules/sdk-coin-apt/src/lib/transaction/transferTransaction.ts b/modules/sdk-coin-apt/src/lib/transaction/transferTransaction.ts new file mode 100644 index 0000000000..c41ab23611 --- /dev/null +++ b/modules/sdk-coin-apt/src/lib/transaction/transferTransaction.ts @@ -0,0 +1,56 @@ +import { Transaction } from './transaction'; +import { AptTransactionExplanation, TransferTxData } from '../iface'; +import { TransactionRecipient, TransactionType } from '@bitgo/sdk-core'; +import { generateSigningMessage, RAW_TRANSACTION_SALT } from '@aptos-labs/ts-sdk'; + +export class TransferTransaction extends Transaction { + constructor(coinConfig) { + super(coinConfig); + this._type = TransactionType.Send; + } + + public get signablePayload(): Buffer { + return Buffer.from(generateSigningMessage(this._rawTransaction.bcsToBytes(), RAW_TRANSACTION_SALT)); + } + + /** @inheritDoc */ + explainTransaction(): AptTransactionExplanation { + const displayOrder = [ + 'id', + 'outputs', + 'outputAmount', + 'changeOutputs', + 'changeAmount', + 'fee', + 'withdrawAmount', + 'sender', + 'type', + ]; + + const outputs: TransactionRecipient[] = [this.recipient]; + const outputAmount = outputs[0].amount; + return { + displayOrder, + id: this.id, + outputs, + outputAmount, + changeOutputs: [], + changeAmount: '0', + fee: { fee: this.getFee() }, + sender: this.sender, + type: this.type, + }; + } + + toJson(): TransferTxData { + return { + id: this.id, + sender: this.sender, + recipient: this.recipient, + sequenceNumber: this.sequenceNumber, + maxGasAmount: this.maxGasAmount, + gasUnitPrice: this.gasUnitPrice, + expirationTime: this.expirationTime, + }; + } +} diff --git a/modules/sdk-coin-apt/src/lib/transactionBuilder.ts b/modules/sdk-coin-apt/src/lib/transactionBuilder.ts index 04175935ff..143674188c 100644 --- a/modules/sdk-coin-apt/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-apt/src/lib/transactionBuilder.ts @@ -3,18 +3,23 @@ import { BaseKey, BaseTransactionBuilder, BuildTransactionError, - FeeOptions, + ParseTransactionError, PublicKey as BasePublicKey, - Signature, + Recipient, TransactionType, } from '@bitgo/sdk-core'; -import { Transaction } from './transaction'; +import { Transaction } from './transaction/transaction'; import utils from './utils'; import BigNumber from 'bignumber.js'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { GasData } from './types'; export abstract class TransactionBuilder extends BaseTransactionBuilder { protected _transaction: Transaction; - private _signatures: Signature[] = []; + + constructor(coinConfig: Readonly) { + super(coinConfig); + } // get and set region /** @@ -32,41 +37,74 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this._transaction = transaction; } - /** @inheritdoc */ - protected signImplementation(key: BaseKey): Transaction { - throw new Error('Method not implemented.'); - } - - /** @inheritDoc */ - addSignature(publicKey: BasePublicKey, signature: Buffer): void { - this._signatures.push({ publicKey, signature }); - } - /** * Sets the sender of this transaction. - * This account will be responsible for paying transaction fees. * * @param {string} senderAddress the account that is sending this transaction * @returns {TransactionBuilder} This transaction builder */ sender(senderAddress: string): this { - throw new Error('Method not implemented.'); + this.validateAddress({ address: senderAddress }); + this.transaction.sender = senderAddress; + return this; + } + + recipient(recipient: Recipient): this { + this.validateAddress({ address: recipient.address }); + this.validateValue(new BigNumber(recipient.amount)); + this.transaction.recipient = recipient; + return this; + } + + gasData(gasData: GasData): this { + this.validateGasData(gasData); + this.transaction.maxGasAmount = gasData.maxGasAmount; + this.transaction.gasUnitPrice = gasData.gasUnitPrice; + this.transaction.gasUsed = gasData.gasUsed ?? 0; + return this; + } + + sequenceNumber(seqNo: number): TransactionBuilder { + this.transaction.sequenceNumber = seqNo; + return this; + } + + expirationTime(expTimeSec: number): TransactionBuilder { + this.transaction.expirationTime = expTimeSec; + return this; } - fee(feeOptions: FeeOptions): this { + /** @inheritdoc */ + protected signImplementation(key: BaseKey): Transaction { throw new Error('Method not implemented.'); } + /** @inheritDoc */ + addSignature(publicKey: BasePublicKey, signature: Buffer): void { + this.transaction.addSignature(publicKey, signature); + } + /** @inheritdoc */ protected fromImplementation(rawTransaction: string): Transaction { - throw new Error('Method not implemented.'); + this.transaction.fromRawTransaction(rawTransaction); + this.transaction.transactionType = this.transactionType; + return this.transaction; } /** @inheritdoc */ protected async buildImplementation(): Promise { - throw new Error('Method not implemented.'); + this.transaction.transactionType = this.transactionType; + await this.transaction.build(); + return this.transaction; } + /** + * Initialize the transaction builder fields using the decoded transaction data + * + * @param {Transaction} tx the transaction data + */ + abstract initBuilder(tx: Transaction): void; + // region Validators /** @inheritdoc */ validateAddress(address: BaseAddress, addressFormat?: string): void { @@ -82,18 +120,35 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { /** @inheritdoc */ validateRawTransaction(rawTransaction: string): void { - throw new Error('Method not implemented.'); + if (!rawTransaction) { + throw new ParseTransactionError('Invalid raw transaction: Undefined'); + } + if (!utils.isValidRawTransaction(rawTransaction)) { + throw new ParseTransactionError('Invalid raw transaction'); + } } /** @inheritdoc */ validateTransaction(transaction?: Transaction): void { - throw new Error('Method not implemented.'); + if (!transaction) { + throw new Error('transaction not defined'); + } + this.validateAddress({ address: transaction.sender }); + this.validateAddress({ address: transaction.recipient.address }); + this.validateValue(new BigNumber(transaction.recipient.amount)); } /** @inheritdoc */ validateValue(value: BigNumber): void { - if (value.isLessThan(0)) { + if (value.isNaN()) { + throw new BuildTransactionError('Invalid amount format'); + } else if (value.isLessThan(0)) { throw new BuildTransactionError('Value cannot be less than zero'); } } + + private validateGasData(gasData: GasData): void { + this.validateValue(new BigNumber(gasData.maxGasAmount)); + this.validateValue(new BigNumber(gasData.gasUnitPrice)); + } } diff --git a/modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts index b963ec4b4d..9fbfb0a466 100644 --- a/modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts @@ -1,20 +1,62 @@ import { BaseTransactionBuilderFactory } from '@bitgo/sdk-core'; import { TransactionBuilder } from './transactionBuilder'; import { TransferBuilder } from './transferBuilder'; +import utils from './utils'; +import { Transaction } from './transaction/transaction'; +import { SignedTransaction } from '@aptos-labs/ts-sdk'; +import { TransferTransaction } from './transaction/transferTransaction'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + /** @inheritdoc */ - from(raw: string): TransactionBuilder { - throw new Error('Method not implemented.'); + from(signedRawTxn: string): TransactionBuilder { + utils.validateRawTransaction(signedRawTxn); + try { + const signedTxn = this.parseTransaction(signedRawTxn); + // Assumption: only a single transaction type exists + // TODO: add txn type switch case + const transferTx = new TransferTransaction(this._coinConfig); + transferTx.fromDeserializedSignedTransaction(signedTxn); + return this.getTransferBuilder(transferTx); + } catch (e) { + throw e; + } } /** @inheritdoc */ - getTransferBuilder(): TransferBuilder { - throw new Error('Method not implemented.'); + getTransferBuilder(tx?: Transaction): TransferBuilder { + return this.initializeBuilder(tx, new TransferBuilder(this._coinConfig)); } /** @inheritdoc */ getWalletInitializationBuilder(): void { throw new Error('Method not implemented.'); } + + /** + * Initialize the builder with the given transaction + * + * @param {Transaction | undefined} tx - the transaction used to initialize the builder + * @param {TransactionBuilder} builder - the builder to be initialized + * @returns {TransactionBuilder} the builder initialized + */ + private initializeBuilder(tx: Transaction | undefined, builder: T): T { + if (tx) { + builder.initBuilder(tx); + } + return builder; + } + + /** Parse the transaction from a signed txn hex string + * + * @param {string} signedRawTransaction - the signed txn hex + * @returns {SignedTransaction} parsedtransaction + */ + private parseTransaction(signedRawTransaction: string): SignedTransaction { + return Transaction.deserializeSignedTransaction(signedRawTransaction); + } } diff --git a/modules/sdk-coin-apt/src/lib/transferBuilder.ts b/modules/sdk-coin-apt/src/lib/transferBuilder.ts index b7ce748c92..b1cd739500 100644 --- a/modules/sdk-coin-apt/src/lib/transferBuilder.ts +++ b/modules/sdk-coin-apt/src/lib/transferBuilder.ts @@ -1,13 +1,24 @@ import { TransactionBuilder } from './transactionBuilder'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { TransactionType } from '@bitgo/sdk-core'; +import { TransferTransaction } from './transaction/transferTransaction'; export class TransferBuilder extends TransactionBuilder { constructor(_coinConfig: Readonly) { super(_coinConfig); + this.transaction = new TransferTransaction(_coinConfig); } protected get transactionType(): TransactionType { return TransactionType.Send; } + + /** + * Initialize the transaction builder fields using the decoded transaction data + * + * @param {Transaction} tx the transaction data + */ + initBuilder(tx: TransferTransaction): void { + this._transaction = tx; + } } diff --git a/modules/sdk-coin-apt/src/lib/types.ts b/modules/sdk-coin-apt/src/lib/types.ts new file mode 100644 index 0000000000..4881e7e680 --- /dev/null +++ b/modules/sdk-coin-apt/src/lib/types.ts @@ -0,0 +1,9 @@ +export interface ExplainTransactionOptions { + txHex: string; +} + +export type GasData = { + maxGasAmount: number; + gasUnitPrice: number; + gasUsed?: number; +}; diff --git a/modules/sdk-coin-apt/src/lib/utils.ts b/modules/sdk-coin-apt/src/lib/utils.ts index 824796bc72..2c62ba80c3 100644 --- a/modules/sdk-coin-apt/src/lib/utils.ts +++ b/modules/sdk-coin-apt/src/lib/utils.ts @@ -1,6 +1,21 @@ -import { AuthenticationKey, Ed25519PublicKey } from '@aptos-labs/ts-sdk'; -import { BaseUtils, isValidEd25519PublicKey, isValidEd25519SecretKey } from '@bitgo/sdk-core'; +import { + AuthenticationKey, + Deserializer, + Ed25519PublicKey, + Hex, + SignedTransaction, + TransactionPayload, + TransactionPayloadEntryFunction, +} from '@aptos-labs/ts-sdk'; +import { + BaseUtils, + isValidEd25519PublicKey, + isValidEd25519SecretKey, + ParseTransactionError, + TransactionRecipient, +} from '@bitgo/sdk-core'; import { APT_ADDRESS_LENGTH, APT_BLOCK_ID_LENGTH, APT_SIGNATURE_LENGTH, APT_TRANSACTION_ID_LENGTH } from './constants'; +import BigNumber from 'bignumber.js'; export class Utils implements BaseUtils { /** @inheritdoc */ @@ -33,7 +48,7 @@ export class Utils implements BaseUtils { return this.isValidHex(txId, APT_TRANSACTION_ID_LENGTH); } - isValidHex(value: string, length: number) { + isValidHex(value: string, length: number): boolean { const regex = new RegExp(`^(0x|0X)[a-fA-F0-9]{${length}}$`); return regex.test(value); } @@ -44,6 +59,58 @@ export class Utils implements BaseUtils { const accountAddress = authKey.derivedAddress(); return accountAddress.toString(); } + + getRecipientFromTransactionPayload(payload: TransactionPayload): TransactionRecipient { + let address = 'INVALID'; + let amount = '0'; + if (payload instanceof TransactionPayloadEntryFunction) { + const entryFunction = payload.entryFunction; + address = entryFunction.args[0].toString(); + const amountBuffer = Buffer.from(entryFunction.args[1].bcsToBytes()); + amount = amountBuffer.readBigUint64LE().toString(); + } + return { address, amount }; + } + + isValidRawTransaction(rawTransaction: string): boolean { + try { + const signedTxn = this.deserializeSignedTransaction(rawTransaction); + const rawTxn = signedTxn.raw_txn; + const senderAddress = rawTxn.sender.toString(); + const recipient = utils.getRecipientFromTransactionPayload(rawTxn.payload); + const recipientAddress = recipient.address; + const recipientAmount = new BigNumber(recipient.amount); + return ( + this.isValidAddress(senderAddress) && this.isValidAddress(recipientAddress) && !recipientAmount.isLessThan(0) + ); + } catch (e) { + console.error('invalid raw transaction', e); + return false; + } + } + + validateRawTransaction(rawTransaction: string): void { + if (!rawTransaction) { + throw new ParseTransactionError('Invalid raw transaction: Undefined'); + } + if (!this.isValidRawTransaction(rawTransaction)) { + throw new ParseTransactionError('Invalid raw transaction'); + } + } + + deserializeSignedTransaction(rawTransaction: string): SignedTransaction { + const txnBytes = Hex.fromHexString(rawTransaction).toUint8Array(); + const deserializer = new Deserializer(txnBytes); + return deserializer.deserialize(SignedTransaction); + } + + getBufferFromHexString(hexString: string): Buffer { + return Buffer.from(Hex.fromHexString(hexString).toUint8Array()); + } + + castToNumber(value: bigint): number { + return new BigNumber(value.toString()).toNumber(); + } } const utils = new Utils(); diff --git a/modules/sdk-coin-apt/test/resources/apt.ts b/modules/sdk-coin-apt/test/resources/apt.ts new file mode 100644 index 0000000000..e06890c8f1 --- /dev/null +++ b/modules/sdk-coin-apt/test/resources/apt.ts @@ -0,0 +1,54 @@ +import { Recipient } from '@bitgo/sdk-core'; + +export const AMOUNT = 1000; + +export const addresses = { + validAddresses: [ + '0xf7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad9', + '0x1aed808916ab9b1b30b07abb53561afd46847285ce28651221d406173a372449', + ], + invalidAddresses: [ + 'randomString', + '0xc4173a804406a365e69dfb297ddfgsdcvf', + '5ne7phA48Jrvpn39AtupB8ZkCCAy8gLTfpGihZPuDqen', + ], +}; + +export const sender = { + address: '0x1aed808916ab9b1b30b07abb53561afd46847285ce28651221d406173a372449', + privateKey: '0x54e1a88eb2b881fe6c222985d0fabc9ba3aca35743dd1d19db18a9a355acbaaa', + publicKey: '0xf73836f42257240e43d439552471fc9dbcc3f1af5bd0b4ed83f44b5f66146442', +}; + +export const sender2 = { + address: '0xc8f02d25aa698b3e9fbd8a08e8da4c8ee261832a25a4cde8731b5ec356537d09', + publicKey: '0x2121dcd098069ae535697dd019cfd8677ca7aba0adac1d1959cbce6dc54b1259', +}; + +export const recipients: Recipient[] = [ + { + address: addresses.validAddresses[0], + amount: AMOUNT.toString(), + }, +]; + +export const invalidRecipients: Recipient[] = [ + { + address: addresses.invalidAddresses[0], + amount: AMOUNT.toString(), + }, + { + address: addresses.validAddresses[0], + amount: '-919191', + }, + { + address: addresses.validAddresses[0], + amount: 'invalidAmount', + }, +]; + +export const TRANSFER = + '0xc8f02d25aa698b3e9fbd8a08e8da4c8ee261832a25a4cde8731b5ec356537d09170000000000000002000000000000000000000000000000000000000000000000000000000000000104636f696e087472616e73666572010700000000000000000000000000000000000000000000000000000000000000010a6170746f735f636f696e094170746f73436f696e000220f7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad908e803000000000000400d0300000000006400000000000000207c7667000000000200202121dcd098069ae535697dd019cfd8677ca7aba0adac1d1959cbce6dc54b12594010f340ec153b724c4dc1c9a435d0fafed1775d851c1e8d965925a7879550c69a4677925d9198334a72ae7ce8998226ff0a83743c7ba8a2831136c072bf21c404'; + +export const INVALID_TRANSFER = + 'AAAAAAAAAAAAA6e7361637469bc4a58e500b9e64cb6547ee9b403000000000000002064ba1fb2f2fbd2938a350015d601f4db89cd7e8e2370d0dd9ae3ac4f635c1581111b8a49f67370bc4a58e500b9e64cb6462e39b802000000000000002064ba1fb2f2fbd2938a350015d601f4db89cd7e8e2370d0dd9ae3ac47aa1ff81f01c4173a804406a365e69dfb297d4eaaf002546ebd016400000000000000cba4a48bb0f8b586c167e5dcefaa1c5e96ab3f0836d6ca08f2081732944d1e5b6b406a4a462e39b8030000000000000020b9490ede63215262c434e03f606d9799f3ba704523ceda184b386d47aa1ff81f01000000000000006400000000000000'; diff --git a/modules/sdk-coin-apt/test/unit/apt.ts b/modules/sdk-coin-apt/test/unit/apt.ts index d81e6d9787..fdbcff0870 100644 --- a/modules/sdk-coin-apt/test/unit/apt.ts +++ b/modules/sdk-coin-apt/test/unit/apt.ts @@ -1,15 +1,296 @@ import 'should'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; -import { Apt, Tapt } from '../../src'; +import { Apt, Tapt, TransferTransaction } from '../../src'; +import * as testData from '../resources/apt'; +import _ from 'lodash'; +import sinon from 'sinon'; +import assert from 'assert'; +import { + AccountAddress, + AccountAuthenticatorEd25519, + Aptos, + APTOS_COIN, + AptosConfig, + Ed25519PublicKey, + Ed25519Signature, + generateUserTransactionHash, + Network, +} from '@aptos-labs/ts-sdk'; +import utils from '../../src/lib/utils'; +import { coins } from '@bitgo/statics'; describe('APT:', function () { let bitgo: TestBitGoAPI; + let basecoin; + let newTxPrebuild; + let newTxParams; + + const txPreBuild = { + txHex: testData.TRANSFER, + txInfo: {}, + }; + + const txParams = { + recipients: testData.recipients, + }; before(function () { bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); bitgo.safeRegister('apt', Apt.createInstance); bitgo.safeRegister('tapt', Tapt.createInstance); bitgo.initializeTestVars(); + basecoin = bitgo.coin('tapt'); + newTxPrebuild = () => { + return _.cloneDeep(txPreBuild); + }; + newTxParams = () => { + return _.cloneDeep(txParams); + }; + }); + + it('should return the right info', function () { + const apt = bitgo.coin('apt'); + const tapt = bitgo.coin('tapt'); + + apt.getChain().should.equal('apt'); + apt.getFamily().should.equal('apt'); + apt.getFullName().should.equal('Aptos'); + apt.getBaseFactor().should.equal(1e8); + + tapt.getChain().should.equal('tapt'); + tapt.getFamily().should.equal('apt'); + tapt.getFullName().should.equal('Testnet Aptos'); + tapt.getBaseFactor().should.equal(1e8); + }); + + describe('Verify transaction: ', () => { + it('should succeed to verify transaction', async function () { + const txPrebuild = newTxPrebuild(); + const txParams = newTxParams(); + const verification = {}; + const isTransactionVerified = await basecoin.verifyTransaction({ txParams, txPrebuild, verification }); + isTransactionVerified.should.equal(true); + }); + + it('should succeed to verify transaction when recipients amount are numbers', async function () { + const txPrebuild = newTxPrebuild(); + const txParamsWithNumberAmounts = newTxParams(); + txParamsWithNumberAmounts.recipients = txParamsWithNumberAmounts.recipients.map(({ address, amount }) => { + return { address, amount: Number(amount) }; + }); + const verification = {}; + const isTransactionVerified = await basecoin.verifyTransaction({ + txParams: txParamsWithNumberAmounts, + txPrebuild, + verification, + }); + isTransactionVerified.should.equal(true); + }); + + it('should fail to verify transaction with invalid param', async function () { + const txPrebuild = {}; + const txParams = newTxParams(); + txParams.recipients = undefined; + await basecoin + .verifyTransaction({ + txParams, + txPrebuild, + }) + .should.rejectedWith('missing required tx prebuild property txHex'); + }); + }); + + describe('Parse and Explain Transactions: ', () => { + const transferInputsResponse = [ + { + address: testData.sender2.address, + amount: testData.AMOUNT.toString(), + }, + ]; + + const transferOutputsResponse = [ + { + address: testData.recipients[0].address, + amount: testData.recipients[0].amount, + }, + ]; + + it('should parse a transfer transaction', async function () { + const parsedTransaction = await basecoin.parseTransaction({ + txHex: testData.TRANSFER, + }); + + parsedTransaction.should.deepEqual({ + inputs: transferInputsResponse, + outputs: transferOutputsResponse, + }); + }); + + it('should explain a transfer transaction', async function () { + const rawTx = newTxPrebuild().txHex; + const transaction = new TransferTransaction(coins.get('tapt')); + transaction.fromRawTransaction(rawTx); + const explainedTx = transaction.explainTransaction(); + console.log(explainedTx); + explainedTx.should.deepEqual({ + displayOrder: [ + 'id', + 'outputs', + 'outputAmount', + 'changeOutputs', + 'changeAmount', + 'fee', + 'withdrawAmount', + 'sender', + 'type', + ], + id: '0x43ea7697550d5effb68c47488fd32a7756ee418e8d2be7d6b7f634f3ac0d7766', + outputs: [ + { + address: '0xf7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad9', + amount: '1000', + }, + ], + outputAmount: '1000', + changeOutputs: [], + changeAmount: '0', + fee: { fee: '0' }, + sender: '0xc8f02d25aa698b3e9fbd8a08e8da4c8ee261832a25a4cde8731b5ec356537d09', + type: 0, + }); + }); + + it('should fail to explain a invalid raw transaction', async function () { + const rawTx = 'invalidRawTransaction'; + const transaction = new TransferTransaction(coins.get('tapt')); + await assert.rejects(async () => transaction.fromRawTransaction(rawTx), { + message: 'invalid raw transaction', + }); + }); + + it('should fail to parse a transfer transaction when explainTransaction response is undefined', async function () { + const stub = sinon.stub(Apt.prototype, 'explainTransaction'); + stub.resolves(undefined); + await basecoin.parseTransaction({ txHex: testData.TRANSFER }).should.be.rejectedWith('Invalid transaction'); + stub.restore(); + }); + }); + + describe('Address Validation', () => { + let keychains; + let commonKeychain; + + before(function () { + commonKeychain = + '19bdfe2a4b498a05511381235a8892d54267807c4a3f654e310b938b8b424ff4adedbe92f4c146de641c67508a961324c8504cdf8e0c0acbb68d6104ccccd781'; + keychains = [ + { + id: '6424c353eaf78d000766e95949868468', + source: 'user', + type: 'tss', + commonKeychain: + '19bdfe2a4b498a05511381235a8892d54267807c4a3f654e310b938b8b424ff4adedbe92f4c146de641c67508a961324c8504cdf8e0c0acbb68d6104ccccd781', + encryptedPrv: + '{"iv":"cZd5i7L4RxtwrALW2rK7UA==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"5zgoH1Bd3Fw=","ct":"9vVlnXFRtrM9FVEo+d2chbGHlM9lFZemueBuAs3BIkPo33Fo7jzwwNK/kIWkEyg+NmEBd5IaqAS157nvvvwzzsmMWlQdUz9qbmXNv3pg987cXFR08exS+4uhwP1YNOjJTRvRNcO9ZqHb46d4fmyJ/yC9/susCge7r/EsbaN5C3afv1dzybuq912FwaQElZLYYp5BICudFOMZ9k0UDMfKM/PMDkH7WexoGHr9GKq/bgCH2B39TZZyHKU6Uy47lXep2s6h0DrMwHOrnmiL3DZjOj88Ynvphlzxuo4eOlD2UHia2+nvIaISYs29Pr0DAvREutchvcBpExj1kWWPv7hQYrv8F0NAdatsbWl3w+xKyfiMKo1USlrwyJviypGtQtXOJyw0XPN0rv2+L5lW8BbjpzHfYYN13fJTedlGTFhhkzVtbbPAKE02kx7zCJcjYaiexdSTsrDLScYNT9/Jhdt27KpsooehwVohLfSKz4vbFfRu2MPZw3/+c/hfiJNgtz6esWbnxGrcE8U2IwPYCaK+Ghk4DcqWNIni59RI5B5kAsQOToII40qPN510uTgxBSPO7q7MHgkxdd4CqBq+ojr9j0P7oao8E5Y+CBDJrojDoCh1oCCDW9vo2dXlVcD8SIbw7U/9AfvEbA4xyE/5md1M7CIwLnWs2Ynv0YtaKoqhdS9x6FmHlMDhN/DKHinrwmowtrTT82fOkpO5g9saSmgU7Qy3gLt8t+VwdEyeFeQUKRSyci8qgqXQaZIg4+aXgaSOnlCFMtmB8ekYxEhTY5uzRfrNgS4s1QeqFBpNtUF+Ydi297pbVXnJoXAN+SVWd80GCx+yI2dpVC89k3rOWK9WeyqlnzuLJWp2RIOB9cdW8GFv/fN+QAJpYeVxOE4+nZDsKnsj8nKcg9t4Dlx1G6gLM1/Vq9YxNLbuzuRC0asUYvdMnoMvszmpm++TxndYisgNYscpZSoz7wvcazJNEPfhPVjEkd6tUUuN4GM35H0DmKCUQNT+a6B6hmHlTZvjxiyGAg5bY59hdjvJ+22QduazlEEC6LI3HrA7uK0TpplWzS1tCIFvTMUhj65DEZmNJ2+ZY9bQ4vsMf+DRR3OOG4t+DMlNfjOd3zNv3QoY95BjfWpryFwPzDq7bCP67JDsoj7j2TY5FRSrRkD77H0Ewlux2cWfjRTwcMHcdQxxuV0OP0aNjGDjybFN"}', + }, + { + id: '6424c353eaf78d000766e96137d4404b', + source: 'backup', + type: 'tss', + commonKeychain: + '19bdfe2a4b498a05511381235a8892d54267807c4a3f654e310b938b8b424ff4adedbe92f4c146de641c67508a961324c8504cdf8e0c0acbb68d6104ccccd781', + encryptedPrv: + '{"iv":"vi0dPef/Rx7kG/pRySQi6Q==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"9efhQsiEvVs=","ct":"Gw6atvf6gxKzsjtl3xseipO3rAxp1mAz7Yu1ihFsi5/lf2vMZegApgZx+pyILFS9KKLHbNF3U6WgSYdrr2t4vzdLsXkH1WIxfHS+cd2C5N59yADZDnPJBT6pv/IRvaYelP0Ck3nIYQ2hSMm8op+VOWC/SzHeh7slYDqwEHTGan0Wigfvk1yRd7CCJTaEAomnc/4eFi2NY3X3gt/3opy9IAgknnwUFohn96EWpEQ0F6pbzH/Z8VF6gF+DUcrrByAxExUPnHQZiFk3YHU/vVV4FxBU/mVAE8xBsBn5ul5e5SUMPfc7TBuJWv4BByTNg9xDShF/91Yx2nbfUm5d9QmM8lpKgzzQvcK8POAPk87gRCuKnsGh5vNS0UppkHc+ocfzRQlGA6jze7QyyQO0rMj5Ly8kWjwk2vISvKYHYS1NR7VU549UIXo7NXjatunKSc3+IreoRUHIshiaLg6hl+pxCCuc0qQ43V0mdIfCjTN8gkGWLNk8R7tAGPz9jyapQPcPEGHgEz0ATIi6yMNWCsibS2eLiE1uVEJONoM4lk6FPl3Q2CHbW2MeEbqjY8hbaw18mNb2xSBH/Fwpiial+Tvi2imqgnCO4ZpO9bllKftZPcQy0stN+eGBlb5ufyflKkDSiChHYroGjEpmiFicdde48cJszF52uKNnf1q67fA9/S2FAHQab3EXojxH2Gbk+kkV2h/TYKFFZSWC3vi4e8mO+vjMUcR0AdsgPFyEIz0SCGuba3CnTLNdEuZwsauAeHkx2vUTnRgJPVgNeeuXmsVG76Sy2ggJHuals0Hj8U2Xda0qO1RuFfoCWfss9wn6HGRwPPkhSB/8oNguAqmRVGKkd8Zwt3IvrTd9fk0/rFFDJKGz7WyNHkYgUmNiGcItD12v0jx7FZ52EJzl3Av1RyJUQK18+8EYPh3SGiU9dt7VX0aF0uo6JouKhOeldUvMP+AugQz8fUclwTQsbboVg27Yxo0DyATVwThW5a56R6Qf5ZiQJluFuzs5y98rq0S5q046lE6o3vVmJpEdwjeSCJoET5CL4nTgkXyWvhm4eB8u/e66l3o0qbaSx8q9YYmT9EpRcl5TP4ThLBKETYdzVvg4exjQfektMatk5EyUpEIhZPXh5vXpJZesdfO9LJ8zTaHBsBjDPU7cdNgQMbebpataRi8A0el2/IJXl+E+olgAz5zC4i2O1Q=="}', + }, + { + id: '6424c353eaf78d000766e9510b125fba', + source: 'bitgo', + type: 'tss', + commonKeychain: + '19bdfe2a4b498a05511381235a8892d54267807c4a3f654e310b938b8b424ff4adedbe92f4c146de641c67508a961324c8504cdf8e0c0acbb68d6104ccccd781', + verifiedVssProof: true, + isBitGo: true, + }, + ]; + }); + + it('should return true when validating a well formatted address prefixed with 0x', async function () { + const address = '0xf941ae3cbe5645dccc15da8346b533f7f91f202089a5521653c062b2ff10b304'; + basecoin.isValidAddress(address).should.equal(true); + }); + + it('should return false when validating an old address', async function () { + const address = '0x2959bfc3fdb7dc23fed8deba2fafb70f3e606a59'; + basecoin.isValidAddress(address).should.equal(false); + }); + + it('should return false when validating an incorrectly formatted', async function () { + const address = 'wrongaddress'; + basecoin.isValidAddress(address).should.equal(false); + }); + + it('should return true for isWalletAddress with valid address for index 4', async function () { + const newAddress = '0x8b3c7807730d75792dd6c49732cf9f014d6984a9c77d386bdb1072a9e537d8d8'; + const index = 4; + + const params = { commonKeychain, address: newAddress, index, keychains }; + (await basecoin.isWalletAddress(params)).should.equal(true); + }); + + it('should throw error for isWalletAddress when keychains is missing', async function () { + const address = '0x2959bfc3fdb7dc23fed8deba2fafb70f3e606a59'; + const index = 0; + + const params = { commonKeychain, address, index }; + await assert.rejects(async () => basecoin.isWalletAddress(params)); + }); + + it('should throw error for isWalletAddress when new address is invalid', async function () { + const wrongAddress = 'badAddress'; + const index = 0; + + const params = { commonKeychain, address: wrongAddress, index }; + await assert.rejects(async () => basecoin.isWalletAddress(params), { + message: `invalid address: ${wrongAddress}`, + }); + }); + }); + + describe('ID Validation', () => { + it('check id', async function () { + const network: Network = Network.TESTNET; + const aptos = new Aptos(new AptosConfig({ network })); + const senderAddress = AccountAddress.fromString( + '0xc8f02d25aa698b3e9fbd8a08e8da4c8ee261832a25a4cde8731b5ec356537d09' + ); + const recipientAddress = AccountAddress.fromString( + '0xf7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad9' + ); + + const transaction = await aptos.transaction.build.simple({ + sender: senderAddress, + data: { + function: '0x1::coin::transfer', + typeArguments: [APTOS_COIN], + functionArguments: [recipientAddress, 1000], + }, + options: { + maxGasAmount: 200000, + gasUnitPrice: 100, + expireTimestamp: 1732699236, + accountSequenceNumber: 14, + }, + }); + + const DEFAULT_PUBLIC_KEY = Buffer.alloc(32); + const DEFAULT_SIGNATURE = Buffer.alloc(64); + const publicKey = new Ed25519PublicKey(utils.getBufferFromHexString(DEFAULT_PUBLIC_KEY.toString('hex'))); + const signature = new Ed25519Signature(DEFAULT_SIGNATURE); + const senderAuthenticator = new AccountAuthenticatorEd25519(publicKey, signature); + const id = generateUserTransactionHash({ transaction, senderAuthenticator }); + + id.should.equal('0x923d1cfed3afac24048451160337db75ba282912157ee43407b572923801d5ba'); + }); }); }); diff --git a/modules/sdk-coin-apt/test/unit/keyPair.ts b/modules/sdk-coin-apt/test/unit/keyPair.ts new file mode 100644 index 0000000000..f202c897cf --- /dev/null +++ b/modules/sdk-coin-apt/test/unit/keyPair.ts @@ -0,0 +1,75 @@ +import assert from 'assert'; +import { KeyPair, Tapt } from '../../src'; +import utils from '../../src/lib/utils'; +import should from 'should'; +import { Eddsa } from '@bitgo/sdk-core'; +import { Ed25519Bip32HdTree, HDTree } from '@bitgo/sdk-lib-mpc'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; + +describe('Apt KeyPair', function () { + let rootKeychain; + let rootPublicKey; + let MPC: Eddsa; + let hdTree: HDTree; + let bitgo: TestBitGoAPI; + let basecoin; + + before(async () => { + hdTree = await Ed25519Bip32HdTree.initialize(); + MPC = await Eddsa.initialize(hdTree); + const A = MPC.keyShare(1, 2, 3); + const B = MPC.keyShare(2, 2, 3); + const C = MPC.keyShare(3, 2, 3); + + const A_combine = MPC.keyCombine(A.uShare, [B.yShares[1], C.yShares[1]]); + + const commonKeychain = A_combine.pShare.y + A_combine.pShare.chaincode; + rootKeychain = MPC.deriveUnhardened(commonKeychain, 'm/0'); + rootPublicKey = Buffer.from(rootKeychain, 'hex').slice(0, 32).toString('hex'); + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.safeRegister('tapt', Tapt.createInstance); + basecoin = bitgo.coin('tapt'); + }); + + describe('should create a valid KeyPair', () => { + it('from an empty value', async () => { + const keyPair = new KeyPair(); + should.exists(keyPair.getKeys().prv); + should.exists(keyPair.getKeys().pub); + const address = await utils.getAddressFromPublicKey(keyPair.getKeys().pub); + console.log('address:', address); + should.exists(address); + }); + }); + + describe('Keypair from derived Public Key', () => { + it('should create keypair with just derived public key', () => { + const keyPair = new KeyPair({ pub: rootPublicKey }); + keyPair.getKeys().pub.should.equal(rootPublicKey); + }); + + it('should derived ed25519 public key should be valid', () => { + utils.isValidPublicKey(rootPublicKey).should.be.true(); + }); + }); + + describe('Keypair from random seed', () => { + it('should generate a keypair from random seed', function () { + const keyPair = basecoin.generateKeyPair(); + keyPair.should.have.property('pub'); + keyPair.should.have.property('prv'); + basecoin.isValidPub(keyPair.pub).should.equal(true); + }); + }); + + describe('should fail to create a KeyPair', function () { + it('from an invalid public key', () => { + const source = { + pub: '01D63D', + }; + + assert.throws(() => new KeyPair(source)); + }); + }); +}); diff --git a/modules/sdk-coin-apt/test/unit/transferBuilder.ts b/modules/sdk-coin-apt/test/unit/transferBuilder.ts new file mode 100644 index 0000000000..6cdd728563 --- /dev/null +++ b/modules/sdk-coin-apt/test/unit/transferBuilder.ts @@ -0,0 +1,161 @@ +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory, TransferTransaction } from '../../src'; +import * as testData from '../resources/apt'; +import utils from '../../src/lib/utils'; +import { TransactionType } from '@bitgo/sdk-core'; +import should from 'should'; + +describe('Apt Transfer Transaction', () => { + const factory = new TransactionBuilderFactory(coins.get('tapt')); + + describe('Succeed', () => { + it('should build a transfer tx', async function () { + const transaction = new TransferTransaction(coins.get('tapt')); + const txBuilder = factory.getTransferBuilder(transaction); + txBuilder.sender(testData.sender2.address); + txBuilder.recipient(testData.recipients[0]); + txBuilder.gasData({ + maxGasAmount: 200000, + gasUnitPrice: 100, + }); + txBuilder.sequenceNumber(14); + txBuilder.expirationTime(1736246155); + const tx = (await txBuilder.build()) as TransferTransaction; + should.equal(tx.sender, testData.sender2.address); + should.equal(tx.recipient.address, testData.recipients[0].address); + should.equal(tx.recipient.amount, testData.recipients[0].amount); + should.equal(tx.maxGasAmount, 200000); + should.equal(tx.gasUnitPrice, 100); + should.equal(tx.sequenceNumber, 14); + should.equal(tx.expirationTime, 1736246155); + should.equal(tx.type, TransactionType.Send); + tx.inputs.length.should.equal(1); + tx.inputs[0].should.deepEqual({ + address: testData.sender2.address, + value: testData.recipients[0].amount, + coin: 'tapt', + }); + tx.outputs.length.should.equal(1); + tx.outputs[0].should.deepEqual({ + address: testData.recipients[0].address, + value: testData.recipients[0].amount, + coin: 'tapt', + }); + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + rawTx.should.equal( + '0xc8f02d25aa698b3e9fbd8a08e8da4c8ee261832a25a4cde8731b5ec356537d090e0000000000000002000000000000000000000000000000000000000000000000000000000000000104636f696e087472616e73666572010700000000000000000000000000000000000000000000000000000000000000010a6170746f735f636f696e094170746f73436f696e000220f7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad908e803000000000000400d03000000000064000000000000008b037d670000000002002000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' + ); + }); + + it('should build and send a signed tx', async function () { + const txBuilder = factory.from(testData.TRANSFER); + const tx = (await txBuilder.build()) as TransferTransaction; + should.equal(tx.type, TransactionType.Send); + tx.inputs.length.should.equal(1); + tx.inputs[0].should.deepEqual({ + address: testData.sender2.address, + value: testData.recipients[0].amount, + coin: 'tapt', + }); + tx.outputs.length.should.equal(1); + tx.outputs[0].should.deepEqual({ + address: testData.recipients[0].address, + value: testData.recipients[0].amount, + coin: 'tapt', + }); + should.equal(tx.id, '0x43ea7697550d5effb68c47488fd32a7756ee418e8d2be7d6b7f634f3ac0d7766'); + should.equal(tx.maxGasAmount, 200000); + should.equal(tx.gasUnitPrice, 100); + should.equal(tx.sequenceNumber, 23); + should.equal(tx.expirationTime, 1735818272); + should.equal(tx.type, TransactionType.Send); + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + should.equal(rawTx, testData.TRANSFER); + }); + + it('should succeed to validate a valid signablePayload', async function () { + const transaction = new TransferTransaction(coins.get('tapt')); + const txBuilder = factory.getTransferBuilder(transaction); + txBuilder.sender(testData.sender2.address); + txBuilder.recipient(testData.recipients[0]); + txBuilder.gasData({ + maxGasAmount: 200000, + gasUnitPrice: 100, + }); + txBuilder.sequenceNumber(14); + txBuilder.expirationTime(1736246155); + const tx = (await txBuilder.build()) as TransferTransaction; + const signablePayload = tx.signablePayload; + should.equal( + signablePayload.toString('hex'), + 'b5e97db07fa0bd0e5598aa3643a9bc6f6693bddc1a9fec9e674a461eaa00b193c8f02d25aa698b3e9fbd8a08e8da4c8ee261832a25a4cde8731b5ec356537d090e0000000000000002000000000000000000000000000000000000000000000000000000000000000104636f696e087472616e73666572010700000000000000000000000000000000000000000000000000000000000000010a6170746f735f636f696e094170746f73436f696e000220f7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad908e803000000000000400d03000000000064000000000000008b037d670000000002' + ); + }); + + it('should build a unsigned tx and validate its toJson', async function () { + const transaction = new TransferTransaction(coins.get('tapt')); + const txBuilder = factory.getTransferBuilder(transaction); + txBuilder.sender(testData.sender2.address); + txBuilder.recipient(testData.recipients[0]); + txBuilder.gasData({ + maxGasAmount: 200000, + gasUnitPrice: 100, + }); + txBuilder.sequenceNumber(14); + txBuilder.expirationTime(1736246155); + const tx = (await txBuilder.build()) as TransferTransaction; + const toJson = tx.toJson(); + should.equal(toJson.sender, testData.sender2.address); + should.deepEqual(toJson.recipient, { + address: testData.recipients[0].address, + amount: testData.recipients[0].amount, + }); + should.equal(toJson.sequenceNumber, 14); + should.equal(toJson.maxGasAmount, 200000); + should.equal(toJson.gasUnitPrice, 100); + should.equal(toJson.expirationTime, 1736246155); + }); + + it('should build a signed tx and validate its toJson', async function () { + const txBuilder = factory.from(testData.TRANSFER); + const tx = (await txBuilder.build()) as TransferTransaction; + const toJson = tx.toJson(); + should.equal(toJson.id, '0x43ea7697550d5effb68c47488fd32a7756ee418e8d2be7d6b7f634f3ac0d7766'); + should.equal(toJson.sender, '0xc8f02d25aa698b3e9fbd8a08e8da4c8ee261832a25a4cde8731b5ec356537d09'); + should.deepEqual(toJson.recipient, { + address: '0xf7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad9', + amount: '1000', + }); + should.equal(toJson.sequenceNumber, 23); + should.equal(toJson.maxGasAmount, 200000); + should.equal(toJson.gasUnitPrice, 100); + should.equal(toJson.expirationTime, 1735818272); + }); + }); + + describe('Fail', () => { + it('should fail for invalid sender', async function () { + const transaction = new TransferTransaction(coins.get('tapt')); + const builder = factory.getTransferBuilder(transaction); + should(() => builder.sender('randomString')).throwError('Invalid address randomString'); + }); + + it('should fail for invalid recipient', async function () { + const builder = factory.getTransferBuilder(); + should(() => builder.recipient(testData.invalidRecipients[0])).throwError('Invalid address randomString'); + should(() => builder.recipient(testData.invalidRecipients[1])).throwError('Value cannot be less than zero'); + should(() => builder.recipient(testData.invalidRecipients[2])).throwError('Invalid amount format'); + }); + it('should fail for invalid gas amount', async function () { + const builder = factory.getTransferBuilder(); + should(() => builder.gasData({ maxGasAmount: -1, gasUnitPrice: 100 })).throwError( + 'Value cannot be less than zero' + ); + should(() => builder.gasData({ maxGasAmount: 200000, gasUnitPrice: -1 })).throwError( + 'Value cannot be less than zero' + ); + }); + }); +}); diff --git a/modules/sdk-coin-apt/test/unit/utils.ts b/modules/sdk-coin-apt/test/unit/utils.ts new file mode 100644 index 0000000000..8cec8c40ec --- /dev/null +++ b/modules/sdk-coin-apt/test/unit/utils.ts @@ -0,0 +1,53 @@ +import * as testData from '../resources/apt'; +import should from 'should'; +import utils from '../../src/lib/utils'; +import { SignedTransaction } from '@aptos-labs/ts-sdk'; + +describe('Aptos util library', function () { + describe('isValidAddress', function () { + it('should succeed to validate raw transactoin', function () { + for (const address of testData.addresses.validAddresses) { + should.equal(utils.isValidAddress(address), true); + } + }); + + it('should fail to validate invalid addresses', function () { + for (const address of testData.addresses.invalidAddresses) { + should.doesNotThrow(() => utils.isValidAddress(address)); + should.equal(utils.isValidAddress(address), false); + } + // @ts-expect-error Testing for missing param, should not throw an error + should.doesNotThrow(() => utils.isValidAddress(undefined)); + // @ts-expect-error Testing for missing param, should return false + should.equal(utils.isValidAddress(undefined), false); + }); + }); + + describe('isValidRawTransaction', function () { + it('should succeed to validate a valid raw transaction', function () { + should.equal(utils.isValidRawTransaction(testData.TRANSFER), true); + }); + it('should fail to validate an invalid raw transaction', function () { + should.doesNotThrow(() => utils.isValidRawTransaction(testData.INVALID_TRANSFER)); + should.equal(utils.isValidRawTransaction(testData.INVALID_TRANSFER), false); + }); + }); + + describe('isValidDeserialize', function () { + it('should succeed to correctly deserialize serialized transaction', function () { + const signedTxn: SignedTransaction = utils.deserializeSignedTransaction(testData.TRANSFER); + const rawTx = signedTxn.raw_txn; + const recipient = utils.getRecipientFromTransactionPayload(rawTx.payload); + should.equal(rawTx.sender, testData.sender2.address); + should.equal(rawTx.max_gas_amount, 200000); + should.equal(rawTx.gas_unit_price, 100); + should.equal(rawTx.sequence_number, 23); + should.equal(rawTx.expiration_timestamp_secs, 1735818272); + should.equal(recipient.address, testData.recipients[0].address); + should.equal(recipient.amount, testData.recipients[0].amount); + }); + it('should fail to deserialize an invalid serialized transaction', function () { + should.throws(() => utils.deserializeSignedTransaction(testData.INVALID_TRANSFER)); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index d88e9624f9..9a3f404704 100644 --- a/yarn.lock +++ b/yarn.lock @@ -41,9 +41,9 @@ got "^11.8.6" "@aptos-labs/ts-sdk@^1.32.0": - version "1.33.0" - resolved "https://registry.npmjs.org/@aptos-labs/ts-sdk/-/ts-sdk-1.33.0.tgz#4da4501d04ad38381630b06d7d8a29bc4b3f1855" - integrity sha512-svdlPH5r2dlSue2D9WXaaTslsmX18WLytAho6IRZJxQjEssglk64I6c1G9S8BTjRQj/ug6ahTwp6lx3eWuyd8Q== + version "1.33.1" + resolved "https://registry.npmjs.org/@aptos-labs/ts-sdk/-/ts-sdk-1.33.1.tgz#b6c6465250c8d9660b9ec755ea743677d8fb4a53" + integrity sha512-d6nWtUI//fyEN8DeLjm3+ro87Ad6+IKwR9pCqfrs/Azahso1xR1Llxd/O6fj/m1DDsuDj/HAsCsy5TC/aKD6Eg== dependencies: "@aptos-labs/aptos-cli" "^1.0.2" "@aptos-labs/aptos-client" "^0.1.1"