Skip to content

Commit 7f40110

Browse files
committed
feat: unify consensus and execution based withdrawals
1 parent 34bba46 commit 7f40110

5 files changed

Lines changed: 176 additions & 15 deletions

File tree

src/coinbase/address/external_address.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export class ExternalAddress extends Address {
6767
if (!IsDedicatedEthUnstakeV2Operation(assetId, "unstake", mode, options)) {
6868
await this.validateCanUnstake(amount, assetId, mode, options);
6969
}
70+
7071
return this.buildStakingOperation(amount, assetId, "unstake", mode, options);
7172
}
7273

src/coinbase/address/wallet_address.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
} from "../types";
2727
import { delay } from "../utils";
2828
import { Wallet as WalletClass } from "../wallet";
29-
import { HasWithdrawalCredentialType0x02Option, IsDedicatedEthUnstakeV2Operation, StakingOperation } from "../staking_operation";
29+
import { IsDedicatedEthUnstakeV2Operation, StakingOperation } from "../staking_operation";
3030
import { PayloadSignature } from "../payload_signature";
3131
import { SmartContract } from "../smart_contract";
3232
import { FundOperation } from "../fund_operation";

src/coinbase/staking_operation.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,20 @@ import { Amount, StakeOptionsMode } from "./types";
1010
import { Asset } from "./asset";
1111
import Decimal from "decimal.js";
1212

13-
export const WithdrawalCredentialType0x02 = "0x02";
13+
export const UnstakeTypeExecution = "execution";
14+
export const UnstakeTypeConsensus = "consensus";
1415

