diff --git a/modules/abstract-eth/src/lib/iface.ts b/modules/abstract-eth/src/lib/iface.ts index a97a67eb0f..f3862daeed 100644 --- a/modules/abstract-eth/src/lib/iface.ts +++ b/modules/abstract-eth/src/lib/iface.ts @@ -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; +} diff --git a/modules/abstract-eth/src/lib/index.ts b/modules/abstract-eth/src/lib/index.ts index 0eca42a295..2729c8e38e 100644 --- a/modules/abstract-eth/src/lib/index.ts +++ b/modules/abstract-eth/src/lib/index.ts @@ -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'; diff --git a/modules/abstract-eth/src/lib/transactionBuilder.ts b/modules/abstract-eth/src/lib/transactionBuilder.ts index 4719d2581a..6e7111f01e 100644 --- a/modules/abstract-eth/src/lib/transactionBuilder.ts +++ b/modules/abstract-eth/src/lib/transactionBuilder.ts @@ -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, @@ -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; @@ -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'); @@ -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; @@ -529,6 +538,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { */ type(type: TransactionType): void { this._type = type; + this._stakingBuilder = undefined; } /** @@ -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; diff --git a/modules/abstract-eth/src/lib/zamaStakingBuilder.ts b/modules/abstract-eth/src/lib/zamaStakingBuilder.ts new file mode 100644 index 0000000000..48680e0a1e --- /dev/null +++ b/modules/abstract-eth/src/lib/zamaStakingBuilder.ts @@ -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(); + } +} diff --git a/modules/abstract-eth/src/lib/zamaStakingUtils.ts b/modules/abstract-eth/src/lib/zamaStakingUtils.ts new file mode 100644 index 0000000000..c24e52e5a1 --- /dev/null +++ b/modules/abstract-eth/src/lib/zamaStakingUtils.ts @@ -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')); +} diff --git a/modules/abstract-eth/test/unit/index.ts b/modules/abstract-eth/test/unit/index.ts index bd8b8e53b9..cd5c067d99 100644 --- a/modules/abstract-eth/test/unit/index.ts +++ b/modules/abstract-eth/test/unit/index.ts @@ -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'; diff --git a/modules/abstract-eth/test/unit/transactionBuilder/index.ts b/modules/abstract-eth/test/unit/transactionBuilder/index.ts index 64973bde89..e81b366d51 100644 --- a/modules/abstract-eth/test/unit/transactionBuilder/index.ts +++ b/modules/abstract-eth/test/unit/transactionBuilder/index.ts @@ -3,3 +3,4 @@ export * from './send'; export * from './walletInitialization'; export * from './flushNft'; export * from './decryptionDelegation'; +export * from './zamaStaking'; diff --git a/modules/abstract-eth/test/unit/transactionBuilder/zamaStaking.ts b/modules/abstract-eth/test/unit/transactionBuilder/zamaStaking.ts new file mode 100644 index 0000000000..3fce657b2a --- /dev/null +++ b/modules/abstract-eth/test/unit/transactionBuilder/zamaStaking.ts @@ -0,0 +1,139 @@ +/** + * TransactionBuilder integration tests for ZAMA ERC-4626 staking delegate flow. + * + * Verifies two staking flows end-to-end through the generic staking() API: + * Flow 1 — ERC20 approve via staking(ZamaStakingBuilder) + * Flow 2 — ERC4626 deposit via staking(ZamaStakingBuilder) + * + * This is a reusable test function exported for coin-specific test suites. + */ +import { TransactionType } from '@bitgo/sdk-core'; +import should from 'should'; +import { TransactionBuilder } from '../../../src'; +import { approveMethodId, depositMethodId } from '../../../src/lib/zamaStakingUtils'; +import { ZamaStakingBuilder, ZamaStakingOperationType } from '../../../src/lib/zamaStakingBuilder'; + +const TOKEN_ADDRESS = '0x94167129172A35ab093B44b8b96213DDbc3cD387'; +const OPERATOR_ADDRESS = '0x1111111111111111111111111111111111111111'; +const RECEIVER_ADDRESS = '0x2222222222222222222222222222222222222222'; +const AMOUNT = '1000000000000000000'; + +export function runZamaStakingTests(coinName: string, getBuilder: (coin: string) => TransactionBuilder): void { + describe(`${coinName} transaction builder — ZAMA staking delegate flows`, () => { + let txBuilder: TransactionBuilder; + + beforeEach(() => { + txBuilder = getBuilder(coinName); + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + }); + + // ------------------------------------------------------------------------- + describe('Flow 1: ERC20 approve via staking()', () => { + it('should build a tx with approve calldata', async () => { + txBuilder.type(TransactionType.ContractCall); + txBuilder.staking( + new ZamaStakingBuilder() + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + ); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.ContractCall); + json.to.should.equal(TOKEN_ADDRESS); + json.data.should.startWith(approveMethodId); + json.value.should.equal('0'); + }); + + it('should serialize and deserialize correctly (rebuild from hex)', async () => { + txBuilder.type(TransactionType.ContractCall); + txBuilder.staking( + new ZamaStakingBuilder() + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + ); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + const rebuiltBuilder = getBuilder(coinName); + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + should.equal(rebuiltTx.toBroadcastFormat(), rawHex); + }); + }); + + // ------------------------------------------------------------------------- + describe('Flow 2: ERC4626 deposit via staking()', () => { + it('should build a tx with deposit calldata', async () => { + txBuilder.type(TransactionType.ContractCall); + txBuilder.staking( + new ZamaStakingBuilder() + .type(ZamaStakingOperationType.DEPOSIT) + .operatorAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + .receiverAddress(RECEIVER_ADDRESS) + ); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.ContractCall); + json.to.should.equal(OPERATOR_ADDRESS); + json.data.should.startWith(depositMethodId); + json.value.should.equal('0'); + }); + + it('should serialize and deserialize correctly (rebuild from hex)', async () => { + txBuilder.type(TransactionType.ContractCall); + txBuilder.staking( + new ZamaStakingBuilder() + .type(ZamaStakingOperationType.DEPOSIT) + .operatorAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + .receiverAddress(RECEIVER_ADDRESS) + ); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + const rebuiltBuilder = getBuilder(coinName); + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + should.equal(rebuiltTx.toBroadcastFormat(), rawHex); + }); + }); + + // ------------------------------------------------------------------------- + describe('validation', () => { + it('should throw when staking() is called on non-ContractCall type', () => { + txBuilder.type(TransactionType.Send); + should.throws( + () => + txBuilder.staking( + new ZamaStakingBuilder() + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + ), + /Staking can only be set for ContractCall transactions/ + ); + }); + + it('should throw when staking builder is missing required fields', async () => { + txBuilder.type(TransactionType.ContractCall); + txBuilder.staking(new ZamaStakingBuilder().type(ZamaStakingOperationType.APPROVE).amount(AMOUNT)); + await txBuilder.build().should.be.rejectedWith(/Missing token contract address for approve/); + }); + }); + }); +} diff --git a/modules/abstract-eth/test/unit/zamaStakingBuilder.ts b/modules/abstract-eth/test/unit/zamaStakingBuilder.ts new file mode 100644 index 0000000000..4e70c9ea78 --- /dev/null +++ b/modules/abstract-eth/test/unit/zamaStakingBuilder.ts @@ -0,0 +1,200 @@ +import should from 'should'; +import { ZamaStakingBuilder, ZamaStakingOperationType } from '../../src/lib/zamaStakingBuilder'; +import { approveMethodId, depositMethodId } from '../../src/lib/zamaStakingUtils'; + +describe('ZamaStakingBuilder', () => { + const TOKEN_ADDRESS = '0x94167129172A35ab093B44b8b96213DDbc3cD387'; + const OPERATOR_ADDRESS = '0x1111111111111111111111111111111111111111'; + const RECEIVER_ADDRESS = '0x2222222222222222222222222222222222222222'; + const AMOUNT = '1000000000000000000'; // 1 ZAMA (18 decimals) + + // ------------------------------------------------------------------------- + describe('fluent approve flow', () => { + it('should build an approve result with correct address and selector', () => { + const builder = new ZamaStakingBuilder(); + const result = builder + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + .build(); + + result.address.should.equal(TOKEN_ADDRESS); + result.data.slice(0, 10).should.equal(approveMethodId); + result.value.should.equal('0'); + }); + + it('should throw when tokenContractAddress is missing', () => { + const builder = new ZamaStakingBuilder(); + builder.type(ZamaStakingOperationType.APPROVE).spenderAddress(OPERATOR_ADDRESS).amount(AMOUNT); + + should.throws(() => builder.build(), /Missing token contract address for approve/); + }); + + it('should throw when spenderAddress is missing', () => { + const builder = new ZamaStakingBuilder(); + builder.type(ZamaStakingOperationType.APPROVE).tokenContractAddress(TOKEN_ADDRESS).amount(AMOUNT); + + should.throws(() => builder.build(), /Missing spender address for approve/); + }); + }); + + // ------------------------------------------------------------------------- + describe('fluent deposit flow', () => { + it('should build a deposit result with correct address and selector', () => { + const builder = new ZamaStakingBuilder(); + const result = builder + .type(ZamaStakingOperationType.DEPOSIT) + .operatorAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + .receiverAddress(RECEIVER_ADDRESS) + .build(); + + result.address.should.equal(OPERATOR_ADDRESS); + result.data.slice(0, 10).should.equal(depositMethodId); + result.value.should.equal('0'); + }); + + it('should throw when operatorAddress is missing', () => { + const builder = new ZamaStakingBuilder(); + builder.type(ZamaStakingOperationType.DEPOSIT).amount(AMOUNT).receiverAddress(RECEIVER_ADDRESS); + + should.throws(() => builder.build(), /Missing operator address for deposit/); + }); + + it('should throw when receiverAddress is missing', () => { + const builder = new ZamaStakingBuilder(); + builder.type(ZamaStakingOperationType.DEPOSIT).operatorAddress(OPERATOR_ADDRESS).amount(AMOUNT); + + should.throws(() => builder.build(), /Missing receiver address for deposit/); + }); + }); + + // ------------------------------------------------------------------------- + describe('common validation', () => { + it('should throw when type is not set', () => { + const builder = new ZamaStakingBuilder(); + builder.amount(AMOUNT); + + should.throws(() => builder.build(), /Missing staking operation type/); + }); + + it('should throw when amount is not set', () => { + const builder = new ZamaStakingBuilder(); + builder + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS); + + should.throws(() => builder.build(), /Missing amount for staking transaction/); + }); + + it('should throw when amount is empty string', () => { + should.throws(() => new ZamaStakingBuilder().amount(''), /Invalid amount for staking transaction/); + }); + + it('should throw when amount is "0"', () => { + should.throws(() => new ZamaStakingBuilder().amount('0'), /Invalid amount for staking transaction/); + }); + + it('should throw for invalid eth address on tokenContractAddress', () => { + should.throws( + () => new ZamaStakingBuilder().tokenContractAddress('not-an-address'), + /Invalid token contract address/ + ); + }); + + it('should throw for invalid eth address on spenderAddress', () => { + should.throws(() => new ZamaStakingBuilder().spenderAddress('bad'), /Invalid spender address/); + }); + + it('should throw for invalid eth address on operatorAddress', () => { + should.throws(() => new ZamaStakingBuilder().operatorAddress('bad'), /Invalid operator address/); + }); + + it('should throw for invalid eth address on receiverAddress', () => { + should.throws(() => new ZamaStakingBuilder().receiverAddress('bad'), /Invalid receiver address/); + }); + }); + + // ------------------------------------------------------------------------- + describe('parameter isolation', () => { + it('changing token address should change the result address for approve', () => { + const TOKEN_2 = '0x3333333333333333333333333333333333333333'; + const r1 = new ZamaStakingBuilder() + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + .build(); + const r2 = new ZamaStakingBuilder() + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_2) + .spenderAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + .build(); + r1.address.should.equal(TOKEN_ADDRESS); + r2.address.should.equal(TOKEN_2); + }); + + it('changing amount should change the calldata', () => { + const r1 = new ZamaStakingBuilder() + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + .build(); + const r2 = new ZamaStakingBuilder() + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS) + .amount('2000000000000000000') + .build(); + r1.data.should.not.equal(r2.data); + }); + }); + + // ------------------------------------------------------------------------- + describe('isStakingData', () => { + it('should return true for approve selector', () => { + ZamaStakingBuilder.isStakingData(approveMethodId + '00'.repeat(64)).should.be.true(); + }); + + it('should return true for deposit selector', () => { + ZamaStakingBuilder.isStakingData(depositMethodId + '00'.repeat(64)).should.be.true(); + }); + + it('should return false for unknown selector', () => { + ZamaStakingBuilder.isStakingData('0xdeadbeef' + '00'.repeat(64)).should.be.false(); + }); + + it('should return false for empty data', () => { + ZamaStakingBuilder.isStakingData('').should.be.false(); + }); + + it('should return false for short data', () => { + ZamaStakingBuilder.isStakingData('0x1234').should.be.false(); + }); + }); + + // ------------------------------------------------------------------------- + describe('value field', () => { + it('should always be "0" for both approve and deposit', () => { + const approve = new ZamaStakingBuilder() + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + .build(); + approve.value.should.equal('0'); + + const deposit = new ZamaStakingBuilder() + .type(ZamaStakingOperationType.DEPOSIT) + .operatorAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + .receiverAddress(RECEIVER_ADDRESS) + .build(); + deposit.value.should.equal('0'); + }); + }); +}); diff --git a/modules/abstract-eth/test/unit/zamaStakingUtils.ts b/modules/abstract-eth/test/unit/zamaStakingUtils.ts new file mode 100644 index 0000000000..0576b3c0c6 --- /dev/null +++ b/modules/abstract-eth/test/unit/zamaStakingUtils.ts @@ -0,0 +1,116 @@ +import 'should'; +import EthereumAbi from 'ethereumjs-abi'; +import { + buildApproveCalldata, + buildDepositCalldata, + approveMethodId, + depositMethodId, + approveTypes, + depositTypes, +} from '../../src/lib/zamaStakingUtils'; + +describe('zamaStakingUtils', () => { + const SPENDER_ADDRESS = '0x1111111111111111111111111111111111111111'; + const RECEIVER_ADDRESS = '0x2222222222222222222222222222222222222222'; + const AMOUNT = '1000000000000000000'; // 1 ZAMA (18 decimals) + const LARGE_AMOUNT = '100000000000000000000000'; // 100,000 ZAMA + + // ------------------------------------------------------------------------- + describe('method selectors', () => { + it('approveMethodId should be 0x095ea7b3', () => { + approveMethodId.should.equal('0x095ea7b3'); + }); + + it('depositMethodId should be 0x6e553f65', () => { + depositMethodId.should.equal('0x6e553f65'); + }); + }); + + // ------------------------------------------------------------------------- + describe('buildApproveCalldata', () => { + it('should produce calldata starting with the approve selector', () => { + const calldata = buildApproveCalldata(SPENDER_ADDRESS, AMOUNT); + calldata.slice(0, 10).should.equal(approveMethodId); + }); + + it('should be 0x-prefixed', () => { + const calldata = buildApproveCalldata(SPENDER_ADDRESS, AMOUNT); + calldata.startsWith('0x').should.be.true(); + }); + + it('should encode the spender address in the calldata', () => { + const calldata = buildApproveCalldata(SPENDER_ADDRESS, AMOUNT); + // Spender address (lowercase, without 0x prefix) should be in the calldata + calldata.toLowerCase().should.containEql(SPENDER_ADDRESS.slice(2).toLowerCase()); + }); + + it('should produce the correct total length (4 selector + 32 address + 32 uint256 = 68 bytes = 136 hex + 2 prefix)', () => { + const calldata = buildApproveCalldata(SPENDER_ADDRESS, AMOUNT); + // 0x + 8 (selector) + 64 (address padded) + 64 (uint256 padded) = 138 chars + calldata.length.should.equal(2 + 8 + 64 + 64); + }); + + it('should produce different calldata for different amounts', () => { + const calldata1 = buildApproveCalldata(SPENDER_ADDRESS, AMOUNT); + const calldata2 = buildApproveCalldata(SPENDER_ADDRESS, LARGE_AMOUNT); + calldata1.should.not.equal(calldata2); + }); + + it('should produce different calldata for different spender addresses', () => { + const calldata1 = buildApproveCalldata(SPENDER_ADDRESS, AMOUNT); + const calldata2 = buildApproveCalldata(RECEIVER_ADDRESS, AMOUNT); + calldata1.should.not.equal(calldata2); + }); + + it('should round-trip decode to the original parameters', () => { + const calldata = buildApproveCalldata(SPENDER_ADDRESS, AMOUNT); + const payload = Buffer.from(calldata.slice(10), 'hex'); + const [decodedSpender, decodedAmount] = EthereumAbi.rawDecode([...approveTypes], payload); + ('0x' + (decodedSpender as Buffer).toString('hex')).should.equal(SPENDER_ADDRESS.toLowerCase()); + decodedAmount.toString().should.equal(AMOUNT); + }); + }); + + // ------------------------------------------------------------------------- + describe('buildDepositCalldata', () => { + it('should produce calldata starting with the deposit selector', () => { + const calldata = buildDepositCalldata(AMOUNT, RECEIVER_ADDRESS); + calldata.slice(0, 10).should.equal(depositMethodId); + }); + + it('should be 0x-prefixed', () => { + const calldata = buildDepositCalldata(AMOUNT, RECEIVER_ADDRESS); + calldata.startsWith('0x').should.be.true(); + }); + + it('should encode the receiver address in the calldata', () => { + const calldata = buildDepositCalldata(AMOUNT, RECEIVER_ADDRESS); + calldata.toLowerCase().should.containEql(RECEIVER_ADDRESS.slice(2).toLowerCase()); + }); + + it('should produce the correct total length (4 selector + 32 uint256 + 32 address = 68 bytes)', () => { + const calldata = buildDepositCalldata(AMOUNT, RECEIVER_ADDRESS); + calldata.length.should.equal(2 + 8 + 64 + 64); + }); + + it('should produce different calldata for different amounts', () => { + const calldata1 = buildDepositCalldata(AMOUNT, RECEIVER_ADDRESS); + const calldata2 = buildDepositCalldata(LARGE_AMOUNT, RECEIVER_ADDRESS); + calldata1.should.not.equal(calldata2); + }); + + it('should produce different calldata for different receiver addresses', () => { + const calldata1 = buildDepositCalldata(AMOUNT, SPENDER_ADDRESS); + const calldata2 = buildDepositCalldata(AMOUNT, RECEIVER_ADDRESS); + calldata1.should.not.equal(calldata2); + }); + + it('should round-trip decode to the original parameters', () => { + const calldata = buildDepositCalldata(AMOUNT, RECEIVER_ADDRESS); + const payload = Buffer.from(calldata.slice(10), 'hex'); + const [decodedAmount, decodedReceiver] = EthereumAbi.rawDecode([...depositTypes], payload); + decodedAmount.toString().should.equal(AMOUNT); + ('0x' + (decodedReceiver as Buffer).toString('hex')).should.equal(RECEIVER_ADDRESS.toLowerCase()); + }); + }); +}); diff --git a/modules/sdk-coin-eth/test/unit/transactionBuilder/zamaStakingTxBuilder.ts b/modules/sdk-coin-eth/test/unit/transactionBuilder/zamaStakingTxBuilder.ts new file mode 100644 index 0000000000..49f784de10 --- /dev/null +++ b/modules/sdk-coin-eth/test/unit/transactionBuilder/zamaStakingTxBuilder.ts @@ -0,0 +1,176 @@ +/** + * TransactionBuilder build/sign/rebuild tests for ZAMA ERC-4626 staking delegate flow. + * + * Tests the generic staking() API end-to-end through the ETH TransactionBuilder + * pipeline: build -> sign -> serialize -> deserialize. + */ +import should from 'should'; +import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionBuilder } from '../../../src'; +import { approveMethodId, depositMethodId, ZamaStakingBuilder, ZamaStakingOperationType } from '@bitgo/abstract-eth'; +import { getBuilder } from '../getBuilder'; + +const TOKEN_ADDRESS = '0x94167129172A35ab093B44b8b96213DDbc3cD387'; +const OPERATOR_ADDRESS = '0x1111111111111111111111111111111111111111'; +const RECEIVER_ADDRESS = '0x2222222222222222222222222222222222222222'; +const AMOUNT = '1000000000000000000'; + +describe('ZAMA Staking TransactionBuilder', () => { + let txBuilder: TransactionBuilder; + + beforeEach(() => { + txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + }); + + // ---- Flow 1: ERC20 approve ------------------------------------------- + + it('Flow 1: should build a ContractCall tx via staking(approve)', async () => { + txBuilder.type(TransactionType.ContractCall); + txBuilder.staking( + new ZamaStakingBuilder() + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + ); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.ContractCall); + json.to.should.equal(TOKEN_ADDRESS.toLowerCase()); + json.data.should.startWith(approveMethodId); + json.value.should.equal('0'); + }); + + it('Flow 1: should rebuild from hex — approve round-trip', async () => { + txBuilder.type(TransactionType.ContractCall); + txBuilder.staking( + new ZamaStakingBuilder() + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + ); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + const rebuiltBuilder = getBuilder('hteth') as TransactionBuilder; + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + should.equal(rebuiltTx.toBroadcastFormat(), rawHex); + rebuiltTx.toJson().to.should.equal(TOKEN_ADDRESS.toLowerCase()); + rebuiltTx.toJson().data.should.startWith(approveMethodId); + }); + + it('Flow 1: should build, deserialize, sign, and serialize approve tx', async () => { + txBuilder.type(TransactionType.ContractCall); + txBuilder.staking( + new ZamaStakingBuilder() + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + ); + const txUnsigned = await txBuilder.build(); + + const builderFrom = getBuilder('hteth') as TransactionBuilder; + builderFrom.from(txUnsigned.toBroadcastFormat()); + builderFrom.sign({ key: '064A3BF8B08A3426E8A719AE5E4115228A75E7A1449CB1B734E51C7DC8A867BE' }); + const txSigned = await builderFrom.build(); + + const json = txSigned.toJson(); + json.to.should.equal(TOKEN_ADDRESS.toLowerCase()); + json.data.should.startWith(approveMethodId); + should.exist(json.v); + should.exist(json.r); + should.exist(json.s); + }); + + // ---- Flow 2: ERC4626 deposit ----------------------------------------- + + it('Flow 2: should build a ContractCall tx via staking(deposit)', async () => { + txBuilder.type(TransactionType.ContractCall); + txBuilder.staking( + new ZamaStakingBuilder() + .type(ZamaStakingOperationType.DEPOSIT) + .operatorAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + .receiverAddress(RECEIVER_ADDRESS) + ); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.ContractCall); + json.to.should.equal(OPERATOR_ADDRESS.toLowerCase()); + json.data.should.startWith(depositMethodId); + json.value.should.equal('0'); + }); + + it('Flow 2: should rebuild from hex — deposit round-trip', async () => { + txBuilder.type(TransactionType.ContractCall); + txBuilder.staking( + new ZamaStakingBuilder() + .type(ZamaStakingOperationType.DEPOSIT) + .operatorAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + .receiverAddress(RECEIVER_ADDRESS) + ); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + const rebuiltBuilder = getBuilder('hteth') as TransactionBuilder; + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + should.equal(rebuiltTx.toBroadcastFormat(), rawHex); + rebuiltTx.toJson().to.should.equal(OPERATOR_ADDRESS.toLowerCase()); + rebuiltTx.toJson().data.should.startWith(depositMethodId); + }); + + it('Flow 2: should build, deserialize, sign, and serialize deposit tx', async () => { + txBuilder.type(TransactionType.ContractCall); + txBuilder.staking( + new ZamaStakingBuilder() + .type(ZamaStakingOperationType.DEPOSIT) + .operatorAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + .receiverAddress(RECEIVER_ADDRESS) + ); + const txUnsigned = await txBuilder.build(); + + const builderFrom = getBuilder('hteth') as TransactionBuilder; + builderFrom.from(txUnsigned.toBroadcastFormat()); + builderFrom.sign({ key: '064A3BF8B08A3426E8A719AE5E4115228A75E7A1449CB1B734E51C7DC8A867BE' }); + const txSigned = await builderFrom.build(); + + const json = txSigned.toJson(); + json.to.should.equal(OPERATOR_ADDRESS.toLowerCase()); + json.data.should.startWith(depositMethodId); + should.exist(json.v); + should.exist(json.r); + should.exist(json.s); + }); + + // ---- Validation ------------------------------------------------------- + + it('should throw when staking() is called on non-ContractCall type', () => { + should.throws( + () => + txBuilder.staking( + new ZamaStakingBuilder() + .type(ZamaStakingOperationType.APPROVE) + .tokenContractAddress(TOKEN_ADDRESS) + .spenderAddress(OPERATOR_ADDRESS) + .amount(AMOUNT) + ), + /Staking can only be set for ContractCall transactions/ + ); + }); +}); diff --git a/modules/statics/src/coinFeatures.ts b/modules/statics/src/coinFeatures.ts index 9a018a14e1..c94daf3972 100644 --- a/modules/statics/src/coinFeatures.ts +++ b/modules/statics/src/coinFeatures.ts @@ -797,4 +797,5 @@ export const ERC7984_TOKEN_FEATURES = [ CoinFeature.TSS_COLD, CoinFeature.CONFIDENTIAL_TRANSFER, CoinFeature.REQUIRES_DECRYPTION_DELEGATION, + CoinFeature.STAKING, ];