Skip to content

Commit

Permalink
[PSDK-361] Gasless Sends Support
Browse files Browse the repository at this point in the history
  • Loading branch information
John-peterson-coinbase committed Aug 12, 2024
1 parent d0b08ae commit b50a439
Show file tree
Hide file tree
Showing 12 changed files with 871 additions and 218 deletions.
155 changes: 101 additions & 54 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,12 @@ export interface CreateTransferRequest {
* @memberof CreateTransferRequest
*/
'destination': string;
/**
* Whether the transfer uses sponsored gas
* @type {boolean}
* @memberof CreateTransferRequest
*/
'gasless'?: boolean;
}
/**
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -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 = {
Expand Down
20 changes: 5 additions & 15 deletions src/coinbase/address/wallet_address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -159,6 +160,7 @@ export class WalletAddress extends Address {
destination,
timeoutSeconds = 10,
intervalSeconds = 0.2,
gasless = false,
}: CreateTransferOptions): Promise<Transfer> {
if (!Coinbase.useServerSigner && !this.key) {
throw new InternalError("Cannot transfer from address without private key loaded");
Expand All @@ -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(
Expand All @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions src/coinbase/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
126 changes: 126 additions & 0 deletions src/coinbase/sponsored_send.ts
Original file line number Diff line number Diff line change
@@ -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()} }`;
}
}
Loading

0 comments on commit b50a439

Please sign in to comment.