1516
/**
16-
* Checks if the given options contain the withdrawal credential type 0x02.
17+
* Checks if the given options contains the unstake type option.
1718
*
1819
* @param options - An object containing various options.
19-
* @returns True if the withdrawal credential type is 0x02, false otherwise.
20+
* @returns True if the unstake type is consensus or execution, false otherwise.
2021
*/
21-
export function HasWithdrawalCredentialType0x02Option(options: { [key: string]: string }): boolean {
22-
return options["withdrawal_credential_type"] === WithdrawalCredentialType0x02;
22+
export function HasUnstakeTypeOption(options: { [key: string]: string }): boolean {
23+
return (
24+
options["unstake_type"] === UnstakeTypeConsensus ||
25+
options["unstake_type"] === UnstakeTypeExecution
26+
);
2327
}
2428

2529
/**
@@ -41,7 +45,7 @@ export function IsDedicatedEthUnstakeV2Operation(
4145
assetId === Coinbase.assets.Eth &&
4246
action == "unstake" &&
4347
mode === StakeOptionsMode.NATIVE &&
44-
HasWithdrawalCredentialType0x02Option(options)
48+
HasUnstakeTypeOption(options)
4549
);
4650
}
4751

@@ -363,10 +367,43 @@ export class ExecutionLayerWithdrawalOptionsBuilder {
363367
}
364368

365369
const executionLayerWithdrawalOptions = {
366-
withdrawal_credential_type: WithdrawalCredentialType0x02,
370+
unstake_type: UnstakeTypeExecution,
367371
validator_unstake_amounts: JSON.stringify(validatorAmounts),
368372
};
369373

370374
return Object.assign({}, options, executionLayerWithdrawalOptions);
371375
}
372376
}
377+
378+
/**
379+
* A builder class for creating consensus layer exit options.
380+
*/
381+
export class ConsensusLayerExitOptionBuilder {
382+
private validatorPubKeys: string[] = [];
383+
384+
/**
385+
* Adds a validator public key to the list of validators.
386+
*
387+
* @param pubKey - The public key of the validator.
388+
*/
389+
addValidator(pubKey: string) {
390+
if (!this.validatorPubKeys.includes(pubKey)) {
391+
this.validatorPubKeys.push(pubKey);
392+
}
393+
}
394+
395+
/**
396+
* Builds the consensus layer exit options.
397+
*
398+
* @param options - Existing options to merge with the built options.
399+
* @returns A promise that resolves to an object containing the consensus layer exit options merged with any provided options.
400+
*/
401+
async build(options: { [key: string]: string } = {}): Promise<{ [key: string]: string }> {
402+
const consensusLayerExitOptions = {
403+
unstake_type: UnstakeTypeConsensus,
404+
validator_pub_keys: this.validatorPubKeys.join(","),
405+
};
406+
407+
return Object.assign({}, options, consensusLayerExitOptions);
408+
}
409+
}

src/tests/external_address_test.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import {
2222
import Decimal from "decimal.js";
2323
import { ExternalAddress } from "../coinbase/address/external_address";
2424
import { StakeOptionsMode } from "../coinbase/types";
25-
import { ExecutionLayerWithdrawalOptionsBuilder, StakingOperation } from "../coinbase/staking_operation";
25+
import {
26+
ConsensusLayerExitOptionBuilder,
27+
ExecutionLayerWithdrawalOptionsBuilder,
28+
StakingOperation,
29+
} from "../coinbase/staking_operation";
2630
import { Asset } from "../coinbase/asset";
2731
import { randomUUID } from "crypto";
2832
import { StakingReward } from "../coinbase/staking_reward";
@@ -410,6 +414,77 @@ describe("ExternalAddress", () => {
410414
expect(Coinbase.apiClients.stake!.buildStakingOperation).toHaveBeenCalledTimes(0);
411415
});
412416

417+
describe("native eth consensus layer exits", () => {
418+
it("should successfully build an unstake operation", async () => {
419+
Coinbase.apiClients.stake!.buildStakingOperation = mockReturnValue(STAKING_OPERATION_MODEL);
420+
Coinbase.apiClients.asset!.getAsset = getAssetMock();
421+
422+
const builder = new ConsensusLayerExitOptionBuilder();
423+
builder.addValidator("0x123");
424+
builder.addValidator("0x456");
425+
builder.addValidator("0x456");
426+
builder.addValidator("0x789");
427+
builder.addValidator("0x789");
428+
const options = await builder.build();
429+
430+
const op = await address.buildUnstakeOperation(
431+
new Decimal("0"),
432+
Coinbase.assets.Eth,
433+
StakeOptionsMode.NATIVE,
434+
options,
435+
);
436+
437+
expect(Coinbase.apiClients.stake!.buildStakingOperation).toHaveBeenCalledWith({
438+
address_id: address.getId(),
439+
network_id: address.getNetworkId(),
440+
asset_id: Coinbase.assets.Eth,
441+
action: "unstake",
442+
options: {
443+
mode: StakeOptionsMode.NATIVE,
444+
unstake_type: "consensus",
445+
validator_pub_keys: "0x123,0x456,0x789",
446+
},
447+
});
448+
expect(op).toBeInstanceOf(StakingOperation);
449+
});
450+
451+
it("should respect existing options", async () => {
452+
Coinbase.apiClients.stake!.buildStakingOperation = mockReturnValue(STAKING_OPERATION_MODEL);
453+
Coinbase.apiClients.asset!.getAsset = getAssetMock();
454+
455+
let options: { [key: string]: string } = { some_other_option: "value" };
456+
457+
const builder = new ConsensusLayerExitOptionBuilder();
458+
builder.addValidator("0x123");
459+
builder.addValidator("0x456");
460+
builder.addValidator("0x456");
461+
builder.addValidator("0x789");
462+
builder.addValidator("0x789");
463+
options = await builder.build(options);
464+
465+
const op = await address.buildUnstakeOperation(
466+
new Decimal("0"),
467+
Coinbase.assets.Eth,
468+
StakeOptionsMode.NATIVE,
469+
options,
470+
);
471+
472+
expect(Coinbase.apiClients.stake!.buildStakingOperation).toHaveBeenCalledWith({
473+
address_id: address.getId(),
474+
network_id: address.getNetworkId(),
475+
asset_id: Coinbase.assets.Eth,
476+
action: "unstake",
477+
options: {
478+
mode: StakeOptionsMode.NATIVE,
479+
some_other_option: "value",
480+
unstake_type: "consensus",
481+
validator_pub_keys: "0x123,0x456,0x789",
482+
},
483+
});
484+
expect(op).toBeInstanceOf(StakingOperation);
485+
});
486+
});
487+
413488
describe("native eth execution layer withdrawals", () => {
414489
it("should successfully build an unstake operation", async () => {
415490
Coinbase.apiClients.stake!.buildStakingOperation = mockReturnValue(STAKING_OPERATION_MODEL);
@@ -434,7 +509,7 @@ describe("ExternalAddress", () => {
434509
action: "unstake",
435510
options: {
436511
mode: StakeOptionsMode.NATIVE,
437-
withdrawal_credential_type: "0x02",
512+
unstake_type: "execution",
438513
validator_unstake_amounts:
439514
'{"0x123":"1000000000000000000000","0x456":"2000000000000000000000"}',
440515
},
@@ -467,7 +542,7 @@ describe("ExternalAddress", () => {
467542
options: {
468543
mode: StakeOptionsMode.NATIVE,
469544
some_other_option: "value",
470-
withdrawal_credential_type: "0x02",
545+
unstake_type: "execution",
471546
validator_unstake_amounts: '{"0x123":"1000000000000000000000"}',
472547
},
473548
});

src/tests/wallet_address_test.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
StakingRewardFormat,
1717
StakingRewardStateEnum,
1818
Trade as TradeModel,
19-
TransferList
19+
TransferList,
2020
} from "../client";
2121
import Decimal from "decimal.js";
2222
import { APIError, FaucetLimitReachedError } from "../coinbase/api_error";
@@ -61,15 +61,19 @@ import {
6161
VALID_TRANSFER_MODEL,
6262
VALID_WALLET_MODEL,
6363
walletsApiMock,
64-
walletStakeApiMock
64+
walletStakeApiMock,
6565
} from "./utils";
6666
import { Transfer } from "../coinbase/transfer";
6767
import { StakeOptionsMode, TransactionStatus } from "../coinbase/types";
6868
import { Trade } from "../coinbase/trade";
6969
import { Transaction } from "../coinbase/transaction";
7070
import { WalletAddress } from "../coinbase/address/wallet_address";
7171
import { Wallet } from "../coinbase/wallet";
72-
import { ExecutionLayerWithdrawalOptionsBuilder, StakingOperation } from "../coinbase/staking_operation";
72+
import {
73+
ConsensusLayerExitOptionBuilder,
74+
ExecutionLayerWithdrawalOptionsBuilder,
75+
StakingOperation,
76+
} from "../coinbase/staking_operation";
7377
import { StakingReward } from "../coinbase/staking_reward";
7478
import { StakingBalance } from "../coinbase/staking_balance";
7579
import { PayloadSignature } from "../coinbase/payload_signature";
@@ -667,6 +671,50 @@ describe("WalletAddress", () => {
667671
expect(op).toBeInstanceOf(StakingOperation);
668672
});
669673

674+
describe("with native eth consensus layer exits", () => {
675+
it("should create a staking operation from the address", async () => {
676+
Coinbase.apiClients.asset!.getAsset = getAssetMock();
677+
Coinbase.apiClients.walletStake!.createStakingOperation =
678+
mockReturnValue(STAKING_OPERATION_MODEL);
679+
Coinbase.apiClients.walletStake!.broadcastStakingOperation =
680+
mockReturnValue(STAKING_OPERATION_MODEL);
681+
STAKING_OPERATION_MODEL.status = StakingOperationStatusEnum.Complete;
682+
Coinbase.apiClients.walletStake!.getStakingOperation =
683+
mockReturnValue(STAKING_OPERATION_MODEL);
684+
685+
const builder = new ConsensusLayerExitOptionBuilder();
686+
builder.addValidator("0x123");
687+
builder.addValidator("0x456");
688+
builder.addValidator("0x456");
689+
builder.addValidator("0x789");
690+
builder.addValidator("0x789");
691+
const options = await builder.build();
692+
693+
const op = await walletAddress.createUnstake(
694+
0.001,
695+
Coinbase.assets.Eth,
696+
StakeOptionsMode.NATIVE,
697+
options,
698+
);
699+
700+
expect(Coinbase.apiClients.walletStake!.createStakingOperation).toHaveBeenCalledWith(
701+
walletAddress.getWalletId(),
702+
walletAddress.getId(),
703+
{
704+
network_id: walletAddress.getNetworkId(),
705+
asset_id: Coinbase.assets.Eth,
706+
action: "unstake",
707+
options: {
708+
mode: StakeOptionsMode.NATIVE,
709+
unstake_type: "consensus",
710+
validator_pub_keys: "0x123,0x456,0x789",
711+
},
712+
},
713+
);
714+
expect(op).toBeInstanceOf(StakingOperation);
715+
});
716+
});
717+
670718
describe("with native eth execution layer withdrawals", () => {
671719
it("should create a staking operation from the address", async () => {
672720
Coinbase.apiClients.asset!.getAsset = getAssetMock();
@@ -699,7 +747,7 @@ describe("WalletAddress", () => {
699747
action: "unstake",
700748
options: {
701749
mode: StakeOptionsMode.NATIVE,
702-
withdrawal_credential_type: "0x02",
750+
unstake_type: "execution",
703751
validator_unstake_amounts:
704752
'{"0x123":"100000000000000000000","0x456":"200000000000000000000"}',
705753
},

0 commit comments

Comments
 (0)