diff --git a/docs/core/errors/errors.mmd b/docs/common/errors.mmd similarity index 100% rename from docs/core/errors/errors.mmd rename to docs/common/errors.mmd diff --git a/docs/thorest/http/http.mmd b/docs/common/http.mmd similarity index 100% rename from docs/thorest/http/http.mmd rename to docs/common/http.mmd diff --git a/docs/thorest/thor/thor.mmd b/docs/thor/thor.mmd similarity index 100% rename from docs/thorest/thor/thor.mmd rename to docs/thor/thor.mmd diff --git a/docs/thorest/thor/accounts/accounts.mmd b/docs/thor/thorest/accounts.mmd similarity index 100% rename from docs/thorest/thor/accounts/accounts.mmd rename to docs/thor/thorest/accounts.mmd diff --git a/docs/thorest/thor/blocks/blocks.mmd b/docs/thor/thorest/blocks.mmd similarity index 100% rename from docs/thorest/thor/blocks/blocks.mmd rename to docs/thor/thorest/blocks.mmd diff --git a/docs/thorest/thor/debug/debug.mmd b/docs/thor/thorest/debug.mmd similarity index 100% rename from docs/thorest/thor/debug/debug.mmd rename to docs/thor/thorest/debug.mmd diff --git a/docs/thorest/thor/fees/fees.mmd b/docs/thor/thorest/fees.mmd similarity index 100% rename from docs/thorest/thor/fees/fees.mmd rename to docs/thor/thorest/fees.mmd diff --git a/docs/thorest/thor/logs/logs.mmd b/docs/thor/thorest/logs.mmd similarity index 100% rename from docs/thorest/thor/logs/logs.mmd rename to docs/thor/thorest/logs.mmd diff --git a/docs/thorest/thor/thorest/model.mmd b/docs/thor/thorest/model.mmd similarity index 96% rename from docs/thorest/thor/thorest/model.mmd rename to docs/thor/thorest/model.mmd index 567354a28..ede72d5e8 100644 --- a/docs/thorest/thor/thorest/model.mmd +++ b/docs/thor/thorest/model.mmd @@ -54,6 +54,7 @@ classDiagram } class SignedTransactionRequest { constructor(SignedTransactionRequestParam param) + SignedTransactionRequestJSON toJSON() } class SignedTransactionRequestParam { <> @@ -68,9 +69,11 @@ classDiagram } class SponsoredTransactionRequest { constructor(SponsoredTransactionRequestParam: param) + SponsoredTransactionRequestJSON toJSON() } class TransactionRequest { constructor(TransactionRequestParam param) + TransactionRequestJSON toJSON() } class TransactionRequestParams { <> diff --git a/docs/thorest/thor/node/node.mmd b/docs/thor/thorest/node.mmd similarity index 100% rename from docs/thorest/thor/node/node.mmd rename to docs/thor/thorest/node.mmd diff --git a/docs/thorest/thor/thorest/signer.mmd b/docs/thor/thorest/signer.mmd similarity index 88% rename from docs/thorest/thor/thorest/signer.mmd rename to docs/thor/thorest/signer.mmd index b25f69235..70109e980 100644 --- a/docs/thorest/thor/thorest/signer.mmd +++ b/docs/thor/thorest/signer.mmd @@ -28,12 +28,12 @@ classDiagram } } class RLPCodec { - Uint8Array encodeSignedTransactionRequest(SignedTransactionRequest transactionRequest) - Uint8Array encodeTransactionRequest(TransactionRequest transactionRequest) + TransactionRequest|SignedTransactionRequest|SponsoredTransactionRequest decode(Uint8Array encoded) + Uint8Array encode(TransactionRequest|SignedTransactionRequest|SponsoredTransactionRequest transactionRequest) } class PrivateKeySigner { constructor(Uint8Array privateKey) - void() + disponse() } class Signer { <> diff --git a/docs/thorest/thor/thorest/signer_sequence.mmd b/docs/thor/thorest/signer_sequence.mmd similarity index 100% rename from docs/thorest/thor/thorest/signer_sequence.mmd rename to docs/thor/thorest/signer_sequence.mmd diff --git a/docs/thorest/thor/subscriptions/subscriptions.mmd b/docs/thor/thorest/subscriptions.mmd similarity index 100% rename from docs/thorest/thor/subscriptions/subscriptions.mmd rename to docs/thor/thorest/subscriptions.mmd diff --git a/docs/thorest/thor/transactions/transactions.mmd b/docs/thor/thorest/transactions.mmd similarity index 100% rename from docs/thorest/thor/transactions/transactions.mmd rename to docs/thor/thorest/transactions.mmd diff --git a/docs/viem/walletClient.mmd b/docs/viem/walletClient.mmd new file mode 100644 index 000000000..9ef8728f9 --- /dev/null +++ b/docs/viem/walletClient.mmd @@ -0,0 +1,91 @@ +--- +title: "@viem.clients.walletClient" +--- +classDiagram + class CreateEventFilterParams { + Address|Address[] address + AbiEvent event + Hex[] args + bigint fromBlock + bigint toBlock + } + class GetFilterChangesParams { + <> + Filter filter + } + class GetFilterLogParams { + <> + Filter filter + } + class PrepareTransactionRequestRequest { + <> + Address to + Hex|number value + Hex data + string comment + Hex abi + Hex blockRef + number chainTag + Hex dependsOn + number expiration + Hex|number gas + number gasPriceCoef + number nonce + boolean isIntendedToBeSponsored + } + class PublicClient { + URL|ThorNetwork network + constructor(URL|ThorNetwork network, HttpClient transport) + Promise~ExecuteCodesResponse~ call(ExecuteCodesRequestJSON request) + Promise~BlockFilter~ createBlockFilter() + EventFilter createEventFilter(CreateEventFilterParams params) + PendingTransactionFilter createPendingTransactionFilter() + Promise~bigint~ getBalance(Address address) + Promise~ExpandedBlockResponse|RawTx|RegularBlockResponse|null~ getBlock(BlockRevision revision, BlockReponseType type) + Promise~number|undefined~ getBlockNumber(BlockRevision revision) + Promise~number|undefined~ getBlockTransactionCount(BlockRevision revision) + Promise~Hex|undefined~ getBytecode(Address address) + Promise~bigint~ getChainId() + Promise~ex|undefined~ getCode(Address address) + Promise~FeeHistory~ getFeeHistory(number blockCount) + Promise~Array~DecodedEventLog|string~~ getFilterChanges(GetFilterChangesParams params) + Promise~DecodedEventLog[]~ getFilterLogs(GetFilterLogParams params) + Promise~bigint[]~ getGasPrice() + Promise~DecodedEventLog[]~ getLogs(EventFilter eventFilter) + Promise~number~ getNonce(Address address) + Promise~Hex~ getStorageAt(Address address, Hex slot) + Promise~GetTxResponse|null~ getTransaction(hash: Hex) + Promise~number~ getTransactionCount(Address address) + Promise~GetTxReceiptResponse|null~ getTransactionReceipt(Hex hash) + Promise~bigint|undefined~ estimateFeePerGas() + Promise~EstimatedGas[]~ estimateGas(ExecuteCodesRequestJSON request) + Promise~ExecuteCodesResponse~ simulateCalls(ExecuteCodesRequestJSON request) + Promise~bigint~ suggestPriorityFeeRequest() + void unistallFilter(BeatsSubscription|BlocksSubscription|EventsSubscription|NewTransactionSubscription|TransfersSubscription subscription) + BlocksSubscription watchBlocks(Hex pos) + BlocksSubscription watchBlockNumber() + void watchEvent(WatchEventParams params) + } + class WatchEventParams { + <> + Function~SubscriptionEventResponse[] logs~ onLogs + Function~Error error~ onError + Address address; + Hex event + Hex[] args + Hex fromBlock + } + class WalletClient { + constructor(URL|ThorNeetwork network, HttpClient transport, Account|null account) + Address[] getAddresses() + TransactionRequest prepareTransactionRequest(PrepareTransactionRequestRequest request) + Promise~Hex~ sendRawTransaction(Hex raw) + Promise~Hex~ sendTransaction(PrepareTransactionRequestRequest|SignedTransactionRequest request) + Promise~Hex~ signTransaction(PrepareTransactionRequestRequest|SignedTransactionRequest request) + } + PublicClient <|-- WalletClient + CreateEventFilterParams "PublicClient.createEventFilter(...)" -- PublicClient + GetFilterChangesParams "PublicClient.getFilterChanges(...)" -- PublicClient + GetFilterLogParams "PublicClient.getFilterLogs(...)" -- PublicClient + PrepareTransactionRequestRequest "WalletClient.sendTransaction() WalletClient.signTransaction(...)" -- WalletClient + WatchEventParams "PublicClient.watchEvent(...)" -- PublicClient diff --git a/packages/sdk/src/thor/thorest/json/SignedTransactionRequestJSON.ts b/packages/sdk/src/thor/thorest/json/SignedTransactionRequestJSON.ts new file mode 100644 index 000000000..6613066e9 --- /dev/null +++ b/packages/sdk/src/thor/thorest/json/SignedTransactionRequestJSON.ts @@ -0,0 +1,12 @@ +import { type TransactionRequestJSON } from '@thor/thorest/json/TransactionRequestJSON'; + +/** + * Represents the content of a {@link SignedTransactionRequest} object in JSON format. + */ +interface SignedTransactionRequestJSON extends TransactionRequestJSON { + origin: string; // hex address + originSignature: string; // hex origin signature + signature: string; // hex signature +} + +export { type SignedTransactionRequestJSON }; diff --git a/packages/sdk/src/thor/thorest/json/SponsoredTransactionRequestJSON.ts b/packages/sdk/src/thor/thorest/json/SponsoredTransactionRequestJSON.ts new file mode 100644 index 000000000..c33c385e6 --- /dev/null +++ b/packages/sdk/src/thor/thorest/json/SponsoredTransactionRequestJSON.ts @@ -0,0 +1,11 @@ +import { type SignedTransactionRequestJSON } from '@thor/thorest/json/SignedTransactionRequestJSON'; + +/** + * Represents the content of a {@link SponsoredTransactionRequest} object in JSON format. + */ +interface SponsoredTransactionRequestJSON extends SignedTransactionRequestJSON { + gasPayer: string; // hex address + gasPayerSignature: string; // hex signature +} + +export { type SponsoredTransactionRequestJSON }; diff --git a/packages/sdk/src/thor/thorest/json/TransactionRequestJSON.ts b/packages/sdk/src/thor/thorest/json/TransactionRequestJSON.ts new file mode 100644 index 000000000..b056a76ac --- /dev/null +++ b/packages/sdk/src/thor/thorest/json/TransactionRequestJSON.ts @@ -0,0 +1,18 @@ +import { type ClauseJSON } from '@thor/thorest/json/ClauseJSON'; + +/** + * Represents the content of a {@link TransactionRequest} object in JSON format. + */ +interface TransactionRequestJSON { + blockRef: string; + chainTag: number; + clauses: ClauseJSON[]; + dependsOn: string | null; + expiration: number; + gas: bigint; + gasPriceCoef: bigint; + nonce: number; + isIntendedToBeSponsored: boolean; +} + +export { type TransactionRequestJSON }; diff --git a/packages/sdk/src/thor/thorest/json/index.ts b/packages/sdk/src/thor/thorest/json/index.ts index 700c9e951..e1bab27fa 100644 --- a/packages/sdk/src/thor/thorest/json/index.ts +++ b/packages/sdk/src/thor/thorest/json/index.ts @@ -3,8 +3,11 @@ export * from './EventJSON'; export * from './OutputJSON'; export * from './ReceiptJSON'; export * from './ReceiptMetaJSON'; +export * from './SignedTransactionRequestJSON'; +export * from './SponsoredTransactionRequestJSON'; export * from './TransferJSON'; export * from './TransferJSON'; +export * from './TransactionRequestJSON'; export * from '../transactions/json'; export * from '../blocks/json'; export * from '../debug/json'; diff --git a/packages/sdk/src/thor/thorest/model/SignedTransactionRequest.ts b/packages/sdk/src/thor/thorest/model/SignedTransactionRequest.ts index 7a2e978e3..7d896f747 100644 --- a/packages/sdk/src/thor/thorest/model/SignedTransactionRequest.ts +++ b/packages/sdk/src/thor/thorest/model/SignedTransactionRequest.ts @@ -2,7 +2,8 @@ import { TransactionRequest, type TransactionRequestParam } from './TransactionRequest'; -import type { Address } from '@common'; +import { type Address, HexUInt } from '@common'; +import { type SignedTransactionRequestJSON } from '@thor/thorest/json'; /** * Represents the parameters required for a signed transaction request. @@ -76,6 +77,20 @@ class SignedTransactionRequest public isSigned(): boolean { return true; } + + /** + * Converts the SignedTransactionRequest object into its JSON representation. + * + * @return {SignedTransactionRequestJSON} The JSON representation of the SignedTransactionRequest object. + */ + public toJSON(): SignedTransactionRequestJSON { + return { + ...super.toJSON(), + origin: this.origin.toString(), + originSignature: HexUInt.of(this.originSignature).toString(), + signature: HexUInt.of(this.signature).toString() + } satisfies SignedTransactionRequestJSON; + } } export { type SignedTransactionRequestParam, SignedTransactionRequest }; diff --git a/packages/sdk/src/thor/thorest/model/SponsoredTransactionRequest.ts b/packages/sdk/src/thor/thorest/model/SponsoredTransactionRequest.ts index 30d480192..88aeba4e0 100644 --- a/packages/sdk/src/thor/thorest/model/SponsoredTransactionRequest.ts +++ b/packages/sdk/src/thor/thorest/model/SponsoredTransactionRequest.ts @@ -1,8 +1,10 @@ -import { type Address } from '@common'; +import { type Address, HexUInt } from '@common'; + +import { type SponsoredTransactionRequestJSON } from '@thor/thorest/json'; import { SignedTransactionRequest, type SignedTransactionRequestParam -} from './SignedTransactionRequest'; +} from '@thor/thorest/model'; /** * Represents the parameters required for a sponsored transaction request. @@ -52,6 +54,19 @@ class SponsoredTransactionRequest this.gasPayer = params.gasPayer; this.gasPayerSignature = new Uint8Array(params.gasPayerSignature); } + + /** + * Converts the SponsoredTransactionRequest object into its JSON representation. + * + * @return {SponsoredTransactionRequestJSON} The JSON representation of the SponsoredTransactionRequest object. + */ + toJSON(): SponsoredTransactionRequestJSON { + return { + ...super.toJSON(), + gasPayer: this.gasPayer.toString(), + gasPayerSignature: HexUInt.of(this.gasPayerSignature).toString() + } satisfies SponsoredTransactionRequestJSON; + } } export { SponsoredTransactionRequest, type SponsoredTransactionRequestParam }; diff --git a/packages/sdk/src/thor/thorest/model/TransactionRequest.ts b/packages/sdk/src/thor/thorest/model/TransactionRequest.ts index 52a36e2e6..de984a341 100644 --- a/packages/sdk/src/thor/thorest/model/TransactionRequest.ts +++ b/packages/sdk/src/thor/thorest/model/TransactionRequest.ts @@ -1,5 +1,6 @@ import { type Clause } from '@thor'; import { type Hex } from '@common'; +import { type TransactionRequestJSON } from '@thor/thorest/json'; /** * Represents the parameters required to create a {@link TransactionRequest} instance. @@ -145,6 +146,25 @@ class TransactionRequest implements TransactionRequestParam { public isSigned(): boolean { return false; } + + /** + * Converts the TransactionRequest object into a JSON representation. + * + * @return {TransactionRequestJSON} The JSON representation of the TransactionRequest object. + */ + public toJSON(): TransactionRequestJSON { + return { + blockRef: this.blockRef.toString(), + chainTag: this.chainTag, + clauses: this.clauses.map((clause: Clause) => clause.toJSON()), + dependsOn: this.dependsOn?.toString() ?? null, + expiration: this.expiration, + gas: this.gas, + gasPriceCoef: this.gasPriceCoef, + nonce: this.nonce, + isIntendedToBeSponsored: this.isIntendedToBeSponsored + } satisfies TransactionRequestJSON; + } } export { TransactionRequest, type TransactionRequestParam }; diff --git a/packages/sdk/src/thor/thorest/signer/PrivateKeySigner.ts b/packages/sdk/src/thor/thorest/signer/PrivateKeySigner.ts index 86b0dbad1..65407bec0 100644 --- a/packages/sdk/src/thor/thorest/signer/PrivateKeySigner.ts +++ b/packages/sdk/src/thor/thorest/signer/PrivateKeySigner.ts @@ -8,7 +8,7 @@ import { import { SignedTransactionRequest, SponsoredTransactionRequest, - type TransactionRequest + TransactionRequest } from '@thor/thorest/model'; import { RLPCodec } from './RLPCodec'; import { type Signer } from './Signer'; @@ -23,7 +23,7 @@ const FQP = 'packages/sdk/src/thor/thorest/signer/PrivateKeySigner.ts!'; * The class implements the {@link Signer} interface, * to sign transaction requests using the provided private key. * - * @remark Call {@link PrivateKeySigner.disponse} method to dispose the private key + * @remark Call {@link PrivateKeySigner.dispose} method to dispose the private key * when the signer is not needed anymore. * This will clear the private key from memory, minimizing the risk of leaking it. * @@ -41,7 +41,7 @@ class PrivateKeySigner implements Signer { * @remark The value should be handled with care, as it may contain sensitive * information. */ - #privateKey: Uint8Array | null = null; + private privateKey: Uint8Array | null = null; /** * Represents the address of the signer. @@ -59,7 +59,7 @@ class PrivateKeySigner implements Signer { constructor(privateKey: Uint8Array) { if (Secp256k1.isValidPrivateKey(privateKey)) { // Defensive copies to avoid external mutation. - this.#privateKey = new Uint8Array(privateKey); + this.privateKey = new Uint8Array(privateKey); this.address = Address.ofPrivateKey(privateKey); } else { throw new InvalidPrivateKeyError( @@ -75,16 +75,16 @@ class PrivateKeySigner implements Signer { * * @return {void} This method does not return a value. * - * @remark Call {@link PrivateKeySigner.disponse} method to dispose the private key + * @remark Call {@link PrivateKeySigner.dispose} method to dispose the private key * when the signer is not needed anymore. * This will clear the private key from memory, minimizing the risk of leaking it. * After this call this {@link Signer} instance can't be used anymore. */ - public disponse(): void { - if (this.#privateKey !== null) { - this.#privateKey.fill(0); + public dispose(): void { + if (this.privateKey !== null) { + this.privateKey.fill(0); } - this.#privateKey = null; + this.privateKey = null; } /** @@ -102,22 +102,13 @@ class PrivateKeySigner implements Signer { private signTransactionRequest( transactionRequest: TransactionRequest ): SignedTransactionRequest { - if (this.#privateKey !== null) { + if (this.privateKey !== null) { const hash = Blake2b256.of( - RLPCodec.encodeTransactionRequest(transactionRequest) + RLPCodec.encode(transactionRequest) ).bytes; - const signature = Secp256k1.sign(hash, this.#privateKey); + const signature = Secp256k1.sign(hash, this.privateKey); return new SignedTransactionRequest({ - blockRef: transactionRequest.blockRef, - chainTag: transactionRequest.chainTag, - clauses: transactionRequest.clauses, - dependsOn: transactionRequest.dependsOn, - expiration: transactionRequest.expiration, - gas: transactionRequest.gas, - gasPriceCoef: transactionRequest.gasPriceCoef, - nonce: transactionRequest.nonce, - isIntendedToBeSponsored: - transactionRequest.isIntendedToBeSponsored, + ...transactionRequest, origin: this.address, originSignature: signature, signature @@ -174,21 +165,33 @@ class PrivateKeySigner implements Signer { private sponsorTransactionRequest( signedTransactionRequest: SignedTransactionRequest ): SponsoredTransactionRequest { - if (this.#privateKey !== null) { + if (this.privateKey !== null) { if (signedTransactionRequest.isIntendedToBeSponsored) { - const hash = Blake2b256.of( + const originHash = Blake2b256.of( + RLPCodec.encode( + new TransactionRequest({ + blockRef: signedTransactionRequest.blockRef, + chainTag: signedTransactionRequest.chainTag, + clauses: signedTransactionRequest.clauses, + dependsOn: signedTransactionRequest.dependsOn, + expiration: signedTransactionRequest.expiration, + gas: signedTransactionRequest.gas, + gasPriceCoef: signedTransactionRequest.gasPriceCoef, + nonce: signedTransactionRequest.nonce, + isIntendedToBeSponsored: + signedTransactionRequest.isIntendedToBeSponsored + }) + ) + ); + const gasPayerHash = Blake2b256.of( nc_utils.concatBytes( - Blake2b256.of( - RLPCodec.encodeTransactionRequest( - signedTransactionRequest - ) - ).bytes, + originHash.bytes, signedTransactionRequest.origin.bytes ) - ).bytes; + ); const gasPayerSignature = Secp256k1.sign( - hash, - this.#privateKey + gasPayerHash.bytes, + this.privateKey ); return new SponsoredTransactionRequest({ blockRef: signedTransactionRequest.blockRef, @@ -211,12 +214,12 @@ class PrivateKeySigner implements Signer { }); } throw new UnsupportedOperationError( - `${FQP}PrivateKeySigner.sign(signedTransactionRequest: SignedTransactionRequest): DelegatedSignedTransactionRequest`, + `${FQP}PrivateKeySigner.sign(signedTransactionRequest: SignedTransactionRequest): SponsoredTransactionRequest`, 'transaction request is not intended to be sponsored' ); } throw new InvalidPrivateKeyError( - `${FQP}PrivateKeySigner.sign(signedTransactionRequest: SignedTransactionRequest): DelegatedSignedTransactionRequest`, + `${FQP}PrivateKeySigner.sign(signedTransactionRequest: SignedTransactionRequest): SponsoredTransactionRequest`, 'no private key' ); } diff --git a/packages/sdk/src/thor/thorest/signer/RLPCodec.ts b/packages/sdk/src/thor/thorest/signer/RLPCodec.ts index 1a0e4961c..78c97348f 100644 --- a/packages/sdk/src/thor/thorest/signer/RLPCodec.ts +++ b/packages/sdk/src/thor/thorest/signer/RLPCodec.ts @@ -1,18 +1,27 @@ +import * as nc_utils from '@noble/curves/abstract/utils'; import { - type Clause, - type SignedTransactionRequest, - type TransactionRequest + Clause, + SignedTransactionRequest, + SponsoredTransactionRequest, + TransactionRequest } from '@thor/thorest/model'; import { + Address, + Blake2b256, BufferKind, CompactFixedHexBlobKind, Hex, HexBlobKind, + HexUInt, + IllegalArgumentError, NumericKind, OptionalFixedHexBlobKind, + Quantity, + RLP, type RLPProfile, RLPProfiler, - type RLPValidObject + type RLPValidObject, + Secp256k1 } from '@common'; // eslint-disable-next-line @typescript-eslint/no-extraneous-class @@ -37,7 +46,7 @@ class RLPCodec { * - `nonce` - Represent the nonce of the transaction. * - `reserved` - Reserved field. */ - public static readonly RLP_FIELDS = [ + private static readonly RLP_FIELDS = [ { name: 'chainTag', kind: new NumericKind(1) }, { name: 'blockRef', kind: new CompactFixedHexBlobKind(8) }, { name: 'expiration', kind: new NumericKind(4) }, @@ -68,7 +77,7 @@ class RLPCodec { * - `name` - A string indicating the name of the field in the RLP structure. * - `kind` - RLP profile type. */ - public static readonly RLP_SIGNATURE = { + private static readonly RLP_SIGNATURE = { name: 'signature', kind: new BufferKind() }; @@ -80,7 +89,7 @@ class RLPCodec { * - `name` - A string indicating the name of the field in the RLP structure. * - `kind` - RLP profile type. */ - public static readonly RLP_SIGNED_TRANSACTION_PROFILE: RLPProfile = { + private static readonly RLP_SIGNED_TRANSACTION_PROFILE: RLPProfile = { name: 'tx', kind: RLPCodec.RLP_FIELDS.concat([RLPCodec.RLP_SIGNATURE]) }; @@ -92,18 +101,141 @@ class RLPCodec { * - `name` - A string indicating the name of the field in the RLP structure. * - `kind` - RLP profile type. */ - public static readonly RLP_UNSIGNED_TRANSACTION_PROFILE: RLPProfile = { + private static readonly RLP_UNSIGNED_TRANSACTION_PROFILE: RLPProfile = { name: 'tx', kind: RLPCodec.RLP_FIELDS }; + /** + * Decodes an encoded transaction and returns an instance of a TransactionRequest, + * SignedTransactionRequest, or SponsoredTransactionRequest based on the encoded data. + * + * @param {Uint8Array} encoded - The encoded transaction data to decode. + * @return {TransactionRequest | SignedTransactionRequest | SponsoredTransactionRequest} + * Returns a TransactionRequest if the transaction is unsigned. + * Returns a SignedTransactionRequest if the transaction is signed. + * Returns a SponsoredTransactionRequest if the transaction is signed and includes a gas payer. + * @throws {IllegalArgumentError} Throws an error if the encoded data is invalid. + */ + public static decode( + encoded: Uint8Array + ): + | TransactionRequest + | SignedTransactionRequest + | SponsoredTransactionRequest { + try { + const isSigned = + (RLP.ofEncoded(encoded).decoded as unknown[]).length > + (RLPCodec.RLP_UNSIGNED_TRANSACTION_PROFILE.kind as []).length; + const decoded = RLPProfiler.ofObjectEncoded( + encoded, + isSigned + ? RLPCodec.RLP_SIGNED_TRANSACTION_PROFILE + : RLPCodec.RLP_UNSIGNED_TRANSACTION_PROFILE + ).object as RLPValidObject; + const clauses = (decoded.clauses as []).map( + (decodedClause: RLPValidObject) => { + return Clause.of({ + to: (decodedClause.to as string) ?? null, + value: + typeof decodedClause.value === 'number' + ? Quantity.of(decodedClause.value).toString() + : typeof decodedClause.value === 'string' + ? Quantity.of( + HexUInt.of(decodedClause.value).bi + ).toString() + : Quantity.PREFIX, + data: (decodedClause.data as string) ?? undefined + }); + } + ); + const isIntendedToBeSponsored = (decoded.reserved as []).length > 0; + const transactionRequest = new TransactionRequest({ + blockRef: HexUInt.of(decoded.blockRef as string), + chainTag: decoded.chainTag as number, + clauses, + dependsOn: + decoded.dependsOn === null + ? null + : Hex.of(decoded.dependsOn as string), + expiration: decoded.expiration as number, + gas: BigInt(decoded.gas as bigint), // Double cast needed else a number is returned. + gasPriceCoef: BigInt(decoded.gasPriceCoef as bigint), // Double cast needed else a number is returned. + nonce: decoded.nonce as number, + isIntendedToBeSponsored + }); + if (isSigned) { + const signature = decoded.signature as Uint8Array; + const encodedTransactionRequest = + RLPCodec.encodeTransactionRequest(transactionRequest); + const originSignature = signature.slice( + 0, + Secp256k1.SIGNATURE_LENGTH + ); + const originHash = Blake2b256.of( + encodedTransactionRequest + ).bytes; + const origin = Address.ofPublicKey( + Secp256k1.recover(originHash, originSignature) + ); + const signedTransactionRequest = new SignedTransactionRequest({ + ...transactionRequest, + origin, + originSignature, + signature + }); + if (signature.length > Secp256k1.SIGNATURE_LENGTH) { + const gasPayerSignature = signature.slice( + Secp256k1.SIGNATURE_LENGTH, + Secp256k1.SIGNATURE_LENGTH * 2 + ); + const gasPayerHash = Blake2b256.of( + nc_utils.concatBytes(originHash, origin.bytes) + ).bytes; + const gasPayer = Address.ofPublicKey( + Secp256k1.recover(gasPayerHash, gasPayerSignature) + ); + return new SponsoredTransactionRequest({ + ...signedTransactionRequest, + gasPayer, + gasPayerSignature + }); + } + return signedTransactionRequest; + } + return transactionRequest; + } catch (error) { + throw new IllegalArgumentError( + `${RLPCodec.decode.name}(encoded: Uint8Array)`, + 'invalid encoded data', + { encoded }, + error as Error + ); + } + } + + /** + * Encodes a given transaction request into a Uint8Array. + * + * @param {TransactionRequest | SignedTransactionRequest} transactionRequest - The transaction request to encode, which can be either a TransactionRequest or a SignedTransactionRequest. + * @return {Uint8Array} The encoded transaction request as a Uint8Array. + */ + public static encode( + transactionRequest: TransactionRequest | SignedTransactionRequest + ): Uint8Array { + if (transactionRequest instanceof SignedTransactionRequest) { + return RLPCodec.encodeSignedTransactionRequest(transactionRequest); + } + return RLPCodec.encodeTransactionRequest(transactionRequest); + } + /** * Encodes a signed transaction request into a Uint8Array format. * * @param {SignedTransactionRequest} transactionRequest - The signed transaction request object containing transaction details. * @return {Uint8Array} The encoded transaction request as a Uint8Array. */ - public static encodeSignedTransactionRequest( + private static encodeSignedTransactionRequest( transactionRequest: SignedTransactionRequest ): Uint8Array { return RLPCodec.encodeSignedBodyField( @@ -123,7 +255,7 @@ class RLPCodec { * @param {TransactionRequest} transactionRequest - The transaction request object to encode. * @return {Uint8Array} The encoded transaction request as a byte array. */ - public static encodeTransactionRequest( + private static encodeTransactionRequest( transactionRequest: TransactionRequest ): Uint8Array { return RLPCodec.encodeUnsignedBodyField({ diff --git a/packages/sdk/src/thor/thorest/transactions/model/Transaction.ts b/packages/sdk/src/thor/thorest/transactions/model/Transaction.ts index 4b43eebeb..62e405d83 100644 --- a/packages/sdk/src/thor/thorest/transactions/model/Transaction.ts +++ b/packages/sdk/src/thor/thorest/transactions/model/Transaction.ts @@ -224,7 +224,9 @@ class Transaction { this.getTransactionHash(this.origin).bytes, this.gasPayerSignature ); - return Address.ofPublicKey(gasPayerPublicKey); + const a = Address.ofPublicKey(gasPayerPublicKey); + console.log('E ' + a.toString()); + return a; } throw new NoSuchElementError( `${FQP}.gasPayer(): Address`, @@ -336,10 +338,11 @@ class Transaction { */ public get origin(): Address { if (this.senderSignature !== undefined) { + const hash = this.getTransactionHash().bytes; return Address.ofPublicKey( // Get the origin public key. Secp256k1.recover( - this.getTransactionHash().bytes, + hash, // Get the (r, s) of ECDSA digital signature without gas payer params. this.senderSignature ) diff --git a/packages/sdk/src/viem/clients/WalletClient.ts b/packages/sdk/src/viem/clients/WalletClient.ts index e7b8353d2..8f626683b 100644 --- a/packages/sdk/src/viem/clients/WalletClient.ts +++ b/packages/sdk/src/viem/clients/WalletClient.ts @@ -1,33 +1,38 @@ import * as nc_utils from '@noble/curves/abstract/utils'; import { type Account } from 'viem'; import { + Clause, SendTransaction, + SignedTransactionRequest, + SponsoredTransactionRequest, type ThorNetworks, - Transaction, - type TransactionBody, - type TransactionClause + TransactionRequest } from '@thor/thorest'; -import { - Address, - Blake2b256, - Hex, - HexInt, - HexUInt, - HexUInt32, - RLPProfiler -} from '@common/vcdm'; +import { Address, Blake2b256, Hex, HexInt, HexUInt } from '@common/vcdm'; import { FetchHttpClient, type HttpClient } from '@common/http'; -import { UnsupportedOperationError } from '@common/errors'; +import { + IllegalArgumentError, + UnsupportedOperationError +} from '@common/errors'; import { PublicClient, type PublicClientConfig } from './PublicClient'; import { RLPCodec } from '@thor/thorest/signer'; +/** + * Fill-Qualified Path + */ const FQP = 'packages/sdk/src/viem/clients/WalletClient.ts!'; /** - * Used internally to tag a transaction without data. + * Creates a new instance of the WalletClient configured with the provided parameters. + * + * @param {Object} config - Configuration object for the WalletClient. + * @param {string} config.network - The network endpoint or base URL for the wallet client. + * @param {Object} [config.transport] - Optional transport layer instance for handling network requests. + * @param {Object|null} [config.account] - Optional account object associated with the wallet client. + * @return {WalletClient} A new instance of WalletClient configured with the specified network, transport, and account. + * + * @see https://viem.sh/docs/clients/wallet#wallet-client */ -const NO_DATA = Hex.PREFIX; - function createWalletClient({ network, transport, @@ -37,9 +42,26 @@ function createWalletClient({ return new WalletClient(network, transportLayer, account ?? null); } +/** + * Represents a client for managing wallet operations, extending the capabilities + * of a `PublicClient` to enable account-specific functionalities like signing + * and sending transactions. + * + * @see https://viem.sh/docs/clients/wallet#wallet-client + */ class WalletClient extends PublicClient { + /** + * Represents a user's account information or null if the account does not exist or is unavailable. + */ private readonly account: Account | null; + /** + * Constructs an instance of the class. + * + * @param {URL | ThorNetworks} network - The network to be used, either a URL or a ThorNetworks instance. + * @param {HttpClient} transport - The HTTP client to handle network communications. + * @param {Account | null} account - The account to associate with the instance, or null if no account is provided. + */ constructor( network: URL | ThorNetworks, transport: HttpClient, @@ -58,46 +80,45 @@ class WalletClient extends PublicClient { return this.account != null ? [Address.of(this.account.address)] : []; } + /** + * Prepares a transaction request by constructing and returning a `TransactionRequest` object. + * + * @param {PrepareTransactionRequestRequest} request - The request object containing details necessary to prepare the transaction. + * @return {TransactionRequest} The prepared transaction request object with all required properties set, ready for execution. + * @throws {UnsupportedOperationError} Throws an error if the provided request object is invalid. + * + * @see https://viem.sh/docs/actions/wallet/prepareTransactionRequest + */ public prepareTransactionRequest( request: PrepareTransactionRequestRequest - ): Transaction { + ): TransactionRequest { try { - const txClause: TransactionClause = { - to: request.to !== undefined ? request.to.toString() : null, - value: - request.value instanceof Hex - ? HexUInt.of(HexInt.of(request.value)).bi - : BigInt(request.value), - data: - request.data instanceof Hex - ? HexUInt.of(request.data).toString() - : NO_DATA, - comment: request.comment, - abi: - request.abi instanceof Hex - ? HexUInt.of(HexInt.of(request.abi)).toString() - : undefined - } satisfies TransactionClause; - const txBody: TransactionBody = { + const clause = new Clause( + request.to ?? null, + request.value instanceof Hex + ? HexUInt.of(HexInt.of(request.value)).bi + : BigInt(request.value), + request.data instanceof Hex ? HexUInt.of(request.data) : null, + request.comment ?? null, + request.abi instanceof Hex + ? HexUInt.of(HexInt.of(request.abi)).toString() + : null + ); + return new TransactionRequest({ + blockRef: request.blockRef, chainTag: request.chainTag, - blockRef: HexInt.of(request.blockRef).toString(), + clauses: [clause], + dependsOn: request.dependsOn ?? null, expiration: request.expiration, - clauses: [txClause], - gasPriceCoef: request.gasPriceCoef, - gas: - request.gas instanceof Hex - ? HexUInt.of(HexInt.of(request.gas)).toString() - : request.gas, - dependsOn: - request.dependsOn instanceof Hex - ? HexUInt32.of(HexInt.of(request.dependsOn)).toString() - : null, - nonce: request.nonce - } satisfies TransactionBody; - return Transaction.of(txBody); + gas: HexUInt.of(request.gas).bi, + gasPriceCoef: BigInt(request.gasPriceCoef), + nonce: request.nonce, + isIntendedToBeSponsored: + request.isIntendedToBeSponsored ?? false + }); } catch (e) { throw new UnsupportedOperationError( - `${FQP}WalletClient.prepareTransactionRequest(request: PrepareTransactionRequestRequest): void`, + `${FQP}WalletClient.prepareTransactionRequest(request: PrepareTransactionRequestRequest): TransactionRequest`, 'invalid request', { request @@ -106,6 +127,20 @@ class WalletClient extends PublicClient { } } + /** + * Signs the provided hash using the given account's signing method. + * + * This method adapts the [Viem](https://viem.sh/docs/actions/wallet/signTransaction) + * Ethereum signing algorithm to the Thor signing algorithm. + * + * @param {Uint8Array} hash The hash to be signed. + * @param {Account} account The account object used for signing the hash. + * @return {Promise} A promise that resolves to the signed hash as a Uint8Array. + * @throws {UnsupportedOperationError} + * + * @remarks Security auditable method, depends on + * - {@link Account.sign} + */ private static async signHash( hash: Uint8Array, account: Account @@ -135,49 +170,174 @@ class WalletClient extends PublicClient { ); } + /** + * Sends a raw transaction to the network through the provided HTTP client. + * + * @param {Hex} raw - The raw hexadecimal representation of the transaction to be sent. + * @return {Promise} A promise that resolves with the transaction ID in hexadecimal format. + * @throws ThorError if the response is invalid or the request fails. + * + * @see https://viem.sh/docs/actions/wallet/sendRawTransaction + */ public async sendRawTransaction(raw: Hex): Promise { return (await SendTransaction.of(raw.bytes).askTo(this.httpClient)) .response.id; } + /** + * Sends a transaction request to the blockchain network. + * The transaction request can either be + * - a prepared transaction request to be signed and sent, + * - a signed transaction request to be sponsored and sent. + * + * @param {PrepareTransactionRequestRequest | SignedTransactionRequest} request - The transaction request object. + * This can either be a prepared transaction request to be signed or a signed transaction request to be sponsored. + * @return {Promise} A promise that resolves to the transaction hash (Hex) of the sent transaction. + * @throws {ThorError} If the transaction fails to send or the response is invalid. + * @throws {UnsupportedOperationError} If the account is not set. + * + * @see https://viem.sh/docs/actions/wallet/sendTransaction + * @see signTransaction + * @see sendRawTransaction + */ public async sendTransaction( - request: PrepareTransactionRequestRequest + request: PrepareTransactionRequestRequest | SignedTransactionRequest ): Promise { - const tx = this.prepareTransactionRequest(request); - const raw = await this.signTransaction(tx); + const transactionRequest = + request instanceof SignedTransactionRequest + ? request + : this.prepareTransactionRequest(request); + const raw = await this.signTransaction(transactionRequest); return await this.sendRawTransaction(raw); } - public async signTransaction(tx: Transaction): Promise { + /** + * Signs the given transaction request and returns the resulting hexadecimal representation. + * The transaction request can either be + * - an unsigned transaction request, + * - an unsigned sponsored transaction request this wallet signs as origin/sender, + * - a signed sponsored transaction request this wallet sisgns as gas-payer.sponsor. + * + * @param {TransactionRequest | SignedTransactionRequest} transactionRequest - The transaction request to be signed. + * @return {Promise} A promise that resolves to the signed transaction in hexadecimal format. + * @throws {UnsupportedOperationError} If the account is not set. + */ + public async signTransaction( + transactionRequest: TransactionRequest | SignedTransactionRequest + ): Promise { if (this.account !== null) { - const encodedTx = RLPProfiler.ofObject( - { - // Existing body and the optional `reserved` field if present. - ...tx.body, - /* - * The `body.clauses` property is already an array, - * albeit TypeScript realize; hence cast is needed - * otherwise encodeObject will throw an error. - */ - clauses: tx.body.clauses as Array<{ - to: string | null; - value: bigint | number; - data: string; - }>, - // New reserved field. - reserved: tx.encodeReservedField() - }, - RLPCodec.RLP_UNSIGNED_TRANSACTION_PROFILE - ).encoded; - const txHash = Blake2b256.of(encodedTx).bytes; - const signature = await WalletClient.signHash(txHash, this.account); - return HexUInt.of(Transaction.of(tx.body, signature).encode(true)); + if (transactionRequest instanceof SignedTransactionRequest) { + return await WalletClient.sponsorTransactionRequest( + transactionRequest, + this.account + ); + } + return await WalletClient.signTransactionRequest( + transactionRequest, + this.account + ); } throw new UnsupportedOperationError( - `${FQP}WalletClient.signTransaction(encodedTx: Hex): Hex`, + `${FQP}WalletClient.signTransaction(transactionRequest: TransactionRequest | SignedTransactionRequest): Hex`, 'account is not set' ); } + + /** + * Signs a given transaction request with the provided account and returns the signed transaction in hexadecimal format. + * + * @param {TransactionRequest} transactionRequest - The transaction request object to be signed. + * @param {Account} account - The account object containing the necessary credentials for signing. + * @return {Promise} A promise that resolves to the signed transaction encoded in hexadecimal format. + */ + private static async signTransactionRequest( + transactionRequest: TransactionRequest, + account: Account + ): Promise { + const originHash = Blake2b256.of( + RLPCodec.encode(transactionRequest) + ).bytes; + const originSignature = await WalletClient.signHash( + originHash, + account + ); + const signedTransactionRequest = new SignedTransactionRequest({ + ...transactionRequest, + origin: Address.of(account.address), + originSignature, + signature: originSignature + }); + return HexUInt.of(RLPCodec.encode(signedTransactionRequest)); + } + + /** + * Sponsors a transaction request and returns the resulting hex-encoded sponsored transaction. + * + * @param {SignedTransactionRequest} signedTransactionRequest - The signed transaction request to be sponsored. + * Must have the `isIntendedToBeSponsored` flag set to true. + * @param {Account} account - The account object providing the gas payer's private key for signing the transaction. + * @return {Promise} A Promise that resolves to the hex-encoded representation of the sponsored transaction request. + * @throws {IllegalArgumentError} If the transaction request is not intended to be sponsored. + */ + private static async sponsorTransactionRequest( + signedTransactionRequest: SignedTransactionRequest, + account: Account + ): Promise { + if (signedTransactionRequest.isIntendedToBeSponsored) { + const originHash = Blake2b256.of( + RLPCodec.encode( + new TransactionRequest({ + blockRef: signedTransactionRequest.blockRef, + chainTag: signedTransactionRequest.chainTag, + clauses: signedTransactionRequest.clauses, + dependsOn: signedTransactionRequest.dependsOn, + expiration: signedTransactionRequest.expiration, + gas: signedTransactionRequest.gas, + gasPriceCoef: signedTransactionRequest.gasPriceCoef, + nonce: signedTransactionRequest.nonce, + isIntendedToBeSponsored: + signedTransactionRequest.isIntendedToBeSponsored + }) + ) + ); + const gasPayerHash = Blake2b256.of( + nc_utils.concatBytes( + originHash.bytes, + signedTransactionRequest.origin.bytes + ) + ); + const gasPayerSignature = await WalletClient.signHash( + gasPayerHash.bytes, + account + ); + const sponsoredTransactionRequest = new SponsoredTransactionRequest( + { + blockRef: signedTransactionRequest.blockRef, + chainTag: signedTransactionRequest.chainTag, + clauses: signedTransactionRequest.clauses, + dependsOn: signedTransactionRequest.dependsOn, + expiration: signedTransactionRequest.expiration, + gas: signedTransactionRequest.gas, + gasPriceCoef: signedTransactionRequest.gasPriceCoef, + nonce: signedTransactionRequest.nonce, + isIntendedToBeSponsored: true, + origin: signedTransactionRequest.origin, + originSignature: signedTransactionRequest.originSignature, + gasPayer: Address.of(account.address), + gasPayerSignature, + signature: nc_utils.concatBytes( + signedTransactionRequest.originSignature, + gasPayerSignature + ) + } + ); + return HexUInt.of(RLPCodec.encode(sponsoredTransactionRequest)); + } + throw new IllegalArgumentError( + `${FQP}WalletClient.signTransaction(signedTransactionRequest: SignedTransactionRequest): Hex`, + 'not intended to be sponsored' + ); + } } interface PrepareTransactionRequestRequest { @@ -195,6 +355,7 @@ interface PrepareTransactionRequestRequest { gas: Hex | number; gasPriceCoef: number; nonce: number; + isIntendedToBeSponsored?: boolean; } interface WalletClientConfig extends PublicClientConfig { diff --git a/packages/sdk/tests/thor/thorest/model/SignedTransactionRequest.unit.test.ts b/packages/sdk/tests/thor/thorest/model/SignedTransactionRequest.unit.test.ts index 74298f02e..af13f1d41 100644 --- a/packages/sdk/tests/thor/thorest/model/SignedTransactionRequest.unit.test.ts +++ b/packages/sdk/tests/thor/thorest/model/SignedTransactionRequest.unit.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, test, beforeEach } from '@jest/globals'; -import { Address, BlockRef } from '@common'; +import { beforeEach, describe, expect, test } from '@jest/globals'; +import { Address, BlockRef, HexUInt } from '@common'; import { TransactionRequest } from '@thor/thorest/model/TransactionRequest'; import { Clause, @@ -111,4 +111,184 @@ describe('SignedTransactionRequest', () => { expect(txRequest.isSigned()).toBe(false); }); }); + + describe('toJSON', () => { + test('ok <- should convert SignedTransactionRequest to JSON with all properties including signatures', () => { + const json = signedTxRequest.toJSON(); + + // Verify all base TransactionRequest properties are included + expect(json.blockRef).toBe(mockParams.blockRef.toString()); + expect(json.chainTag).toBe(mockParams.chainTag); + expect(json.clauses).toEqual([]); + expect(json.dependsOn).toBeNull(); + expect(json.expiration).toBe(mockParams.expiration); + expect(json.gas).toBe(mockParams.gas); + expect(json.gasPriceCoef).toBe(mockParams.gasPriceCoef); + expect(json.nonce).toBe(mockParams.nonce); + expect(json.isIntendedToBeSponsored).toBe( + mockParams.isIntendedToBeSponsored + ); + + // Verify signed-specific properties + expect(json.origin).toBe(mockParams.origin.toString()); + expect(json.originSignature).toBe('0x0102030405'); + expect(json.signature).toBe('0x060708090a'); + }); + + test('ok <- should convert SignedTransactionRequest to JSON with clauses', () => { + const clauseParams = { + ...mockParams, + clauses: [new Clause(mockOrigin, 1000n, null, null, null)] + }; + const signedRequestWithClause = new SignedTransactionRequest( + clauseParams + ); + + const json = signedRequestWithClause.toJSON(); + + expect(json.clauses).toHaveLength(1); + expect(json.clauses[0]).toEqual(clauseParams.clauses[0].toJSON()); + }); + + test('ok <- should convert SignedTransactionRequest to JSON with dependsOn value', () => { + const dependsOnValue = BlockRef.of( + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' + ); + const paramsWithDependsOn = { + ...mockParams, + dependsOn: dependsOnValue + }; + const signedRequestWithDependsOn = new SignedTransactionRequest( + paramsWithDependsOn + ); + + const json = signedRequestWithDependsOn.toJSON(); + + expect(json.dependsOn).toBe(dependsOnValue.toString()); + }); + + test('ok <- should convert SignedTransactionRequest to JSON with isIntendedToBeSponsored true', () => { + const sponsoredParams = { + ...mockParams, + isIntendedToBeSponsored: true + }; + const sponsoredSignedRequest = new SignedTransactionRequest( + sponsoredParams + ); + + const json = sponsoredSignedRequest.toJSON(); + + expect(json.isIntendedToBeSponsored).toBe(true); + }); + + test('ok <- should properly convert Uint8Array signatures to hex strings', () => { + const largeOriginSignature = new Uint8Array([ + 255, 254, 253, 252, 251, 0, 1, 2 + ]); + const largeSignature = new Uint8Array([ + 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 + ]); + + const paramsWithLargeSignatures = { + ...mockParams, + originSignature: largeOriginSignature, + signature: largeSignature + }; + const signedRequestWithLargeSignatures = + new SignedTransactionRequest(paramsWithLargeSignatures); + + const json = signedRequestWithLargeSignatures.toJSON(); + + expect(json.originSignature).toEqual( + HexUInt.of(largeOriginSignature).toString() + ); + expect(json.signature).toEqual( + HexUInt.of(largeSignature).toString() + ); + expect(typeof json.originSignature).toBe('string'); + expect(typeof json.signature).toBe('string'); + }); + + test('ok <- should handle empty signature arrays', () => { + const emptySignatureParams = { + ...mockParams, + originSignature: new Uint8Array([]), + signature: new Uint8Array([]) + }; + const signedRequestWithEmptySignatures = + new SignedTransactionRequest(emptySignatureParams); + + const json = signedRequestWithEmptySignatures.toJSON(); + + expect(json.originSignature).toBe('0x'); + expect(json.signature).toBe('0x'); + }); + + test('ok <- should handle single byte signatures', () => { + const singleByteParams = { + ...mockParams, + originSignature: new Uint8Array([42]), + signature: new Uint8Array([123]) + }; + const signedRequestWithSingleByte = new SignedTransactionRequest( + singleByteParams + ); + + const json = signedRequestWithSingleByte.toJSON(); + + expect(json.originSignature).toBe('0x2a'); + expect(json.signature).toBe('0x7b'); + }); + + test('ok <- should produce JSON that extends TransactionRequestJSON with signature properties', () => { + const json = signedTxRequest.toJSON(); + + // Verify all TransactionRequestJSON properties exist + expect(json).toHaveProperty('blockRef'); + expect(json).toHaveProperty('chainTag'); + expect(json).toHaveProperty('clauses'); + expect(json).toHaveProperty('dependsOn'); + expect(json).toHaveProperty('expiration'); + expect(json).toHaveProperty('gas'); + expect(json).toHaveProperty('gasPriceCoef'); + expect(json).toHaveProperty('nonce'); + expect(json).toHaveProperty('isIntendedToBeSponsored'); + + // Verify additional SignedTransactionRequestJSON properties exist + expect(json).toHaveProperty('origin'); + expect(json).toHaveProperty('originSignature'); + expect(json).toHaveProperty('signature'); + + // Verify types + expect(typeof json.origin).toBe('string'); + expect(typeof json.originSignature).toBe('string'); + expect(typeof json.signature).toBe('string'); + + // Verify hex format + expect(json.origin.startsWith('0x')).toBe(true); + expect(json.originSignature.startsWith('0x')).toBe(true); + expect(json.signature.startsWith('0x')).toBe(true); + }); + + test('ok <- should handle defensive copy mutation protection in JSON conversion', () => { + // Modify the original signature arrays after construction + const originalOriginSignature = new Uint8Array( + mockParams.originSignature + ); + const originalSignature = new Uint8Array(mockParams.signature); + + mockParams.originSignature[0] = 255; + mockParams.signature[0] = 255; + + const json = signedTxRequest.toJSON(); + + // The JSON should reflect the original values, not the modified ones + expect(json.originSignature).toBe('0x0102030405'); + expect(json.signature).toBe('0x060708090a'); + + // Restore original values for other tests + mockParams.originSignature.set(originalOriginSignature); + mockParams.signature.set(originalSignature); + }); + }); }); diff --git a/packages/sdk/tests/thor/thorest/model/SponsoredTransactionRequest.unit.test.ts b/packages/sdk/tests/thor/thorest/model/SponsoredTransactionRequest.unit.test.ts index 310bf92bc..45a1851f5 100644 --- a/packages/sdk/tests/thor/thorest/model/SponsoredTransactionRequest.unit.test.ts +++ b/packages/sdk/tests/thor/thorest/model/SponsoredTransactionRequest.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from '@jest/globals'; -import { Address, BlockRef } from '@common'; +import { Address, BlockRef, HexUInt } from '@common'; import { + Clause, SignedTransactionRequest, SponsoredTransactionRequest, type SponsoredTransactionRequestParam, @@ -119,4 +120,208 @@ describe('SponsoredTransactionRequest', () => { expect(sponsoredTxRequest.isSigned()).toBe(true); }); }); + + describe('toJSON', () => { + test('ok <- should convert SponsoredTransactionRequest to JSON with all properties including gas payer info', () => { + const json = sponsoredTxRequest.toJSON(); + + // Verify all base TransactionRequest properties are included + expect(json.blockRef).toBe(mockParams.blockRef.toString()); + expect(json.chainTag).toBe(mockParams.chainTag); + expect(json.clauses).toEqual([]); + expect(json.dependsOn).toBeNull(); + expect(json.expiration).toBe(mockParams.expiration); + expect(json.gas).toBe(mockParams.gas); + expect(json.gasPriceCoef).toBe(mockParams.gasPriceCoef); + expect(json.nonce).toBe(mockParams.nonce); + expect(json.isIntendedToBeSponsored).toBe( + mockParams.isIntendedToBeSponsored + ); + + // Verify SignedTransactionRequest properties are included + expect(json.origin).toBe(mockParams.origin.toString()); + expect(json.originSignature).toBe('0x0102030405'); + expect(json.signature).toBe('0x060708090a'); + + // Verify SponsoredTransactionRequest specific properties + expect(json.gasPayer).toBe(mockParams.gasPayer.toString()); + expect(json.gasPayerSignature).toBe('0x0b0c0d0e0f'); + }); + + test('ok <- should convert SponsoredTransactionRequest to JSON with clauses', () => { + const clauseParams = { + ...mockParams, + clauses: [new Clause(mockOrigin, 1000n, null, null, null)] + }; + const sponsoredRequestWithClause = new SponsoredTransactionRequest( + clauseParams + ); + + const json = sponsoredRequestWithClause.toJSON(); + + expect(json.clauses).toHaveLength(1); + expect(json.clauses[0]).toEqual(clauseParams.clauses[0].toJSON()); + }); + + test('ok <- should convert SponsoredTransactionRequest to JSON with dependsOn value', () => { + const dependsOnValue = BlockRef.of( + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' + ); + const paramsWithDependsOn = { + ...mockParams, + dependsOn: dependsOnValue + }; + const sponsoredRequestWithDependsOn = + new SponsoredTransactionRequest(paramsWithDependsOn); + + const json = sponsoredRequestWithDependsOn.toJSON(); + + expect(json.dependsOn).toBe(dependsOnValue.toString()); + }); + + test('ok <- should properly convert Uint8Array gas payer signature to hex string', () => { + const largeGasPayerSignature = new Uint8Array([ + 255, 254, 253, 252, 251, 250, 249, 248 + ]); + + const paramsWithLargeGasPayerSignature = { + ...mockParams, + gasPayerSignature: largeGasPayerSignature + }; + const sponsoredRequestWithLargeGasPayerSignature = + new SponsoredTransactionRequest( + paramsWithLargeGasPayerSignature + ); + + const json = sponsoredRequestWithLargeGasPayerSignature.toJSON(); + + expect(json.gasPayerSignature).toEqual( + HexUInt.of(largeGasPayerSignature).toString() + ); + expect(typeof json.gasPayerSignature).toBe('string'); + }); + + test('ok <- should handle empty gas payer signature array', () => { + const emptyGasPayerSignatureParams = { + ...mockParams, + gasPayerSignature: new Uint8Array([]) + }; + const sponsoredRequestWithEmptyGasPayerSignature = + new SponsoredTransactionRequest(emptyGasPayerSignatureParams); + + const json = sponsoredRequestWithEmptyGasPayerSignature.toJSON(); + + expect(json.gasPayerSignature).toBe('0x'); + }); + + test('ok <- should handle single byte gas payer signature', () => { + const singleByteGasPayerSignatureParams = { + ...mockParams, + gasPayerSignature: new Uint8Array([200]) + }; + const sponsoredRequestWithSingleByteGasPayerSignature = + new SponsoredTransactionRequest( + singleByteGasPayerSignatureParams + ); + + const json = + sponsoredRequestWithSingleByteGasPayerSignature.toJSON(); + + expect(json.gasPayerSignature).toBe('0xc8'); + }); + + test('ok <- should handle all signature arrays with different values', () => { + const differentSignaturesParams = { + ...mockParams, + originSignature: new Uint8Array([10, 20, 30]), + signature: new Uint8Array([40, 50, 60]), + gasPayerSignature: new Uint8Array([70, 80, 90]) + }; + const sponsoredRequestWithDifferentSignatures = + new SponsoredTransactionRequest(differentSignaturesParams); + + const json = sponsoredRequestWithDifferentSignatures.toJSON(); + + expect(json.originSignature).toBe('0x0a141e'); + expect(json.signature).toBe('0x28323c'); + expect(json.gasPayerSignature).toBe('0x46505a'); + }); + + test('ok <- should produce JSON that extends SignedTransactionRequestJSON with gas payer properties', () => { + const json = sponsoredTxRequest.toJSON(); + + // Verify all TransactionRequestJSON properties exist + expect(json).toHaveProperty('blockRef'); + expect(json).toHaveProperty('chainTag'); + expect(json).toHaveProperty('clauses'); + expect(json).toHaveProperty('dependsOn'); + expect(json).toHaveProperty('expiration'); + expect(json).toHaveProperty('gas'); + expect(json).toHaveProperty('gasPriceCoef'); + expect(json).toHaveProperty('nonce'); + expect(json).toHaveProperty('isIntendedToBeSponsored'); + + // Verify SignedTransactionRequestJSON properties exist + expect(json).toHaveProperty('origin'); + expect(json).toHaveProperty('originSignature'); + expect(json).toHaveProperty('signature'); + + // Verify additional SponsoredTransactionRequestJSON properties exist + expect(json).toHaveProperty('gasPayer'); + expect(json).toHaveProperty('gasPayerSignature'); + + // Verify types for gas payer properties + expect(typeof json.gasPayer).toBe('string'); + expect(typeof json.gasPayerSignature).toBe('string'); + + // Verify hex format for gas payer properties + expect(json.gasPayer.startsWith('0x')).toBe(true); + expect(json.gasPayerSignature.startsWith('0x')).toBe(true); + }); + + test('ok <- should handle defensive copy mutation protection in JSON conversion', () => { + // Modify the original signature arrays after construction + const originalGasPayerSignature = new Uint8Array( + mockParams.gasPayerSignature + ); + + mockParams.gasPayerSignature[0] = 255; + + const json = sponsoredTxRequest.toJSON(); + + // The JSON should reflect the original values, not the modified ones + expect(json.gasPayerSignature).toBe('0x0b0c0d0e0f'); + + // Restore original values for other tests + mockParams.gasPayerSignature.set(originalGasPayerSignature); + }); + + test('ok <- should maintain consistency with different gas payer addresses', () => { + const differentGasPayerParams = { + ...mockParams, + gasPayer: Address.of( + '0x1234567890123456789012345678901234567890' + ) + }; + const sponsoredRequestWithDifferentGasPayer = + new SponsoredTransactionRequest(differentGasPayerParams); + + const json = sponsoredRequestWithDifferentGasPayer.toJSON(); + + expect(json.gasPayer).toBe( + '0x1234567890123456789012345678901234567890' + ); + expect(json.origin).toBe(mockParams.origin.toString()); + expect(json.gasPayer).not.toBe(json.origin); + }); + + test('ok <- should handle sponsored transaction with isIntendedToBeSponsored true', () => { + // This should always be true for sponsored transactions + expect(sponsoredTxRequest.isIntendedToBeSponsored).toBe(true); + + const json = sponsoredTxRequest.toJSON(); + + expect(json.isIntendedToBeSponsored).toBe(true); + }); + }); }); diff --git a/packages/sdk/tests/thor/thorest/model/TransactionRequest.unit.test.ts b/packages/sdk/tests/thor/thorest/model/TransactionRequest.unit.test.ts index db2479545..ff2b70d85 100644 --- a/packages/sdk/tests/thor/thorest/model/TransactionRequest.unit.test.ts +++ b/packages/sdk/tests/thor/thorest/model/TransactionRequest.unit.test.ts @@ -61,7 +61,7 @@ describe('TransactionRequest UNIT tests', () => { gas: validGas, gasPriceCoef: validGasPriceCoef, nonce: validNonce - } as any); // Type assertion to test default behavior + }); // Type assertion to test default behavior expect(request.isIntendedToBeSponsored).toBe(false); }); @@ -145,4 +145,204 @@ describe('TransactionRequest UNIT tests', () => { expect(request.isSigned()).toBe(false); }); }); + + describe('toJSON', () => { + test('ok <- should convert TransactionRequest to JSON with all properties', () => { + const request = new TransactionRequest({ + blockRef: validBlockRef, + chainTag: validChainTag, + clauses: validClauses, + dependsOn: validDependsOn, + expiration: validExpiration, + gas: validGas, + gasPriceCoef: validGasPriceCoef, + nonce: validNonce, + isIntendedToBeSponsored: true + }); + + const json = request.toJSON(); + + expect(json.blockRef).toBe(validBlockRef.toString()); + expect(json.chainTag).toBe(validChainTag); + expect(json.clauses).toHaveLength(1); + expect(json.clauses[0]).toEqual(validClause.toJSON()); + expect(json.dependsOn).toBe(validDependsOn.toString()); + expect(json.expiration).toBe(validExpiration); + expect(json.gas).toBe(validGas); + expect(json.gasPriceCoef).toBe(validGasPriceCoef); + expect(json.nonce).toBe(validNonce); + expect(json.isIntendedToBeSponsored).toBe(true); + }); + + test('ok <- should convert TransactionRequest to JSON with null dependsOn', () => { + const request = new TransactionRequest({ + blockRef: validBlockRef, + chainTag: validChainTag, + clauses: validClauses, + dependsOn: null, + expiration: validExpiration, + gas: validGas, + gasPriceCoef: validGasPriceCoef, + nonce: validNonce, + isIntendedToBeSponsored: false + }); + + const json = request.toJSON(); + + expect(json.dependsOn).toBeNull(); + expect(json.isIntendedToBeSponsored).toBe(false); + }); + + test('ok <- should convert TransactionRequest to JSON with empty clauses array', () => { + const request = new TransactionRequest({ + blockRef: validBlockRef, + chainTag: validChainTag, + clauses: [], + dependsOn: null, + expiration: validExpiration, + gas: validGas, + gasPriceCoef: validGasPriceCoef, + nonce: validNonce + }); + + const json = request.toJSON(); + + expect(json.clauses).toEqual([]); + expect(Array.isArray(json.clauses)).toBe(true); + }); + + test('ok <- should convert TransactionRequest to JSON with multiple clauses', () => { + const clause1 = new Clause( + Address.of('0x9e7911de289c3c856ce7f421034f66b6cde49c39'), + 1n, + null, + null, + null + ); + const clause2 = new Clause( + Address.of('0x8e7911de289c3c856ce7f421034f66b6cde49c38'), + 2n, + HexUInt.of('0x12345'), + 'Test clause', + null + ); + + const request = new TransactionRequest({ + blockRef: validBlockRef, + chainTag: validChainTag, + clauses: [clause1, clause2], + dependsOn: null, + expiration: validExpiration, + gas: validGas, + gasPriceCoef: validGasPriceCoef, + nonce: validNonce + }); + + const json = request.toJSON(); + + expect(json.clauses).toHaveLength(2); + expect(json.clauses[0]).toEqual(clause1.toJSON()); + expect(json.clauses[1]).toEqual(clause2.toJSON()); + }); + + test('ok <- should convert TransactionRequest to JSON with default isIntendedToBeSponsored', () => { + const request = new TransactionRequest({ + blockRef: validBlockRef, + chainTag: validChainTag, + clauses: validClauses, + dependsOn: null, + expiration: validExpiration, + gas: validGas, + gasPriceCoef: validGasPriceCoef, + nonce: validNonce + // isIntendedToBeSponsored not provided, should default to false + }); + + const json = request.toJSON(); + + expect(json.isIntendedToBeSponsored).toBe(false); + }); + + test('ok <- should convert TransactionRequest to JSON with bigint values preserved', () => { + const largeGas = 999999999999999999n; + const largeGasPriceCoef = 123456789n; + + const request = new TransactionRequest({ + blockRef: validBlockRef, + chainTag: validChainTag, + clauses: validClauses, + dependsOn: null, + expiration: validExpiration, + gas: largeGas, + gasPriceCoef: largeGasPriceCoef, + nonce: validNonce + }); + + const json = request.toJSON(); + + expect(json.gas).toBe(largeGas); + expect(json.gasPriceCoef).toBe(largeGasPriceCoef); + expect(typeof json.gas).toBe('bigint'); + expect(typeof json.gasPriceCoef).toBe('bigint'); + }); + + test('ok <- should convert TransactionRequest to JSON with all string values properly formatted', () => { + const request = new TransactionRequest({ + blockRef: validBlockRef, + chainTag: validChainTag, + clauses: validClauses, + dependsOn: validDependsOn, + expiration: validExpiration, + gas: validGas, + gasPriceCoef: validGasPriceCoef, + nonce: validNonce + }); + + const json = request.toJSON(); + + expect(typeof json.blockRef).toBe('string'); + expect(typeof json.dependsOn).toBe('string'); + expect(json.blockRef.startsWith('0x')).toBe(true); + expect(json.dependsOn?.startsWith('0x')).toBe(true); + }); + + test('ok <- should produce JSON that satisfies TransactionRequestJSON interface', () => { + const request = new TransactionRequest({ + blockRef: validBlockRef, + chainTag: validChainTag, + clauses: validClauses, + dependsOn: validDependsOn, + expiration: validExpiration, + gas: validGas, + gasPriceCoef: validGasPriceCoef, + nonce: validNonce, + isIntendedToBeSponsored: true + }); + + const json = request.toJSON(); + + // Verify all required properties exist and have correct types + expect(json).toHaveProperty('blockRef'); + expect(json).toHaveProperty('chainTag'); + expect(json).toHaveProperty('clauses'); + expect(json).toHaveProperty('dependsOn'); + expect(json).toHaveProperty('expiration'); + expect(json).toHaveProperty('gas'); + expect(json).toHaveProperty('gasPriceCoef'); + expect(json).toHaveProperty('nonce'); + expect(json).toHaveProperty('isIntendedToBeSponsored'); + + expect(typeof json.blockRef).toBe('string'); + expect(typeof json.chainTag).toBe('number'); + expect(Array.isArray(json.clauses)).toBe(true); + expect( + typeof json.dependsOn === 'string' || json.dependsOn === null + ).toBe(true); + expect(typeof json.expiration).toBe('number'); + expect(typeof json.gas).toBe('bigint'); + expect(typeof json.gasPriceCoef).toBe('bigint'); + expect(typeof json.nonce).toBe('number'); + expect(typeof json.isIntendedToBeSponsored).toBe('boolean'); + }); + }); }); diff --git a/packages/sdk/tests/thor/thorest/signer/PrivateKeySigner.unit.test.ts b/packages/sdk/tests/thor/thorest/signer/PrivateKeySigner.unit.test.ts index f821145b7..58a9f907a 100644 --- a/packages/sdk/tests/thor/thorest/signer/PrivateKeySigner.unit.test.ts +++ b/packages/sdk/tests/thor/thorest/signer/PrivateKeySigner.unit.test.ts @@ -15,7 +15,6 @@ import { TransactionRequest } from '@thor/thorest/model'; import { PrivateKeySigner, RLPCodec } from '@thor/thorest/signer'; -import { newTransactionFromTransactionRequest } from './RLPCodec.unit.test'; import * as nc_utils from '@noble/curves/abstract/utils'; /** @@ -31,18 +30,6 @@ describe('PrivateKeySigner', () => { const mockHash = new Uint8Array(32).fill(2); const mockSignature = new Uint8Array(65).fill(3); - const mockSender = { - privateKey: HexUInt.of( - 'ea5383ac1f9e625220039a4afac6a7f868bf1ad4f48ce3a1dd78bd214ee4ace5' - ).bytes - }; - - const mockGasPayer = { - privateKey: HexUInt.of( - '432f38bcf338c374523e83fdb2ebe1030aba63c7f1e81f7d76c5f53f4d42e766' - ).bytes - }; - // Common transaction request parameters const transactionParams = { blockRef: BlockRef.of('0x1234567890abcdef'), @@ -69,7 +56,7 @@ describe('PrivateKeySigner', () => { jest.spyOn(Secp256k1, 'sign').mockReturnValue(mockSignature); - jest.spyOn(RLPCodec, 'encodeTransactionRequest').mockReturnValue( + jest.spyOn(RLPCodec, 'encode').mockReturnValue( new Uint8Array(10).fill(4) ); }); @@ -78,32 +65,6 @@ describe('PrivateKeySigner', () => { jest.restoreAllMocks(); }); - describe('disponse method', () => { - test('ok <- clear the private key and set it to null', () => { - const privateKey = new Uint8Array(32).fill(1); - const signer = new PrivateKeySigner(privateKey); - - signer.disponse(); - - // Attempt to sign to verify the private key is cleared - const txRequest = new TransactionRequest(transactionParams); - expect(() => signer.sign(txRequest)).toThrow( - InvalidPrivateKeyError - ); - }); - - test('ok <- should be safe to call disponse multiple times', () => { - const signer = new PrivateKeySigner(validPrivateKey); - - signer.disponse(); - signer.disponse(); // Second call should not throw - - expect(() => { - signer.disponse(); - }).not.toThrow(); - }); - }); - describe('constructor', () => { test('ok <- create a new instance with valid private key', () => { const signer = new PrivateKeySigner(validPrivateKey); @@ -152,21 +113,45 @@ describe('PrivateKeySigner', () => { }); }); - describe('sign method with TransactionRequest', () => { - test('ok <- sign a regular transaction request', () => { + describe('dispose method', () => { + test('ok <- clear the private key and set it to null', () => { + const privateKey = new Uint8Array(32).fill(1); + const signer = new PrivateKeySigner(privateKey); + + signer.dispose(); + + // Attempt to sign to verify the private key is cleared + const txRequest = new TransactionRequest(transactionParams); + expect(() => signer.sign(txRequest)).toThrow( + InvalidPrivateKeyError + ); + }); + + test('ok <- should be safe to call dispose multiple times', () => { const signer = new PrivateKeySigner(validPrivateKey); + + signer.dispose(); + signer.dispose(); // Second call should not throw + + expect(() => { + signer.dispose(); + }).not.toThrow(); + }); + }); + + describe('sign', () => { + test('ok <- sign a not-sponsored unsigned transaction request', () => { + const originSigner = new PrivateKeySigner(validPrivateKey); const txRequest = new TransactionRequest({ ...transactionParams, isIntendedToBeSponsored: false }); - const signedTx = signer.sign(txRequest); + const signedTx = originSigner.sign(txRequest); expect(signedTx).toBeInstanceOf(SignedTransactionRequest); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(RLPCodec.encodeTransactionRequest).toHaveBeenCalledWith( - txRequest - ); + expect(RLPCodec.encode).toHaveBeenCalledWith(txRequest); // eslint-disable-next-line @typescript-eslint/unbound-method expect(Blake2b256.of).toHaveBeenCalled(); // eslint-disable-next-line @typescript-eslint/unbound-method @@ -183,50 +168,41 @@ describe('PrivateKeySigner', () => { expect(signedTx.chainTag).toBe(txRequest.chainTag); expect(signedTx.clauses).toBe(txRequest.clauses); expect(signedTx.isIntendedToBeSponsored).toBe(false); - }); - test('ok <- signature clones the origin signature', () => { - const txRequest = new TransactionRequest(transactionParams); - const sender = new PrivateKeySigner(mockSender.privateKey); - const signedTx = sender.sign(txRequest); expect(signedTx.originSignature).toEqual(signedTx.signature); - - // Temporary until Transaction exists. - const expectedSignedTx = newTransactionFromTransactionRequest( - txRequest - ).sign(mockSender.privateKey); - expect(signedTx.signature).toEqual(expectedSignedTx.signature); }); - test('ok <- sign a transaction request marked for sponsorship', () => { - const signer = new PrivateKeySigner(validPrivateKey); + test('ok <- sign a sponsored unsigned transaction request', () => { + const originSigner = new PrivateKeySigner(validPrivateKey); const txRequest = new TransactionRequest({ ...transactionParams, isIntendedToBeSponsored: true }); - const signedTx = signer.sign(txRequest); + const signedTx = originSigner.sign(txRequest); expect(signedTx).toBeInstanceOf(SignedTransactionRequest); expect(signedTx.isIntendedToBeSponsored).toBe(true); + + expect(signedTx.originSignature).toEqual(signedTx.signature); }); - test('err <- throw error if private key is voided before signing', () => { - const signer = new PrivateKeySigner(validPrivateKey); + test('err <- throw error if private key is disposed before signing', () => { + const originSigner = new PrivateKeySigner(validPrivateKey); const txRequest = new TransactionRequest(transactionParams); - signer.disponse(); + originSigner.dispose(); - expect(() => signer.sign(txRequest)).toThrow( + expect(() => originSigner.sign(txRequest)).toThrow( InvalidPrivateKeyError ); }); - }); - describe('sign method with SignedTransactionRequest (sponsoring)', () => { - test('ok <- sponsor a signed transaction request', () => { - const sender = new PrivateKeySigner(new Uint8Array(32).fill(5)); - const gasPayer = new PrivateKeySigner(validPrivateKey); + test('ok <- sponsor a sponsored signed transaction request', () => { + const originSigner = new PrivateKeySigner( + new Uint8Array(32).fill(5) + ); + const gasPayerSigner = new PrivateKeySigner(validPrivateKey); // Create a transaction request marked for sponsorship const txRequest = new TransactionRequest({ @@ -234,8 +210,8 @@ describe('PrivateKeySigner', () => { isIntendedToBeSponsored: true }); - // Sign it with the origin gasPayer - const signedTx = sender.sign(txRequest); + // Sign it with the origin gasPayerSigner + const signedTx = originSigner.sign(txRequest); // Mock the concatenated hash jest.spyOn(nc_utils, 'concatBytes').mockImplementation( @@ -243,7 +219,7 @@ describe('PrivateKeySigner', () => { ); // Sponsor the transaction - const sponsoredTx = gasPayer.sign( + const sponsoredTx = gasPayerSigner.sign( signedTx ) as SponsoredTransactionRequest; @@ -264,45 +240,17 @@ describe('PrivateKeySigner', () => { signedTx.originSignature, mockSignature ); - }); - test('ok <- signature combines origin and gas payer signatures', () => { - const txRequest = new TransactionRequest({ - ...transactionParams, - isIntendedToBeSponsored: true - }); - const sender = new PrivateKeySigner(mockSender.privateKey); - const signedTx = sender.sign(txRequest); - const gasPayer: PrivateKeySigner = new PrivateKeySigner( - mockGasPayer.privateKey - ); - const sponsoredTx = gasPayer.sign(signedTx); expect(sponsoredTx.signature).toEqual( nc_utils.concatBytes( signedTx.signature, - (sponsoredTx as SponsoredTransactionRequest) - .gasPayerSignature + sponsoredTx.gasPayerSignature ) ); - - // Temporary until Transaction exists. - const expectedSignedTx = newTransactionFromTransactionRequest( - txRequest - ).signAsSender(mockSender.privateKey); - expect(sponsoredTx.originSignature).toEqual( - expectedSignedTx.signature - ); - const expectedSponsoredTx = expectedSignedTx.signAsGasPayer( - sender.address, - mockGasPayer.privateKey - ); - expect(sponsoredTx.signature).toEqual( - expectedSponsoredTx.signature - ); }); test('err <- throw UnsupportedOperationError when sponsoring non-sponsored transaction', () => { - const signer = new PrivateKeySigner(validPrivateKey); + const gasPayerSigner = new PrivateKeySigner(validPrivateKey); const originSigner = new PrivateKeySigner( new Uint8Array(32).fill(5) ); @@ -313,17 +261,17 @@ describe('PrivateKeySigner', () => { isIntendedToBeSponsored: false }); - // Sign it with the origin signer + // Sign it with the origin gasPayerSigner const signedTx = originSigner.sign(txRequest); // Attempt to sponsor the transaction - expect(() => signer.sign(signedTx)).toThrow( + expect(() => gasPayerSigner.sign(signedTx)).toThrow( UnsupportedOperationError ); }); test('err <- throw InvalidPrivateKeyError if private key is voided before sponsoring', () => { - const signer = new PrivateKeySigner(validPrivateKey); + const gasPayerSigner = new PrivateKeySigner(validPrivateKey); const originSigner = new PrivateKeySigner( new Uint8Array(32).fill(5) ); @@ -334,14 +282,16 @@ describe('PrivateKeySigner', () => { isIntendedToBeSponsored: true }); - // Sign it with the origin signer + // Sign it with the origin gasPayerSigner const signedTx = originSigner.sign(txRequest); - // Void the signer's private key - signer.disponse(); + // Void the gasPayerSigner's private key + gasPayerSigner.dispose(); // Attempt to sponsor the transaction - expect(() => signer.sign(signedTx)).toThrow(InvalidPrivateKeyError); + expect(() => gasPayerSigner.sign(signedTx)).toThrow( + InvalidPrivateKeyError + ); }); }); }); diff --git a/packages/sdk/tests/thor/thorest/signer/RLPCodec.unit.test.ts b/packages/sdk/tests/thor/thorest/signer/RLPCodec.unit.test.ts index ded462d4e..1c6080143 100644 --- a/packages/sdk/tests/thor/thorest/signer/RLPCodec.unit.test.ts +++ b/packages/sdk/tests/thor/thorest/signer/RLPCodec.unit.test.ts @@ -1,55 +1,16 @@ import { describe, expect, test } from '@jest/globals'; -import { Address, HexUInt } from '@common/vcdm'; +import { Address, HexUInt, Quantity } from '@common/vcdm'; import { Clause, - SignedTransactionRequest, + type SponsoredTransactionRequest, TransactionRequest } from '@thor/thorest/model'; -import { - Transaction, - type TransactionBody, - type TransactionClause -} from '@thor/thorest/transactions/model'; -import { RLPCodec } from '@thor/thorest/signer'; +import { PrivateKeySigner, RLPCodec } from '@thor/thorest/signer'; import { TEST_ACCOUNTS } from '../../../fixture'; +import { IllegalArgumentError } from '@common'; const { TRANSACTION_SENDER, TRANSACTION_RECEIVER } = TEST_ACCOUNTS.TRANSACTION; -// Temporary until Transaction exists. -function newTransactionBodyFromTransactionRequest( - txRequest: TransactionRequest -): TransactionBody { - return { - chainTag: txRequest.chainTag, - blockRef: txRequest.blockRef.toString(), - dependsOn: txRequest.dependsOn?.toString() ?? null, - expiration: txRequest.expiration, - clauses: txRequest.clauses.map((clause: Clause): TransactionClause => { - return { - to: clause.to?.toString() ?? null, - value: clause.value, - data: clause.data?.toString() ?? '0x', - comment: clause.comment ?? undefined, - abi: clause.abi ?? undefined - } satisfies TransactionClause; - }), - gasPriceCoef: Number(txRequest.gasPriceCoef), - gas: Number(txRequest.gas), - nonce: txRequest.nonce, - reserved: { - features: txRequest.isIntendedToBeSponsored ? 1 : 0, - unused: [] - } - } satisfies TransactionBody; -} - -// Temporary until Transaction exists. -export function newTransactionFromTransactionRequest( - txRequest: TransactionRequest -): Transaction { - return Transaction.of(newTransactionBodyFromTransactionRequest(txRequest)); -} - /** * @group unit/thor/thorest/signer */ @@ -61,312 +22,251 @@ describe('RLPCodec', () => { ); const mockGas = 21000n; - describe('encodeTransactionRequest', () => { - test('ok <- should encode a non-sponsored transaction request correctly', () => { - // Create a simple transaction request - const txRequest = new TransactionRequest({ - blockRef: mockBlockRef, - chainTag: 1, - clauses: [], - dependsOn: null, - expiration: 32, - gas: mockGas, - gasPriceCoef: 0n, - nonce: 1, - isIntendedToBeSponsored: false - }); + const mockOrigin = new PrivateKeySigner( + HexUInt.of(TRANSACTION_SENDER.privateKey).bytes + ); - // Call the method - const actual = RLPCodec.encodeTransactionRequest(txRequest); + const mockGasPayer = new PrivateKeySigner( + HexUInt.of(TRANSACTION_RECEIVER.privateKey).bytes + ); - // Assert actual - expect(actual.length).toBeGreaterThan(0); + const mockValue = Quantity.of(1000); + + describe('decode transaction request', () => { + test('err <- invalid input', () => { + const expected = mockGasPayer.sign( + mockOrigin.sign( + new TransactionRequest({ + blockRef: mockBlockRef, + chainTag: 1, + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: mockValue.toString() + }) + ], + dependsOn: null, + expiration: 32, + gas: mockGas, + gasPriceCoef: 0n, + nonce: 3, + isIntendedToBeSponsored: true + }) + ) + ); - // Temporary until Transaction exists. - const expected = - newTransactionFromTransactionRequest(txRequest).encode(false); - expect(actual).toEqual(expected); + const encoded = RLPCodec.encode(expected).slice(0); + expect(() => + RLPCodec.decode(encoded.slice(0, encoded.length / 2)) + ).toThrow(IllegalArgumentError); }); + }); - test('ok <- should encode a sponsored transaction request correctly', () => { - // Create a simple sponsored transaction request - const txRequest = new TransactionRequest({ + describe('encode/decode', () => { + test('ok <- non-sponsored signed transaction request', () => { + const expected = mockOrigin.sign( + new TransactionRequest({ + blockRef: mockBlockRef, + chainTag: 1, + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: mockValue.toString() + }) + ], + dependsOn: null, + expiration: 32, + gas: mockGas, + gasPriceCoef: 0n, + nonce: 3, + isIntendedToBeSponsored: false + }) + ); + expect(expected.origin.toString()).toEqual( + mockOrigin.address.toString() + ); + const encoded = RLPCodec.encode(expected); + const actual = RLPCodec.decode(encoded); + expect(actual.toJSON()).toEqual(expected.toJSON()); + }); + + test('ok <- non-sponsored unsigned transaction request', () => { + const expected = new TransactionRequest({ blockRef: mockBlockRef, chainTag: 1, - clauses: [], + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: mockValue.toString() + }) + ], dependsOn: null, expiration: 32, gas: mockGas, gasPriceCoef: 0n, - nonce: 2, - isIntendedToBeSponsored: true + nonce: 3, + isIntendedToBeSponsored: false }); - - // Call the method - const actual = RLPCodec.encodeTransactionRequest(txRequest); - - // Assert actual - expect(actual.length).toBeGreaterThan(0); - - // Temporary until Transaction exists. - const expected = - newTransactionFromTransactionRequest(txRequest).encode(false); - expect(actual).toEqual(expected); + const encoded = RLPCodec.encode(expected); + const actual = RLPCodec.decode(encoded); + expect(actual.toJSON()).toEqual(expected.toJSON()); }); - test('ok <- should handle transaction without clause.data correctly', () => { - // Create a transaction request with clauses - const clause = new Clause( - Address.of(TRANSACTION_RECEIVER.address), - 1000n, - null, - null, - null - ); - + test('ok <- sponsored signed transaction request', () => { const txRequest = new TransactionRequest({ blockRef: mockBlockRef, chainTag: 1, - clauses: [clause], + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: mockValue.toString() + }) + ], dependsOn: null, expiration: 32, gas: mockGas, gasPriceCoef: 0n, nonce: 3, - isIntendedToBeSponsored: false + isIntendedToBeSponsored: true }); + const expected = mockGasPayer.sign( + mockOrigin.sign(txRequest) + ) as SponsoredTransactionRequest; + expect(expected.origin.toString()).toEqual( + mockOrigin.address.toString() + ); + expect(expected.gasPayer.toString()).toEqual( + mockGasPayer.address.toString() + ); - // Call the method - const actual = RLPCodec.encodeTransactionRequest(txRequest); - - // Assert actual - expect(actual.length).toBeGreaterThan(0); - - // Temporary until Transaction exists. - const expected = - newTransactionFromTransactionRequest(txRequest).encode(false); - expect(actual).toEqual(expected); + const encoded = RLPCodec.encode(expected); + const actual = RLPCodec.decode(encoded); + expect(actual.toJSON()).toEqual(expected.toJSON()); }); - test('ok <- should handle transaction without clause.to correctly', () => { - // Create a transaction request with clauses - const clause = new Clause( - null, - 1000n, - HexUInt.of('0xabcdef'), - null, - null - ); - - const txRequest = new TransactionRequest({ + test('ok <- sponsored unsigned transaction request', () => { + const expected = new TransactionRequest({ blockRef: mockBlockRef, chainTag: 1, - clauses: [clause], + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: mockValue.toString() + }) + ], dependsOn: null, expiration: 32, gas: mockGas, gasPriceCoef: 0n, nonce: 3, - isIntendedToBeSponsored: false + isIntendedToBeSponsored: true }); - - // Call the method - const actual = RLPCodec.encodeTransactionRequest(txRequest); - - // Assert actual - expect(actual.length).toBeGreaterThan(0); - - // Temporary until Transaction exists. - const expected = - newTransactionFromTransactionRequest(txRequest).encode(false); - expect(actual).toEqual(expected); + const encoded = RLPCodec.encode(expected); + const actual = RLPCodec.decode(encoded); + expect(actual.toJSON()).toEqual(expected.toJSON()); }); + }); - test('ok <- should handle transaction without optional clauses correctly', () => { - // Create a transaction request with clauses - const clause = new Clause( - Address.of(TRANSACTION_RECEIVER.address), - 1000n, - HexUInt.of('0xabcdef'), - 'test comment', - '0xabcdef' - ); - - const txRequest = new TransactionRequest({ + describe('encode/decode transaction request clauses', () => { + test('ok <- handle transactions without clauses', () => { + const expected = new TransactionRequest({ blockRef: mockBlockRef, chainTag: 1, - clauses: [clause], + clauses: [], dependsOn: null, expiration: 32, gas: mockGas, gasPriceCoef: 0n, - nonce: 3, - isIntendedToBeSponsored: false + nonce: 3 }); - - // Call the method - const actual = RLPCodec.encodeTransactionRequest(txRequest); - - // Assert actual - expect(actual.length).toBeGreaterThan(0); - - // Temporary until Transaction exists. - const expected = - newTransactionFromTransactionRequest(txRequest).encode(false); - expect(actual).toEqual(expected); + const actual = RLPCodec.decode(RLPCodec.encode(expected)); + expect(actual.toJSON()).toEqual(expected.toJSON()); }); - test('ok <- should handle transaction with optional clauses correctly', () => { - // Create a transaction request with clauses - const clause = new Clause( - Address.of(TRANSACTION_RECEIVER.address), - 1000n, - HexUInt.of('0xabcdef'), - 'test comment', - '0xabcdef' - ); - + test('ok <- handle transaction without clause.data correctly', () => { const txRequest = new TransactionRequest({ blockRef: mockBlockRef, chainTag: 1, - clauses: [clause], + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: mockValue.toString() + }) + ], dependsOn: null, expiration: 32, gas: mockGas, gasPriceCoef: 0n, - nonce: 3, - isIntendedToBeSponsored: false + nonce: 3 }); - // Call the method - const actual = RLPCodec.encodeTransactionRequest(txRequest); - - // Assert actual - expect(actual.length).toBeGreaterThan(0); - - // Temporary until Transaction exists. - const expected = - newTransactionFromTransactionRequest(txRequest).encode(false); - expect(actual).toEqual(expected); + const actual = RLPCodec.decode(RLPCodec.encode(txRequest)); + expect(actual.toJSON()).toEqual(txRequest.toJSON()); }); - test('ok <- should handle transaction with dependsOn correctly', () => { - // Create a transaction request with dependsOn - const txRequest = new TransactionRequest({ + test('ok <- handle transaction without clause.to correctly', () => { + const expected = new TransactionRequest({ blockRef: mockBlockRef, chainTag: 1, - clauses: [], - dependsOn: mockDependsOn, + clauses: [ + Clause.of({ + to: null, + value: mockValue.toString(), + data: '0xabcdef' + }) + ], + dependsOn: null, expiration: 32, gas: mockGas, gasPriceCoef: 0n, - nonce: 4, - isIntendedToBeSponsored: false + nonce: 3 }); - // Call the method - const actual = RLPCodec.encodeTransactionRequest(txRequest); - - // Assert actual - expect(actual.length).toBeGreaterThan(0); - - // Temporary until Transaction exists. - const expected = - newTransactionFromTransactionRequest(txRequest).encode(false); - expect(actual).toEqual(expected); + const actual = RLPCodec.decode(RLPCodec.encode(expected)); + expect(actual.toJSON()).toEqual(expected.toJSON()); }); - }); - - describe('encodeSignedTransactionRequest', () => { - test('ok <- should encode a non-sponsored signed transaction request correctly', () => { - const clause = new Clause( - Address.of(TRANSACTION_RECEIVER.address), - 1000n, - HexUInt.of('0xabcdef'), - 'test comment', - '0xabcdef' - ); - // Create a simple signed transaction request - const txRequest = new TransactionRequest({ + test('ok <- handle transaction with optional clause properties correctly', () => { + const expected = new TransactionRequest({ blockRef: mockBlockRef, chainTag: 1, - clauses: [clause, clause], + clauses: [ + new Clause( + Address.of(TRANSACTION_RECEIVER.address), + mockValue.bi, + HexUInt.of('0xabcdef'), + 'test comment', + '0xabcdef' + ) + ], dependsOn: null, expiration: 32, gas: mockGas, gasPriceCoef: 0n, - nonce: 5, - isIntendedToBeSponsored: false - }); - - // Temporary until Transaction exists. - const signedTx = newTransactionFromTransactionRequest( - txRequest - ).sign(HexUInt.of(TRANSACTION_SENDER.privateKey).bytes); - const signedTxRequest = new SignedTransactionRequest({ - ...txRequest, - origin: signedTx.origin, - originSignature: signedTx.signature as Uint8Array, - signature: signedTx.signature as Uint8Array + nonce: 3 }); - // Call the method - const actual = - RLPCodec.encodeSignedTransactionRequest(signedTxRequest); - - // Assert actual - expect(actual.length).toBeGreaterThan(0); - - // Temporary until Transaction exists. - expect(actual).toEqual(signedTx.encode(true)); + const actual = RLPCodec.decode(RLPCodec.encode(expected)); + expect(actual.toJSON()).toEqual(expected.toJSON()); }); - test('ok <- should encode a sponsored signed transaction request correctly', () => { - const clause = new Clause( - Address.of(TRANSACTION_RECEIVER.address), - 1000n, - HexUInt.of('0xabcdef'), - 'test comment', - '0xabcdef' - ); - - // Create a simple sponsored signed transaction request - const txRequest = new TransactionRequest({ + test('ok <- handle transaction with dependsOn correctly', () => { + // Create a transaction request with dependsOn + const expected = new TransactionRequest({ blockRef: mockBlockRef, chainTag: 1, - clauses: [clause, clause], - dependsOn: null, + clauses: [], + dependsOn: mockDependsOn, expiration: 32, gas: mockGas, gasPriceCoef: 0n, - nonce: 6, - isIntendedToBeSponsored: true - }); - - // Temporary until Transaction exists. - const signedTx = newTransactionFromTransactionRequest( - txRequest - ).signAsSenderAndGasPayer( - HexUInt.of(TRANSACTION_SENDER.privateKey).bytes, - HexUInt.of(TRANSACTION_RECEIVER.privateKey).bytes - ); - const signedTxRequest = new SignedTransactionRequest({ - ...txRequest, - origin: signedTx.origin, - originSignature: signedTx.signature as Uint8Array, - signature: signedTx.signature as Uint8Array + nonce: 4, + isIntendedToBeSponsored: false }); // Call the method - const actual = - RLPCodec.encodeSignedTransactionRequest(signedTxRequest); - - // Assert actual - expect(actual.length).toBeGreaterThan(0); - - // Temporary until Transaction exists. - expect(actual).toEqual(signedTx.encode(true)); + const actual = RLPCodec.decode(RLPCodec.encode(expected)); + expect(actual.toJSON()).toEqual(expected.toJSON()); }); }); }); diff --git a/packages/sdk/tests/viem/clients/WalletClient.demo.test.ts b/packages/sdk/tests/viem/clients/WalletClient.demo.test.ts new file mode 100644 index 000000000..7827f8aff --- /dev/null +++ b/packages/sdk/tests/viem/clients/WalletClient.demo.test.ts @@ -0,0 +1,149 @@ +import { describe } from '@jest/globals'; +import { + Address, + BlockRef, + FetchHttpClient, + Quantity, + Revision +} from '@common'; +import { + RetrieveExpandedBlock, + type SignedTransactionRequest, + SOLO_NETWORK, + ThorNetworks +} from '@thor'; +import { createWalletClient } from '@viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { TEST_ACCOUNTS } from '../../fixture'; +import { RLPCodec } from '@thor/thorest/signer'; +import { mockHttpClient } from '../../MockHttpClient'; + +const { TRANSACTION_SENDER, TRANSACTION_RECEIVER } = TEST_ACCOUNTS.TRANSACTION; + +describe('VIEM WALLET CLIENT DEMO', () => { + test('SOLO DEMO', async () => { + // 1. Create the transport layer to THOR: JS Fetch HTTP Client + const transport = FetchHttpClient.at(new URL(ThorNetworks.SOLONET)); + + // 2. Create the transaction's origin account (the signer authority) and wallet. + const originAccount = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const originWallet = createWalletClient({ + network: transport.baseURL, + account: originAccount + }); + + // 3. Create the transaction's gas-payer account (the signer authority) and wallet. + const gasPayerAccount = privateKeyToAccount( + `0x${TRANSACTION_RECEIVER.privateKey}` + ); + const gasPayerWallet = createWalletClient({ + network: transport.baseURL, + account: gasPayerAccount + }); + + // 4. Get the latest block from the Thor network. + const latestBlock = ( + await RetrieveExpandedBlock.of(Revision.BEST).askTo(transport) + ).response; + if (latestBlock === undefined || latestBlock === null) + throw new Error( + 'Failed to retrieve latest block from Thor network.' + ); + + // 5. Prepare the transaction request. + const transactionRequest = originWallet.prepareTransactionRequest({ + to: Address.of(TRANSACTION_RECEIVER.address), + value: Quantity.of(1000), + blockRef: BlockRef.of(latestBlock.id), + chainTag: SOLO_NETWORK.chainTag, + expiration: 32, + gas: 21000, + nonce: 5, + gasPriceCoef: 0, + isIntendedToBeSponsored: true + }); + + // 6. The transaction's origin/sender signs the transaction request. + // VIEM specifications require signing methods return a hex expression + // of transaction RLP encoded. + // `RLPCodec.decode` is used to decode the hex expression + // into a SignedTransactionRequest. + const signedTransactionRequest = RLPCodec.decode( + (await originWallet.signTransaction(transactionRequest)).bytes + ) as SignedTransactionRequest; + + // 7. The transaction's gas-payer signs and sends the signed transaction request.' + const transactionId = await gasPayerWallet.sendTransaction( + signedTransactionRequest + ); + + // 8. Print the transaction ID. + console.log(transactionId.toString()); + }); + + test('UNIT DEMO', async () => { + // 0. Mock best block and THOR URL. + const mockBlockRef = BlockRef.of('0x1234567890abcdef'); + const mockUrl = new URL('https://mock-url'); + + // 1. Mock the transport layer to THOR. + const transport = mockHttpClient( + { + id: '0x712c8e7985d53c36a2acafacc45b3cb225a03dd9fd7d764dd92c49b8b751de65' + }, + 'post' + ); + + // 2. Create the transaction's origin account (the signer authority) and wallet. + const originAccount = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const originWallet = createWalletClient({ + network: mockUrl, + transport, + account: originAccount + }); + + // 3. Create the transaction's gas-payer account (the signer authority) and wallet. + const gasPayerAccount = privateKeyToAccount( + `0x${TRANSACTION_RECEIVER.privateKey}` + ); + const gasPayerWallet = createWalletClient({ + network: mockUrl, + transport, + account: gasPayerAccount + }); + + // 5. Prepare the transaction request. + const transactionRequest = originWallet.prepareTransactionRequest({ + to: Address.of(TRANSACTION_RECEIVER.address), + value: Quantity.of(1000), + blockRef: mockBlockRef, + chainTag: SOLO_NETWORK.chainTag, + expiration: 32, + gas: 21000, + nonce: 5, + gasPriceCoef: 0, + isIntendedToBeSponsored: true + }); + + // 6. The transaction's origin/sender signs the transaction request. + // VIEM specifications require signing methods return a hex expression + // of transaction RLP encoded. + // `RLPCodec.decode` is used to decode the hex expression + // into a SignedTransactionRequest. + const signedTransactionRequest = RLPCodec.decode( + (await originWallet.signTransaction(transactionRequest)).bytes + ) as SignedTransactionRequest; + + // 7. The transaction's gas-payer signs and sends the signed transaction request.' + const transactionId = await gasPayerWallet.sendTransaction( + signedTransactionRequest + ); + + // 8. Print the transaction ID. + console.log(transactionId.toString()); + }); +}); diff --git a/packages/sdk/tests/viem/clients/WalletClient.unit.test.ts b/packages/sdk/tests/viem/clients/WalletClient.unit.test.ts index 32a4f1398..ebbf6f52e 100644 --- a/packages/sdk/tests/viem/clients/WalletClient.unit.test.ts +++ b/packages/sdk/tests/viem/clients/WalletClient.unit.test.ts @@ -1,20 +1,22 @@ -import type { RegularBlockResponseJSON } from '@thor/thorest/json'; import { - ClauseBuilder, - Transaction, - type TransactionBody + Clause, + SignedTransactionRequest, + ThorError, + TransactionRequest } from '@thor/thorest'; -import { Address, BlockRef, Hex, HexUInt } from '@common/vcdm'; +import { Address, BlockRef, Hex, HexUInt, Quantity } from '@common/vcdm'; import { SOLO_NETWORK } from '@thor/utils'; import { TEST_ACCOUNTS } from '../../fixture'; import { privateKeyToAccount } from 'viem/accounts'; -import { expect } from '@jest/globals'; +import { describe, expect, test } from '@jest/globals'; import { createWalletClient, - type PrepareTransactionRequestRequest + type PrepareTransactionRequestRequest, + WalletClient } from '@viem/clients'; import { mockHttpClient } from '../../MockHttpClient'; -import { log } from '@common/logging'; +import { PrivateKeySigner, RLPCodec } from '@thor/thorest/signer'; +import { IllegalArgumentError, UnsupportedOperationError } from '@common'; const { TRANSACTION_SENDER, TRANSACTION_RECEIVER } = TEST_ACCOUNTS.TRANSACTION; @@ -24,48 +26,121 @@ const MOCK_URL = new URL('https://mock-url'); * @group unit/clients */ describe('WalletClient UNIT tests', () => { + const mockBlockRef = BlockRef.of('0x1234567890abcdef'); + const mockGas = 21000n; + const mockValue = Quantity.of(1000); + + const mockOriginSigner = new PrivateKeySigner( + HexUInt.of(TRANSACTION_SENDER.privateKey).bytes + ); + + const mockGasPayerSigner = new PrivateKeySigner( + HexUInt.of(TRANSACTION_RECEIVER.privateKey).bytes + ); + + describe('constructor', () => { + test('ok <- create WalletClient with account', () => { + const account = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const walletClient = new WalletClient( + MOCK_URL, + mockHttpClient({}, 'post'), + account + ); + expect(walletClient).toBeInstanceOf(WalletClient); + expect(walletClient.getAddresses()).toHaveLength(1); + }); + + test('ok <- create WalletClient without account', () => { + const walletClient = new WalletClient( + MOCK_URL, + mockHttpClient({}, 'post'), + null + ); + expect(walletClient).toBeInstanceOf(WalletClient); + expect(walletClient.getAddresses()).toHaveLength(0); + }); + }); + + describe('createWalletClient', () => { + test('ok <- create WalletClient with provided transport', () => { + const account = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const transport = mockHttpClient({}, 'post'); + const walletClient = createWalletClient({ + network: MOCK_URL, + transport, + account + }); + expect(walletClient).toBeInstanceOf(WalletClient); + }); + + test('ok <- create WalletClient with default transport', () => { + const account = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const walletClient = createWalletClient({ + network: MOCK_URL, + account + }); + expect(walletClient).toBeInstanceOf(WalletClient); + }); + + test('ok <- create WalletClient without account', () => { + const walletClient = createWalletClient({ + network: MOCK_URL + }); + expect(walletClient).toBeInstanceOf(WalletClient); + expect(walletClient.getAddresses()).toHaveLength(0); + }); + }); + + describe('getAddresses', () => { + test('ok <- return account address when account is set', () => { + const account = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const walletClient = createWalletClient({ + network: MOCK_URL, + transport: mockHttpClient({}, 'post'), + account + }); + const addresses = walletClient.getAddresses(); + expect(addresses).toHaveLength(1); + expect(addresses[0].toString().toLowerCase()).toBe( + account.address.toLowerCase() + ); + }); + + test('ok <- return empty array when account is null', () => { + const walletClient = createWalletClient({ + network: MOCK_URL, + transport: mockHttpClient({}, 'post') + }); + const addresses = walletClient.getAddresses(); + expect(addresses).toHaveLength(0); + }); + }); + describe('prepareTransactionRequest', () => { - test('ok <- thor and viem equivalence', () => { - const latestBlock = { - number: 88, - id: '0x00000058f9f240032e073f4a078c5f0f3e04ae7272e4550de41f10723d6f8b2e', - size: 364, - parentID: - '0x000000577127e6426fbe5a303755ba64c167f173bb4e9b60156a62bced1551d8', - timestamp: 1749224420, - gasLimit: '150000000', - beneficiary: '0xf077b491b355e64048ce21e3a6fc4751eeea77fa', - gasUsed: '0', - totalScore: 88, - txsRoot: - '0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0', - txsFeatures: 1, - stateRoot: - '0xe030c534b66bd1c1b156ada9508bd639cdcbeb7ea1e932f4fd998857b3c4f30a', - receiptsRoot: - '0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0', - com: false, - signer: '0xf077b491b355e64048ce21e3a6fc4751eeea77fa', - isTrunk: true, - isFinalized: false, - baseFeePerGas: '0x9184e72a000', - transactions: [] - } satisfies RegularBlockResponseJSON; - const transferClause = ClauseBuilder.transferVET( - Address.of(TRANSACTION_RECEIVER.address), - 1n - ); - const txBody: TransactionBody = { + test('ok <- test Viem PrepareTransactionRequestRequest and Thor TransactionRequest equivalence', () => { + const expected = new TransactionRequest({ + blockRef: mockBlockRef, chainTag: SOLO_NETWORK.chainTag, - blockRef: BlockRef.of(latestBlock.id).toString(), - expiration: 32, - clauses: [transferClause], - gasPriceCoef: 0, - gas: 100000, + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: mockValue.toString() + }) + ], dependsOn: null, - nonce: 8 - }; - const expected = Transaction.of(txBody); + expiration: 32, + gas: mockGas, + gasPriceCoef: 0n, + nonce: 3 + }); const account = privateKeyToAccount( `0x${TRANSACTION_SENDER.privateKey}` @@ -76,73 +151,113 @@ describe('WalletClient UNIT tests', () => { account }); const request: PrepareTransactionRequestRequest = { - to: Address.of(transferClause.to as string), - value: Hex.of(transferClause.value), - blockRef: Hex.of(txBody.blockRef), - chainTag: txBody.chainTag, - expiration: txBody.expiration, - gas: txBody.gas as number, - nonce: txBody.nonce, - gasPriceCoef: 0 + to: expected.clauses[0].to as Address, + value: HexUInt.of(expected.clauses[0].value), + blockRef: expected.blockRef, + chainTag: expected.chainTag, + expiration: expected.expiration, + gas: HexUInt.of(expected.gas), + nonce: expected.nonce, + gasPriceCoef: Number(expected.gasPriceCoef) } satisfies PrepareTransactionRequestRequest; const actual = walletClient.prepareTransactionRequest(request); - expect(actual.encoded).toEqual(expected.encoded); + expect(actual.toJSON()).toEqual(expected.toJSON()); }); - }); - describe('signTransaction', () => { - test('ok <- thor and viem equivalence', async () => { - const latestBlock = { - number: 88, - id: '0x00000058f9f240032e073f4a078c5f0f3e04ae7272e4550de41f10723d6f8b2e', - size: 364, - parentID: - '0x000000577127e6426fbe5a303755ba64c167f173bb4e9b60156a62bced1551d8', - timestamp: 1749224420, - gasLimit: '150000000', - beneficiary: '0xf077b491b355e64048ce21e3a6fc4751eeea77fa', - gasUsed: '0', - totalScore: 88, - txsRoot: - '0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0', - txsFeatures: 1, - stateRoot: - '0xe030c534b66bd1c1b156ada9508bd639cdcbeb7ea1e932f4fd998857b3c4f30a', - receiptsRoot: - '0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0', - com: false, - signer: '0xf077b491b355e64048ce21e3a6fc4751eeea77fa', - isTrunk: true, - isFinalized: false, - baseFeePerGas: '0x9184e72a000', - transactions: [] - } satisfies RegularBlockResponseJSON; - const transferClause = ClauseBuilder.transferVET( - Address.of(TRANSACTION_RECEIVER.address), - 1n - ); - const txBody: TransactionBody = { + test('ok <- prepare transaction request with numeric value', () => { + const account = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const walletClient = createWalletClient({ + network: MOCK_URL, + transport: mockHttpClient({}, 'post'), + account + }); + + const request: PrepareTransactionRequestRequest = { + to: Address.of(TRANSACTION_RECEIVER.address), + value: 1000, + blockRef: mockBlockRef, + chainTag: SOLO_NETWORK.chainTag, + expiration: 32, + gas: 21000, + nonce: 3, + gasPriceCoef: 0 + }; + + const result = walletClient.prepareTransactionRequest(request); + expect(result).toBeInstanceOf(TransactionRequest); + expect(result.clauses[0].value).toBe(1000n); + }); + + test('ok <- prepare transaction request with optional fields', () => { + const account = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const walletClient = createWalletClient({ + network: MOCK_URL, + transport: mockHttpClient({}, 'post'), + account + }); + + const dependsOn = Hex.of('0xabcdef1234567890'); + const data = Hex.of('0x1234'); + const comment = 'test comment'; + const abi = Hex.of('0x5678'); + + const request: PrepareTransactionRequestRequest = { + to: Address.of(TRANSACTION_RECEIVER.address), + value: HexUInt.of(1000), + data, + comment, + abi, + blockRef: mockBlockRef, chainTag: SOLO_NETWORK.chainTag, - blockRef: latestBlock.id.toString().slice(0, 18), + dependsOn, expiration: 32, - clauses: [transferClause], + gas: HexUInt.of(21000), + nonce: 3, gasPriceCoef: 0, - gas: 100000, - dependsOn: null, - nonce: 8 + isIntendedToBeSponsored: true }; - const signedTx = Transaction.of(txBody).sign( - HexUInt.of(TRANSACTION_SENDER.privateKey).bytes + const result = walletClient.prepareTransactionRequest(request); + expect(result).toBeInstanceOf(TransactionRequest); + expect(result.dependsOn).toEqual(dependsOn); + expect(result.isIntendedToBeSponsored).toBe(true); + expect(result.clauses[0].data).toEqual(HexUInt.of(data)); + expect(result.clauses[0].comment).toBe(comment); + }); + + test('err <- throw UnsupportedOperationError for invalid request', () => { + const account = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` ); - const thorSigned = HexUInt.of(signedTx.encoded); - log({ - verbosity: 'debug', - message: 'thorSigned', - source: 'WalletClient.unit.test', - context: { thorSigned: thorSigned.toString() } + const walletClient = createWalletClient({ + network: MOCK_URL, + transport: mockHttpClient({}, 'post'), + account }); + // Create an invalid request that will cause an error during processing + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const invalidRequest = { + value: 'invalid', // This should cause an error + blockRef: mockBlockRef, + chainTag: SOLO_NETWORK.chainTag, + expiration: 32, + gas: 21000, + nonce: 3, + gasPriceCoef: 0 + } as unknown as PrepareTransactionRequestRequest; + + expect(() => + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + walletClient.prepareTransactionRequest(invalidRequest) + ).toThrow(UnsupportedOperationError); + }); + + test('ok <- handle transaction request with all optional fields as undefined', () => { const account = privateKeyToAccount( `0x${TRANSACTION_SENDER.privateKey}` ); @@ -151,15 +266,373 @@ describe('WalletClient UNIT tests', () => { transport: mockHttpClient({}, 'post'), account }); - const tx = Transaction.of(txBody); - const signedViem = await walletClient.signTransaction(tx); - log({ - verbosity: 'debug', - message: 'signedViem', - source: 'WalletClient.unit.test', - context: { signedViem: signedViem.toString() } + + const request: PrepareTransactionRequestRequest = { + value: 1000, + blockRef: mockBlockRef, + chainTag: SOLO_NETWORK.chainTag, + expiration: 32, + gas: 21000, + nonce: 3, + gasPriceCoef: 0 + // All optional fields are undefined + }; + + const result = walletClient.prepareTransactionRequest(request); + expect(result).toBeInstanceOf(TransactionRequest); + expect(result.clauses[0].to).toBeNull(); + expect(result.dependsOn).toBeNull(); + expect(result.isIntendedToBeSponsored).toBe(false); + }); + }); + + describe('sendTransaction', () => { + test('ok <- send transaction from PrepareTransactionRequestRequest', async () => { + const expected = { + id: Hex.of( + '0x0000000000000000000000000000000000000000000000001234567890abcdef' + ) + }; + const transport = mockHttpClient(expected, 'post'); + + const account = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const walletClient = createWalletClient({ + network: MOCK_URL, + transport, + account + }); + + const request: PrepareTransactionRequestRequest = { + to: Address.of(TRANSACTION_RECEIVER.address), + value: 1000, + blockRef: mockBlockRef, + chainTag: SOLO_NETWORK.chainTag, + expiration: 32, + gas: 21000, + nonce: 3, + gasPriceCoef: 0 + }; + + const actual = await walletClient.sendTransaction(request); + expect(actual.toString()).toEqual(expected.id.toString()); + }); + + test('ok <- send transaction from SignedTransactionRequest', async () => { + const expected = { + id: Hex.of( + '0x0000000000000000000000000000000000000000000000001234567890abcdef' + ) + }; + const transport = mockHttpClient(expected, 'post'); + + const gasPayerAccount = privateKeyToAccount( + `0x${TRANSACTION_RECEIVER.privateKey}` + ); + const gasPayerWallet = createWalletClient({ + network: MOCK_URL, + transport, + account: gasPayerAccount + }); + + // Create a signed transaction request that is intended to be sponsored + const signedTxRequest = mockOriginSigner.sign( + new TransactionRequest({ + blockRef: mockBlockRef, + chainTag: 1, + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: '1000' + }) + ], + dependsOn: null, + expiration: 32, + gas: mockGas, + gasPriceCoef: 0n, + nonce: 3, + isIntendedToBeSponsored: true + }) + ); + + const actual = + await gasPayerWallet.sendTransaction(signedTxRequest); + expect(actual.toString()).toEqual(expected.id.toString()); + }); + + test('err <- throw UnsupportedOperationError when account is not set', async () => { + const walletClient = createWalletClient({ + network: MOCK_URL, + transport: mockHttpClient({}, 'post') + }); + + const request: PrepareTransactionRequestRequest = { + to: Address.of(TRANSACTION_RECEIVER.address), + value: 1000, + blockRef: mockBlockRef, + chainTag: SOLO_NETWORK.chainTag, + expiration: 32, + gas: 21000, + nonce: 3, + gasPriceCoef: 0 + }; + + await expect(walletClient.sendTransaction(request)).rejects.toThrow( + UnsupportedOperationError + ); + }); + }); + + describe('sendRawTransaction', () => { + test('ok <- send raw transaction successfully', async () => { + const expected = { + id: Hex.of( + '0x0000000000000000000000000000000000000000000000001234567890abcdef' + ) + }; + const transport = mockHttpClient(expected, 'post'); + + const account = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const walletClient = createWalletClient({ + network: MOCK_URL, + transport, + account + }); + + const rawTx = Hex.of('0x1234567890abcdef'); + const actual = await walletClient.sendRawTransaction(rawTx); + + expect(actual.toString()).toEqual(expected.id.toString()); + }); + + test('err <- throw ThorError when sending raw transaction fails', async () => { + const transport = mockHttpClient(null, 'post', true); // Mock error + + const account = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const walletClient = createWalletClient({ + network: MOCK_URL, + transport, + account + }); + + const rawTx = Hex.of('0x1234567890abcdef'); + await expect( + walletClient.sendRawTransaction(rawTx) + ).rejects.toThrow(ThorError); + }); + }); + + describe('signTransaction', () => { + test('ok <- sign a not-sponsored unsigned transaction request', async () => { + const txRequest = new TransactionRequest({ + blockRef: mockBlockRef, + chainTag: SOLO_NETWORK.chainTag, + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: mockValue.toString() + }) + ], + dependsOn: null, + expiration: 32, + gas: mockGas, + gasPriceCoef: 0n, + nonce: 3 + }); + const expected = mockOriginSigner.sign(txRequest); + const originAccount = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const originWallet = createWalletClient({ + network: MOCK_URL, + transport: mockHttpClient({}, 'post'), + account: originAccount + }); + const encoded = await originWallet.signTransaction(txRequest); + const actual = RLPCodec.decode(encoded.bytes); + expect(actual.toJSON()).toEqual(expected.toJSON()); + }); + + test('ok <- sign a sponsored unsigned transaction request', async () => { + const txRequest = new TransactionRequest({ + blockRef: mockBlockRef, + chainTag: 1, + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: mockValue.toString() + }) + ], + dependsOn: null, + expiration: 32, + gas: mockGas, + gasPriceCoef: 0n, + nonce: 3, + isIntendedToBeSponsored: true + }); + const expected = mockOriginSigner.sign(txRequest); + + const originAccount = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const originWallet = createWalletClient({ + network: MOCK_URL, + transport: mockHttpClient({}, 'post'), + account: originAccount + }); + const actual = RLPCodec.decode( + (await originWallet.signTransaction(txRequest)).bytes + ); + expect(actual.toJSON()).toEqual(expected.toJSON()); + }); + + test('ok <- sponsor a sponsored signed transaction request', async () => { + const txRequest = new TransactionRequest({ + blockRef: mockBlockRef, + chainTag: 1, + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: mockValue.toString() + }) + ], + dependsOn: null, + expiration: 32, + gas: mockGas, + gasPriceCoef: 0n, + nonce: 3, + isIntendedToBeSponsored: true + }); + const expected = mockGasPayerSigner.sign( + mockOriginSigner.sign(txRequest) + ); + + const originAccount = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const originWallet = createWalletClient({ + network: MOCK_URL, + transport: mockHttpClient({}, 'post'), + account: originAccount + }); + const signedTxRequest = RLPCodec.decode( + (await originWallet.signTransaction(txRequest)).bytes + ); + + const gasPayerAccount = privateKeyToAccount( + `0x${TRANSACTION_RECEIVER.privateKey}` + ); + const gasPayerWallet = createWalletClient({ + network: MOCK_URL, + transport: mockHttpClient({}, 'post'), + account: gasPayerAccount + }); + const actual = RLPCodec.decode( + (await gasPayerWallet.signTransaction(signedTxRequest)).bytes + ); + expect(actual.toJSON()).toEqual(expected.toJSON()); + }); + + test('err <- throw UnsupportedOperationError when account is null', async () => { + const walletClient = createWalletClient({ + network: MOCK_URL, + transport: mockHttpClient({}, 'post') + }); + + const txRequest = new TransactionRequest({ + blockRef: mockBlockRef, + chainTag: SOLO_NETWORK.chainTag, + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: '1000' + }) + ], + dependsOn: null, + expiration: 32, + gas: mockGas, + gasPriceCoef: 0n, + nonce: 3 + }); + + await expect( + walletClient.signTransaction(txRequest) + ).rejects.toThrow(UnsupportedOperationError); + }); + + test('err <- throw UnsupportedOperationError when account has not sign method', async () => { + // Create a mock account without sign method + const invalidAccount = { + address: TRANSACTION_SENDER.address, + type: 'local' as const + }; + + const walletClient = new WalletClient( + MOCK_URL, + mockHttpClient({}, 'post'), + invalidAccount as unknown + ); + + const txRequest = new TransactionRequest({ + blockRef: mockBlockRef, + chainTag: SOLO_NETWORK.chainTag, + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: '1000' + }) + ], + dependsOn: null, + expiration: 32, + gas: mockGas, + gasPriceCoef: 0n, + nonce: 3 + }); + + await expect( + walletClient.signTransaction(txRequest) + ).rejects.toThrow(UnsupportedOperationError); + }); + + test('err <- throw IllegalArgumentError when SignedTransactionRequest is not intended to be sponsored', async () => { + const originAccount = privateKeyToAccount( + `0x${TRANSACTION_SENDER.privateKey}` + ); + const originWallet = createWalletClient({ + network: MOCK_URL, + transport: mockHttpClient({}, 'post'), + account: originAccount + }); + + // Create a signed transaction request that is not intended to be sponsored + const signedTxRequest = new SignedTransactionRequest({ + blockRef: mockBlockRef, + chainTag: 1, + clauses: [ + Clause.of({ + to: TRANSACTION_RECEIVER.address, + value: '1000' + }) + ], + dependsOn: null, + expiration: 32, + gas: mockGas, + gasPriceCoef: 0n, + nonce: 3, + isIntendedToBeSponsored: false, + origin: Address.of(TRANSACTION_SENDER.address), + originSignature: new Uint8Array(65), + signature: new Uint8Array(65) }); - expect(signedViem.toString()).toEqual(thorSigned.toString()); + + await expect( + originWallet.signTransaction(signedTxRequest) + ).rejects.toThrow(IllegalArgumentError); }); }); });