From c8b5e85c3a7d568e06e735a40336fb6a60c329c3 Mon Sep 17 00:00:00 2001 From: John Peterson Date: Fri, 9 Aug 2024 19:07:49 -0400 Subject: [PATCH] [PSDK-361] Gasless Sends Support --- src/client/api.ts | 155 ++++++++++++++++--------- src/coinbase/address/wallet_address.ts | 20 +--- src/coinbase/errors.ts | 18 +++ src/coinbase/sponsored_send.ts | 128 ++++++++++++++++++++ src/coinbase/transaction.ts | 9 ++ src/coinbase/transfer.ts | 133 +++++++++++++-------- src/coinbase/types.ts | 12 ++ 7 files changed, 356 insertions(+), 119 deletions(-) create mode 100644 src/coinbase/sponsored_send.ts 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..7e403a9b 100644 --- a/src/coinbase/errors.ts +++ b/src/coinbase/errors.ts @@ -97,3 +97,21 @@ 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. + */ + 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..a1531a59 --- /dev/null +++ b/src/coinbase/sponsored_send.ts @@ -0,0 +1,128 @@ +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; + private signed: boolean | undefined; + + /** + * 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; + this.signed = true; + // 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 | undefined { + return this.signed; + } + + /** + * 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..df0ff26a 100644 --- a/src/coinbase/transaction.ts +++ b/src/coinbase/transaction.ts @@ -141,6 +141,15 @@ export class Transaction { 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. * diff --git a/src/coinbase/transfer.ts b/src/coinbase/transfer.ts index fd2dc1d7..7cf2e873 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 @@ -103,31 +104,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 +119,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 +141,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 transfer without signature"); // TODO: make a derived class error for this case. + + 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; }; /**