Skip to content

Commit 5a9ab35

Browse files
authored
Merge pull request #407 from coinbase/v0.22.0
Releasing v0.22.0
2 parents 67e78f9 + 110ea20 commit 5a9ab35

11 files changed

Lines changed: 249 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## Unreleased
44

5+
## [0.22.0] - 2025-04-02
6+
7+
- Add `ExecutionLayerWithdrawalOptionsBuilder` to allow for native ETH execution layer withdrawals as defined in https://eips.ethereum.org/EIPS/eip-7002.
8+
- Add `Hoodi` network support.
9+
510
## [0.21.0] - 2025-02-28
611

712
- Add `getWithdrawalCredentials` getter for `Validator` object to expose withdrawal credentials of an Ethereum validator.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"license": "ISC",
55
"description": "Coinbase Platform SDK",
66
"repository": "https://github.com/coinbase/coinbase-sdk-nodejs",
7-
"version": "0.21.0",
7+
"version": "0.22.0",
88
"main": "dist/index.js",
99
"types": "dist/index.d.ts",
1010
"scripts": {

quickstart-template/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"dependencies": {
2323
"@solana/web3.js": "^2.0.0-rc.1",
2424
"bs58": "^6.0.0",
25-
"@coinbase/coinbase-sdk": "^0.21.0",
25+
"@coinbase/coinbase-sdk": "^0.22.0",
2626
"csv-parse": "^5.5.6",
2727
"csv-writer": "^1.6.0",
2828
"viem": "^2.21.6"

src/client/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2364,6 +2364,7 @@ export const NetworkIdentifier = {
23642364
BaseSepolia: 'base-sepolia',
23652365
BaseMainnet: 'base-mainnet',
23662366
EthereumHolesky: 'ethereum-holesky',
2367+
EthereumHoodi: 'ethereum-hoodi',
23672368
EthereumSepolia: 'ethereum-sepolia',
23682369
EthereumMainnet: 'ethereum-mainnet',
23692370
PolygonMainnet: 'polygon-mainnet',

src/coinbase/address/external_address.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Amount, BroadcastExternalTransactionResponse, StakeOptionsMode } from "
33
import { Coinbase } from "../coinbase";
44
import Decimal from "decimal.js";
55
import { Asset } from "../asset";
6-
import { StakingOperation } from "../staking_operation";
6+
import { HasWithdrawalCredentialType0x02Option, StakingOperation } from "../staking_operation";
77

88
/**
99
* 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 {
6363
mode: StakeOptionsMode = StakeOptionsMode.DEFAULT,
6464
options: { [key: string]: string } = {},
6565
): Promise<StakingOperation> {
66-
await this.validateCanUnstake(amount, assetId, mode, options);
66+
if (!HasWithdrawalCredentialType0x02Option(options)) {
67+
await this.validateCanUnstake(amount, assetId, mode, options);
68+
}
6769
return this.buildStakingOperation(amount, assetId, "unstake", mode, options);
6870
}
6971

@@ -109,16 +111,20 @@ export class ExternalAddress extends Address {
109111
mode: StakeOptionsMode,
110112
options: { [key: string]: string },
111113
): Promise<StakingOperation> {
112-
const stakingAmount = new Decimal(amount.toString());
113-
if (stakingAmount.lessThanOrEqualTo(0)) {
114-
throw new Error(`Amount required greater than zero.`);
115-
}
116114
const asset = await Asset.fetch(this.getNetworkId(), assetId);
117115

118116
const newOptions = this.copyOptions(options);
119117

120118
newOptions.mode = mode;
121-
newOptions.amount = asset.toAtomicAmount(new Decimal(amount.toString())).toString();
119+
120+
if (!HasWithdrawalCredentialType0x02Option(options)) {
121+
const stakingAmount = new Decimal(amount.toString());
122+
if (stakingAmount.lessThanOrEqualTo(0)) {
123+
throw new Error(`Amount required greater than zero.`);
124+
}
125+
126+
newOptions.amount = asset.toAtomicAmount(new Decimal(amount.toString())).toString();
127+
}
122128

123129
const request = {
124130
network_id: this.getNetworkId(),

src/coinbase/address/wallet_address.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,23 @@ import { Transfer } from "../transfer";
1010
import { ContractInvocation } from "../contract_invocation";
1111
import {
1212
Amount,
13-
CreateTransferOptions,
14-
CreateTradeOptions,
1513
CreateContractInvocationOptions,
16-
Destination,
17-
StakeOptionsMode,
14+
CreateCustomContractOptions,
15+
CreateERC1155Options,
1816
CreateERC20Options,
1917
CreateERC721Options,
20-
CreateERC1155Options,
21-
PaginationOptions,
22-
PaginationResponse,
2318
CreateFundOptions,
2419
CreateQuoteOptions,
25-
CreateCustomContractOptions,
20+
CreateTradeOptions,
21+
CreateTransferOptions,
22+
Destination,
23+
PaginationOptions,
24+
PaginationResponse,
25+
StakeOptionsMode,
2626
} from "../types";
2727
import { delay } from "../utils";
2828
import { Wallet as WalletClass } from "../wallet";
29-
import { StakingOperation } from "../staking_operation";
29+
import { HasWithdrawalCredentialType0x02Option, StakingOperation } from "../staking_operation";
3030
import { PayloadSignature } from "../payload_signature";
3131
import { SmartContract } from "../smart_contract";
3232
import { FundOperation } from "../fund_operation";
@@ -716,7 +716,9 @@ export class WalletAddress extends Address {
716716
timeoutSeconds = 600,
717717
intervalSeconds = 0.2,
718718
): Promise<StakingOperation> {
719-
await this.validateCanUnstake(amount, assetId, mode, options);
719+
if (!HasWithdrawalCredentialType0x02Option(options)) {
720+
await this.validateCanUnstake(amount, assetId, mode, options);
721+
}
720722
return this.createStakingOperation(
721723
amount,
722724
assetId,
@@ -1008,8 +1010,10 @@ export class WalletAddress extends Address {
10081010
timeoutSeconds: number,
10091011
intervalSeconds: number,
10101012
): Promise<StakingOperation> {
1011-
if (new Decimal(amount.toString()).lessThanOrEqualTo(0)) {
1012-
throw new Error("Amount required greater than zero.");
1013+
if (!HasWithdrawalCredentialType0x02Option(options)) {
1014+
if (new Decimal(amount.toString()).lessThanOrEqualTo(0)) {
1015+
throw new Error("Amount required greater than zero.");
1016+
}
10131017
}
10141018

10151019
let stakingOperation = await this.createStakingOperationRequest(
@@ -1072,9 +1076,12 @@ export class WalletAddress extends Address {
10721076
): Promise<StakingOperation> {
10731077
const asset = await Asset.fetch(this.getNetworkId(), assetId);
10741078

1075-
options.amount = asset.toAtomicAmount(new Decimal(amount.toString())).toString();
10761079
options.mode = mode ? mode : StakeOptionsMode.DEFAULT;
10771080

1081+
if (!HasWithdrawalCredentialType0x02Option(options)) {
1082+
options.amount = asset.toAtomicAmount(new Decimal(amount.toString())).toString();
1083+
}
1084+
10781085
const stakingOperationRequest = {
10791086
network_id: this.getNetworkId(),
10801087
asset_id: Asset.primaryDenomination(assetId),

src/coinbase/staking_operation.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@ import {
66
import { Transaction } from "./transaction";
77
import { Coinbase } from "./coinbase";
88
import { delay } from "./utils";
9+
import { Amount } from "./types";
10+
import { Asset } from "./asset";
11+
import Decimal from "decimal.js";
12+
13+
export const WithdrawalCredentialType0x02 = "0x02";
14+
15+
/**
16+
* Checks if the given options contain the withdrawal credential type 0x02.
17+
*
18+
* @param options - An object containing various options.
19+
* @returns True if the withdrawal credential type is 0x02, false otherwise.
20+
*/
21+
export function HasWithdrawalCredentialType0x02Option(options: { [key: string]: string }): boolean {
22+
return options["withdrawal_credential_type"] === WithdrawalCredentialType0x02;
23+
}
924

1025
/**
1126
* A representation of a staking operation (stake, unstake, claim stake, etc.). It
@@ -281,3 +296,54 @@ export class StakingOperation {
281296
}
282297
}
283298
}
299+
300+
/**
301+
* A builder class for creating execution layer withdrawal options.
302+
*/
303+
export class ExecutionLayerWithdrawalOptionsBuilder {
304+
private readonly networkId: string;
305+
private validatorAmounts: { [key: string]: Amount } = {};
306+
307+
/**
308+
* Creates an instance of ExecutionLayerWithdrawalOptionsBuilder.
309+
*
310+
* @param networkId - The network ID.
311+
*/
312+
constructor(networkId: string) {
313+
this.networkId = networkId;
314+
}
315+
316+
/**
317+
* Adds a validator withdrawal with the specified public key and amount.
318+
*
319+
* @param pubKey - The public key of the validator.
320+
* @param amount - The amount to withdraw.
321+
*/
322+
addValidatorWithdrawal(pubKey: string, amount: Amount) {
323+
this.validatorAmounts[pubKey] = amount;
324+
}
325+
326+
/**
327+
* Builds the execution layer withdrawal options.
328+
*
329+
* @param options - Existing options to merge with the built options.
330+
* @returns A promise that resolves to an object containing the execution layer withdrawal options merged with any provided options.
331+
*/
332+
async build(options: { [key: string]: string } = {}): Promise<{ [key: string]: string }> {
333+
const asset = await Asset.fetch(this.networkId, Coinbase.assets.Eth);
334+
335+
const validatorAmounts: { [key: string]: string } = {};
336+
337+
for (const pubKey in this.validatorAmounts) {
338+
const amount = this.validatorAmounts[pubKey];
339+
validatorAmounts[pubKey] = asset.toAtomicAmount(new Decimal(amount.toString())).toString();
340+
}
341+
342+
const executionLayerWithdrawalOptions = {
343+
withdrawal_credential_type: WithdrawalCredentialType0x02,
344+
validator_unstake_amounts: JSON.stringify(validatorAmounts),
345+
};
346+
347+
return Object.assign({}, options, executionLayerWithdrawalOptions);
348+
}
349+
}

src/tests/authenticator_test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe("Authenticator tests", () => {
6666
const config = await authenticator.authenticateRequest(VALID_CONFIG, true);
6767
const correlationContext = config.headers["Correlation-Context"] as string;
6868
expect(correlationContext).toContain(
69-
"sdk_version=0.21.0,sdk_language=typescript,source=mockSource",
69+
"sdk_version=0.22.0,sdk_language=typescript,source=mockSource",
7070
);
7171
});
7272
});
@@ -204,7 +204,7 @@ describe("Authenticator tests for Edwards key", () => {
204204
const config = await authenticator.authenticateRequest(VALID_CONFIG, true);
205205
const correlationContext = config.headers["Correlation-Context"] as string;
206206
expect(correlationContext).toContain(
207-
"sdk_version=0.21.0,sdk_language=typescript,source=mockSource",
207+
"sdk_version=0.22.0,sdk_language=typescript,source=mockSource",
208208
);
209209
});
210210
});

