Skip to content
Draft
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
17 changes: 17 additions & 0 deletions modules/abstract-eth/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,20 @@ export interface ForwarderInitializationData {
addressCreationSalt?: string;
feeAddress?: string;
}

/**
* Generic result from a staking builder's build() method.
*
* Any staking builder (ZAMA, future tokens) can implement a build() method
* returning this interface. The TransactionBuilder uses it to populate the
* transaction's target address and calldata without knowing the specifics
* of the staking protocol.
*/
export interface StakingBuildResult {
/** Target contract address for the staking transaction. */
address: string;
/** ABI-encoded calldata for the staking operation. */
data: string;
/** ETH value to send with the transaction (typically '0'). */
value: string;
}
2 changes: 2 additions & 0 deletions modules/abstract-eth/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export * from './constants';
export * from './zamaUtils';
export * from './zamaStakingUtils';
export * from './decryptionDelegationBuilder';
export * from './zamaStakingBuilder';
export * from './contractCall';
export * from './iface';
export * from './keyPair';
Expand Down
42 changes: 41 additions & 1 deletion modules/abstract-eth/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
} from '@bitgo/sdk-core';

import { KeyPair } from './keyPair';
import { ETHTransactionType, Fee, SignatureParts, TxData } from './iface';
import { ETHTransactionType, Fee, SignatureParts, StakingBuildResult, TxData } from './iface';
import {
calculateForwarderAddress,
calculateForwarderV1Address,
Expand Down Expand Up @@ -89,6 +89,9 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
// encoded contract call hex
private _data: string;

// Generic staking builder — any builder whose build() returns StakingBuildResult
private _stakingBuilder?: { build(): StakingBuildResult };

// Common parameter for wallet initialization and address initialization transaction
private _salt: string;

Expand Down Expand Up @@ -158,6 +161,9 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
return this.buildBase('0x');
case TransactionType.ContractCall:
case TransactionType.DecryptionDelegation:
if (this._stakingBuilder) {
return this.buildStakingTransaction();
}
return this.buildGenericContractCallTransaction();
default:
throw new BuildTransactionError('Unsupported transaction type');
Expand Down Expand Up @@ -447,6 +453,9 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
break;
case TransactionType.ContractCall:
case TransactionType.DecryptionDelegation:
if (this._stakingBuilder) {
break; // validated by _stakingBuilder.build()
}
this.validateContractAddress();
this.validateDataField();
break;
Expand Down Expand Up @@ -529,6 +538,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
*/
type(type: TransactionType): void {
this._type = type;
this._stakingBuilder = undefined;
}

/**
Expand Down Expand Up @@ -882,6 +892,36 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
}
// endregion

// region staking

/**
* Set a staking builder for this transaction.
*
* Accepts any object whose `build()` method returns a `StakingBuildResult`
* (`{ address, data, value }`). The TransactionBuilder will call `build()` at
* transaction build time to get the target contract address and encoded calldata.
*
* This is generic — new staking protocols just need a builder that produces
* `StakingBuildResult`, without any changes to the TransactionBuilder.
*
* @param builder A staking builder (e.g. ZamaStakingBuilder)
*/
staking(builder: { build(): StakingBuildResult }): void {
if (this._type !== TransactionType.ContractCall) {
throw new BuildTransactionError('Staking can only be set for ContractCall transactions');
}
this._stakingBuilder = builder;
}

private buildStakingTransaction(): TxData {
const stakingResult = this._stakingBuilder!.build();
const txData = this.buildBase(stakingResult.data);
txData.to = stakingResult.address;
return txData;
}

// endregion

/** @inheritdoc */
protected get transaction(): Transaction {
return this._transaction;
Expand Down
194 changes: 194 additions & 0 deletions modules/abstract-eth/src/lib/zamaStakingBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { BuildTransactionError } from '@bitgo/sdk-core';
import { isValidEthAddress } from './utils';
import { buildApproveCalldata, buildDepositCalldata, approveMethodId, depositMethodId } from './zamaStakingUtils';
import { StakingBuildResult } from './iface';

/**
* Distinguishes between the two staking operations in the delegate flow.
*/
export enum ZamaStakingOperationType {
/** ERC20 approve — grant OperatorStaking spending allowance. */
APPROVE = 'approve',
/** ERC4626 deposit — deposit ZAMA tokens into the OperatorStaking vault. */
DEPOSIT = 'deposit',
}

/**
* Fluent builder for ZAMA ERC-4626 staking delegate flow transactions.
*
* Used as a helper owned by the abstract-eth TransactionBuilder. Pass an instance
* to `txBuilder.staking(builder)` to integrate it into the transaction pipeline.
*
* The delegate flow consists of two transactions:
*
* 1. **Approve (TX1):** ERC20 `approve(address,uint256)` on the ZAMA token contract,
* granting the OperatorStaking contract permission to transfer tokens.
*
* 2. **Deposit (TX2):** ERC4626 `deposit(uint256,address)` on the OperatorStaking
* contract, depositing ZAMA tokens and receiving stZAMA shares.
*
* Usage via TransactionBuilder:
* txBuilder.type(TransactionType.ContractCall);
* txBuilder.staking(
* new ZamaStakingBuilder()
* .type(ZamaStakingOperationType.APPROVE)
* .tokenContractAddress('0xZamaToken...')
* .spenderAddress('0xOperatorStaking...')
* .amount('1000000000000000000')
* );
* const tx = await txBuilder.build();
*/
export class ZamaStakingBuilder {
private _type?: ZamaStakingOperationType;
private _amount?: string;
private _tokenContractAddress?: string;
private _spenderAddress?: string;
private _operatorAddress?: string;
private _receiverAddress?: string;

/**
* Set the staking operation type.
*
* @param type APPROVE or DEPOSIT
*/
type(type: ZamaStakingOperationType): this {
this._type = type;
return this;
}

/**
* Set the amount of ZAMA tokens (18 decimals, as a decimal string).
*
* @param value Token amount
*/
amount(value: string): this {
if (!value || value === '0') {
throw new BuildTransactionError('Invalid amount for staking transaction');
}
this._amount = value;
return this;
}

/**
* Set the ZAMA ERC-20 token contract address (used for APPROVE).
*
* @param address Token contract address
*/
tokenContractAddress(address: string): this {
if (!isValidEthAddress(address)) {
throw new BuildTransactionError('Invalid token contract address: ' + address);
}
this._tokenContractAddress = address;
return this;
}

/**
* Set the OperatorStaking contract address — the approved spender (used for APPROVE).
*
* @param address Spender address
*/
spenderAddress(address: string): this {
if (!isValidEthAddress(address)) {
throw new BuildTransactionError('Invalid spender address: ' + address);
}
this._spenderAddress = address;
return this;
}

/**
* Set the OperatorStaking contract address to deposit into (used for DEPOSIT).
*
* @param address Operator contract address
*/
operatorAddress(address: string): this {
if (!isValidEthAddress(address)) {
throw new BuildTransactionError('Invalid operator address: ' + address);
}
this._operatorAddress = address;
return this;
}

/**
* Set the address that will receive the minted stZAMA shares (used for DEPOSIT).
*
* @param address Receiver address
*/
receiverAddress(address: string): this {
if (!isValidEthAddress(address)) {
throw new BuildTransactionError('Invalid receiver address: ' + address);
}
this._receiverAddress = address;
return this;
}

/**
* Build the staking transaction.
*
* Validates required fields and produces a StakingBuildResult with the target
* contract address and ABI-encoded calldata.
*
* @returns StakingBuildResult containing {address, data, value}
* @throws BuildTransactionError if required fields are missing
*/
build(): StakingBuildResult {
if (this._type === undefined) {
throw new BuildTransactionError('Missing staking operation type');
}
if (this._amount === undefined) {
throw new BuildTransactionError('Missing amount for staking transaction');
}

switch (this._type) {
case ZamaStakingOperationType.APPROVE:
return this.buildApprove();
case ZamaStakingOperationType.DEPOSIT:
return this.buildDeposit();
default:
throw new BuildTransactionError('Invalid staking operation type: ' + this._type);
}
}

private buildApprove(): StakingBuildResult {
if (!this._tokenContractAddress) {
throw new BuildTransactionError('Missing token contract address for approve');
}
if (!this._spenderAddress) {
throw new BuildTransactionError('Missing spender address for approve');
}

return {
address: this._tokenContractAddress,
data: buildApproveCalldata(this._spenderAddress, this._amount!),
value: '0',
};
}

private buildDeposit(): StakingBuildResult {
if (!this._operatorAddress) {
throw new BuildTransactionError('Missing operator address for deposit');
}
if (!this._receiverAddress) {
throw new BuildTransactionError('Missing receiver address for deposit');
}

return {
address: this._operatorAddress,
data: buildDepositCalldata(this._amount!, this._receiverAddress),
value: '0',
};
}

/**
* Classify staking operation type from serialized calldata.
*
* @param data ABI-encoded calldata hex string
* @returns true if the data matches a known ZAMA staking selector
*/
static isStakingData(data: string): boolean {
if (!data || data.length < 10) {
return false;
}
const selector = data.slice(0, 10).toLowerCase();
return selector === approveMethodId.toLowerCase() || selector === depositMethodId.toLowerCase();
}
}
62 changes: 62 additions & 0 deletions modules/abstract-eth/src/lib/zamaStakingUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { addHexPrefix } from 'ethereumjs-util';
import EthereumAbi from 'ethereumjs-abi';

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

// ABI parameter type arrays
export const approveTypes = ['address', 'uint256'] as const;
export const depositTypes = ['uint256', 'address'] as const;

/**
* Function selector for ERC20.approve(address,uint256)
* = keccak256('approve(address,uint256)')[0:4] = 0x095ea7b3
*/
export const approveMethodId = addHexPrefix(EthereumAbi.methodID('approve', [...approveTypes]).toString('hex'));

/**
* Function selector for ERC4626.deposit(uint256,address)
* = keccak256('deposit(uint256,address)')[0:4] = 0x6e553f65
*/
export const depositMethodId = addHexPrefix(EthereumAbi.methodID('deposit', [...depositTypes]).toString('hex'));

// ---------------------------------------------------------------------------
// Encoding functions
// ---------------------------------------------------------------------------

/**
* Encodes an ERC20 approve(address,uint256) call.
*
* Grants `spenderAddress` permission to transfer up to `amount` ZAMA tokens
* on behalf of the caller (msg.sender). Used as TX1 of the delegate flow
* to authorize the OperatorStaking contract before depositing.
*
* @param spenderAddress OperatorStaking contract address (the approved spender)
* @param amount Amount of ZAMA tokens to approve (18 decimals, as a decimal string)
* @returns ABI-encoded calldata hex string (0x-prefixed)
*/
export function buildApproveCalldata(spenderAddress: string, amount: string): string {
const method = EthereumAbi.methodID('approve', [...approveTypes]);
const args = EthereumAbi.rawEncode([...approveTypes], [spenderAddress, amount]);
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
}

/**
* Encodes an ERC4626 deposit(uint256,address) call.
*
* Deposits `amount` ZAMA tokens (18 decimals) into the OperatorStaking vault
* and mints stZAMA shares (20 decimals) to `receiverAddress`.
*
* Requires a prior ERC20 approve() call granting the OperatorStaking contract
* at least `amount` allowance.
*
* @param amount Amount of ZAMA tokens to deposit (18 decimals, as a decimal string)
* @param receiverAddress Address that will receive the minted stZAMA shares
* @returns ABI-encoded calldata hex string (0x-prefixed)
*/
export function buildDepositCalldata(amount: string, receiverAddress: string): string {
const method = EthereumAbi.methodID('deposit', [...depositTypes]);
const args = EthereumAbi.rawEncode([...depositTypes], [amount, receiverAddress]);
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
}
2 changes: 2 additions & 0 deletions modules/abstract-eth/test/unit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ export * from './transaction';
export * from './coin';
export * from './messages';
export * from './zamaUtils';
export * from './zamaStakingUtils';
export * from './zamaStakingBuilder';
export * from './decryptionDelegationBuilder';
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './send';
export * from './walletInitialization';
export * from './flushNft';
export * from './decryptionDelegation';
export * from './zamaStaking';
Loading
Loading