From 69c24c9319dde262552962190c98ce27c60ffa82 Mon Sep 17 00:00:00 2001 From: Ryan Gilbert Date: Tue, 14 Jan 2025 14:31:37 -0500 Subject: [PATCH] feat(PSDK-782): allow users to send sponsored transactions immediately (#357) --- CHANGELOG.md | 5 + README.md | 11 + src/client/api.ts | 368 ++++++++++++++++++++++++- src/coinbase/address/wallet_address.ts | 7 + src/coinbase/types.ts | 1 + src/coinbase/wallet.ts | 1 + src/tests/wallet_address_test.ts | 58 ++++ src/tests/wallet_transfer_test.ts | 75 +++++ 8 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 src/tests/wallet_transfer_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dd1710f8..79f368a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Coinbase Node.js SDK Changelog +## Unreleased + +### Added +- Add `skipBatching` option to `Wallet.createTransfer` to allow for lower latency gasless transfers. + ## [0.13.0] - 2024-12-19 ### Added diff --git a/README.md b/README.md index c18bd5bf..fbd024e9 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,17 @@ let transfer = await wallet.createTransfer({ amount: 0.00001, assetId: Coinbase. transfer = await transfer.wait(); ``` +By default, gasless transfers are batched with other transfers, and might take longer to submit. If you want to opt out of batching, you can set the `skipBatching` option to `true`, which will submit the transaction immediately. +```typescript +let transfer = await wallet.createTransfer({ + amount: 0.00001, + assetId: Coinbase.assets.Usdc, + destination: anotherWallet, + gasless: true, + skipBatching: true +}); +transfer = await transfer.wait(); +``` ### Trading Funds diff --git a/src/client/api.ts b/src/client/api.ts index a3748041..c151c71d 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -321,6 +321,19 @@ export interface BroadcastContractInvocationRequest { */ 'signed_payload': string; } +/** + * + * @export + * @interface BroadcastExternalTransferRequest + */ +export interface BroadcastExternalTransferRequest { + /** + * The hex-encoded signed payload of the external transfer + * @type {string} + * @memberof BroadcastExternalTransferRequest + */ + 'signed_payload': string; +} /** * * @export @@ -679,6 +692,43 @@ export interface CreateContractInvocationRequest { */ 'amount'?: string; } +/** + * + * @export + * @interface CreateExternalTransferRequest + */ +export interface CreateExternalTransferRequest { + /** + * The amount to transfer + * @type {string} + * @memberof CreateExternalTransferRequest + */ + 'amount': string; + /** + * The ID of the asset to transfer. Can be an asset symbol or a token contract address. + * @type {string} + * @memberof CreateExternalTransferRequest + */ + 'asset_id': string; + /** + * The destination address, which can be a 0x address, Basename, or ENS name + * @type {string} + * @memberof CreateExternalTransferRequest + */ + 'destination': string; + /** + * Whether the transfer uses sponsored gas + * @type {boolean} + * @memberof CreateExternalTransferRequest + */ + 'gasless': boolean; + /** + * When true, the transfer will be submitted immediately. Otherwise, the transfer will be batched. Defaults to false. Note: Requires the gasless option to be set to true. + * @type {boolean} + * @memberof CreateExternalTransferRequest + */ + 'skip_batching'?: boolean; +} /** * * @export @@ -692,7 +742,7 @@ export interface CreateFundOperationRequest { */ 'amount': string; /** - * The ID of the asset to fund the address with. + * The ID of the asset to fund the address with. Can be an asset symbol or a token contract address. * @type {string} * @memberof CreateFundOperationRequest */ @@ -717,7 +767,7 @@ export interface CreateFundQuoteRequest { */ 'amount': string; /** - * The ID of the asset to fund the address with. + * The ID of the asset to fund the address with. Can be an asset symbol alias or a token contract address. * @type {string} * @memberof CreateFundQuoteRequest */ @@ -863,7 +913,7 @@ export interface CreateTransferRequest { */ 'network_id': string; /** - * The ID of the asset to transfer + * The ID of the asset to transfer. Can be an asset symbol or a token contract address. * @type {string} * @memberof CreateTransferRequest */ @@ -880,6 +930,12 @@ export interface CreateTransferRequest { * @memberof CreateTransferRequest */ 'gasless'?: boolean; + /** + * When true, the transfer will be submitted immediately. Otherwise, the transfer will be batched. Defaults to false + * @type {boolean} + * @memberof CreateTransferRequest + */ + 'skip_batching'?: boolean; } /** * @@ -5969,6 +6025,104 @@ export class ContractInvocationsApi extends BaseAPI implements ContractInvocatio */ export const ExternalAddressesApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * Broadcast an external address\'s transfer with a signed payload + * @summary Broadcast an external address\' transfer + * @param {string} networkId The ID of the network the address belongs to + * @param {string} addressId The ID of the address the transfer belongs to + * @param {string} transferId The ID of the transfer to broadcast + * @param {BroadcastExternalTransferRequest} broadcastExternalTransferRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + broadcastExternalTransfer: async (networkId: string, addressId: string, transferId: string, broadcastExternalTransferRequest: BroadcastExternalTransferRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'networkId' is not null or undefined + assertParamExists('broadcastExternalTransfer', 'networkId', networkId) + // verify required parameter 'addressId' is not null or undefined + assertParamExists('broadcastExternalTransfer', 'addressId', addressId) + // verify required parameter 'transferId' is not null or undefined + assertParamExists('broadcastExternalTransfer', 'transferId', transferId) + // verify required parameter 'broadcastExternalTransferRequest' is not null or undefined + assertParamExists('broadcastExternalTransfer', 'broadcastExternalTransferRequest', broadcastExternalTransferRequest) + const localVarPath = `/v1/networks/{network_id}/addresses/{address_id}/transfers/{transfer_id}/broadcast` + .replace(`{${"network_id"}}`, encodeURIComponent(String(networkId))) + .replace(`{${"address_id"}}`, encodeURIComponent(String(addressId))) + .replace(`{${"transfer_id"}}`, encodeURIComponent(String(transferId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication apiKey required + await setApiKeyToObject(localVarHeaderParameter, "Jwt", configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(broadcastExternalTransferRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Create a new transfer between addresses. + * @summary Create a new transfer + * @param {string} networkId The ID of the network the address is on + * @param {string} addressId The ID of the address to transfer from + * @param {CreateExternalTransferRequest} createExternalTransferRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createExternalTransfer: async (networkId: string, addressId: string, createExternalTransferRequest: CreateExternalTransferRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'networkId' is not null or undefined + assertParamExists('createExternalTransfer', 'networkId', networkId) + // verify required parameter 'addressId' is not null or undefined + assertParamExists('createExternalTransfer', 'addressId', addressId) + // verify required parameter 'createExternalTransferRequest' is not null or undefined + assertParamExists('createExternalTransfer', 'createExternalTransferRequest', createExternalTransferRequest) + const localVarPath = `/v1/networks/{network_id}/addresses/{address_id}/transfers` + .replace(`{${"network_id"}}`, encodeURIComponent(String(networkId))) + .replace(`{${"address_id"}}`, encodeURIComponent(String(addressId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication apiKey required + await setApiKeyToObject(localVarHeaderParameter, "Jwt", configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(createExternalTransferRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Get the balance of an asset in an external address * @summary Get the balance of an asset in an external address @@ -6008,6 +6162,51 @@ export const ExternalAddressesApiAxiosParamCreator = function (configuration?: C + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Get an external address\' transfer by ID + * @summary Get a external address\' transfer + * @param {string} networkId The ID of the network the address is on + * @param {string} addressId The ID of the address the transfer belongs to + * @param {string} transferId The ID of the transfer to fetch + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getExternalTransfer: async (networkId: string, addressId: string, transferId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'networkId' is not null or undefined + assertParamExists('getExternalTransfer', 'networkId', networkId) + // verify required parameter 'addressId' is not null or undefined + assertParamExists('getExternalTransfer', 'addressId', addressId) + // verify required parameter 'transferId' is not null or undefined + assertParamExists('getExternalTransfer', 'transferId', transferId) + const localVarPath = `/v1/networks/{network_id}/addresses/{address_id}/transfers/{transfer_id}` + .replace(`{${"network_id"}}`, encodeURIComponent(String(networkId))) + .replace(`{${"address_id"}}`, encodeURIComponent(String(addressId))) + .replace(`{${"transfer_id"}}`, encodeURIComponent(String(transferId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication apiKey required + await setApiKeyToObject(localVarHeaderParameter, "Jwt", configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -6178,6 +6377,37 @@ export const ExternalAddressesApiAxiosParamCreator = function (configuration?: C export const ExternalAddressesApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = ExternalAddressesApiAxiosParamCreator(configuration) return { + /** + * Broadcast an external address\'s transfer with a signed payload + * @summary Broadcast an external address\' transfer + * @param {string} networkId The ID of the network the address belongs to + * @param {string} addressId The ID of the address the transfer belongs to + * @param {string} transferId The ID of the transfer to broadcast + * @param {BroadcastExternalTransferRequest} broadcastExternalTransferRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async broadcastExternalTransfer(networkId: string, addressId: string, transferId: string, broadcastExternalTransferRequest: BroadcastExternalTransferRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.broadcastExternalTransfer(networkId, addressId, transferId, broadcastExternalTransferRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExternalAddressesApi.broadcastExternalTransfer']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Create a new transfer between addresses. + * @summary Create a new transfer + * @param {string} networkId The ID of the network the address is on + * @param {string} addressId The ID of the address to transfer from + * @param {CreateExternalTransferRequest} createExternalTransferRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createExternalTransfer(networkId: string, addressId: string, createExternalTransferRequest: CreateExternalTransferRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createExternalTransfer(networkId, addressId, createExternalTransferRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExternalAddressesApi.createExternalTransfer']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Get the balance of an asset in an external address * @summary Get the balance of an asset in an external address @@ -6193,6 +6423,21 @@ export const ExternalAddressesApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['ExternalAddressesApi.getExternalAddressBalance']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Get an external address\' transfer by ID + * @summary Get a external address\' transfer + * @param {string} networkId The ID of the network the address is on + * @param {string} addressId The ID of the address the transfer belongs to + * @param {string} transferId The ID of the transfer to fetch + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getExternalTransfer(networkId: string, addressId: string, transferId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getExternalTransfer(networkId, addressId, transferId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExternalAddressesApi.getExternalTransfer']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Get the status of a faucet transaction * @summary Get the status of a faucet transaction @@ -6249,6 +6494,31 @@ export const ExternalAddressesApiFp = function(configuration?: Configuration) { export const ExternalAddressesApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = ExternalAddressesApiFp(configuration) return { + /** + * Broadcast an external address\'s transfer with a signed payload + * @summary Broadcast an external address\' transfer + * @param {string} networkId The ID of the network the address belongs to + * @param {string} addressId The ID of the address the transfer belongs to + * @param {string} transferId The ID of the transfer to broadcast + * @param {BroadcastExternalTransferRequest} broadcastExternalTransferRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + broadcastExternalTransfer(networkId: string, addressId: string, transferId: string, broadcastExternalTransferRequest: BroadcastExternalTransferRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.broadcastExternalTransfer(networkId, addressId, transferId, broadcastExternalTransferRequest, options).then((request) => request(axios, basePath)); + }, + /** + * Create a new transfer between addresses. + * @summary Create a new transfer + * @param {string} networkId The ID of the network the address is on + * @param {string} addressId The ID of the address to transfer from + * @param {CreateExternalTransferRequest} createExternalTransferRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createExternalTransfer(networkId: string, addressId: string, createExternalTransferRequest: CreateExternalTransferRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.createExternalTransfer(networkId, addressId, createExternalTransferRequest, options).then((request) => request(axios, basePath)); + }, /** * Get the balance of an asset in an external address * @summary Get the balance of an asset in an external address @@ -6261,6 +6531,18 @@ export const ExternalAddressesApiFactory = function (configuration?: Configurati getExternalAddressBalance(networkId: string, addressId: string, assetId: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.getExternalAddressBalance(networkId, addressId, assetId, options).then((request) => request(axios, basePath)); }, + /** + * Get an external address\' transfer by ID + * @summary Get a external address\' transfer + * @param {string} networkId The ID of the network the address is on + * @param {string} addressId The ID of the address the transfer belongs to + * @param {string} transferId The ID of the transfer to fetch + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getExternalTransfer(networkId: string, addressId: string, transferId: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getExternalTransfer(networkId, addressId, transferId, options).then((request) => request(axios, basePath)); + }, /** * Get the status of a faucet transaction * @summary Get the status of a faucet transaction @@ -6307,6 +6589,31 @@ export const ExternalAddressesApiFactory = function (configuration?: Configurati * @interface ExternalAddressesApi */ export interface ExternalAddressesApiInterface { + /** + * Broadcast an external address\'s transfer with a signed payload + * @summary Broadcast an external address\' transfer + * @param {string} networkId The ID of the network the address belongs to + * @param {string} addressId The ID of the address the transfer belongs to + * @param {string} transferId The ID of the transfer to broadcast + * @param {BroadcastExternalTransferRequest} broadcastExternalTransferRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExternalAddressesApiInterface + */ + broadcastExternalTransfer(networkId: string, addressId: string, transferId: string, broadcastExternalTransferRequest: BroadcastExternalTransferRequest, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * Create a new transfer between addresses. + * @summary Create a new transfer + * @param {string} networkId The ID of the network the address is on + * @param {string} addressId The ID of the address to transfer from + * @param {CreateExternalTransferRequest} createExternalTransferRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExternalAddressesApiInterface + */ + createExternalTransfer(networkId: string, addressId: string, createExternalTransferRequest: CreateExternalTransferRequest, options?: RawAxiosRequestConfig): AxiosPromise; + /** * Get the balance of an asset in an external address * @summary Get the balance of an asset in an external address @@ -6319,6 +6626,18 @@ export interface ExternalAddressesApiInterface { */ getExternalAddressBalance(networkId: string, addressId: string, assetId: string, options?: RawAxiosRequestConfig): AxiosPromise; + /** + * Get an external address\' transfer by ID + * @summary Get a external address\' transfer + * @param {string} networkId The ID of the network the address is on + * @param {string} addressId The ID of the address the transfer belongs to + * @param {string} transferId The ID of the transfer to fetch + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExternalAddressesApiInterface + */ + getExternalTransfer(networkId: string, addressId: string, transferId: string, options?: RawAxiosRequestConfig): AxiosPromise; + /** * Get the status of a faucet transaction * @summary Get the status of a faucet transaction @@ -6365,6 +6684,35 @@ export interface ExternalAddressesApiInterface { * @extends {BaseAPI} */ export class ExternalAddressesApi extends BaseAPI implements ExternalAddressesApiInterface { + /** + * Broadcast an external address\'s transfer with a signed payload + * @summary Broadcast an external address\' transfer + * @param {string} networkId The ID of the network the address belongs to + * @param {string} addressId The ID of the address the transfer belongs to + * @param {string} transferId The ID of the transfer to broadcast + * @param {BroadcastExternalTransferRequest} broadcastExternalTransferRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExternalAddressesApi + */ + public broadcastExternalTransfer(networkId: string, addressId: string, transferId: string, broadcastExternalTransferRequest: BroadcastExternalTransferRequest, options?: RawAxiosRequestConfig) { + return ExternalAddressesApiFp(this.configuration).broadcastExternalTransfer(networkId, addressId, transferId, broadcastExternalTransferRequest, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Create a new transfer between addresses. + * @summary Create a new transfer + * @param {string} networkId The ID of the network the address is on + * @param {string} addressId The ID of the address to transfer from + * @param {CreateExternalTransferRequest} createExternalTransferRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExternalAddressesApi + */ + public createExternalTransfer(networkId: string, addressId: string, createExternalTransferRequest: CreateExternalTransferRequest, options?: RawAxiosRequestConfig) { + return ExternalAddressesApiFp(this.configuration).createExternalTransfer(networkId, addressId, createExternalTransferRequest, options).then((request) => request(this.axios, this.basePath)); + } + /** * Get the balance of an asset in an external address * @summary Get the balance of an asset in an external address @@ -6379,6 +6727,20 @@ export class ExternalAddressesApi extends BaseAPI implements ExternalAddressesAp return ExternalAddressesApiFp(this.configuration).getExternalAddressBalance(networkId, addressId, assetId, options).then((request) => request(this.axios, this.basePath)); } + /** + * Get an external address\' transfer by ID + * @summary Get a external address\' transfer + * @param {string} networkId The ID of the network the address is on + * @param {string} addressId The ID of the address the transfer belongs to + * @param {string} transferId The ID of the transfer to fetch + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExternalAddressesApi + */ + public getExternalTransfer(networkId: string, addressId: string, transferId: string, options?: RawAxiosRequestConfig) { + return ExternalAddressesApiFp(this.configuration).getExternalTransfer(networkId, addressId, transferId, options).then((request) => request(this.axios, this.basePath)); + } + /** * Get the status of a faucet transaction * @summary Get the status of a faucet transaction diff --git a/src/coinbase/address/wallet_address.ts b/src/coinbase/address/wallet_address.ts index 51e8a2c8..47d851ba 100644 --- a/src/coinbase/address/wallet_address.ts +++ b/src/coinbase/address/wallet_address.ts @@ -203,6 +203,7 @@ export class WalletAddress extends Address { * @param options.assetId - The ID of the Asset to send. For Ether, Coinbase.assets.Eth, Coinbase.assets.Gwei, and Coinbase.assets.Wei supported. * @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.gasless - Whether the Transfer should be gasless. Defaults to false. + * @param options.skipBatching - When true, the Transfer will be submitted immediately. Otherwise, the Transfer will be batched. Defaults to false. Note: requires gasless option to be set to true. * @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. @@ -212,6 +213,7 @@ export class WalletAddress extends Address { assetId, destination, gasless = false, + skipBatching = false, }: CreateTransferOptions): Promise { if (!Coinbase.useServerSigner && !this.key) { throw new Error("Cannot transfer from address without private key loaded"); @@ -228,12 +230,17 @@ export class WalletAddress extends Address { ); } + if (skipBatching && !gasless) { + throw new ArgumentError("skipBatching requires gasless to be true"); + } + const createTransferRequest = { amount: asset.toAtomicAmount(normalizedAmount).toString(), network_id: destinationNetworkId, asset_id: asset.primaryDenomination(), destination: destinationAddress, gasless: gasless, + skip_batching: skipBatching, }; const response = await Coinbase.apiClients.transfer!.createTransfer( diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index 8eb3c757..0903f7e7 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -1085,6 +1085,7 @@ export type CreateTransferOptions = { assetId: string; destination: Destination; gasless?: boolean; + skipBatching?: boolean; }; /** diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts index 3c850465..b23f3c3f 100644 --- a/src/coinbase/wallet.ts +++ b/src/coinbase/wallet.ts @@ -840,6 +840,7 @@ export class Wallet { * @param options.assetId - The ID of the Asset to send. * @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.gasless - Whether the Transfer should be gasless. Defaults to false. + * @param options.skipBatching - When true, the Transfer will be submitted immediately. Otherwise, the Transfer will be batched. Defaults to false. Note: requires gasless option to be set to true. * @returns The created Transfer object. * @throws {APIError} if the API request to create a Transfer fails. * @throws {APIError} if the API request to broadcast a Transfer fails. diff --git a/src/tests/wallet_address_test.ts b/src/tests/wallet_address_test.ts index 35c7982f..3a2a8246 100644 --- a/src/tests/wallet_address_test.ts +++ b/src/tests/wallet_address_test.ts @@ -707,6 +707,63 @@ describe("WalletAddress", () => { expect(transfer.getId()).toBe(VALID_TRANSFER_MODEL.transfer_id); }); + it("should default skipBatching to false", async () => { + Coinbase.apiClients.transfer!.createTransfer = mockReturnValue(VALID_TRANSFER_MODEL); + Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnValue({ + transaction_hash: "0x6c087c1676e8269dd81e0777244584d0cbfd39b6997b3477242a008fa9349e11", + ...VALID_TRANSFER_MODEL, + }); + + await address.createTransfer({ + amount: weiAmount, + assetId: Coinbase.assets.Wei, + destination, + }); + + expect(Coinbase.apiClients.transfer!.createTransfer).toHaveBeenCalledWith( + address.getWalletId(), + address.getId(), + expect.objectContaining({ + skip_batching: false, + }), + ); + }); + + it("should allow skipBatching to be set to true", async () => { + Coinbase.apiClients.transfer!.createTransfer = mockReturnValue(VALID_TRANSFER_MODEL); + Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnValue({ + transaction_hash: "0x6c087c1676e8269dd81e0777244584d0cbfd39b6997b3477242a008fa9349e11", + ...VALID_TRANSFER_MODEL, + }); + + await address.createTransfer({ + amount: weiAmount, + assetId: Coinbase.assets.Wei, + destination, + gasless: true, + skipBatching: true, + }); + + expect(Coinbase.apiClients.transfer!.createTransfer).toHaveBeenCalledWith( + address.getWalletId(), + address.getId(), + expect.objectContaining({ + skip_batching: true, + }), + ); + }); + + it("should throw an ArgumentError if skipBatching is true but gasless is false", async () => { + await expect( + address.createTransfer({ + amount: weiAmount, + assetId: Coinbase.assets.Wei, + destination, + skipBatching: true, + }), + ).rejects.toThrow(ArgumentError); + }); + it("should successfully construct createTransfer request when using a large number that causes scientific notation", async () => { Coinbase.apiClients.transfer!.createTransfer = mockReturnValue(VALID_TRANSFER_MODEL); Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnValue({ @@ -745,6 +802,7 @@ describe("WalletAddress", () => { destination: destination.getId(), gasless: false, network_id: Coinbase.networks.BaseSepolia, + skip_batching: false, }, ); diff --git a/src/tests/wallet_transfer_test.ts b/src/tests/wallet_transfer_test.ts new file mode 100644 index 00000000..7b0dd695 --- /dev/null +++ b/src/tests/wallet_transfer_test.ts @@ -0,0 +1,75 @@ +import { Wallet } from "../coinbase/wallet"; +import { WalletAddress } from "../coinbase/address/wallet_address"; +import { newAddressModel } from "./utils"; +import { Coinbase, Transfer } from ".."; +import { FeatureSet, Wallet as WalletModel } from "../client/api"; + +describe("Wallet Transfer", () => { + let wallet: Wallet; + let walletModel: WalletModel; + let defaultAddress: WalletAddress; + const walletId = "test-wallet-id"; + const addressId = "0x123abc..."; + + beforeEach(() => { + const addressModel = newAddressModel(walletId, addressId); + defaultAddress = new WalletAddress(addressModel); + + walletModel = { + id: walletId, + network_id: Coinbase.networks.BaseSepolia, + default_address: addressModel, + feature_set: {} as FeatureSet, + }; + + wallet = Wallet.init(walletModel, ""); + + // Mock getDefaultAddress to return our test address + jest.spyOn(wallet, "getDefaultAddress").mockResolvedValue(defaultAddress); + + // Mock the createTransfer method on the default address + jest.spyOn(defaultAddress, "createTransfer").mockResolvedValue({} as Transfer); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("#createTransfer", () => { + it("should pass through skipBatching to defaultAddress.createTransfer", async () => { + const assetId = "eth"; + + await wallet.createTransfer({ + amount: 1, + assetId, + destination: "0x123abc...", + gasless: true, + skipBatching: true, + }); + + expect(defaultAddress.createTransfer).toHaveBeenCalledWith({ + amount: 1, + assetId, + destination: "0x123abc...", + gasless: true, + skipBatching: true, + }); + + await wallet.createTransfer({ + amount: 1, + assetId, + destination: "0x123abc...", + gasless: true, + skipBatching: false, + }); + + expect(defaultAddress.createTransfer).toHaveBeenCalledWith({ + amount: 1, + assetId, + destination: "0x123abc...", + gasless: true, + skipBatching: false, + }); + }); + }); +});