Skip to content
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion quickstart-template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
20 changes: 13 additions & 7 deletions src/coinbase/address/external_address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,7 +63,9 @@ export class ExternalAddress extends Address {
mode: StakeOptionsMode = StakeOptionsMode.DEFAULT,
options: { [key: string]: string } = {},
): Promise<StakingOperation> {
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);
}

Expand Down Expand Up @@ -109,16 +111,20 @@ export class ExternalAddress extends Address {
mode: StakeOptionsMode,
options: { [key: string]: string },
): Promise<StakingOperation> {
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(),
Expand Down
33 changes: 20 additions & 13 deletions src/coinbase/address/wallet_address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -716,7 +716,9 @@ export class WalletAddress extends Address {
timeoutSeconds = 600,
intervalSeconds = 0.2,
): Promise<StakingOperation> {
await this.validateCanUnstake(amount, assetId, mode, options);
if (!HasWithdrawalCredentialType0x02Option(options)) {
await this.validateCanUnstake(amount, assetId, mode, options);
}
return this.createStakingOperation(
amount,
assetId,
Expand Down Expand Up @@ -1008,8 +1010,10 @@ export class WalletAddress extends Address {
timeoutSeconds: number,
intervalSeconds: number,
): Promise<StakingOperation> {
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(
Expand Down Expand Up @@ -1072,9 +1076,12 @@ export class WalletAddress extends Address {
): Promise<StakingOperation> {
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),
Expand Down
66 changes: 66 additions & 0 deletions src/coinbase/staking_operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
4 changes: 2 additions & 2 deletions src/tests/authenticator_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
});
Expand Down Expand Up @@ -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",
);
});
});
Expand Down
71 changes: 68 additions & 3 deletions src/tests/external_address_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading