Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PSDK-361] Gasless Sends Support #142

Merged
merged 3 commits into from
Aug 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- USD value conversion details to the StakingReward object
- Gasless USDC Sends

## [0.0.14] - 2024-08-05

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ const transfer = await wallet.createTransfer({ amount: 0.00001, assetId: Coinbas
```


### Gasless USDC Transfers

To transfer USDC without needing to hold ETH for gas, you can use the `createTransfer` method with the `gasless` option set to `true`.
```typescript
const transfer = await wallet.createTransfer({ amount: 0.00001, assetId: Coinbase.assets.Usdc, destination: anotherWallet, gasless: true });
```


### Trading Funds

```typescript
Expand Down
4 changes: 2 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ module.exports = {
"./src/coinbase/**": {
branches: 80,
functions: 90,
statements: 95,
lines: 95,
statements: 90,
lines: 90,
},
},
};
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);
}
}
}
Loading
Loading