From 34bba461162dfaa1365ef49c16260677777d461c Mon Sep 17 00:00:00 2001 From: Danny Joyce Date: Thu, 3 Apr 2025 10:13:48 -0400 Subject: [PATCH 1/4] chore: fix how options are parsed when not doing native eth unstake v2 (#409) * chore: fix how options are parsed when not doing native eth unstake v2 * rename function used to check for unstake operation --- src/coinbase/address/external_address.ts | 8 ++- src/coinbase/address/wallet_address.ts | 11 +-- src/coinbase/staking_operation.ts | 25 ++++++- src/tests/external_address_test.ts | 84 ++++++++++++++++++++-- src/tests/wallet_address_test.ts | 89 +++++++++++++++++++++--- 5 files changed, 197 insertions(+), 20 deletions(-) diff --git a/src/coinbase/address/external_address.ts b/src/coinbase/address/external_address.ts index 6d6d4ff2..4addc8ea 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 { HasWithdrawalCredentialType0x02Option, StakingOperation } from "../staking_operation"; +import { IsDedicatedEthUnstakeV2Operation, 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,8 @@ export class ExternalAddress extends Address { mode: StakeOptionsMode = StakeOptionsMode.DEFAULT, options: { [key: string]: string } = {}, ): Promise { - if (!HasWithdrawalCredentialType0x02Option(options)) { + // If performing a native eth unstake v2, validation is always performed server-side. + if (!IsDedicatedEthUnstakeV2Operation(assetId, "unstake", mode, options)) { await this.validateCanUnstake(amount, assetId, mode, options); } return this.buildStakingOperation(amount, assetId, "unstake", mode, options); @@ -117,7 +118,8 @@ export class ExternalAddress extends Address { newOptions.mode = mode; - if (!HasWithdrawalCredentialType0x02Option(options)) { + // If performing a native eth unstake v2, the amount is not required. + if (!IsDedicatedEthUnstakeV2Operation(assetId, action, mode, newOptions)) { const stakingAmount = new Decimal(amount.toString()); if (stakingAmount.lessThanOrEqualTo(0)) { throw new Error(`Amount required greater than zero.`); diff --git a/src/coinbase/address/wallet_address.ts b/src/coinbase/address/wallet_address.ts index f0e90cec..1e27db39 100644 --- a/src/coinbase/address/wallet_address.ts +++ b/src/coinbase/address/wallet_address.ts @@ -26,7 +26,7 @@ import { } from "../types"; import { delay } from "../utils"; import { Wallet as WalletClass } from "../wallet"; -import { HasWithdrawalCredentialType0x02Option, StakingOperation } from "../staking_operation"; +import { HasWithdrawalCredentialType0x02Option, IsDedicatedEthUnstakeV2Operation, StakingOperation } from "../staking_operation"; import { PayloadSignature } from "../payload_signature"; import { SmartContract } from "../smart_contract"; import { FundOperation } from "../fund_operation"; @@ -716,7 +716,8 @@ export class WalletAddress extends Address { timeoutSeconds = 600, intervalSeconds = 0.2, ): Promise { - if (!HasWithdrawalCredentialType0x02Option(options)) { + // If performing a native ETH unstake, validation is always performed server-side. + if (!IsDedicatedEthUnstakeV2Operation(assetId, "unstake", mode, options)) { await this.validateCanUnstake(amount, assetId, mode, options); } return this.createStakingOperation( @@ -1010,7 +1011,8 @@ export class WalletAddress extends Address { timeoutSeconds: number, intervalSeconds: number, ): Promise { - if (!HasWithdrawalCredentialType0x02Option(options)) { + // If performing a native ETH unstake, the amount is not required. + if (!IsDedicatedEthUnstakeV2Operation(assetId, action, mode, options)) { if (new Decimal(amount.toString()).lessThanOrEqualTo(0)) { throw new Error("Amount required greater than zero."); } @@ -1078,7 +1080,8 @@ export class WalletAddress extends Address { options.mode = mode ? mode : StakeOptionsMode.DEFAULT; - if (!HasWithdrawalCredentialType0x02Option(options)) { + // If performing a native ETH unstake, the amount is not required. + if (!IsDedicatedEthUnstakeV2Operation(assetId, action, mode, options)) { options.amount = asset.toAtomicAmount(new Decimal(amount.toString())).toString(); } diff --git a/src/coinbase/staking_operation.ts b/src/coinbase/staking_operation.ts index 00f908f3..e83fc216 100644 --- a/src/coinbase/staking_operation.ts +++ b/src/coinbase/staking_operation.ts @@ -6,7 +6,7 @@ import { import { Transaction } from "./transaction"; import { Coinbase } from "./coinbase"; import { delay } from "./utils"; -import { Amount } from "./types"; +import { Amount, StakeOptionsMode } from "./types"; import { Asset } from "./asset"; import Decimal from "decimal.js"; @@ -22,6 +22,29 @@ export function HasWithdrawalCredentialType0x02Option(options: { [key: string]: return options["withdrawal_credential_type"] === WithdrawalCredentialType0x02; } +/** + * Determines if the given parameters represent a native ETH unstake operation (version 2). + * + * @param assetId - The ID of the asset. + * @param action - The action being performed. + * @param mode - The mode of the stake options. + * @param options - An object containing various options. + * @returns True if the parameters represent a native ETH unstake operation (version 2), false otherwise. + */ +export function IsDedicatedEthUnstakeV2Operation( + assetId: string, + action: string, + mode: string, + options: { [key: string]: string }, +): boolean { + return ( + assetId === Coinbase.assets.Eth && + action == "unstake" && + mode === StakeOptionsMode.NATIVE && + HasWithdrawalCredentialType0x02Option(options) + ); +} + /** * A representation of a staking operation (stake, unstake, claim stake, etc.). It * may have multiple steps with some being transactions to sign, and others to wait. diff --git a/src/tests/external_address_test.ts b/src/tests/external_address_test.ts index ac56ca3b..a564a08d 100644 --- a/src/tests/external_address_test.ts +++ b/src/tests/external_address_test.ts @@ -35,7 +35,7 @@ describe("ExternalAddress", () => { const STAKING_CONTEXT_MODEL: StakingContextModel = { context: { stakeable_balance: { - amount: "3000000000000000000", + amount: "128000000000000000000", asset: { asset_id: Coinbase.assets.Eth, network_id: Coinbase.networks.EthereumHolesky, @@ -233,11 +233,87 @@ describe("ExternalAddress", () => { expect(op).toBeInstanceOf(StakingOperation); }); + describe("native eth staking", () => { + it("should successfully build an 0x01 stake operation", async () => { + Coinbase.apiClients.stake!.getStakingContext = mockReturnValue(STAKING_CONTEXT_MODEL); + Coinbase.apiClients.stake!.buildStakingOperation = mockReturnValue(STAKING_OPERATION_MODEL); + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + const op = await address.buildStakeOperation( + new Decimal("32"), + Coinbase.assets.Eth, + StakeOptionsMode.NATIVE, + { + withdrawal_credential_type: "0x01", + }, + ); + + expect(Coinbase.apiClients.stake!.getStakingContext).toHaveBeenCalledWith({ + address_id: address.getId(), + network_id: address.getNetworkId(), + asset_id: Coinbase.assets.Eth, + options: { + mode: StakeOptionsMode.NATIVE, + withdrawal_credential_type: "0x01", + }, + }); + expect(Coinbase.apiClients.stake!.buildStakingOperation).toHaveBeenCalledWith({ + address_id: address.getId(), + network_id: address.getNetworkId(), + asset_id: Coinbase.assets.Eth, + action: "stake", + options: { + mode: StakeOptionsMode.NATIVE, + amount: "32000000000000000000", + withdrawal_credential_type: "0x01", + }, + }); + + expect(op).toBeInstanceOf(StakingOperation); + }); + + it("should successfully build an 0x02 stake operation", async () => { + Coinbase.apiClients.stake!.getStakingContext = mockReturnValue(STAKING_CONTEXT_MODEL); + Coinbase.apiClients.stake!.buildStakingOperation = mockReturnValue(STAKING_OPERATION_MODEL); + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + const op = await address.buildStakeOperation( + new Decimal("64"), + Coinbase.assets.Eth, + StakeOptionsMode.NATIVE, + { + withdrawal_credential_type: "0x02", + }, + ); + + expect(Coinbase.apiClients.stake!.getStakingContext).toHaveBeenCalledWith({ + address_id: address.getId(), + network_id: address.getNetworkId(), + asset_id: Coinbase.assets.Eth, + options: { + mode: StakeOptionsMode.NATIVE, + withdrawal_credential_type: "0x02", + }, + }); + expect(Coinbase.apiClients.stake!.buildStakingOperation).toHaveBeenCalledWith({ + address_id: address.getId(), + network_id: address.getNetworkId(), + asset_id: Coinbase.assets.Eth, + action: "stake", + options: { + mode: StakeOptionsMode.NATIVE, + amount: "64000000000000000000", + withdrawal_credential_type: "0x02", + }, + }); + + expect(op).toBeInstanceOf(StakingOperation); + }); + }); + it("should return an error for not enough amount to stake", async () => { Coinbase.apiClients.stake!.getStakingContext = mockReturnValue(STAKING_CONTEXT_MODEL); await expect( - address.buildStakeOperation(new Decimal("3.1"), Coinbase.assets.Eth), + address.buildStakeOperation(new Decimal("300"), Coinbase.assets.Eth), ).rejects.toThrow(Error); expect(Coinbase.apiClients.stake!.getStakingContext).toHaveBeenCalledWith({ address_id: address.getId(), @@ -688,7 +764,7 @@ describe("ExternalAddress", () => { it("should return the stakeable balance successfully with default params", async () => { Coinbase.apiClients.stake!.getStakingContext = mockReturnValue(STAKING_CONTEXT_MODEL); const stakeableBalance = await address.stakeableBalance(Coinbase.assets.Eth); - expect(stakeableBalance).toEqual(new Decimal("3")); + expect(stakeableBalance).toEqual(new Decimal("128")); expect(Coinbase.apiClients.stake!.getStakingContext).toHaveBeenCalledWith({ address_id: address.getId(), network_id: address.getNetworkId(), @@ -706,7 +782,7 @@ describe("ExternalAddress", () => { StakeOptionsMode.PARTIAL, {}, ); - expect(stakeableBalance).toEqual(new Decimal("3")); + expect(stakeableBalance).toEqual(new Decimal("128")); expect(Coinbase.apiClients.stake!.getStakingContext).toHaveBeenCalledWith({ address_id: address.getId(), network_id: address.getNetworkId(), diff --git a/src/tests/wallet_address_test.ts b/src/tests/wallet_address_test.ts index f81965ba..1d699692 100644 --- a/src/tests/wallet_address_test.ts +++ b/src/tests/wallet_address_test.ts @@ -16,7 +16,7 @@ import { StakingRewardFormat, StakingRewardStateEnum, Trade as TradeModel, - TransferList, + TransferList } from "../client"; import Decimal from "decimal.js"; import { APIError, FaucetLimitReachedError } from "../coinbase/api_error"; @@ -61,7 +61,7 @@ import { VALID_TRANSFER_MODEL, VALID_WALLET_MODEL, walletsApiMock, - walletStakeApiMock, + walletStakeApiMock } from "./utils"; import { Transfer } from "../coinbase/transfer"; import { StakeOptionsMode, TransactionStatus } from "../coinbase/types"; @@ -69,10 +69,7 @@ import { Trade } from "../coinbase/trade"; import { Transaction } from "../coinbase/transaction"; import { WalletAddress } from "../coinbase/address/wallet_address"; import { Wallet } from "../coinbase/wallet"; -import { - ExecutionLayerWithdrawalOptionsBuilder, - 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"; @@ -374,7 +371,7 @@ describe("WalletAddress", () => { const STAKING_CONTEXT_MODEL: StakingContextModel = { context: { stakeable_balance: { - amount: "3000000000000000000", + amount: "128000000000000000000", asset: { asset_id: Coinbase.assets.Eth, network_id: Coinbase.networks.EthereumHolesky, @@ -533,6 +530,82 @@ describe("WalletAddress", () => { expect(op).toBeInstanceOf(StakingOperation); }); + describe("native eth staking", () => { + it("should successfully create an 0x01 stake operation", async () => { + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + Coinbase.apiClients.stake!.getStakingContext = mockReturnValue(STAKING_CONTEXT_MODEL); + 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 op = await walletAddress.createStake( + 32, + Coinbase.assets.Eth, + StakeOptionsMode.NATIVE, + { + withdrawal_credential_type: "0x01", + }, + ); + + expect(Coinbase.apiClients.walletStake!.createStakingOperation).toHaveBeenCalledWith( + walletAddress.getWalletId(), + walletAddress.getId(), + { + network_id: walletAddress.getNetworkId(), + asset_id: Coinbase.assets.Eth, + action: "stake", + options: { + mode: StakeOptionsMode.NATIVE, + amount: "32000000000000000000", + withdrawal_credential_type: "0x01", + }, + }, + ); + expect(op).toBeInstanceOf(StakingOperation); + }); + + it("should successfully create an 0x02 stake operation", async () => { + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + Coinbase.apiClients.stake!.getStakingContext = mockReturnValue(STAKING_CONTEXT_MODEL); + 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 op = await walletAddress.createStake( + 64, + Coinbase.assets.Eth, + StakeOptionsMode.NATIVE, + { + withdrawal_credential_type: "0x02", + }, + ); + + expect(Coinbase.apiClients.walletStake!.createStakingOperation).toHaveBeenCalledWith( + walletAddress.getWalletId(), + walletAddress.getId(), + { + network_id: walletAddress.getNetworkId(), + asset_id: Coinbase.assets.Eth, + action: "stake", + options: { + mode: StakeOptionsMode.NATIVE, + amount: "64000000000000000000", + withdrawal_credential_type: "0x02", + }, + }, + ); + expect(op).toBeInstanceOf(StakingOperation); + }); + }); + it("should create a staking operation from the address but in failed status", async () => { Coinbase.apiClients.asset!.getAsset = getAssetMock(); Coinbase.apiClients.stake!.getStakingContext = mockReturnValue(STAKING_CONTEXT_MODEL); @@ -659,7 +732,7 @@ describe("WalletAddress", () => { it("should return the stakeable balance successfully with default params", async () => { Coinbase.apiClients.stake!.getStakingContext = mockReturnValue(STAKING_CONTEXT_MODEL); const stakeableBalance = await walletAddress.stakeableBalance(Coinbase.assets.Eth); - expect(stakeableBalance).toEqual(new Decimal("3")); + expect(stakeableBalance).toEqual(new Decimal("128")); }); }); From 7f401103905e2720e1b6b67e3ca5a821fbf99877 Mon Sep 17 00:00:00 2001 From: Rohit Durvasula Date: Wed, 16 Apr 2025 10:43:53 -0700 Subject: [PATCH 2/4] feat: unify consensus and execution based withdrawals --- src/coinbase/address/external_address.ts | 1 + src/coinbase/address/wallet_address.ts | 2 +- src/coinbase/staking_operation.ts | 51 +++++++++++++-- src/tests/external_address_test.ts | 81 +++++++++++++++++++++++- src/tests/wallet_address_test.ts | 56 ++++++++++++++-- 5 files changed, 176 insertions(+), 15 deletions(-) diff --git a/src/coinbase/address/external_address.ts b/src/coinbase/address/external_address.ts index 4addc8ea..dd975f0c 100644 --- a/src/coinbase/address/external_address.ts +++ b/src/coinbase/address/external_address.ts @@ -67,6 +67,7 @@ export class ExternalAddress extends Address { if (!IsDedicatedEthUnstakeV2Operation(assetId, "unstake", mode, options)) { await this.validateCanUnstake(amount, assetId, mode, options); } + return this.buildStakingOperation(amount, assetId, "unstake", mode, options); } diff --git a/src/coinbase/address/wallet_address.ts b/src/coinbase/address/wallet_address.ts index 1e27db39..f56d07c1 100644 --- a/src/coinbase/address/wallet_address.ts +++ b/src/coinbase/address/wallet_address.ts @@ -26,7 +26,7 @@ import { } from "../types"; import { delay } from "../utils"; import { Wallet as WalletClass } from "../wallet"; -import { HasWithdrawalCredentialType0x02Option, IsDedicatedEthUnstakeV2Operation, StakingOperation } from "../staking_operation"; +import { IsDedicatedEthUnstakeV2Operation, StakingOperation } from "../staking_operation"; import { PayloadSignature } from "../payload_signature"; import { SmartContract } from "../smart_contract"; import { FundOperation } from "../fund_operation"; diff --git a/src/coinbase/staking_operation.ts b/src/coinbase/staking_operation.ts index e83fc216..fefac629 100644 --- a/src/coinbase/staking_operation.ts +++ b/src/coinbase/staking_operation.ts @@ -10,16 +10,20 @@ import { Amount, StakeOptionsMode } from "./types"; import { Asset } from "./asset"; import Decimal from "decimal.js"; -export const WithdrawalCredentialType0x02 = "0x02"; +export const UnstakeTypeExecution = "execution"; +export const UnstakeTypeConsensus = "consensus"; /** - * Checks if the given options contain the withdrawal credential type 0x02. + * Checks if the given options contains the unstake type option. * * @param options - An object containing various options. - * @returns True if the withdrawal credential type is 0x02, false otherwise. + * @returns True if the unstake type is consensus or execution, false otherwise. */ -export function HasWithdrawalCredentialType0x02Option(options: { [key: string]: string }): boolean { - return options["withdrawal_credential_type"] === WithdrawalCredentialType0x02; +export function HasUnstakeTypeOption(options: { [key: string]: string }): boolean { + return ( + options["unstake_type"] === UnstakeTypeConsensus || + options["unstake_type"] === UnstakeTypeExecution + ); } /** @@ -41,7 +45,7 @@ export function IsDedicatedEthUnstakeV2Operation( assetId === Coinbase.assets.Eth && action == "unstake" && mode === StakeOptionsMode.NATIVE && - HasWithdrawalCredentialType0x02Option(options) + HasUnstakeTypeOption(options) ); } @@ -363,10 +367,43 @@ export class ExecutionLayerWithdrawalOptionsBuilder { } const executionLayerWithdrawalOptions = { - withdrawal_credential_type: WithdrawalCredentialType0x02, + unstake_type: UnstakeTypeExecution, validator_unstake_amounts: JSON.stringify(validatorAmounts), }; return Object.assign({}, options, executionLayerWithdrawalOptions); } } + +/** + * A builder class for creating consensus layer exit options. + */ +export class ConsensusLayerExitOptionBuilder { + private validatorPubKeys: string[] = []; + + /** + * Adds a validator public key to the list of validators. + * + * @param pubKey - The public key of the validator. + */ + addValidator(pubKey: string) { + if (!this.validatorPubKeys.includes(pubKey)) { + this.validatorPubKeys.push(pubKey); + } + } + + /** + * Builds the consensus layer exit options. + * + * @param options - Existing options to merge with the built options. + * @returns A promise that resolves to an object containing the consensus layer exit options merged with any provided options. + */ + async build(options: { [key: string]: string } = {}): Promise<{ [key: string]: string }> { + const consensusLayerExitOptions = { + unstake_type: UnstakeTypeConsensus, + validator_pub_keys: this.validatorPubKeys.join(","), + }; + + return Object.assign({}, options, consensusLayerExitOptions); + } +} diff --git a/src/tests/external_address_test.ts b/src/tests/external_address_test.ts index a564a08d..2f590686 100644 --- a/src/tests/external_address_test.ts +++ b/src/tests/external_address_test.ts @@ -22,7 +22,11 @@ import { import Decimal from "decimal.js"; import { ExternalAddress } from "../coinbase/address/external_address"; import { StakeOptionsMode } from "../coinbase/types"; -import { ExecutionLayerWithdrawalOptionsBuilder, StakingOperation } from "../coinbase/staking_operation"; +import { + ConsensusLayerExitOptionBuilder, + ExecutionLayerWithdrawalOptionsBuilder, + StakingOperation, +} from "../coinbase/staking_operation"; import { Asset } from "../coinbase/asset"; import { randomUUID } from "crypto"; import { StakingReward } from "../coinbase/staking_reward"; @@ -410,6 +414,77 @@ describe("ExternalAddress", () => { expect(Coinbase.apiClients.stake!.buildStakingOperation).toHaveBeenCalledTimes(0); }); + describe("native eth consensus layer exits", () => { + it("should successfully build an unstake operation", async () => { + Coinbase.apiClients.stake!.buildStakingOperation = mockReturnValue(STAKING_OPERATION_MODEL); + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + + const builder = new ConsensusLayerExitOptionBuilder(); + builder.addValidator("0x123"); + builder.addValidator("0x456"); + builder.addValidator("0x456"); + builder.addValidator("0x789"); + builder.addValidator("0x789"); + 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, + unstake_type: "consensus", + validator_pub_keys: "0x123,0x456,0x789", + }, + }); + 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 ConsensusLayerExitOptionBuilder(); + builder.addValidator("0x123"); + builder.addValidator("0x456"); + builder.addValidator("0x456"); + builder.addValidator("0x789"); + builder.addValidator("0x789"); + 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", + unstake_type: "consensus", + validator_pub_keys: "0x123,0x456,0x789", + }, + }); + expect(op).toBeInstanceOf(StakingOperation); + }); + }); + describe("native eth execution layer withdrawals", () => { it("should successfully build an unstake operation", async () => { Coinbase.apiClients.stake!.buildStakingOperation = mockReturnValue(STAKING_OPERATION_MODEL); @@ -434,7 +509,7 @@ describe("ExternalAddress", () => { action: "unstake", options: { mode: StakeOptionsMode.NATIVE, - withdrawal_credential_type: "0x02", + unstake_type: "execution", validator_unstake_amounts: '{"0x123":"1000000000000000000000","0x456":"2000000000000000000000"}', }, @@ -467,7 +542,7 @@ describe("ExternalAddress", () => { options: { mode: StakeOptionsMode.NATIVE, some_other_option: "value", - withdrawal_credential_type: "0x02", + unstake_type: "execution", validator_unstake_amounts: '{"0x123":"1000000000000000000000"}', }, }); diff --git a/src/tests/wallet_address_test.ts b/src/tests/wallet_address_test.ts index 1d699692..a549b2ad 100644 --- a/src/tests/wallet_address_test.ts +++ b/src/tests/wallet_address_test.ts @@ -16,7 +16,7 @@ import { StakingRewardFormat, StakingRewardStateEnum, Trade as TradeModel, - TransferList + TransferList, } from "../client"; import Decimal from "decimal.js"; import { APIError, FaucetLimitReachedError } from "../coinbase/api_error"; @@ -61,7 +61,7 @@ import { VALID_TRANSFER_MODEL, VALID_WALLET_MODEL, walletsApiMock, - walletStakeApiMock + walletStakeApiMock, } from "./utils"; import { Transfer } from "../coinbase/transfer"; import { StakeOptionsMode, TransactionStatus } from "../coinbase/types"; @@ -69,7 +69,11 @@ import { Trade } from "../coinbase/trade"; import { Transaction } from "../coinbase/transaction"; import { WalletAddress } from "../coinbase/address/wallet_address"; import { Wallet } from "../coinbase/wallet"; -import { ExecutionLayerWithdrawalOptionsBuilder, StakingOperation } from "../coinbase/staking_operation"; +import { + ConsensusLayerExitOptionBuilder, + ExecutionLayerWithdrawalOptionsBuilder, + StakingOperation, +} from "../coinbase/staking_operation"; import { StakingReward } from "../coinbase/staking_reward"; import { StakingBalance } from "../coinbase/staking_balance"; import { PayloadSignature } from "../coinbase/payload_signature"; @@ -667,6 +671,50 @@ describe("WalletAddress", () => { expect(op).toBeInstanceOf(StakingOperation); }); + describe("with native eth consensus layer exits", () => { + 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 ConsensusLayerExitOptionBuilder(); + builder.addValidator("0x123"); + builder.addValidator("0x456"); + builder.addValidator("0x456"); + builder.addValidator("0x789"); + builder.addValidator("0x789"); + 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, + unstake_type: "consensus", + validator_pub_keys: "0x123,0x456,0x789", + }, + }, + ); + 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(); @@ -699,7 +747,7 @@ describe("WalletAddress", () => { action: "unstake", options: { mode: StakeOptionsMode.NATIVE, - withdrawal_credential_type: "0x02", + unstake_type: "execution", validator_unstake_amounts: '{"0x123":"100000000000000000000","0x456":"200000000000000000000"}', }, From dba5db4372ce4d552457bdd5ca12e2c07a2c4b23 Mon Sep 17 00:00:00 2001 From: Rohit Durvasula Date: Wed, 16 Apr 2025 14:05:58 -0700 Subject: [PATCH 3/4] chore: Prep for v0.23.0 release --- CHANGELOG.md | 4 ++++ package-lock.json | 4 ++-- package.json | 2 +- src/tests/authenticator_test.ts | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be22b0c3..76d14985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +## [0.23.0] - 2025-04-16 + +- Add support for both consensus and execution based withdrawals post-pectra. + ## [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. diff --git a/package-lock.json b/package-lock.json index 5585b8f9..911afa05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@coinbase/coinbase-sdk", - "version": "0.22.0", + "version": "0.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@coinbase/coinbase-sdk", - "version": "0.22.0", + "version": "0.23.0", "license": "ISC", "dependencies": { "@scure/bip32": "^1.4.0", diff --git a/package.json b/package.json index cae29094..9dcbf7b7 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.22.0", + "version": "0.23.0", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { diff --git a/src/tests/authenticator_test.ts b/src/tests/authenticator_test.ts index 8bc7cb58..d7d078b0 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.22.0,sdk_language=typescript,source=mockSource", + "sdk_version=0.23.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.22.0,sdk_language=typescript,source=mockSource", + "sdk_version=0.23.0,sdk_language=typescript,source=mockSource", ); }); }); From 26808e550035e6745ff2b5c0c66fb06b01416eea Mon Sep 17 00:00:00 2001 From: Rohit Durvasula Date: Thu, 17 Apr 2025 07:54:43 -0700 Subject: [PATCH 4/4] update version in quickstart template --- quickstart-template/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quickstart-template/package.json b/quickstart-template/package.json index 9ac33906..d8a4a74f 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.22.0", + "@coinbase/coinbase-sdk": "^0.23.0", "csv-parse": "^5.5.6", "csv-writer": "^1.6.0", "viem": "^2.21.6"