diff --git a/src/client/api.ts b/src/client/api.ts index a59e6c7c..a5628337 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -519,6 +519,12 @@ export interface CreateTransferRequest { * @memberof CreateTransferRequest */ 'destination': string; + /** + * Whether the transfer uses sponsored gas + * @type {boolean} + * @memberof CreateTransferRequest + */ + 'gasless'?: boolean; } /** * @@ -832,56 +838,6 @@ export interface ModelError { */ 'message': string; } -/** - * The native eth staking context. - * @export - * @interface NativeEthStakingContext - */ -export interface NativeEthStakingContext { - /** - * - * @type {Balance} - * @memberof NativeEthStakingContext - */ - 'stakeable_balance': Balance; - /** - * - * @type {Balance} - * @memberof NativeEthStakingContext - */ - 'unstakeable_balance': Balance; - /** - * - * @type {Balance} - * @memberof NativeEthStakingContext - */ - 'claimable_balance': Balance; -} -/** - * The partial eth staking context. - * @export - * @interface PartialEthStakingContext - */ -export interface PartialEthStakingContext { - /** - * - * @type {Balance} - * @memberof PartialEthStakingContext - */ - 'stakeable_balance': Balance; - /** - * - * @type {Balance} - * @memberof PartialEthStakingContext - */ - 'unstakeable_balance': Balance; - /** - * - * @type {Balance} - * @memberof PartialEthStakingContext - */ - 'claimable_balance': Balance; -} /** * An event representing a seed creation. * @export @@ -1171,6 +1127,66 @@ export interface SignedVoluntaryExitMessageMetadata { */ 'signed_voluntary_exit': string; } +/** + * An onchain sponsored gasless send. + * @export + * @interface SponsoredSend + */ +export interface SponsoredSend { + /** + * The onchain address of the recipient + * @type {string} + * @memberof SponsoredSend + */ + 'to_address_id': string; + /** + * The raw typed data for the sponsored send + * @type {string} + * @memberof SponsoredSend + */ + 'raw_typed_data': string; + /** + * The typed data hash for the sponsored send. This is the typed data hash that needs to be signed by the sender. + * @type {string} + * @memberof SponsoredSend + */ + 'typed_data_hash': string; + /** + * The signed hash of the sponsored send typed data. + * @type {string} + * @memberof SponsoredSend + */ + 'signature'?: string; + /** + * The hash of the onchain sponsored send transaction + * @type {string} + * @memberof SponsoredSend + */ + 'transaction_hash'?: string; + /** + * The link to view the transaction on a block explorer. This is optional and may not be present for all transactions. + * @type {string} + * @memberof SponsoredSend + */ + 'transaction_link'?: string; + /** + * The status of the sponsored send + * @type {string} + * @memberof SponsoredSend + */ + 'status': SponsoredSendStatusEnum; +} + +export const SponsoredSendStatusEnum = { + Pending: 'pending', + Signed: 'signed', + Submitted: 'submitted', + Complete: 'complete', + Failed: 'failed' +} as const; + +export type SponsoredSendStatusEnum = typeof SponsoredSendStatusEnum[keyof typeof SponsoredSendStatusEnum]; + /** * Context needed to perform a staking operation * @export @@ -1185,11 +1201,30 @@ export interface StakingContext { 'context': StakingContextContext; } /** - * @type StakingContextContext + * * @export + * @interface StakingContextContext */ -export type StakingContextContext = NativeEthStakingContext | PartialEthStakingContext; - +export interface StakingContextContext { + /** + * + * @type {Balance} + * @memberof StakingContextContext + */ + 'stakeable_balance': Balance; + /** + * + * @type {Balance} + * @memberof StakingContextContext + */ + 'unstakeable_balance': Balance; + /** + * + * @type {Balance} + * @memberof StakingContextContext + */ + 'claimable_balance': Balance; +} /** * A list of onchain transactions to help realize a staking action. * @export @@ -1576,7 +1611,13 @@ export interface Transfer { * @type {Transaction} * @memberof Transfer */ - 'transaction': Transaction; + 'transaction'?: Transaction; + /** + * + * @type {SponsoredSend} + * @memberof Transfer + */ + 'sponsored_send'?: SponsoredSend; /** * The unsigned payload of the transfer. This is the payload that needs to be signed by the sender. * @type {string} @@ -1601,6 +1642,12 @@ export interface Transfer { * @memberof Transfer */ 'status'?: TransferStatusEnum; + /** + * Whether the transfer uses sponsored gas + * @type {boolean} + * @memberof Transfer + */ + 'gasless': boolean; } export const TransferStatusEnum = { diff --git a/src/coinbase/address/wallet_address.ts b/src/coinbase/address/wallet_address.ts index ac3ee4f4..27b1c5d8 100644 --- a/src/coinbase/address/wallet_address.ts +++ b/src/coinbase/address/wallet_address.ts @@ -148,6 +148,7 @@ export class WalletAddress extends Address { * @param options.destination - The destination of the transfer. If a Wallet, sends to the Wallet's default address. If a String, interprets it as the address ID. * @param options.timeoutSeconds - The maximum amount of time to wait for the Transfer to complete, in seconds. * @param options.intervalSeconds - The interval at which to poll the Network for Transfer status, in seconds. + * @param options.gasless - Whether the Transfer should be gasless. Defaults to false. * @returns The transfer object. * @throws {APIError} if the API request to create a Transfer fails. * @throws {APIError} if the API request to broadcast a Transfer fails. @@ -159,6 +160,7 @@ export class WalletAddress extends Address { destination, timeoutSeconds = 10, intervalSeconds = 0.2, + gasless = false, }: CreateTransferOptions): Promise { if (!Coinbase.useServerSigner && !this.key) { throw new InternalError("Cannot transfer from address without private key loaded"); @@ -180,6 +182,7 @@ export class WalletAddress extends Address { network_id: destinationNetworkId, asset_id: asset.primaryDenomination(), destination: destinationAddress, + gasless: gasless, }; let response = await Coinbase.apiClients.transfer!.createTransfer( @@ -192,22 +195,9 @@ export class WalletAddress extends Address { if (!Coinbase.useServerSigner) { const wallet = new ethers.Wallet(this.key!.privateKey); - const transaction = transfer.getTransaction(); - let signedPayload = await wallet!.signTransaction(transaction); - signedPayload = signedPayload.slice(2); - - const broadcastTransferRequest = { - signed_payload: signedPayload, - }; - - response = await Coinbase.apiClients.transfer!.broadcastTransfer( - this.getWalletId(), - this.getId(), - transfer.getId(), - broadcastTransferRequest, - ); + await transfer.sign(wallet); - transfer = Transfer.fromModel(response.data); + transfer = await transfer.broadcast(); } const startTime = Date.now(); diff --git a/src/coinbase/errors.ts b/src/coinbase/errors.ts index 46ea85b9..0e8e4c3b 100644 --- a/src/coinbase/errors.ts +++ b/src/coinbase/errors.ts @@ -97,3 +97,23 @@ export class InvalidUnsignedPayload extends Error { } } } + +/** + * AlreadySignedError is thrown when a resource is already signed. + */ +export class AlreadySignedError extends Error { + static DEFAULT_MESSAGE = "Resource already signed"; + + /** + * Initializes a new AlreadySignedError instance. + * + * @param message - The error message. + */ + constructor(message: string = AlreadySignedError.DEFAULT_MESSAGE) { + super(message); + this.name = "AlreadySignedError"; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, AlreadySignedError); + } + } +} diff --git a/src/coinbase/sponsored_send.ts b/src/coinbase/sponsored_send.ts new file mode 100644 index 00000000..a9221f07 --- /dev/null +++ b/src/coinbase/sponsored_send.ts @@ -0,0 +1,126 @@ +import { ethers } from "ethers"; +import { SponsoredSend as SponsoredSendModel } from "../client/api"; +import { SponsoredSendStatus } from "./types"; + +/** + * A representation of an onchain Sponsored Send. + */ +export class SponsoredSend { + private model: SponsoredSendModel; + + /** + * Sponsored Sends should be constructed via higher level abstractions like Transfer. + * + * @class + * @param model - The underlying Sponsored Send object. + */ + constructor(model: SponsoredSendModel) { + if (!model) { + throw new Error("Invalid model type"); + } + this.model = model; + } + + /** + * Returns the Keccak256 hash of the typed data. This payload must be signed + * by the sender to be used as an approval in the EIP-3009 transaction. + * + * @returns The Keccak256 hash of the typed data. + */ + getTypedDataHash(): string { + return this.model.typed_data_hash; + } + + /** + * Returns the signature of the typed data. + * + * @returns The Keccak256 hash of the typed data. + */ + getSignature(): string | undefined { + return this.model.signature; + } + + /** + * Signs the Sponsored Send with the provided key and returns the hex signature. + * + * @param key - The key to sign the Sponsored Send with + * @returns The hex-encoded signature + */ + async sign(key: ethers.Wallet) { + ethers.toBeArray; + const signature = key.signingKey.sign(ethers.getBytes(this.getTypedDataHash())).serialized; + this.model.signature = signature; + // Removes the '0x' prefix as required by the API. + return signature.slice(2); + } + + /** + * Returns whether the Sponsored Send has been signed. + * + * @returns if the Sponsored Send has been signed. + */ + isSigned(): boolean { + return this.getSignature() ? true : false; + } + + /** + * Returns the Status of the Sponsored Send. + * + * @returns the Status of the Sponsored Send + */ + getStatus(): SponsoredSendStatus | undefined { + switch (this.model.status) { + case SponsoredSendStatus.PENDING: + return SponsoredSendStatus.PENDING; + case SponsoredSendStatus.SIGNED: + return SponsoredSendStatus.SIGNED; + case SponsoredSendStatus.SUBMITTED: + return SponsoredSendStatus.SUBMITTED; + case SponsoredSendStatus.COMPLETE: + return SponsoredSendStatus.COMPLETE; + case SponsoredSendStatus.FAILED: + return SponsoredSendStatus.FAILED; + default: + undefined; + } + } + + /** + * Returns whether the Sponsored Send is in a terminal State. + * + * @returns Whether the Sponsored Send is in a terminal State + */ + isTerminalState(): boolean { + const status = this.getStatus(); + return !status + ? false + : [SponsoredSendStatus.COMPLETE, SponsoredSendStatus.FAILED].includes(status); + } + + /** + * Returns the Transaction Hash of the Sponsored Send. + * + * @returns The Transaction Hash + */ + getTransactionHash(): string | undefined { + return this.model.transaction_hash; + } + + /** + * Returns the link to the Sponsored Send on the blockchain explorer. + * + * @returns The link to the Sponsored Send on the blockchain explorer + */ + getTransactionLink(): string | undefined { + return this.model.transaction_link; + } + + /** + * Returns a string representation of the Sponsored Send. + * + * @returns A string representation of the Sponsored Send + */ + toString(): string { + return `SponsoredSend { transactionHash: '${this.getTransactionHash()}', status: '${this.getStatus()}', typedDataHash: '${this.getTypedDataHash()}', signature: ${this.getSignature()}, transactionLink: ${this.getTransactionLink()} }`; + } +} diff --git a/src/coinbase/transaction.ts b/src/coinbase/transaction.ts index c8ac6b78..8cc31f7f 100644 --- a/src/coinbase/transaction.ts +++ b/src/coinbase/transaction.ts @@ -9,7 +9,6 @@ import { parseUnsignedPayload } from "./utils"; export class Transaction { private model: TransactionModel; private raw?: ethers.Transaction; - private signed: boolean | undefined; /** * Transactions should be constructed via higher level abstractions like Trade or Transfer. @@ -136,18 +135,26 @@ export class Transaction { async sign(key: ethers.Wallet) { const signedPayload = await key!.signTransaction(this.rawTransaction()); this.model.signed_payload = signedPayload; - this.signed = true; // Removes the '0x' prefix as required by the API. return signedPayload.slice(2); } + /** + * Returns the Signed Payload of the Transaction. + * + * @returns The Signed Payload + */ + getSignature(): string | undefined { + return this.getSignedPayload(); + } + /** * Returns whether the transaction has been signed. * * @returns if the transaction has been signed. */ - isSigned(): boolean | undefined { - return this.signed; + isSigned(): boolean { + return this.getSignature() ? true : false; } /** diff --git a/src/coinbase/transfer.ts b/src/coinbase/transfer.ts index fd2dc1d7..0a350ed7 100644 --- a/src/coinbase/transfer.ts +++ b/src/coinbase/transfer.ts @@ -1,10 +1,11 @@ import { Decimal } from "decimal.js"; -import { TransferStatus } from "./types"; +import { TransactionStatus, SponsoredSendStatus, TransferStatus } from "./types"; +import { Transaction } from "./transaction"; +import { SponsoredSend } from "./sponsored_send"; import { Coinbase } from "./coinbase"; import { Transfer as TransferModel } from "../client/api"; import { ethers } from "ethers"; import { InternalError } from "./errors"; -import { parseUnsignedPayload } from "./utils"; /** * A representation of a Transfer, which moves an Amount of an Asset from @@ -13,7 +14,6 @@ import { parseUnsignedPayload } from "./utils"; */ export class Transfer { private model: TransferModel; - private transaction?: ethers.Transaction; /** * Private constructor to prevent direct instantiation outside of the factory methods. @@ -103,31 +103,13 @@ export class Transfer { return amount.dividedBy(new Decimal(10).pow(this.model.asset.decimals!)); } - /** - * Returns the Unsigned Payload of the Transfer. - * - * @returns The Unsigned Payload as a Hex string. - */ - public getUnsignedPayload(): string { - return this.model.transaction.unsigned_payload; - } - - /** - * Returns the Signed Payload of the Transfer. - * - * @returns The Signed Payload as a Hex string, or undefined if not yet available. - */ - public getSignedPayload(): string | undefined { - return this.model.transaction.signed_payload; - } - /** * Returns the Transaction Hash of the Transfer. * * @returns The Transaction Hash as a Hex string, or undefined if not yet available. */ public getTransactionHash(): string | undefined { - return this.model.transaction.transaction_hash; + return this.getSendTransactionDelegate()?.getTransactionHash(); } /** @@ -136,33 +118,20 @@ export class Transfer { * @returns The ethers.js Transaction object. * @throws (InvalidUnsignedPayload) If the Unsigned Payload is invalid. */ - public getTransaction(): ethers.Transaction { - if (this.transaction) return this.transaction; - - const transaction = new ethers.Transaction(); - - const parsedPayload = parseUnsignedPayload(this.getUnsignedPayload()); - - transaction.chainId = BigInt(parsedPayload.chainId); - transaction.nonce = BigInt(parsedPayload.nonce); - transaction.maxPriorityFeePerGas = BigInt(parsedPayload.maxPriorityFeePerGas); - transaction.maxFeePerGas = BigInt(parsedPayload.maxFeePerGas); - transaction.gasLimit = BigInt(parsedPayload.gas); - transaction.to = parsedPayload.to; - transaction.value = BigInt(parsedPayload.value); - transaction.data = parsedPayload.input; - - this.transaction = transaction; - return transaction; + public getRawTransaction(): ethers.Transaction | undefined { + if (!this.getTransaction()) return undefined; + return this.getTransaction()!.rawTransaction(); } /** - * Sets the Signed Transaction of the Transfer. + * Signs the Transfer with the provided key and returns the hex signature + * required for broadcasting the Transfer. * - * @param transaction - The Signed Transaction. + * @param key - The key to sign the Transfer with + * @returns The hex-encoded signed payload */ - public setSignedTransaction(transaction: ethers.Transaction): void { - this.transaction = transaction; + async sign(key: ethers.Wallet): Promise { + return this.getSendTransactionDelegate()!.sign(key); } /** @@ -171,27 +140,90 @@ export class Transfer { * @returns The Status of the Transfer. */ public getStatus(): TransferStatus | undefined { - switch (this.model.transaction.status) { - case TransferStatus.PENDING: + switch (this.getSendTransactionDelegate()!.getStatus()!) { + case TransactionStatus.PENDING: + return TransferStatus.PENDING; + case SponsoredSendStatus.PENDING: return TransferStatus.PENDING; - case TransferStatus.BROADCAST: + case SponsoredSendStatus.SIGNED: + return TransferStatus.PENDING; + case TransactionStatus.BROADCAST: + return TransferStatus.BROADCAST; + case SponsoredSendStatus.SUBMITTED: return TransferStatus.BROADCAST; - case TransferStatus.COMPLETE: + case TransactionStatus.COMPLETE: + return TransferStatus.COMPLETE; + case SponsoredSendStatus.COMPLETE: return TransferStatus.COMPLETE; - case TransferStatus.FAILED: + case TransactionStatus.FAILED: + return TransferStatus.FAILED; + case SponsoredSendStatus.FAILED: return TransferStatus.FAILED; default: return undefined; } } + /** + * Returns the Transaction of the Transfer. + * + * @returns The Transaction + */ + public getTransaction(): Transaction | undefined { + if (!this.model.transaction) return undefined; + return new Transaction(this.model.transaction!); + } + + /** + * Returns the Sponsored Send of the Transfer. + * + * @returns The Sponsored Send + */ + public getSponsoredSend(): SponsoredSend | undefined { + if (!this.model.sponsored_send) return undefined; + return new SponsoredSend(this.model.sponsored_send!); + } + + /** + * Returns the Send Transaction Delegate of the Transfer. + * + * @returns Either the Transaction or the Sponsored Send + */ + public getSendTransactionDelegate(): Transaction | SponsoredSend | undefined { + return !this.getTransaction() ? this.getSponsoredSend() : this.getTransaction(); + } + /** * Returns the link to the Transaction on the blockchain explorer. * * @returns The link to the Transaction on the blockchain explorer. */ - public getTransactionLink(): string { - return `https://sepolia.basescan.org/tx/${this.getTransactionHash()}`; + public getTransactionLink(): string | undefined { + return this.getSendTransactionDelegate()?.getTransactionLink(); + } + + /** + * Broadcasts the Transfer to the Network. + * + * @returns The Transfer object + * @throws {APIError} if the API request to broadcast a Transfer fails. + */ + public async broadcast(): Promise { + if (!this.getSendTransactionDelegate()?.isSigned()) + throw new Error("Cannot broadcast unsigned Transfer"); + + const broadcastTransferRequest = { + signed_payload: this.getSendTransactionDelegate()!.getSignature()!.slice(2), + }; + + const response = await Coinbase.apiClients.transfer!.broadcastTransfer( + this.getWalletId(), + this.getFromAddressId(), + this.getId(), + broadcastTransferRequest, + ); + + return Transfer.fromModel(response.data); } /** diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index eb4c59f7..a2c9d56c 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -612,6 +612,17 @@ export enum TransactionStatus { FAILED = "failed", } +/** + * Sponsored Send status type definition. + */ +export enum SponsoredSendStatus { + PENDING = "pending", + SIGNED = "signed", + SUBMITTED = "submitted", + COMPLETE = "complete", + FAILED = "failed", +} + /** * The Wallet Data type definition. * The data required to recreate a Wallet. @@ -746,6 +757,7 @@ export type CreateTransferOptions = { destination: Destination; timeoutSeconds?: number; intervalSeconds?: number; + gasless?: boolean; }; /** diff --git a/src/tests/error_test.ts b/src/tests/error_test.ts index 6e4fa45f..fe6213ec 100644 --- a/src/tests/error_test.ts +++ b/src/tests/error_test.ts @@ -4,66 +4,79 @@ import { InvalidAPIKeyFormat, InvalidConfiguration, InvalidUnsignedPayload, + AlreadySignedError, } from "../coinbase/errors"; describe("Error Classes", () => { - test("InvalidAPIKeyFormat should have the correct message and name", () => { + it("InvalidAPIKeyFormat should have the correct message and name", () => { const error = new InvalidAPIKeyFormat(); expect(error.message).toBe(InvalidAPIKeyFormat.DEFAULT_MESSAGE); expect(error.name).toBe("InvalidAPIKeyFormat"); }); - test("InvalidAPIKeyFormat should accept a custom message", () => { + it("InvalidAPIKeyFormat should accept a custom message", () => { const customMessage = "Custom invalid API key format message"; const error = new InvalidAPIKeyFormat(customMessage); expect(error.message).toBe(customMessage); }); - test("ArgumentError should have the correct message and name", () => { + it("ArgumentError should have the correct message and name", () => { const error = new ArgumentError(); expect(error.message).toBe(ArgumentError.DEFAULT_MESSAGE); expect(error.name).toBe("ArgumentError"); }); - test("ArgumentError should accept a custom message", () => { + it("ArgumentError should accept a custom message", () => { const customMessage = "Custom argument error message"; const error = new ArgumentError(customMessage); expect(error.message).toBe(customMessage); }); - test("InternalError should have the correct message and name", () => { + it("InternalError should have the correct message and name", () => { const error = new InternalError(); expect(error.message).toBe(InternalError.DEFAULT_MESSAGE); expect(error.name).toBe("InternalError"); }); - test("InternalError should accept a custom message", () => { + it("InternalError should accept a custom message", () => { const customMessage = "Custom internal error message"; const error = new InternalError(customMessage); expect(error.message).toBe(customMessage); }); - test("InvalidConfiguration should have the correct message and name", () => { + it("InvalidConfiguration should have the correct message and name", () => { const error = new InvalidConfiguration(); expect(error.message).toBe(InvalidConfiguration.DEFAULT_MESSAGE); expect(error.name).toBe("InvalidConfiguration"); }); - test("InvalidConfiguration should accept a custom message", () => { + it("InvalidConfiguration should accept a custom message", () => { const customMessage = "Custom invalid configuration message"; const error = new InvalidConfiguration(customMessage); expect(error.message).toBe(customMessage); }); - test("InvalidUnsignedPayload should have the correct message and name", () => { + it("InvalidUnsignedPayload should have the correct message and name", () => { const error = new InvalidUnsignedPayload(); expect(error.message).toBe(InvalidUnsignedPayload.DEFAULT_MESSAGE); expect(error.name).toBe("InvalidUnsignedPayload"); }); - test("InvalidUnsignedPayload should accept a custom message", () => { + it("InvalidUnsignedPayload should accept a custom message", () => { const customMessage = "Custom invalid unsigned payload message"; const error = new InvalidUnsignedPayload(customMessage); expect(error.message).toBe(customMessage); }); + + it("AlreadySignedError should have the correct message and name", () => { + const error = new AlreadySignedError(); + expect(error.message).toBe(AlreadySignedError.DEFAULT_MESSAGE); + expect(error.name).toBe("AlreadySIgnedError"); + }); + + it("AlreadySignedError should accept a custom message", () => { + const customMessage = "Custom already signed error message"; + const error = new AlreadySignedError(customMessage); + expect(error.message).toBe(customMessage); + }); }); diff --git a/src/tests/sponsored_send_test.ts b/src/tests/sponsored_send_test.ts new file mode 100644 index 00000000..fe9af3b3 --- /dev/null +++ b/src/tests/sponsored_send_test.ts @@ -0,0 +1,196 @@ +import { ethers } from "ethers"; +import { SponsoredSend as SponsoredSendModel } from "../client/api"; +import { SponsoredSend } from "./../coinbase/sponsored_send"; +import { SponsoredSendStatus } from "../coinbase/types"; + +describe("SponsoredSend", () => { + let fromKey; + let toAddressId; + let rawTypedData; + let typedDataHash; + let signature; + let transactionHash; + let transactionLink; + let model; + let signedModel; + let completedModel; + let sponsoredSend; + + beforeEach(() => { + fromKey = ethers.Wallet.createRandom(); + toAddressId = "0x4D9E4F3f4D1A8B5F4f7b1F5b5C7b8d6b2B3b1b0b"; + typedDataHash = "0x7523946e17c0b8090ee18c84d6f9a8d63bab4d579a6507f0998dde0791891823"; + signature = "0x7523946e17c0b8090ee18c84d6f9a8d63bab4d579a6507f0998dde0791891823"; + transactionHash = "0xdea671372a8fff080950d09ad5994145a661c8e95a9216ef34772a19191b5690"; + transactionLink = `https://sepolia.basescan.org/tx/${transactionHash}`; + + model = { + status: "pending", + to_address_id: toAddressId, + typed_data_hash: typedDataHash, + } as SponsoredSendModel; + + signedModel = { + status: "signed", + to_address_id: toAddressId, + typed_data_hash: typedDataHash, + signature: signature, + } as SponsoredSendModel; + + completedModel = { + status: "complete", + to_address_id: toAddressId, + typed_data_hash: typedDataHash, + signature: signature, + transaction_hash: transactionHash, + transaction_link: transactionLink, + } as SponsoredSendModel; + + sponsoredSend = new SponsoredSend(model); + }); + + describe("constructor", () => { + it("initializes a new SponsoredSend", () => { + expect(sponsoredSend).toBeInstanceOf(SponsoredSend); + }); + + it("should raise an error when initialized with a model of a different type", () => { + expect(() => new SponsoredSend(null!)).toThrow("Invalid model type"); + }); + }); + + describe("#getTypedDataHash", () => { + it("returns the typed data hash", () => { + expect(sponsoredSend.getTypedDataHash()).toEqual(typedDataHash); + }); + }); + + describe("#getSignature", () => { + it("should return undefined when the SponsoredSend has not been signed", () => { + expect(sponsoredSend.getSignature()).toBeUndefined(); + }); + + it("should return the signature when the SponsoredSend has been signed", () => { + const sponsoredSend = new SponsoredSend(signedModel); + expect(sponsoredSend.getSignature()).toEqual(signature); + }); + }); + + describe("#getTransactionHash", () => { + it("should return undefined when the SponsoredSend has not been broadcast on chain", () => { + expect(sponsoredSend.getTransactionHash()).toBeUndefined(); + }); + + it("should return the transaction hash when the SponsoredSend has been broadcast on chain", () => { + const sponsoredSend = new SponsoredSend(completedModel); + expect(sponsoredSend.getTransactionHash()).toEqual(transactionHash); + }); + }); + + describe("#getTransactionLink", () => { + it("should return the transaction link when the transaction hash is available", () => { + const sponsoredSend = new SponsoredSend(completedModel); + expect(sponsoredSend.getTransactionLink()).toEqual( + `https://sepolia.basescan.org/tx/${transactionHash}`, + ); + }); + }); + + describe("#sign", () => { + let signature: string; + + beforeEach(async () => { + signature = await sponsoredSend.sign(fromKey); + }); + + it("should return a string when the SponsoredSend is signed", async () => { + expect(typeof signature).toBe("string"); + }); + + it("signs the raw typed data hash", async () => { + expect(signature).not.toBeNull(); + }); + + it("returns a hex representation of the signed typed data hash", async () => { + expect(signature).not.toBeNull(); + expect(signature.length).toBeGreaterThan(0); + }); + + it("sets the signed boolean", () => { + expect(sponsoredSend.isSigned()).toEqual(true); + }); + + it("sets the signature", () => { + expect(sponsoredSend.getSignature().slice(2)).toEqual(signature); + }); + }); + + describe("#getStatus", () => { + it("should return undefined when the SponsoredSend has not been initiated with a model", async () => { + model.status = ""; + const sponsoredSend = new SponsoredSend(model); + expect(sponsoredSend.getStatus()).toBeUndefined(); + }); + + it("should return a pending status", () => { + model.status = SponsoredSendStatus.PENDING; + const sponsoredSend = new SponsoredSend(model); + expect(sponsoredSend.getStatus()).toEqual("pending"); + }); + + it("should return a submitted status", () => { + model.status = SponsoredSendStatus.SUBMITTED; + const sponsoredSend = new SponsoredSend(model); + expect(sponsoredSend.getStatus()).toEqual("submitted"); + }); + + it("should return a complete status", () => { + model.status = SponsoredSendStatus.COMPLETE; + const sponsoredSend = new SponsoredSend(model); + expect(sponsoredSend.getStatus()).toEqual("complete"); + }); + + it("should return a failed status", () => { + model.status = SponsoredSendStatus.FAILED; + const sponsoredSend = new SponsoredSend(model); + expect(sponsoredSend.getStatus()).toEqual("failed"); + }); + }); + + describe("#isTerminalState", () => { + it("should not be in a terminal state", () => { + expect(sponsoredSend.isTerminalState()).toEqual(false); + }); + + it("should be in a terminal state", () => { + model.status = SponsoredSendStatus.COMPLETE; + const sponsoredSend = new SponsoredSend(model); + expect(sponsoredSend.isTerminalState()).toEqual(true); + }); + + it("should not be in a terminal state with an undefined status", () => { + model.status = "foo-status"; + const sponsoredSend = new SponsoredSend(model); + expect(sponsoredSend.isTerminalState()).toEqual(false); + }); + }); + + describe("#toString", () => { + it("includes SponsoredSend details", () => { + const sponsoredSend = new SponsoredSend(completedModel); + expect(sponsoredSend.toString()).toContain(sponsoredSend.getStatus()); + }); + + it("returns the same value as toString", () => { + const sponsoredSend = new SponsoredSend(completedModel); + expect(sponsoredSend.toString()).toEqual( + `SponsoredSend { transactionHash: '${sponsoredSend.getTransactionHash()}', status: '${sponsoredSend.getStatus()}', typedDataHash: '${sponsoredSend.getTypedDataHash()}', signature: ${sponsoredSend.getSignature()}, transactionLink: ${sponsoredSend.getTransactionLink()} }`, + ); + }); + + it("should include the transaction hash when the SponsoredSend has been broadcast on chain", () => { + const sponsoredSend = new SponsoredSend(completedModel); + expect(sponsoredSend.toString()).toContain(sponsoredSend.getTransactionHash()); + }); + }); +}); diff --git a/src/tests/transaction_test.ts b/src/tests/transaction_test.ts index 908c984e..6233364c 100644 --- a/src/tests/transaction_test.ts +++ b/src/tests/transaction_test.ts @@ -48,6 +48,7 @@ describe("Transaction", () => { transaction_hash: transactionHash, transaction_link: `https://sepolia.basescan.org/tx/${transactionHash}`, } as TransactionModel; + transaction = new Transaction(model); }); @@ -61,13 +62,13 @@ describe("Transaction", () => { }); }); - describe("#unsignedPayload", () => { + describe("#getUnsignedPayload", () => { it("returns the unsigned payload", () => { expect(transaction.getUnsignedPayload()).toEqual(unsignedPayload); }); }); - describe("#signedPayload", () => { + describe("#getSignedPayload", () => { it("should return undefined when the transaction has not been broadcast on chain", () => { expect(transaction.getSignedPayload()).toBeUndefined(); }); @@ -78,7 +79,7 @@ describe("Transaction", () => { }); }); - describe("#transactionHash", () => { + describe("#getTransactionHash", () => { it("should return undefined when the transaction has not been broadcast on chain", () => { expect(transaction.getTransactionHash()).toBeUndefined(); }); @@ -89,7 +90,7 @@ describe("Transaction", () => { }); }); - describe("#rawTransaction", () => { + describe("#getRawTransaction", () => { let raw: ethers.Transaction, rawPayload; beforeEach(() => { diff --git a/src/tests/transfer_test.ts b/src/tests/transfer_test.ts index 1847f14e..310a1fe6 100644 --- a/src/tests/transfer_test.ts +++ b/src/tests/transfer_test.ts @@ -1,11 +1,24 @@ import { ethers } from "ethers"; import { Decimal } from "decimal.js"; -import { Transfer as TransferModel, TransactionStatusEnum } from "../client/api"; -import { TransferStatus } from "../coinbase/types"; +import { + Transfer as TransferModel, + TransactionStatusEnum, + SponsoredSendStatusEnum, +} from "../client/api"; +import { TransactionStatus, TransferStatus, SponsoredSendStatus } from "../coinbase/types"; import { Transfer } from "../coinbase/transfer"; +import { SponsoredSend } from "../coinbase/sponsored_send"; +import { Transaction } from "../coinbase/transaction"; import { Coinbase } from "../coinbase/coinbase"; import { WEI_PER_ETHER } from "../coinbase/constants"; -import { VALID_TRANSFER_MODEL, mockReturnValue, transfersApiMock } from "./utils"; +import { + VALID_TRANSFER_MODEL, + VALID_TRANSFER_SPONSORED_SEND_MODEL, + mockReturnValue, + mockReturnRejectedValue, + transfersApiMock, +} from "./utils"; +import { APIError } from "../coinbase/api_error"; const amount = new Decimal(ethers.parseUnits("100", 18).toString()); const ethAmount = amount.div(WEI_PER_ETHER); @@ -69,7 +82,7 @@ describe("Transfer Class", () => { }); }); - describe(".getAssetId", () => { + describe("#getAssetId", () => { it("should return the asset ID", () => { expect(transfer.getAssetId()).toEqual(VALID_TRANSFER_MODEL.asset_id); }); @@ -89,110 +102,269 @@ describe("Transfer Class", () => { }); }); - describe("#getUnsignedPayload", () => { - it("should return the unsigned payload", () => { - expect(transfer.getUnsignedPayload()).toEqual( - VALID_TRANSFER_MODEL.transaction.unsigned_payload, - ); + describe("#getTransactionHash", () => { + it("should return the transaction hash", () => { + const transfer = Transfer.fromModel({ + ...VALID_TRANSFER_MODEL, + transaction: { + ...VALID_TRANSFER_MODEL.transaction!, + transaction_hash: transactionHash, + }, + }); + expect(transfer.getTransactionHash()).toEqual(transactionHash); }); }); - describe("#setSignedTransaction", () => { - it("should return the unsigned payload", () => { - const transfer = Transfer.fromModel(transferModel); - const transaction = new ethers.Transaction(); - transfer.setSignedTransaction(transaction); - expect(transfer.getTransaction()).toEqual(transaction); + describe("#getTransactionLink", () => { + it("should return the transaction link when the transaction hash is available", () => { + expect(transfer.getTransactionLink()).toEqual( + `https://sepolia.basescan.org/tx/${transferModel.transaction!.transaction_hash}`, + ); }); }); - describe("#getSignedPayload", () => { - it("should return undefined when the transfer has not been broadcast on chain", () => { - expect(transfer.getSignedPayload()).toBeUndefined(); + describe("#getTransaction", () => { + it("should return the Transfer Transaction", () => { + const transaction = transfer.getTransaction(); + expect(transaction).toBeInstanceOf(Transaction); }); - it("should return the signed payload when the transfer has been broadcast on chain", () => { - transferModel.transaction.signed_payload = signedPayload; - transfer = Transfer.fromModel(transferModel); - expect(transfer.getSignedPayload()).toEqual(signedPayload); + it("should return undefined when using sponsored sends", () => { + const transfer = Transfer.fromModel(VALID_TRANSFER_SPONSORED_SEND_MODEL); + const transaction = transfer.getTransaction(); + expect(transaction).toEqual(undefined); }); }); - describe("#getTransactionHash", () => { - it("should return undefined when the transfer has not been broadcast on chain", () => { - transferModel.transaction.transaction_hash = undefined; - transfer = Transfer.fromModel(transferModel); - expect(transfer.getTransactionHash()).toBeUndefined(); + describe("#getRawTransaction", () => { + it("should return the Transfer raw transaction", () => { + const rawTransaction = transfer.getRawTransaction(); + expect(rawTransaction).toBeInstanceOf(ethers.Transaction); }); - it("should return the transaction hash when the transfer has been broadcast on chain", () => { - transferModel.transaction.transaction_hash = transactionHash; - transfer = Transfer.fromModel(transferModel); - expect(transfer.getTransactionHash()).toEqual(transactionHash); + it("should return undefined when using sponsored sends", () => { + const transfer = Transfer.fromModel(VALID_TRANSFER_SPONSORED_SEND_MODEL); + const rawTransaction = transfer.getRawTransaction(); + expect(rawTransaction).toEqual(undefined); }); }); - describe("#getTransactionLink", () => { - it("should return the transaction link when the transaction hash is available", () => { - expect(transfer.getTransactionLink()).toEqual( - `https://sepolia.basescan.org/tx/${transferModel.transaction.transaction_hash}`, - ); + describe("#getSponsoredSend", () => { + it("should return the Transfer SponsoredSend", () => { + const transfer = Transfer.fromModel(VALID_TRANSFER_SPONSORED_SEND_MODEL); + const sponsoredSend = transfer.getSponsoredSend(); + expect(sponsoredSend).toBeInstanceOf(SponsoredSend); }); - }); - describe("#getTransaction", () => { - it("should return the Transfer transaction", () => { - const transaction = transfer.getTransaction(); - expect(transaction).toBeInstanceOf(ethers.Transaction); - expect(transaction.chainId).toEqual(BigInt("0x14a34")); - expect(transaction.nonce).toEqual(Number("0x0")); - expect(transaction.maxPriorityFeePerGas).toEqual(BigInt("0x59682f00")); - expect(transaction.maxFeePerGas).toEqual(BigInt("0x59682f00")); - expect(transaction.gasLimit).toEqual(BigInt("0x5208")); - expect(transaction.to).toEqual(VALID_TRANSFER_MODEL.destination); - expect(transaction.value).toEqual(BigInt(amount.toFixed(0))); - expect(transaction.data).toEqual("0x"); + it("should return undefined when not using sponsored sends", () => { + const sponsoredSend = transfer.getSponsoredSend(); + expect(sponsoredSend).toEqual(undefined); }); }); - describe("#getStatus", () => { - it("should return PENDING when the transaction has not been created", async () => { - const status = transfer.getStatus(); - expect(status).toEqual(TransferStatus.PENDING); + describe("#getSendTransactionDelegate", () => { + it("should return the Transfer SponsoredSend", () => { + const transfer = Transfer.fromModel(VALID_TRANSFER_SPONSORED_SEND_MODEL); + const sponsoredSend = transfer.getSendTransactionDelegate(); + expect(sponsoredSend).toBeInstanceOf(SponsoredSend); }); - it("should return PENDING when the transaction has been created but not broadcast", async () => { - transfer = Transfer.fromModel(transferModel); - const status = transfer.getStatus(); - expect(status).toEqual(TransferStatus.PENDING); + it("should return the Transfer Transaction", () => { + const transaction = transfer.getSendTransactionDelegate(); + expect(transaction).toBeInstanceOf(Transaction); }); - it("should return BROADCAST when the transaction has been broadcast but not included in a block", async () => { - transferModel.transaction.status = TransactionStatusEnum.Broadcast; - transfer = Transfer.fromModel(transferModel); - const status = transfer.getStatus(); - expect(status).toEqual(TransferStatus.BROADCAST); + it("should return undefined when no SponsoredSend or Transaction is defined", () => { + const transfer = Transfer.fromModel({ + ...VALID_TRANSFER_MODEL, + transaction: undefined, + }); + const sponsoredSend = transfer.getSponsoredSend(); + expect(sponsoredSend).toEqual(undefined); }); + }); - it("should return COMPLETE when the transaction has confirmed", async () => { - transferModel.transaction.status = TransactionStatusEnum.Complete; - transfer = Transfer.fromModel(transferModel); - const status = transfer.getStatus(); - expect(status).toEqual(TransferStatus.COMPLETE); + describe("#getStatus", () => { + describe("when the send transaction delegate is a Transaction", () => { + it("should return PENDING when the transaction has not been created", async () => { + const status = transfer.getStatus(); + expect(status).toEqual(TransferStatus.PENDING); + }); + + it("should return PENDING when the transaction has been created but not broadcast", async () => { + transfer = Transfer.fromModel(transferModel); + const status = transfer.getStatus(); + expect(status).toEqual(TransferStatus.PENDING); + }); + + it("should return BROADCAST when the transaction has been broadcast but not included in a block", async () => { + transferModel.transaction!.status = TransactionStatusEnum.Broadcast; + transfer = Transfer.fromModel(transferModel); + const status = transfer.getStatus(); + expect(status).toEqual(TransferStatus.BROADCAST); + }); + + it("should return COMPLETE when the transaction has confirmed", async () => { + transferModel.transaction!.status = TransactionStatusEnum.Complete; + transfer = Transfer.fromModel(transferModel); + const status = transfer.getStatus(); + expect(status).toEqual(TransferStatus.COMPLETE); + }); + + it("should return FAILED when the transaction has failed", async () => { + transferModel.transaction!.status = TransactionStatusEnum.Failed; + transfer = Transfer.fromModel(transferModel); + const status = transfer.getStatus(); + expect(status).toEqual(TransferStatus.FAILED); + }); + + it("should return undefined when the transaction does not exist", async () => { + transferModel.transaction!.status = "" as TransactionStatusEnum; + transfer = Transfer.fromModel(transferModel); + const status = transfer.getStatus(); + expect(status).toEqual(undefined); + }); }); - it("should return FAILED when the transaction has failed", async () => { - transferModel.transaction.status = TransactionStatusEnum.Failed; - transfer = Transfer.fromModel(transferModel); - const status = transfer.getStatus(); - expect(status).toEqual(TransferStatus.FAILED); + describe("when the send transaction delegate is a SponsoredSend", () => { + const transfer = Transfer.fromModel(VALID_TRANSFER_SPONSORED_SEND_MODEL); + + it("should return PENDING when the SponsoredSend has not been signed", async () => { + const status = transfer.getStatus(); + expect(status).toEqual(TransferStatus.PENDING); + }); + + it("should return PENDING when the SponsoredSend has been signed but not submitted", async () => { + const transfer = Transfer.fromModel({ + ...VALID_TRANSFER_SPONSORED_SEND_MODEL, + sponsored_send: { + ...VALID_TRANSFER_SPONSORED_SEND_MODEL.sponsored_send!, + status: SponsoredSendStatusEnum.Signed, + }, + }); + const status = transfer.getStatus(); + expect(status).toEqual(TransferStatus.PENDING); + }); + + it("should return BROADCAST when the SponsoredSend has been submitted", async () => { + const transfer = Transfer.fromModel({ + ...VALID_TRANSFER_SPONSORED_SEND_MODEL, + sponsored_send: { + ...VALID_TRANSFER_SPONSORED_SEND_MODEL.sponsored_send!, + status: SponsoredSendStatusEnum.Submitted, + }, + }); + const status = transfer.getStatus(); + expect(status).toEqual(TransferStatus.BROADCAST); + }); + + it("should return COMPLETE when the SponsoredSend has been completed", async () => { + const transfer = Transfer.fromModel({ + ...VALID_TRANSFER_SPONSORED_SEND_MODEL, + sponsored_send: { + ...VALID_TRANSFER_SPONSORED_SEND_MODEL.sponsored_send!, + status: SponsoredSendStatusEnum.Complete, + }, + }); + const status = transfer.getStatus(); + expect(status).toEqual(TransferStatus.COMPLETE); + }); + + it("should return FAILED when the SponsoredSend has failed", async () => { + const transfer = Transfer.fromModel({ + ...VALID_TRANSFER_SPONSORED_SEND_MODEL, + sponsored_send: { + ...VALID_TRANSFER_SPONSORED_SEND_MODEL.sponsored_send!, + status: SponsoredSendStatusEnum.Failed, + }, + }); + const status = transfer.getStatus(); + expect(status).toEqual(TransferStatus.FAILED); + }); + + it("should return undefined when the SponsoredSend does not exist", async () => { + const transfer = Transfer.fromModel({ + ...VALID_TRANSFER_SPONSORED_SEND_MODEL, + sponsored_send: { + ...VALID_TRANSFER_SPONSORED_SEND_MODEL.sponsored_send!, + status: "" as SponsoredSendStatusEnum, + }, + }); + const status = transfer.getStatus(); + expect(status).toEqual(undefined); + }); }); + }); - it("should return undefined when the transaction does not exist", async () => { - transferModel.transaction.status = "" as TransactionStatusEnum; - transfer = Transfer.fromModel(transferModel); - const status = transfer.getStatus(); - expect(status).toEqual(undefined); + describe("#broadcast", () => { + it("should return a broadcasted transfer when the send transaction delegate is a Transaction", async () => { + const transfer = Transfer.fromModel({ + ...VALID_TRANSFER_MODEL, + transaction: { + ...VALID_TRANSFER_MODEL.transaction!, + signed_payload: "0xsignedHash", + }, + }); + Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnValue({ + ...VALID_TRANSFER_MODEL, + transaction: { + ...VALID_TRANSFER_MODEL.transaction!, + status: TransactionStatus.BROADCAST, + }, + }); + const broadcastedTransfer = await transfer.broadcast(); + expect(broadcastedTransfer).toBeInstanceOf(Transfer); + expect(broadcastedTransfer.getStatus()).toEqual(TransferStatus.BROADCAST); + }); + it("should return a broadcasted transfer when the send transaction delegate is a SponsoredSend", async () => { + const transfer = Transfer.fromModel({ + ...VALID_TRANSFER_SPONSORED_SEND_MODEL, + sponsored_send: { + ...VALID_TRANSFER_SPONSORED_SEND_MODEL.sponsored_send!, + signature: "0xsignedHash", + }, + }); + Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnValue({ + ...VALID_TRANSFER_SPONSORED_SEND_MODEL, + sponsored_send: { + ...VALID_TRANSFER_SPONSORED_SEND_MODEL.sponsored_send!, + status: SponsoredSendStatus.SUBMITTED, + }, + }); + const broadcastedTransfer = await transfer.broadcast(); + expect(broadcastedTransfer).toBeInstanceOf(Transfer); + expect(broadcastedTransfer.getStatus()).toEqual(TransferStatus.BROADCAST); + }); + it("should throw when the sned transaction delegate has not been signed", async () => { + expect(transfer.broadcast()).rejects.toThrow(new Error("Cannot broadcast unsigned Transfer")); + }); + it("should thorw an APIErrror if the broadcastTransfer API call fails", async () => { + const transfer = Transfer.fromModel({ + ...VALID_TRANSFER_MODEL, + transaction: { + ...VALID_TRANSFER_MODEL.transaction!, + signed_payload: "0xsignedHash", + }, + }); + Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnRejectedValue( + new APIError("Failed to broadcast transfer"), + ); + expect(transfer.broadcast()).rejects.toThrow(APIError); + }); + }); + + describe("#sign", () => { + let signingKey: any = ethers.Wallet.createRandom(); + it("should return the signature", async () => { + const transfer = Transfer.fromModel({ + ...VALID_TRANSFER_MODEL, + transaction: { + ...VALID_TRANSFER_MODEL.transaction!, + signed_payload: "0xsignedHash", + }, + }); + const signature = await transfer.sign(signingKey); + expect(signature).toEqual(transfer.getTransaction()!.getSignature()!.slice(2)); }); }); @@ -201,7 +373,7 @@ describe("Transfer Class", () => { Coinbase.apiClients.transfer!.getTransfer = mockReturnValue({ ...VALID_TRANSFER_MODEL, transaction: { - ...VALID_TRANSFER_MODEL, + ...VALID_TRANSFER_MODEL.transaction!, status: TransactionStatusEnum.Pending, }, }); @@ -214,7 +386,7 @@ describe("Transfer Class", () => { Coinbase.apiClients.transfer!.getTransfer = mockReturnValue({ ...VALID_TRANSFER_MODEL, transaction: { - ...VALID_TRANSFER_MODEL, + ...VALID_TRANSFER_MODEL.transaction!, status: TransactionStatusEnum.Complete, }, }); @@ -227,7 +399,7 @@ describe("Transfer Class", () => { Coinbase.apiClients.transfer!.getTransfer = mockReturnValue({ ...VALID_TRANSFER_MODEL, transaction: { - ...VALID_TRANSFER_MODEL, + ...VALID_TRANSFER_MODEL.transaction!, status: TransactionStatusEnum.Failed, }, status: TransferStatus.FAILED, @@ -237,4 +409,15 @@ describe("Transfer Class", () => { expect(Coinbase.apiClients.transfer!.getTransfer).toHaveBeenCalledTimes(1); }); }); + + describe("#toString", () => { + it("returns the same value as toString", () => { + expect(transfer.toString()).toEqual( + `Transfer{transferId: '${transfer.getId()}', networkId: '${transfer.getNetworkId()}', ` + + `fromAddressId: '${transfer.getFromAddressId()}', destinationAddressId: '${transfer.getDestinationAddressId()}', ` + + `assetId: '${transfer.getAssetId()}', amount: '${transfer.getAmount()}', transactionHash: '${transfer.getTransactionHash()}', ` + + `transactionLink: '${transfer.getTransactionLink()}', status: '${transfer.getStatus()!}'}`, + ); + }); + }); }); diff --git a/src/tests/utils.ts b/src/tests/utils.ts index 95737bf4..f1280abb 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -139,6 +139,32 @@ export const VALID_TRANSFER_MODEL: TransferModel = { destination: "0x4D9E4F3f4D1A8B5F4f7b1F5b5C7b8d6b2B3b1b0b", asset_id: Coinbase.assets.Eth, amount: new Decimal(ethers.parseUnits("100", 18).toString()).toString(), + gasless: false, +}; + +export const VALID_TRANSFER_SPONSORED_SEND_MODEL: TransferModel = { + transfer_id: transferId, + network_id: Coinbase.networks.BaseSepolia, + wallet_id: walletId, + asset: { + asset_id: Coinbase.assets.Usdc, + network_id: Coinbase.networks.BaseSepolia, + decimals: 18, + contract_address: "0xusdc", + }, + sponsored_send: { + to_address_id: "0xdeadbeef", + raw_typed_data: "0xhash", + typed_data_hash: "0x7523946e17c0b8090ee18c84d6f9a8d63bab4d579a6507f0998dde0791891823", + transaction_hash: "0xdeadbeef", + transaction_link: "https://sepolia.basescan.org/tx/0xdeadbeef", + status: "pending", + }, + address_id: ethers.Wallet.createRandom().address, + destination: "0x4D9E4F3f4D1A8B5F4f7b1F5b5C7b8d6b2B3b1b0b", + asset_id: Coinbase.assets.Eth, + amount: new Decimal(ethers.parseUnits("100", 18).toString()).toString(), + gasless: false, }; export const VALID_STAKING_OPERATION_MODEL: StakingOperationModel = {