Skip to content

Commit 4d2d1ec

Browse files
feat(abstract-eth): add ZAMA staking delegate flow (approve + deposit)
Implement SDK builder and ABI encoding utilities for the ZAMA ERC-4626 staking delegate flow: - zamaStakingUtils.ts: ABI encoding for ERC20 approve(address,uint256) and ERC4626 deposit(uint256,address) with correct selectors (0x095ea7b3, 0x6e553f65) - zamaStakingBuilder.ts: ZamaStakingBuilder class with buildApprove() and buildDeposit() methods producing {to, data, value} tx requests - Add CoinFeature.STAKING to ERC7984_TOKEN_FEATURES - Comprehensive unit tests (38 new tests) with ABI round-trip decoding TICKET: SI-560 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6d21987 commit 4d2d1ec

11 files changed

Lines changed: 929 additions & 0 deletions

File tree

modules/abstract-eth/src/lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export * from './constants';
22
export * from './zamaUtils';
3+
export * from './zamaStakingUtils';
34
export * from './decryptionDelegationBuilder';
5+
export * from './zamaStakingBuilder';
46
export * from './contractCall';
57
export * from './iface';
68
export * from './keyPair';

modules/abstract-eth/src/lib/transactionBuilder.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from '@bitgo/sdk-core';
2121

