diff --git a/CHANGELOG.md b/CHANGELOG.md index af0f72af..be22b0c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +## [0.22.0] - 2025-04-02 + +- Add `ExecutionLayerWithdrawalOptionsBuilder` to allow for native ETH execution layer withdrawals as defined in https://eips.ethereum.org/EIPS/eip-7002. +- Add `Hoodi` network support. + ## [0.21.0] - 2025-02-28 - Add `getWithdrawalCredentials` getter for `Validator` object to expose withdrawal credentials of an Ethereum validator. diff --git a/package-lock.json b/package-lock.json index 466d5cac..5585b8f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@coinbase/coinbase-sdk", - "version": "0.21.0", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@coinbase/coinbase-sdk", - "version": "0.21.0", + "version": "0.22.0", "license": "ISC", "dependencies": { "@scure/bip32": "^1.4.0", diff --git a/package.json b/package.json index bc689e3a..cae29094 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "ISC", "description": "Coinbase Platform SDK", "repository": "https://github.com/coinbase/coinbase-sdk-nodejs", - "version": "0.21.0", + "version": "0.22.0", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { diff --git a/quickstart-template/package.json b/quickstart-template/package.json index 5c2427a1..9ac33906 100644 --- a/quickstart-template/package.json +++ b/quickstart-template/package.json @@ -22,7 +22,7 @@ "dependencies": { "@solana/web3.js": "^2.0.0-rc.1", "bs58": "^6.0.0", - "@coinbase/coinbase-sdk": "^0.21.0", + "@coinbase/coinbase-sdk": "^0.22.0", "csv-parse": "^5.5.6", "csv-writer": "^1.6.0", "viem": "^2.21.6" diff --git a/src/client/api.ts b/src/client/api.ts index 371881ea..c644a871 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -2364,6 +2364,7 @@ export const NetworkIdentifier = { BaseSepolia: 'base-sepolia', BaseMainnet: 'base-mainnet', EthereumHolesky: 'ethereum-holesky', + EthereumHoodi: 'ethereum-hoodi', EthereumSepolia: 'ethereum-sepolia', EthereumMainnet: 'ethereum-mainnet', PolygonMainnet: 'polygon-mainnet', diff --git a/src/coinbase/address/external_address.ts b/src/coinbase/address/external_address.ts index 4e95cc93..6d6d4ff2 100644 --- a/src/coinbase/address/external_address.ts +++ b/src/coinbase/address/external_address.ts @@ -3,7 +3,7 @@ import { Amount, BroadcastExternalTransactionResponse, StakeOptionsMode } from " import { Coinbase } from "../coinbase"; import Decimal from "decimal.js"; import { Asset } from "../asset"; -import { StakingOperation } from "../staking_operation"; +import { HasWithdrawalCredentialType0x02Option, StakingOperation } from "../staking_operation"; /** * A representation of a blockchain Address, which is a user-controlled account on a Network. Addresses are used to @@ -63,7 +63,9 @@ export class ExternalAddress extends Address { mode: StakeOptionsMode = StakeOptionsMode.DEFAULT, options: { [key: string]: string } = {}, ): Promise { - await this.validateCanUnstake(amount, assetId, mode, options); + if (!HasWithdrawalCredentialType0x02Option(options)) { + await this.validateCanUnstake(amount, assetId, mode, options); + } return this.buildStakingOperation(amount, assetId, "unstake", mode, options); } @@ -109,16 +111,20 @@ export class ExternalAddress extends Address { mode: StakeOptionsMode, options: { [key: string]: string }, ): Promise { - const stakingAmount = new Decimal(amount.toString()); - if (stakingAmount.lessThanOrEqualTo(0)) { - throw new Error(`Amount required greater than zero.`); - } const asset = await Asset.fetch(this.getNetworkId(), assetId); const newOptions = this.copyOptions(options); newOptions.mode = mode; - newOptions.amount = asset.toAtomicAmount(new Decimal(amount.toString())).toString(); + + if (!HasWithdrawalCredentialType0x02Option(options)) { + const stakingAmount = new Decimal(amount.toString()); + if (stakingAmount.lessThanOrEqualTo(0)) { + throw new Error(`Amount required greater than zero.`); + } + + newOptions.amount = asset.toAtomicAmount(new Decimal(amount.toString())).toString(); + } const request = { network_id: this.getNetworkId(), diff --git a/src/coinbase/address/wallet_address.ts b/src/coinbase/address/wallet_address.ts index 13fab8fb..f0e90cec 100644 --- a/src/coinbase/address/wallet_address.ts +++ b/src/coinbase/address/wallet_address.ts @@ -10,23 +10,23 @@ import { Transfer } from "../transfer"; import { ContractInvocation } from "../contract_invocation"; import { Amount, - CreateTransferOptions, - CreateTradeOptions, CreateContractInvocationOptions, - Destination, - StakeOptionsMode, + CreateCustomContractOptions, + CreateERC1155Options, CreateERC20Options, CreateERC721Options, - CreateERC1155Options, - PaginationOptions, - PaginationResponse, CreateFundOptions, CreateQuoteOptions, - CreateCustomContractOptions, + CreateTradeOptions, + CreateTransferOptions, + Destination, + PaginationOptions, + PaginationResponse, + StakeOptionsMode, } from "../types"; import { delay } from "../utils"; import { Wallet as WalletClass } from "../wallet"; -import { StakingOperation } from "../staking_operation"; +import { HasWithdrawalCredentialType0x02Option, StakingOperation } from "../staking_operation"; import { PayloadSignature } from "../payload_signature"; import { SmartContract } from "../smart_contract"; import { FundOperation } from "../fund_operation"; @@ -716,7 +716,9 @@ export class WalletAddress extends Address { timeoutSeconds = 600, intervalSeconds = 0.2, ): Promise { - await this.validateCanUnstake(amount, assetId, mode, options); + if (!HasWithdrawalCredentialType0x02Option(options)) { + await this.validateCanUnstake(amount, assetId, mode, options); + } return this.createStakingOperation( amount, assetId, @@ -1008,8 +1010,10 @@ export class WalletAddress extends Address { timeoutSeconds: number, intervalSeconds: number, ): Promise { - if (new Decimal(amount.toString()).lessThanOrEqualTo(0)) { - throw new Error("Amount required greater than zero."); + if (!HasWithdrawalCredentialType0x02Option(options)) { + if (new Decimal(amount.toString()).lessThanOrEqualTo(0)) { + throw new Error("Amount required greater than zero."); + } } let stakingOperation = await this.createStakingOperationRequest( @@ -1072,9 +1076,12 @@ export class WalletAddress extends Address { ): Promise { const asset = await Asset.fetch(this.getNetworkId(), assetId); - options.amount = asset.toAtomicAmount(new Decimal(amount.toString())).toString(); options.mode = mode ? mode : StakeOptionsMode.DEFAULT; + if (!HasWithdrawalCredentialType0x02Option(options)) { + options.amount = asset.toAtomicAmount(new Decimal(amount.toString())).toString(); + } + const stakingOperationRequest = { network_id: this.getNetworkId(), asset_id: Asset.primaryDenomination(assetId), diff --git a/src/coinbase/staking_operation.ts b/src/coinbase/staking_operation.ts index 0954a58f..00f908f3 100644 --- a/src/coinbase/staking_operation.ts +++ b/src/coinbase/staking_operation.ts @@ -6,6 +6,21 @@ import { import { Transaction } from "./transaction"; import { Coinbase } from "./coinbase"; import { delay } from "./utils"; +import { Amount } from "./types"; +import { Asset } from "./asset"; +import Decimal from "decimal.js"; + +export const WithdrawalCredentialType0x02 = "0x02"; + +/** + * Checks if the given options contain the withdrawal credential type 0x02. + * + * @param options - An object containing various options. + * @returns True if the withdrawal credential type is 0x02, false otherwise. + */ +export function HasWithdrawalCredentialType0x02Option(options: { [key: string]: string }): boolean { + return options["withdrawal_credential_type"] === WithdrawalCredentialType0x02; +} /** * A representation of a staking operation (stake, unstake, claim stake, etc.). It @@ -281,3 +296,54 @@ export class StakingOperation { } } } + +/** + * A builder class for creating execution layer withdrawal options. + */ +export class ExecutionLayerWithdrawalOptionsBuilder { + private readonly networkId: string; + private validatorAmounts: { [key: string]: Amount } = {}; + + /** + * Creates an instance of ExecutionLayerWithdrawalOptionsBuilder. + * + * @param networkId - The network ID. + */ + constructor(networkId: string) { + this.networkId = networkId; + } + + /** + * Adds a validator withdrawal with the specified public key and amount. + * + * @param pubKey - The public key of the validator. + * @param amount - The amount to withdraw. + */ + addValidatorWithdrawal(pubKey: string, amount: Amount) { + this.validatorAmounts[pubKey] = amount; + } + + /** + * Builds the execution layer withdrawal options. + * + * @param options - Existing options to merge with the built options. + * @returns A promise that resolves to an object containing the execution layer withdrawal options merged with any provided options. + */ + async build(options: { [key: string]: string } = {}): Promise<{ [key: string]: string }> { + const asset = await Asset.fetch(this.networkId, Coinbase.assets.Eth); + + const validatorAmounts: { [key: string]: string } = {}; + + for (const pubKey in this.validatorAmounts) { + const amount = this.validatorAmounts[pubKey]; + validatorAmounts[pubKey] = asset.toAtomicAmount(new Decimal(amount.toString())).toString(); + } + + const executionLayerWithdrawalOptions = { + withdrawal_credential_type: WithdrawalCredentialType0x02, + validator_unstake_amounts: JSON.stringify(validatorAmounts), + }; + + return Object.assign({}, options, executionLayerWithdrawalOptions); + } +} diff --git a/src/tests/authenticator_test.ts b/src/tests/authenticator_test.ts index 71664125..8bc7cb58 100644 --- a/src/tests/authenticator_test.ts +++ b/src/tests/authenticator_test.ts @@ -66,7 +66,7 @@ describe("Authenticator tests", () => { const config = await authenticator.authenticateRequest(VALID_CONFIG, true); const correlationContext = config.headers["Correlation-Context"] as string; expect(correlationContext).toContain( - "sdk_version=0.21.0,sdk_language=typescript,source=mockSource", + "sdk_version=0.22.0,sdk_language=typescript,source=mockSource", ); }); }); @@ -204,7 +204,7 @@ describe("Authenticator tests for Edwards key", () => { const config = await authenticator.authenticateRequest(VALID_CONFIG, true); const correlationContext = config.headers["Correlation-Context"] as string; expect(correlationContext).toContain( - "sdk_version=0.21.0,sdk_language=typescript,source=mockSource", + "sdk_version=0.22.0,sdk_language=typescript,source=mockSource", ); }); }); diff --git a/src/tests/external_address_test.ts b/src/tests/external_address_test.ts index 81e2b4d3..ac56ca3b 100644 --- a/src/tests/external_address_test.ts +++ b/src/tests/external_address_test.ts @@ -11,18 +11,18 @@ import { import { AddressBalanceList, Balance, - FetchStakingRewards200Response, FetchHistoricalStakingBalances200Response, + FetchStakingRewards200Response, StakingContext as StakingContextModel, StakingOperation as StakingOperationModel, + StakingOperationStatusEnum, StakingRewardFormat, StakingRewardStateEnum, - StakingOperationStatusEnum, } from "../client"; import Decimal from "decimal.js"; import { ExternalAddress } from "../coinbase/address/external_address"; import { StakeOptionsMode } from "../coinbase/types"; -import { StakingOperation } from "../coinbase/staking_operation"; +import { ExecutionLayerWithdrawalOptionsBuilder, StakingOperation } from "../coinbase/staking_operation"; import { Asset } from "../coinbase/asset"; import { randomUUID } from "crypto"; import { StakingReward } from "../coinbase/staking_reward"; @@ -333,6 +333,71 @@ describe("ExternalAddress", () => { }); expect(Coinbase.apiClients.stake!.buildStakingOperation).toHaveBeenCalledTimes(0); }); + + describe("native eth execution layer withdrawals", () => { + it("should successfully build an unstake operation", async () => { + Coinbase.apiClients.stake!.buildStakingOperation = mockReturnValue(STAKING_OPERATION_MODEL); + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + + const builder = new ExecutionLayerWithdrawalOptionsBuilder(address.getNetworkId()); + builder.addValidatorWithdrawal("0x123", new Decimal("1000")); + builder.addValidatorWithdrawal("0x456", new Decimal("2000")); + const options = await builder.build(); + + const op = await address.buildUnstakeOperation( + new Decimal("0"), + Coinbase.assets.Eth, + StakeOptionsMode.NATIVE, + options, + ); + + expect(Coinbase.apiClients.stake!.buildStakingOperation).toHaveBeenCalledWith({ + address_id: address.getId(), + network_id: address.getNetworkId(), + asset_id: Coinbase.assets.Eth, + action: "unstake", + options: { + mode: StakeOptionsMode.NATIVE, + withdrawal_credential_type: "0x02", + validator_unstake_amounts: + '{"0x123":"1000000000000000000000","0x456":"2000000000000000000000"}', + }, + }); + expect(op).toBeInstanceOf(StakingOperation); + }); + + it("should respect existing options", async () => { + Coinbase.apiClients.stake!.buildStakingOperation = mockReturnValue(STAKING_OPERATION_MODEL); + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + + let options: { [key: string]: string } = { some_other_option: "value" }; + + const builder = new ExecutionLayerWithdrawalOptionsBuilder(address.getNetworkId()); + builder.addValidatorWithdrawal("0x123", new Decimal("1000")); + options = await builder.build(options); + + const op = await address.buildUnstakeOperation( + new Decimal("0"), + Coinbase.assets.Eth, + StakeOptionsMode.NATIVE, + options, + ); + + expect(Coinbase.apiClients.stake!.buildStakingOperation).toHaveBeenCalledWith({ + address_id: address.getId(), + network_id: address.getNetworkId(), + asset_id: Coinbase.assets.Eth, + action: "unstake", + options: { + mode: StakeOptionsMode.NATIVE, + some_other_option: "value", + withdrawal_credential_type: "0x02", + validator_unstake_amounts: '{"0x123":"1000000000000000000000"}', + }, + }); + expect(op).toBeInstanceOf(StakingOperation); + }); + }); }); describe("#buildClaimStakeOperation", () => { diff --git a/src/tests/wallet_address_test.ts b/src/tests/wallet_address_test.ts index 20593e62..f81965ba 100644 --- a/src/tests/wallet_address_test.ts +++ b/src/tests/wallet_address_test.ts @@ -1,14 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as crypto from "crypto"; -import { AxiosError } from "axios"; import { randomUUID } from "crypto"; +import { AxiosError } from "axios"; import { ethers } from "ethers"; import { FaucetTransaction } from "../coinbase/faucet_transaction"; import { Address as AddressModel, Balance as BalanceModel, - FetchStakingRewards200Response, FetchHistoricalStakingBalances200Response, + FetchStakingRewards200Response, + SmartContractType, StakingContext as StakingContextModel, StakingOperation as StakingOperationModel, StakingOperationStatusEnum, @@ -16,7 +17,6 @@ import { StakingRewardStateEnum, Trade as TradeModel, TransferList, - SmartContractType, } from "../client"; import Decimal from "decimal.js"; import { APIError, FaucetLimitReachedError } from "../coinbase/api_error"; @@ -25,51 +25,54 @@ import { ArgumentError } from "../coinbase/errors"; import { addressesApiMock, assetsApiMock, - externalAddressApiMock, contractInvocationApiMock, + ERC1155_URI, + ERC20_NAME, + ERC20_SYMBOL, + ERC20_TOTAL_SUPPLY, + ERC721_BASE_URI, + ERC721_NAME, + ERC721_SYMBOL, + externalAddressApiMock, generateRandomHash, getAssetMock, + MINT_NFT_ABI, + MINT_NFT_ARGS, mockFn, mockReturnRejectedValue, mockReturnValue, newAddressModel, + smartContractApiMock, stakeApiMock, - walletStakeApiMock, tradeApiMock, transfersApiMock, VALID_ADDRESS_BALANCE_LIST, VALID_ADDRESS_MODEL, + VALID_COMPILED_CONTRACT_MODEL, + VALID_CONTRACT_INVOCATION_MODEL, VALID_FAUCET_TRANSACTION_MODEL, - VALID_TRANSFER_MODEL, - VALID_WALLET_MODEL, - VALID_PAYLOAD_SIGNATURE_MODEL, VALID_PAYLOAD_SIGNATURE_LIST, - VALID_CONTRACT_INVOCATION_MODEL, + VALID_PAYLOAD_SIGNATURE_MODEL, VALID_SIGNED_CONTRACT_INVOCATION_MODEL, - MINT_NFT_ABI, - MINT_NFT_ARGS, - walletsApiMock, + VALID_SMART_CONTRACT_CUSTOM_MODEL, + VALID_SMART_CONTRACT_ERC1155_MODEL, VALID_SMART_CONTRACT_ERC20_MODEL, - smartContractApiMock, - ERC20_NAME, - ERC20_SYMBOL, - ERC20_TOTAL_SUPPLY, VALID_SMART_CONTRACT_ERC721_MODEL, - ERC721_NAME, - ERC721_SYMBOL, - ERC721_BASE_URI, - VALID_SMART_CONTRACT_ERC1155_MODEL, - ERC1155_URI, - VALID_SMART_CONTRACT_CUSTOM_MODEL, - VALID_COMPILED_CONTRACT_MODEL, + VALID_TRANSFER_MODEL, + VALID_WALLET_MODEL, + walletsApiMock, + walletStakeApiMock, } from "./utils"; import { Transfer } from "../coinbase/transfer"; -import { TransactionStatus } from "../coinbase/types"; +import { StakeOptionsMode, TransactionStatus } from "../coinbase/types"; import { Trade } from "../coinbase/trade"; import { Transaction } from "../coinbase/transaction"; import { WalletAddress } from "../coinbase/address/wallet_address"; import { Wallet } from "../coinbase/wallet"; -import { StakingOperation } from "../coinbase/staking_operation"; +import { + ExecutionLayerWithdrawalOptionsBuilder, + StakingOperation, +} from "../coinbase/staking_operation"; import { StakingReward } from "../coinbase/staking_reward"; import { StakingBalance } from "../coinbase/staking_balance"; import { PayloadSignature } from "../coinbase/payload_signature"; @@ -590,6 +593,48 @@ describe("WalletAddress", () => { expect(op).toBeInstanceOf(StakingOperation); }); + + describe("with native eth execution layer withdrawals", () => { + it("should create a staking operation from the address", async () => { + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + Coinbase.apiClients.walletStake!.createStakingOperation = + mockReturnValue(STAKING_OPERATION_MODEL); + Coinbase.apiClients.walletStake!.broadcastStakingOperation = + mockReturnValue(STAKING_OPERATION_MODEL); + STAKING_OPERATION_MODEL.status = StakingOperationStatusEnum.Complete; + Coinbase.apiClients.walletStake!.getStakingOperation = + mockReturnValue(STAKING_OPERATION_MODEL); + + const builder = new ExecutionLayerWithdrawalOptionsBuilder(walletAddress.getNetworkId()); + builder.addValidatorWithdrawal("0x123", 100); + builder.addValidatorWithdrawal("0x456", 200); + const options = await builder.build(); + + const op = await walletAddress.createUnstake( + 0.001, + Coinbase.assets.Eth, + StakeOptionsMode.NATIVE, + options, + ); + + expect(Coinbase.apiClients.walletStake!.createStakingOperation).toHaveBeenCalledWith( + walletAddress.getWalletId(), + walletAddress.getId(), + { + network_id: walletAddress.getNetworkId(), + asset_id: Coinbase.assets.Eth, + action: "unstake", + options: { + mode: StakeOptionsMode.NATIVE, + withdrawal_credential_type: "0x02", + validator_unstake_amounts: + '{"0x123":"100000000000000000000","0x456":"200000000000000000000"}', + }, + }, + ); + expect(op).toBeInstanceOf(StakingOperation); + }); + }); }); describe("#createClaimStake", () => {