diff --git a/modules/sdk-coin-trx/src/lib/accountCreateTxBuilder.ts b/modules/sdk-coin-trx/src/lib/accountCreateTxBuilder.ts new file mode 100644 index 0000000000..00ce8701fc --- /dev/null +++ b/modules/sdk-coin-trx/src/lib/accountCreateTxBuilder.ts @@ -0,0 +1,217 @@ +import { createHash } from 'crypto'; +import { TransactionType, BaseKey, ExtendTransactionError, BuildTransactionError, SigningError } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from './transaction'; +import { TransactionReceipt, AccountCreateContract } from './iface'; +import { protocol } from '../../resources/protobuf/tron'; +import { + decodeTransaction, + getByteArrayFromHexAddress, + getBase58AddressFromHex, + getHexAddressFromBase58Address, + TRANSACTION_MAX_EXPIRATION, + TRANSACTION_DEFAULT_EXPIRATION, +} from './utils'; +import { ACCOUNT_CREATE_TYPE_URL } from './constants'; + +import ContractType = protocol.Transaction.Contract.ContractType; + +export class AccountCreateTxBuilder extends TransactionBuilder { + protected _signingKeys: BaseKey[]; + // Stored as hex address, consistent with _ownerAddress + protected _accountAddress: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._signingKeys = []; + this.transaction = new Transaction(_coinConfig); + } + + /** @inheritdoc */ + protected get transactionType(): TransactionType { + return TransactionType.AccountCreate; + } + + /** + * Sets the account address (Base58) to be created/activated on-chain. + * Stored internally as hex for protobuf encoding. + * + * @param {object} address - object containing the Base58 address of the new account + * @returns {this} + */ + setAccountAddress(address: { address: string }): this { + this.validateAddress(address); + this._accountAddress = getHexAddressFromBase58Address(address.address); + return this; + } + + /** @inheritdoc */ + extendValidTo(extensionMs: number): void { + if (this.transaction.signature && this.transaction.signature.length > 0) { + throw new ExtendTransactionError('Cannot extend a signed transaction'); + } + + if (extensionMs <= 0) { + throw new Error('Value cannot be below zero'); + } + + if (extensionMs > TRANSACTION_MAX_EXPIRATION) { + throw new ExtendTransactionError('The expiration cannot be extended more than one year'); + } + + if (this._expiration) { + this._expiration = this._expiration + extensionMs; + } else { + throw new Error('There is not expiration to extend'); + } + } + + initBuilder(rawTransaction: TransactionReceipt | string): this { + this.transaction = this.fromImplementation(rawTransaction); + this.transaction.setTransactionType(this.transactionType); + this.validateRawTransaction(rawTransaction); + const tx = this.fromImplementation(rawTransaction); + this.transaction = tx; + this._signingKeys = []; + const rawData = tx.toJson().raw_data; + this._refBlockBytes = rawData.ref_block_bytes; + this._refBlockHash = rawData.ref_block_hash; + this._expiration = rawData.expiration; + this._timestamp = rawData.timestamp; + const contractCall = rawData.contract[0] as AccountCreateContract; + this.initAccountCreateContractCall(contractCall); + return this; + } + + /** + * Initialize the account create contract call specific data. + * Addresses stored in the receipt are hex (set by createAccountCreateTransaction). + * + * @param {AccountCreateContract} accountCreateContractCall object with account create contract data + */ + protected initAccountCreateContractCall(accountCreateContractCall: AccountCreateContract): void { + const { owner_address, account_address } = accountCreateContractCall.parameter.value; + if (owner_address) { + // owner_address stored in receipt is hex; source() expects Base58 + this.source({ address: getBase58AddressFromHex(owner_address) }); + } + if (account_address) { + // account_address stored in receipt is hex; store directly + this._accountAddress = account_address; + } + } + + protected async buildImplementation(): Promise { + this.createAccountCreateTransaction(); + if (this._signingKeys.length > 0) { + this.applySignatures(); + } + + if (!this.transaction.id) { + throw new BuildTransactionError('A valid transaction must have an id'); + } + return Promise.resolve(this.transaction); + } + + /** + * Helper method to create the account create transaction + */ + private createAccountCreateTransaction(): void { + const rawDataHex = this.getAccountCreateTxRawDataHex(); + const rawData = decodeTransaction(rawDataHex); + const contract = rawData.contract[0] as AccountCreateContract; + const contractParameter = contract.parameter; + contractParameter.value.owner_address = this._ownerAddress.toLocaleLowerCase(); + contractParameter.value.account_address = this._accountAddress.toLocaleLowerCase(); + contractParameter.type_url = ACCOUNT_CREATE_TYPE_URL; + contract.type = 'AccountCreateContract'; + const hexBuffer = Buffer.from(rawDataHex, 'hex'); + const id = createHash('sha256').update(hexBuffer).digest('hex'); + const txReceipt: TransactionReceipt = { + raw_data: rawData, + raw_data_hex: rawDataHex, + txID: id, + signature: this.transaction.signature, + }; + this.transaction = new Transaction(this._coinConfig, txReceipt); + } + + /** + * Helper method to get the account create transaction raw data hex + * + * @returns {string} the account create transaction raw data hex + */ + private getAccountCreateTxRawDataHex(): string { + const rawContract = { + ownerAddress: getByteArrayFromHexAddress(this._ownerAddress), + accountAddress: getByteArrayFromHexAddress(this._accountAddress), + }; + const accountCreateContract = protocol.AccountCreateContract.fromObject(rawContract); + const accountCreateContractBytes = protocol.AccountCreateContract.encode(accountCreateContract).finish(); + const txContract = { + type: ContractType.AccountCreateContract, + parameter: { + value: accountCreateContractBytes, + type_url: ACCOUNT_CREATE_TYPE_URL, + }, + }; + const raw = { + refBlockBytes: Buffer.from(this._refBlockBytes, 'hex'), + refBlockHash: Buffer.from(this._refBlockHash, 'hex'), + expiration: this._expiration || Date.now() + TRANSACTION_DEFAULT_EXPIRATION, + timestamp: this._timestamp || Date.now(), + contract: [txContract], + }; + const rawTx = protocol.Transaction.raw.create(raw); + return Buffer.from(protocol.Transaction.raw.encode(rawTx).finish()).toString('hex'); + } + + /** @inheritdoc */ + protected signImplementation(key: BaseKey): Transaction { + if (this._signingKeys.some((signingKey) => signingKey.key === key.key)) { + throw new SigningError('Duplicated key'); + } + this._signingKeys.push(key); + + // We keep this return for compatibility but is not meant to be use + return this.transaction; + } + + private applySignatures(): void { + if (!this.transaction.inputs) { + throw new SigningError('Transaction has no inputs'); + } + + this._signingKeys.forEach((key) => this.applySignature(key)); + } + + /** + * Validates the transaction + * + * @param {Transaction} transaction - The transaction to validate + * @throws {BuildTransactionError} when the transaction is invalid + */ + validateTransaction(transaction: Transaction): void { + this.validateAccountCreateTransactionFields(); + } + + /** + * Validates if the transaction is a valid account create transaction + * + * @throws {BuildTransactionError} when the transaction is invalid + */ + private validateAccountCreateTransactionFields(): void { + if (!this._ownerAddress) { + throw new BuildTransactionError('Missing parameter: source'); + } + + if (!this._accountAddress) { + throw new BuildTransactionError('Missing parameter: account address'); + } + + if (!this._refBlockBytes || !this._refBlockHash) { + throw new BuildTransactionError('Missing block reference information'); + } + } +} diff --git a/modules/sdk-coin-trx/src/lib/constants.ts b/modules/sdk-coin-trx/src/lib/constants.ts index 22b5c24691..7cdffb3903 100644 --- a/modules/sdk-coin-trx/src/lib/constants.ts +++ b/modules/sdk-coin-trx/src/lib/constants.ts @@ -1,2 +1,3 @@ export const DELEGATION_TYPE_URL = 'type.googleapis.com/protocol.DelegateResourceContract'; export const UNDELEGATION_TYPE_URL = 'type.googleapis.com/protocol.UnDelegateResourceContract'; +export const ACCOUNT_CREATE_TYPE_URL = 'type.googleapis.com/protocol.AccountCreateContract'; diff --git a/modules/sdk-coin-trx/src/lib/enum.ts b/modules/sdk-coin-trx/src/lib/enum.ts index f2d4ce1b42..4322f7e31c 100644 --- a/modules/sdk-coin-trx/src/lib/enum.ts +++ b/modules/sdk-coin-trx/src/lib/enum.ts @@ -42,6 +42,10 @@ export enum ContractType { * This is the contract for un-delegating resource */ UnDelegateResourceContract, + /** + * This is the contract for creating/activating a new account + */ + AccountCreate, } export enum PermissionType { diff --git a/modules/sdk-coin-trx/src/lib/iface.ts b/modules/sdk-coin-trx/src/lib/iface.ts index 2c69f97878..e7ab0bda71 100644 --- a/modules/sdk-coin-trx/src/lib/iface.ts +++ b/modules/sdk-coin-trx/src/lib/iface.ts @@ -51,7 +51,8 @@ export interface RawData { | UnfreezeBalanceV2Contract[] | WithdrawExpireUnfreezeContract[] | WithdrawBalanceContract[] - | ResourceManagementContract[]; + | ResourceManagementContract[] + | AccountCreateContract[]; } export interface Value { @@ -363,6 +364,38 @@ export interface ResourceManagementContractParameter { }; } +/** + * AccountCreate contract value fields + */ +export interface AccountCreateValueFields { + owner_address: string; + account_address: string; +} + +/** + * AccountCreate contract value interface + */ +export interface AccountCreateValue { + type_url?: string; + value: AccountCreateValueFields; +} + +/** + * AccountCreate contract interface + */ +export interface AccountCreateContract { + parameter: AccountCreateValue; + type?: string; +} + +/** + * AccountCreate contract decoded interface + */ +export interface AccountCreateContractDecoded { + ownerAddress?: string; + accountAddress?: string; +} + /** * Delegate/Undelegate resource contract decoded interface */ diff --git a/modules/sdk-coin-trx/src/lib/transaction.ts b/modules/sdk-coin-trx/src/lib/transaction.ts index 5d5824cdfa..d953b1e2ff 100644 --- a/modules/sdk-coin-trx/src/lib/transaction.ts +++ b/modules/sdk-coin-trx/src/lib/transaction.ts @@ -29,6 +29,7 @@ import { WithdrawExpireUnfreezeContract, ResourceManagementContract, WithdrawBalanceContract, + AccountCreateContract, } from './iface'; /** @@ -226,6 +227,13 @@ export class Transaction extends BaseTransaction { value: undelegateValue.balance.toString(), }; break; + case ContractType.AccountCreate: { + this._type = TransactionType.AccountCreate; + const createValue = (rawData.contract[0] as AccountCreateContract).parameter.value; + output = { address: createValue.account_address, value: '0' }; + input = { address: createValue.owner_address, value: '0' }; + break; + } default: throw new ParseTransactionError('Unsupported contract type'); } diff --git a/modules/sdk-coin-trx/src/lib/utils.ts b/modules/sdk-coin-trx/src/lib/utils.ts index a0fea40661..501fc6b30d 100644 --- a/modules/sdk-coin-trx/src/lib/utils.ts +++ b/modules/sdk-coin-trx/src/lib/utils.ts @@ -22,10 +22,12 @@ import { WithdrawContractDecoded, ResourceManagementContractParameter, ResourceManagementContractDecoded, + AccountCreateContract, + AccountCreateContractDecoded, } from './iface'; import { ContractType, PermissionType, TronResource } from './enum'; import { AbiCoder, hexConcat } from 'ethers/lib/utils'; -import { DELEGATION_TYPE_URL } from './constants'; +import { DELEGATION_TYPE_URL, ACCOUNT_CREATE_TYPE_URL } from './constants'; export const TRANSACTION_MAX_EXPIRATION = 86400000; // one day export const TRANSACTION_DEFAULT_EXPIRATION = 10800000; // three hours @@ -233,6 +235,10 @@ export function decodeTransaction(hexString: string): RawData { contractType = ContractType.UnDelegateResourceContract; contract = decodeUnDelegateResourceContract(rawTransaction.contracts[0].parameter.value); break; + case ACCOUNT_CREATE_TYPE_URL: + contractType = ContractType.AccountCreate; + contract = decodeAccountCreateContract(rawTransaction.contracts[0].parameter.value); + break; default: throw new UtilsError('Unsupported contract type'); } @@ -753,6 +759,47 @@ export function decodeUnDelegateResourceContract(base64: string): ResourceManage ]; } +/** + * Deserialize the segment of the txHex corresponding with the account create contract + * + * @param {Uint8Array} value - The raw protobuf bytes from the contract parameter + * @returns {AccountCreateContract[]} - Array containing the decoded account create contract + */ +export function decodeAccountCreateContract(base64: string): AccountCreateContract[] { + let decoded: AccountCreateContractDecoded; + try { + decoded = protocol.AccountCreateContract.decode(Buffer.from(base64, 'base64')).toJSON(); + } catch (e) { + throw new UtilsError('There was an error decoding the account create contract in the transaction.'); + } + + if (!decoded.ownerAddress) { + throw new UtilsError('Owner address does not exist in this account create contract.'); + } + + if (!decoded.accountAddress) { + throw new UtilsError('Account address does not exist in this account create contract.'); + } + + const owner_address = getBase58AddressFromByteArray( + getByteArrayFromHexAddress(Buffer.from(decoded.ownerAddress, 'base64').toString('hex')) + ); + const account_address = getBase58AddressFromByteArray( + getByteArrayFromHexAddress(Buffer.from(decoded.accountAddress, 'base64').toString('hex')) + ); + + return [ + { + parameter: { + value: { + owner_address, + account_address, + }, + }, + }, + ]; +} + /** * @param raw */ diff --git a/modules/sdk-coin-trx/src/lib/wrappedBuilder.ts b/modules/sdk-coin-trx/src/lib/wrappedBuilder.ts index a47f1df2e3..22bf8220c5 100644 --- a/modules/sdk-coin-trx/src/lib/wrappedBuilder.ts +++ b/modules/sdk-coin-trx/src/lib/wrappedBuilder.ts @@ -17,6 +17,7 @@ import { WithdrawExpireUnfreezeTxBuilder } from './withdrawExpireUnfreezeTxBuild import { WithdrawBalanceTxBuilder } from './withdrawBuilder'; import { DelegateResourceTxBuilder } from './delegateResourceTxBuilder'; import { UndelegateResourceTxBuilder } from './undelegateResourceTxBuilder'; +import { AccountCreateTxBuilder } from './accountCreateTxBuilder'; /** * Wrapped Builder class @@ -120,6 +121,16 @@ export class WrappedBuilder extends TransactionBuilder { return this.initializeBuilder(tx, new UndelegateResourceTxBuilder(this._coinConfig)); } + /** + * Returns a specific builder to create an account create transaction + * + * @param {TransactionReceipt | string} [tx] The transaction to initialize builder + * @returns {AccountCreateTxBuilder} The specific account create builder + */ + getAccountCreateTxBuilder(tx?: TransactionReceipt | string): AccountCreateTxBuilder { + return this.initializeBuilder(tx, new AccountCreateTxBuilder(this._coinConfig)); + } + private initializeBuilder(tx: TransactionReceipt | string | undefined, builder: T): T { if (tx) { builder.initBuilder(tx); @@ -182,6 +193,9 @@ export class WrappedBuilder extends TransactionBuilder { case ContractType.UnDelegateResourceContract: this._builder = this.getUnDelegateResourceTxBuilder(raw); break; + case ContractType.AccountCreate: + this._builder = this.getAccountCreateTxBuilder(raw); + break; default: throw new InvalidTransactionError('Invalid transaction type: ' + contractType); } diff --git a/modules/sdk-coin-trx/test/resources.ts b/modules/sdk-coin-trx/test/resources.ts index 2aa7c409fa..1b0e16bce4 100644 --- a/modules/sdk-coin-trx/test/resources.ts +++ b/modules/sdk-coin-trx/test/resources.ts @@ -199,6 +199,20 @@ export const UNDELEGATE_RESOURCE_CONTRACT = [ }, ]; +// owner = TLWh67P93KgtnZNCtGnEHM1H33Nhq2uvvN (custodian), account = TLBPLGBuYhQmdzQAEQvBhw7LKzcw1TSSG9 (to) +export const ACCOUNT_CREATE_CONTRACT = [ + { + parameter: { + value: { + owner_address: '4173a5993cd182ae152adad8203163f780c65a8aa5', + account_address: '416ffedf93921506c3efdb510f7c4f256036c48a6a', + }, + type_url: 'type.googleapis.com/protocol.AccountCreateContract', + }, + type: 'AccountCreateContract', + }, +]; + // DO NOT RE-USE THIS PRV FOR REAL MONEY export const FirstPrivateKey = '2DBEAC1C22849F47514445A56AEF2EF164528A502DE4BD289E23EA1E2D4C4B06'; export const SecondPrivateKey = 'FB3AA887E0BE3FAC9D75E661DAFF4A7FE0E91AAB13DA9775CD8586D7CB9B7640'; diff --git a/modules/sdk-coin-trx/test/unit/transactionBuilder/accountCreateTxBuilder.ts b/modules/sdk-coin-trx/test/unit/transactionBuilder/accountCreateTxBuilder.ts new file mode 100644 index 0000000000..4ca73cb977 --- /dev/null +++ b/modules/sdk-coin-trx/test/unit/transactionBuilder/accountCreateTxBuilder.ts @@ -0,0 +1,283 @@ +import assert from 'assert'; +import { TransactionType } from '@bitgo/sdk-core'; +import { describe, it } from 'node:test'; +import { PARTICIPANTS, BLOCK_HASH, BLOCK_NUMBER, EXPIRATION, ACCOUNT_CREATE_CONTRACT } from '../../resources'; +import { getBuilder } from '../../../src/lib/builder'; +import { Transaction, WrappedBuilder } from '../../../src'; + +describe('Tron AccountCreate builder', function () { + const initTxBuilder = () => { + const builder = (getBuilder('ttrx') as WrappedBuilder).getAccountCreateTxBuilder(); + builder + .source({ address: PARTICIPANTS.custodian.address }) + .setAccountAddress({ address: PARTICIPANTS.to.address }) + .block({ number: BLOCK_NUMBER, hash: BLOCK_HASH }); + + return builder; + }; + + describe('should build successfully', () => { + it('a transaction with correct inputs', async () => { + const timestamp = Date.now(); + const txBuilder = initTxBuilder(); + txBuilder.timestamp(timestamp); + txBuilder.expiration(timestamp + 40000); + const tx = (await txBuilder.build()) as Transaction; + const txJson = tx.toJson(); + assert.equal(tx.type, TransactionType.AccountCreate); + assert.equal(tx.inputs.length, 1); + assert.equal(tx.inputs[0].address, PARTICIPANTS.custodian.address); + assert.equal(tx.inputs[0].value, '0'); + assert.equal(tx.outputs[0].value, '0'); + assert.deepStrictEqual(txJson.raw_data.contract, ACCOUNT_CREATE_CONTRACT); + }); + + it('an unsigned transaction from a string and from a JSON', async () => { + const timestamp = Date.now(); + const txBuilder = initTxBuilder(); + txBuilder.timestamp(timestamp); + txBuilder.expiration(timestamp + 40000); + const tx = await txBuilder.build(); + + const txBuilder2 = getBuilder('ttrx').from(tx.toBroadcastFormat()); + txBuilder2.sign({ key: PARTICIPANTS.custodian.pk }); + const tx2 = await txBuilder2.build(); + + const txBuilder3 = getBuilder('ttrx').from(tx.toJson()); + txBuilder3.sign({ key: PARTICIPANTS.custodian.pk }); + const tx3 = await txBuilder3.build(); + + assert.deepStrictEqual(tx2, tx3); + }); + + it('an unsigned transaction with extended duration', async () => { + const timestamp = Date.now(); + const expiration = timestamp + EXPIRATION; + const extension = 60000; + const txBuilder = initTxBuilder(); + txBuilder.timestamp(timestamp); + txBuilder.expiration(expiration); + const tx = await txBuilder.build(); + + const txBuilder2 = getBuilder('ttrx').from(tx.toBroadcastFormat()); + txBuilder2.extendValidTo(extension); + txBuilder2.sign({ key: PARTICIPANTS.custodian.pk }); + const tx2 = await txBuilder2.build(); + + assert.equal(tx2.inputs.length, 1); + assert.equal(tx2.inputs[0].address, PARTICIPANTS.custodian.address); + assert.equal(tx2.inputs[0].value, '0'); + assert.equal(tx2.outputs[0].value, '0'); + const txJson = tx2.toJson(); + assert.equal(txJson.raw_data.expiration, expiration + extension); + }); + + it('a transaction signed multiple times', async () => { + const timestamp = Date.now(); + const txBuilder = initTxBuilder(); + txBuilder.timestamp(timestamp); + txBuilder.expiration(timestamp + EXPIRATION); + const tx = await txBuilder.build(); + let txJson = tx.toJson(); + let rawData = txJson.raw_data; + assert.deepStrictEqual(rawData.contract, ACCOUNT_CREATE_CONTRACT); + assert.equal(txJson.signature.length, 0); + + const txBuilder2 = getBuilder('ttrx').from(tx.toJson()); + txBuilder2.sign({ key: PARTICIPANTS.custodian.pk }); + const tx2 = await txBuilder2.build(); + txJson = tx2.toJson(); + rawData = txJson.raw_data; + assert.deepStrictEqual(rawData.contract, ACCOUNT_CREATE_CONTRACT); + assert.equal(txJson.signature.length, 1); + + const txBuilder3 = getBuilder('ttrx').from(tx2.toJson()); + txBuilder3.sign({ key: PARTICIPANTS.from.pk }); + const tx3 = await txBuilder3.build(); + txJson = tx3.toJson(); + rawData = txJson.raw_data; + assert.deepStrictEqual(rawData.contract, ACCOUNT_CREATE_CONTRACT); + assert.equal(txJson.signature.length, 2); + assert.equal(rawData.expiration, timestamp + EXPIRATION); + assert.equal(rawData.timestamp, timestamp); + }); + + it('preserves consistent transaction ID across round-trips', async () => { + const timestamp = Date.now(); + const txBuilder = initTxBuilder(); + txBuilder.timestamp(timestamp); + txBuilder.expiration(timestamp + EXPIRATION); + const tx = await txBuilder.build(); + const originalTxId = tx.toJson().txID; + + const txBuilder2 = getBuilder('ttrx').from(tx.toBroadcastFormat()); + const tx2 = await txBuilder2.build(); + + assert.equal(tx2.toJson().txID, originalTxId); + }); + }); + + describe('should validate', () => { + it('a valid expiration', async () => { + const now = Date.now(); + const expiration = now + EXPIRATION; + const txBuilder = initTxBuilder(); + txBuilder.timestamp(now); + txBuilder.expiration(expiration + 1000); + txBuilder.expiration(expiration); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + assert.equal(txJson.raw_data.expiration, expiration); + }); + + it('an expiration greater than one year', async () => { + const now = Date.now(); + const txBuilder = initTxBuilder(); + txBuilder.timestamp(now); + assert.throws( + () => { + txBuilder.expiration(now + 31536000001); + }, + (e: any) => e.message === 'Expiration must not be greater than one day' + ); + }); + + it('an expiration less than the current date', async () => { + const now = Date.now(); + const txBuilder = initTxBuilder(); + txBuilder.timestamp(now - 2000); + assert.throws( + () => { + txBuilder.expiration(now - 1000); + }, + (e: any) => e.message === 'Expiration must be greater than current time' + ); + }); + + it('an expiration less than the timestamp', async () => { + const now = Date.now(); + const txBuilder = initTxBuilder(); + txBuilder.timestamp(now + 2000); + assert.throws( + () => { + txBuilder.expiration(now + 1000); + }, + (e: any) => e.message === 'Expiration must be greater than timestamp' + ); + }); + + it('an expiration set after build', async () => { + const now = Date.now(); + const expiration = now + EXPIRATION; + const txBuilder = initTxBuilder(); + await txBuilder.build(); + assert.throws( + () => { + txBuilder.expiration(expiration); + }, + (e: any) => e.message === 'Expiration is already set, it can only be extended' + ); + }); + + it('an expiration set after deserializing', async () => { + const now = Date.now(); + const expiration = now + EXPIRATION; + const txBuilder = initTxBuilder(); + const tx = await txBuilder.build(); + const txBuilder2 = getBuilder('ttrx').from(tx.toBroadcastFormat()); + assert.throws( + () => { + txBuilder2.expiration(expiration); + }, + (e: any) => e.message === 'Expiration is already set, it can only be extended' + ); + }); + + it('an extension without a set expiration', async () => { + const txBuilder = initTxBuilder(); + assert.throws( + () => { + txBuilder.extendValidTo(20000); + }, + (e: any) => e.message === 'There is not expiration to extend' + ); + }); + + it('a zero millisecond extension', async () => { + const txBuilder = initTxBuilder(); + const expiration = Date.now() + EXPIRATION; + txBuilder.expiration(expiration); + const tx = await txBuilder.build(); + + const txBuilder2 = getBuilder('ttrx').from(tx.toBroadcastFormat()); + assert.throws( + () => { + txBuilder2.extendValidTo(0); + }, + (e: any) => e.message === 'Value cannot be below zero' + ); + }); + + it('an extension greater than one year', async () => { + const txBuilder = initTxBuilder(); + const expiration = Date.now() + EXPIRATION; + txBuilder.expiration(expiration); + const tx = await txBuilder.build(); + + const txBuilder2 = getBuilder('ttrx').from(tx.toBroadcastFormat()); + assert.throws( + () => { + txBuilder2.extendValidTo(31536000001); + }, + (e: any) => e.message === 'The expiration cannot be extended more than one year' + ); + }); + + it('an extension after signing', async () => { + const txBuilder = initTxBuilder(); + txBuilder.sign({ key: PARTICIPANTS.custodian.pk }); + const tx = await txBuilder.build(); + + const txBuilder2 = getBuilder('ttrx').from(tx.toBroadcastFormat()); + assert.throws( + () => { + txBuilder2.extendValidTo(20000); + }, + (e: any) => e.message === 'Cannot extend a signed transaction' + ); + }); + + it('transaction mandatory fields', async () => { + const txBuilder = (getBuilder('ttrx') as WrappedBuilder).getAccountCreateTxBuilder(); + + await assert.rejects(txBuilder.build(), { + message: 'Missing parameter: source', + }); + + txBuilder.source({ address: PARTICIPANTS.custodian.address }); + await assert.rejects(txBuilder.build(), { + message: 'Missing parameter: account address', + }); + + txBuilder.setAccountAddress({ address: PARTICIPANTS.to.address }); + await assert.rejects(txBuilder.build(), { + message: 'Missing block reference information', + }); + + txBuilder.block({ number: BLOCK_NUMBER, hash: BLOCK_HASH }); + assert.doesNotReject(() => { + return txBuilder.build(); + }); + }); + + it('rejects an invalid account address', async () => { + const txBuilder = (getBuilder('ttrx') as WrappedBuilder).getAccountCreateTxBuilder(); + assert.throws( + () => { + txBuilder.setAccountAddress({ address: '4173a5993cd182ae152adad8203163f780c65a8aa5' }); + }, + (e: any) => e.message.includes('is not a valid base58 address') + ); + }); + }); +}); diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index 3eb4751e86..2e2cb6d884 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -134,6 +134,8 @@ export enum TransactionType { // xrp — delete an account and recover the full balance including reserve AccountDelete, + // Create an account on-chain (e.g. TRX AccountCreateContract) + AccountCreate, } /**