2222
import { KeyPair } from './keyPair';
23+
import { ZamaStakingBuilder, ZamaStakingOperationType } from './zamaStakingBuilder';
2324
import { ETHTransactionType, Fee, SignatureParts, TxData } from './iface';
2425
import {
2526
calculateForwarderAddress,
@@ -89,6 +90,9 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
8990
// encoded contract call hex
9091
private _data: string;
9192

93+
// ZAMA staking builder (approve + deposit flow)
94+
private _zamaStakingBuilder?: ZamaStakingBuilder;
95+
9296
// Common parameter for wallet initialization and address initialization transaction
9397
private _salt: string;
9498

@@ -158,6 +162,9 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
158162
return this.buildBase('0x');
159163
case TransactionType.ContractCall:
160164
case TransactionType.DecryptionDelegation:
165+
if (this._zamaStakingBuilder) {
166+
return this.buildZamaStakingTransaction();
167+
}
161168
return this.buildGenericContractCallTransaction();
162169
default:
163170
throw new BuildTransactionError('Unsupported transaction type');
@@ -447,6 +454,9 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
447454
break;
448455
case TransactionType.ContractCall:
449456
case TransactionType.DecryptionDelegation:
457+
if (this._zamaStakingBuilder) {
458+
break; // validated by _zamaStakingBuilder.build()
459+
}
450460
this.validateContractAddress();
451461
this.validateDataField();
452462
break;
@@ -529,6 +539,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
529539
*/
530540
type(type: TransactionType): void {
531541
this._type = type;
542+
this._zamaStakingBuilder = undefined;
532543
}
533544

534545
/**
@@ -882,6 +893,49 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
882893
}
883894
// endregion
884895

896+
// region ZAMA staking (approve + deposit)
897+
898+
/**
899+
* Get the staking approve builder for an ERC20 approve transaction.
900+
* Returns the ZamaStakingBuilder for fluent chaining.
901+
*
902+
* @returns {ZamaStakingBuilder} the staking builder configured for APPROVE
903+
*/
904+
stakingApprove(): ZamaStakingBuilder {
905+
if (this._type !== TransactionType.ContractCall) {
906+
throw new BuildTransactionError('Staking approve can only be set for ContractCall transactions');
907+
}
908+
if (!this._zamaStakingBuilder) {
909+
this._zamaStakingBuilder = new ZamaStakingBuilder().type(ZamaStakingOperationType.APPROVE);
910+
}
911+
return this._zamaStakingBuilder;
912+
}
913+
914+
/**
915+
* Get the staking deposit builder for an ERC4626 deposit transaction.
916+
* Returns the ZamaStakingBuilder for fluent chaining.
917+
*
918+
* @returns {ZamaStakingBuilder} the staking builder configured for DEPOSIT
919+
*/
920+
stakingDeposit(): ZamaStakingBuilder {
921+
if (this._type !== TransactionType.ContractCall) {
922+
throw new BuildTransactionError('Staking deposit can only be set for ContractCall transactions');
923+
}
924+
if (!this._zamaStakingBuilder) {
925+
this._zamaStakingBuilder = new ZamaStakingBuilder().type(ZamaStakingOperationType.DEPOSIT);
926+
}
927+
return this._zamaStakingBuilder;
928+
}
929+
930+
private buildZamaStakingTransaction(): TxData {
931+
const stakingResult = this._zamaStakingBuilder!.build();
932+
const txData = this.buildBase(stakingResult.data);
933+
txData.to = stakingResult.address;
934+
return txData;
935+
}
936+
937+
// endregion
938+
885939
/** @inheritdoc */
886940
protected get transaction(): Transaction {
887941
return this._transaction;
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { BuildTransactionError } from '@bitgo/sdk-core';
2+
import { isValidEthAddress } from './utils';
3+
import { buildApproveCalldata, buildDepositCalldata, approveMethodId, depositMethodId } from './zamaStakingUtils';
4+
5+
/**
6+
* Distinguishes between the two staking operations in the delegate flow.
7+
*/
8+
export enum ZamaStakingOperationType {
9+
/** ERC20 approve — grant OperatorStaking spending allowance. */
10+
APPROVE = 'approve',
11+
/** ERC4626 deposit — deposit ZAMA tokens into the OperatorStaking vault. */
12+
DEPOSIT = 'deposit',
13+
}
14+
15+
/**
16+
* The output of ZamaStakingBuilder.build().
17+
*
18+
* Contains everything the TransactionBuilder needs to construct a signed Ethereum
19+
* transaction: the target contract address, ABI-encoded calldata, and ETH value.
20+
*/
21+
export interface ZamaStakingBuildResult {
22+
/** Target contract address (token for approve, operator for deposit). */
23+
address: string;
24+
/** ABI-encoded calldata (approve or deposit). */
25+
data: string;
26+
/** Always '0' — staking transactions carry no ETH value. */
27+
value: string;
28+
}
29+
30+
/**
31+
* Fluent builder for ZAMA ERC-4626 staking delegate flow transactions.
32+
*
33+
* Used as a helper owned by the abstract-eth TransactionBuilder. The TransactionBuilder
34+
* exposes `stakingApprove()` and `stakingDeposit()` methods that create and return this
35+
* builder for fluent configuration.
36+
*
37+
* The delegate flow consists of two transactions:
38+
*
39+
* 1. **Approve (TX1):** ERC20 `approve(address,uint256)` on the ZAMA token contract,
40+
* granting the OperatorStaking contract permission to transfer tokens.
41+
*
42+
* 2. **Deposit (TX2):** ERC4626 `deposit(uint256,address)` on the OperatorStaking
43+
* contract, depositing ZAMA tokens and receiving stZAMA shares.
44+
*
45+
* Usage via TransactionBuilder:
46+
* txBuilder.type(TransactionType.ContractCall);
47+
* txBuilder.stakingApprove()
48+
* .tokenContractAddress('0xZamaToken...')
49+
* .spenderAddress('0xOperatorStaking...')
50+
* .amount('1000000000000000000');
51+
* const tx = await txBuilder.build();
52+
*/
53+
export class ZamaStakingBuilder {
54+
private _type?: ZamaStakingOperationType;
55+
private _amount?: string;
56+
private _tokenContractAddress?: string;
57+
private _spenderAddress?: string;
58+
private _operatorAddress?: string;
59+
private _receiverAddress?: string;
60+
61+
/**
62+
* Set the staking operation type.
63+
*
64+
* @param type APPROVE or DEPOSIT
65+
*/
66+
type(type: ZamaStakingOperationType): this {
67+
this._type = type;
68+
return this;
69+
}
70+
71+
/**
72+
* Set the amount of ZAMA tokens (18 decimals, as a decimal string).
73+
*
74+
* @param value Token amount
75+
*/
76+
amount(value: string): this {
77+
if (!value || value === '0') {
78+
throw new BuildTransactionError('Invalid amount for staking transaction');
79+
}
80+
this._amount = value;
81+
return this;
82+
}
83+
84+
/**
85+
* Set the ZAMA ERC-20 token contract address (used for APPROVE).
86+
*
87+
* @param address Token contract address
88+
*/
89+
tokenContractAddress(address: string): this {
90+
if (!isValidEthAddress(address)) {
91+
throw new BuildTransactionError('Invalid token contract address: ' + address);
92+
}
93+
this._tokenContractAddress = address;
94+
return this;
95+
}
96+
97+
/**
98+
* Set the OperatorStaking contract address — the approved spender (used for APPROVE).
99+
*
100+
* @param address Spender address
101+
*/
102+
spenderAddress(address: string): this {
103+
if (!isValidEthAddress(address)) {
104+
throw new BuildTransactionError('Invalid spender address: ' + address);
105+
}
106+
this._spenderAddress = address;
107+
return this;
108+
}
109+
110+
/**
111+
* Set the OperatorStaking contract address to deposit into (used for DEPOSIT).
112+
*
113+
* @param address Operator contract address
114+
*/
115+
operatorAddress(address: string): this {
116+
if (!isValidEthAddress(address)) {
117+
throw new BuildTransactionError('Invalid operator address: ' + address);
118+
}
119+
this._operatorAddress = address;
120+
return this;
121+
}
122+
123+
/**
124+
* Set the address that will receive the minted stZAMA shares (used for DEPOSIT).
125+
*
126+
* @param address Receiver address
127+
*/
128+
receiverAddress(address: string): this {
129+
if (!isValidEthAddress(address)) {
130+
throw new BuildTransactionError('Invalid receiver address: ' + address);
131+
}
132+
this._receiverAddress = address;
133+
return this;
134+
}
135+
136+
/**
137+
* Build the staking transaction.
138+
*
139+
* Validates required fields and produces a ZamaStakingBuildResult with the target
140+
* contract address and ABI-encoded calldata.
141+
*
142+
* @returns ZamaStakingBuildResult containing {address, data, value}
143+
* @throws BuildTransactionError if required fields are missing
144+
*/
145+
build(): ZamaStakingBuildResult {
146+
if (this._type === undefined) {
147+
throw new BuildTransactionError('Missing staking operation type');
148+
}
149+
if (this._amount === undefined) {
150+
throw new BuildTransactionError('Missing amount for staking transaction');
151+
}
152+
153+
switch (this._type) {
154+
case ZamaStakingOperationType.APPROVE:
155+
return this.buildApprove();
156+
case ZamaStakingOperationType.DEPOSIT:
157+
return this.buildDeposit();
158+
default:
159+
throw new BuildTransactionError('Invalid staking operation type: ' + this._type);
160+
}
161+
}
162+
163+
private buildApprove(): ZamaStakingBuildResult {
164+
if (!this._tokenContractAddress) {
165+
throw new BuildTransactionError('Missing token contract address for approve');
166+
}
167+
if (!this._spenderAddress) {
168+
throw new BuildTransactionError('Missing spender address for approve');
169+
}
170+
171+
return {
172+
address: this._tokenContractAddress,
173+
data: buildApproveCalldata(this._spenderAddress, this._amount!),
174+
value: '0',
175+
};
176+
}
177+
178+
private buildDeposit(): ZamaStakingBuildResult {
179+
if (!this._operatorAddress) {
180+
throw new BuildTransactionError('Missing operator address for deposit');
181+
}
182+
if (!this._receiverAddress) {
183+
throw new BuildTransactionError('Missing receiver address for deposit');
184+
}
185+
186+
return {
187+
address: this._operatorAddress,
188+
data: buildDepositCalldata(this._amount!, this._receiverAddress),
189+
value: '0',
190+
};
191+
}
192+
193+
/**
194+
* Classify staking operation type from serialized calldata.
195+
*
196+
* @param data ABI-encoded calldata hex string
197+
* @returns true if the data matches a known staking selector
198+
*/
199+
static isStakingData(data: string): boolean {
200+
if (!data || data.length < 10) {
201+
return false;
202+
}
203+
const selector = data.slice(0, 10).toLowerCase();
204+
return selector === approveMethodId.toLowerCase() || selector === depositMethodId.toLowerCase();
205+
}
206+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { addHexPrefix } from 'ethereumjs-util';
2+
import EthereumAbi from 'ethereumjs-abi';
3+
4+
// ---------------------------------------------------------------------------
5+
// Constants
6+
// ---------------------------------------------------------------------------
7+
8+
// ABI parameter type arrays
9+
export const approveTypes = ['address', 'uint256'] as const;
10+
export const depositTypes = ['uint256', 'address'] as const;
11+
12+
/**
13+
* Function selector for ERC20.approve(address,uint256)
14+
* = keccak256('approve(address,uint256)')[0:4] = 0x095ea7b3
15+
*/
16+
export const approveMethodId = addHexPrefix(EthereumAbi.methodID('approve', [...approveTypes]).toString('hex'));
17+
18+
/**
19+
* Function selector for ERC4626.deposit(uint256,address)
20+
* = keccak256('deposit(uint256,address)')[0:4] = 0x6e553f65
21+
*/
22+
export const depositMethodId = addHexPrefix(EthereumAbi.methodID('deposit', [...depositTypes]).toString('hex'));
23+
24+
// ---------------------------------------------------------------------------
25+
// Encoding functions
26+
// ---------------------------------------------------------------------------
27+
28+
/**
29+
* Encodes an ERC20 approve(address,uint256) call.
30+
*
31+
* Grants `spenderAddress` permission to transfer up to `amount` ZAMA tokens
32+
* on behalf of the caller (msg.sender). Used as TX1 of the delegate flow
33+
* to authorize the OperatorStaking contract before depositing.
34+
*
35+
* @param spenderAddress OperatorStaking contract address (the approved spender)
36+
* @param amount Amount of ZAMA tokens to approve (18 decimals, as a decimal string)
37+
* @returns ABI-encoded calldata hex string (0x-prefixed)
38+
*/
39+
export function buildApproveCalldata(spenderAddress: string, amount: string): string {
40+
const method = EthereumAbi.methodID('approve', [...approveTypes]);
41+
const args = EthereumAbi.rawEncode([...approveTypes], [spenderAddress, amount]);
42+
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
43+
}
44+
45+
/**
46+
* Encodes an ERC4626 deposit(uint256,address) call.
47+
*
48+
* Deposits `amount` ZAMA tokens (18 decimals) into the OperatorStaking vault
49+
* and mints stZAMA shares (20 decimals) to `receiverAddress`.
50+
*
51+
* Requires a prior ERC20 approve() call granting the OperatorStaking contract
52+
* at least `amount` allowance.
53+
*
54+
* @param amount Amount of ZAMA tokens to deposit (18 decimals, as a decimal string)
55+
* @param receiverAddress Address that will receive the minted stZAMA shares
56+
* @returns ABI-encoded calldata hex string (0x-prefixed)
57+
*/
58+
export function buildDepositCalldata(amount: string, receiverAddress: string): string {
59+
const method = EthereumAbi.methodID('deposit', [...depositTypes]);
60+
const args = EthereumAbi.rawEncode([...depositTypes], [amount, receiverAddress]);
61+
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
62+
}

modules/abstract-eth/test/unit/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ export * from './transaction';
44
export * from './coin';
55
export * from './messages';
66
export * from './zamaUtils';
7+
export * from './zamaStakingUtils';
8+
export * from './zamaStakingBuilder';
79
export * from './decryptionDelegationBuilder';

modules/abstract-eth/test/unit/transactionBuilder/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './send';
33
export * from './walletInitialization';
44
export * from './flushNft';
55
export * from './decryptionDelegation';
6+
export * from './zamaStaking';

0 commit comments

Comments
 (0)