src/tests/external_address_test.ts

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,18 @@ import {
1111
import {
1212
AddressBalanceList,
1313
Balance,
14-
FetchStakingRewards200Response,
1514
FetchHistoricalStakingBalances200Response,
15+
FetchStakingRewards200Response,
1616
StakingContext as StakingContextModel,
1717
StakingOperation as StakingOperationModel,
18+
StakingOperationStatusEnum,
1819
StakingRewardFormat,
1920
StakingRewardStateEnum,
20-
StakingOperationStatusEnum,
2121
} from "../client";
2222
import Decimal from "decimal.js";
2323
import { ExternalAddress } from "../coinbase/address/external_address";
2424
import { StakeOptionsMode } from "../coinbase/types";
25-
import { StakingOperation } from "../coinbase/staking_operation";
25+
import { ExecutionLayerWithdrawalOptionsBuilder, StakingOperation } from "../coinbase/staking_operation";
2626
import { Asset } from "../coinbase/asset";
2727
import { randomUUID } from "crypto";
2828
import { StakingReward } from "../coinbase/staking_reward";
@@ -333,6 +333,71 @@ describe("ExternalAddress", () => {
333333
});
334334
expect(Coinbase.apiClients.stake!.buildStakingOperation).toHaveBeenCalledTimes(0);
335335
});
336+
337+
describe("native eth execution layer withdrawals", () => {
338+
it("should successfully build an unstake operation", async () => {
339+
Coinbase.apiClients.stake!.buildStakingOperation = mockReturnValue(STAKING_OPERATION_MODEL);
340+
Coinbase.apiClients.asset!.getAsset = getAssetMock();
341+
342+
const builder = new ExecutionLayerWithdrawalOptionsBuilder(address.getNetworkId());
343+
builder.addValidatorWithdrawal("0x123", new Decimal("1000"));
344+
builder.addValidatorWithdrawal("0x456", new Decimal("2000"));
345+
const options = await builder.build();
346+
347+
const op = await address.buildUnstakeOperation(
348+
new Decimal("0"),
349+
Coinbase.assets.Eth,
350+
StakeOptionsMode.NATIVE,
351+
options,
352+
);
353+
354+
expect(Coinbase.apiClients.stake!.buildStakingOperation).toHaveBeenCalledWith({
355+
address_id: address.getId(),
356+
network_id: address.getNetworkId(),
357+
asset_id: Coinbase.assets.Eth,
358+
action: "unstake",
359+
options: {
360+
mode: StakeOptionsMode.NATIVE,
361+
withdrawal_credential_type: "0x02",
362+
validator_unstake_amounts:
363+
'{"0x123":"1000000000000000000000","0x456":"2000000000000000000000"}',
364+
},
365+
});
366+
expect(op).toBeInstanceOf(StakingOperation);
367+
});
368+
369+
it("should respect existing options", async () => {
370+
Coinbase.apiClients.stake!.buildStakingOperation = mockReturnValue(STAKING_OPERATION_MODEL);
371+
Coinbase.apiClients.asset!.getAsset = getAssetMock();
372+
373+
let options: { [key: string]: string } = { some_other_option: "value" };
374+
375+
const builder = new ExecutionLayerWithdrawalOptionsBuilder(address.getNetworkId());
376+
builder.addValidatorWithdrawal("0x123", new Decimal("1000"));
377+
options = await builder.build(options);
378+
379+
const op = await address.buildUnstakeOperation(
380+
new Decimal("0"),
381+
Coinbase.assets.Eth,
382+
StakeOptionsMode.NATIVE,
383+
options,
384+
);
385+
386+
expect(Coinbase.apiClients.stake!.buildStakingOperation).toHaveBeenCalledWith({
387+
address_id: address.getId(),
388+
network_id: address.getNetworkId(),
389+
asset_id: Coinbase.assets.Eth,
390+
action: "unstake",
391+
options: {
392+
mode: StakeOptionsMode.NATIVE,
393+
some_other_option: "value",
394+
withdrawal_credential_type: "0x02",
395+
validator_unstake_amounts: '{"0x123":"1000000000000000000000"}',
396+
},
397+
});
398+
expect(op).toBeInstanceOf(StakingOperation);
399+
});
400+
});
336401
});
337402

338403
describe("#buildClaimStakeOperation", () => {

0 commit comments

Comments
 (0)