diff --git a/modules/abstract-eth/src/lib/transferBuilders/delegateVotesBuilder.ts b/modules/abstract-eth/src/lib/transferBuilders/delegateVotesBuilder.ts new file mode 100644 index 0000000000..d8245bdb32 --- /dev/null +++ b/modules/abstract-eth/src/lib/transferBuilders/delegateVotesBuilder.ts @@ -0,0 +1,114 @@ +import { BuildTransactionError, InvalidParameterValueError } from '@bitgo/sdk-core'; +import { BigNumber } from 'ethers'; + +import { ContractCall } from '../contractCall'; +import { isValidEthAddress } from '../utils'; +import { delegateBySigMethodId, delegateBySigTypes } from '../walletUtil'; + +export interface DelegationSignature { + v: number; + r: string; + s: string; +} + +/** + * Builds encoded calldata for ERC20Votes delegateBySig on-chain submission. + * + * Usage: + * const calldata = new DelegateVotesBuilder() + * .delegatee('0xHotWallet') + * .nonce(0) + * .expiry(Math.floor(Date.now() / 1000) + 3600) + * .signature({ v, r, s }) + * .build(); + * + * The calldata can then be submitted to the token contract via a ContractCall + * transaction (TransactionType.ContractCall) with TransactionBuilder.data(). + * + * EIP-712 domain for WLFI: + * name: "World Liberty Financial", version: "2", chainId: 1, + * verifyingContract: "0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6" + * + * The cold wallet signs the following typed data before calling build(): + * primaryType: "Delegation" + * types: { Delegation: [ + * { name: "delegatee", type: "address" }, + * { name: "nonce", type: "uint256" }, + * { name: "expiry", type: "uint256" } + * ]} + */ +export class DelegateVotesBuilder { + private _delegatee: string; + private _nonce: BigNumber; + private _expiry: BigNumber; + private _v: number; + private _r: string; + private _s: string; + + delegatee(address: string): this { + if (!isValidEthAddress(address)) { + throw new InvalidParameterValueError('Invalid delegatee address'); + } + this._delegatee = address; + return this; + } + + nonce(value: number | string): this { + const bn = BigNumber.from(value); + if (bn.lt(0)) { + throw new InvalidParameterValueError('Nonce must be non-negative'); + } + this._nonce = bn; + return this; + } + + expiry(timestamp: number | string): this { + const bn = BigNumber.from(timestamp); + if (bn.lte(0)) { + throw new InvalidParameterValueError('Expiry must be a positive unix timestamp'); + } + this._expiry = bn; + return this; + } + + signature(sig: DelegationSignature): this { + if (sig.v !== 27 && sig.v !== 28) { + throw new InvalidParameterValueError('v must be 27 or 28'); + } + if (!sig.r.match(/^0x[0-9a-fA-F]{64}$/)) { + throw new InvalidParameterValueError('r must be a 32-byte hex string'); + } + if (!sig.s.match(/^0x[0-9a-fA-F]{64}$/)) { + throw new InvalidParameterValueError('s must be a 32-byte hex string'); + } + this._v = sig.v; + this._r = sig.r; + this._s = sig.s; + return this; + } + + build(): string { + if ( + this._delegatee === undefined || + this._nonce === undefined || + this._expiry === undefined || + this._v === undefined || + this._r === undefined || + this._s === undefined + ) { + throw new BuildTransactionError( + 'Missing required fields: delegatee, nonce, expiry, and signature (v, r, s) are all required' + ); + } + + const contractCall = new ContractCall(delegateBySigMethodId, delegateBySigTypes, [ + this._delegatee, + this._nonce, + this._expiry, + this._v, + this._r, + this._s, + ]); + return contractCall.serialize(); + } +} diff --git a/modules/abstract-eth/src/lib/transferBuilders/index.ts b/modules/abstract-eth/src/lib/transferBuilders/index.ts index 946d247e92..7a1261cd18 100644 --- a/modules/abstract-eth/src/lib/transferBuilders/index.ts +++ b/modules/abstract-eth/src/lib/transferBuilders/index.ts @@ -1,3 +1,4 @@ export * from './baseNFTTransferBuilder'; +export * from './delegateVotesBuilder'; export * from './transferBuilderERC1155'; export * from './transferBuilderERC721'; diff --git a/modules/abstract-eth/src/lib/walletUtil.ts b/modules/abstract-eth/src/lib/walletUtil.ts index add5c680f6..6d57587780 100644 --- a/modules/abstract-eth/src/lib/walletUtil.ts +++ b/modules/abstract-eth/src/lib/walletUtil.ts @@ -45,3 +45,7 @@ export const ERC1155SafeTransferTypes = ['address', 'address', 'uint256', 'uint2 export const ERC1155BatchTransferTypes = ['address', 'address', 'uint256[]', 'uint256[]', 'bytes']; export const createV1ForwarderTypes = ['address', 'bytes32']; export const createV4ForwarderTypes = ['address', 'address', 'bytes32']; + +// ERC20Votes delegateBySig: keccak256("delegateBySig(address,uint256,uint256,uint8,bytes32,bytes32)") +export const delegateBySigMethodId = '0x5c19a95c'; +export const delegateBySigTypes = ['address', 'uint256', 'uint256', 'uint8', 'bytes32', 'bytes32']; diff --git a/modules/abstract-eth/test/unit/delegateVotesBuilder.ts b/modules/abstract-eth/test/unit/delegateVotesBuilder.ts new file mode 100644 index 0000000000..34c289b655 --- /dev/null +++ b/modules/abstract-eth/test/unit/delegateVotesBuilder.ts @@ -0,0 +1,103 @@ +import 'should'; +import { DelegateVotesBuilder } from '../../src'; + +describe('DelegateVotesBuilder', () => { + const delegatee = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + const nonce = 0; + // expiry: year 2030 unix timestamp + const expiry = 1893456000; + const v = 27; + const r = '0x' + 'ab'.repeat(32); + const s = '0x' + 'cd'.repeat(32); + + describe('build', () => { + it('should build valid calldata for delegateBySig', () => { + const calldata = new DelegateVotesBuilder() + .delegatee(delegatee) + .nonce(nonce) + .expiry(expiry) + .signature({ v, r, s }) + .build(); + + // Must start with delegateBySig selector 0x5c19a95c + calldata.should.startWith('0x5c19a95c'); + // Calldata: 4 bytes selector + 6 * 32 bytes params = 196 bytes = 392 hex chars + '0x' prefix + calldata.length.should.equal(2 + 8 + 6 * 64); + }); + + it('should accept string nonce and expiry', () => { + const calldata = new DelegateVotesBuilder() + .delegatee(delegatee) + .nonce('42') + .expiry('1893456000') + .signature({ v, r, s }) + .build(); + + calldata.should.startWith('0x5c19a95c'); + }); + + it('should accept v=28', () => { + const calldata = new DelegateVotesBuilder() + .delegatee(delegatee) + .nonce(nonce) + .expiry(expiry) + .signature({ v: 28, r, s }) + .build(); + + calldata.should.startWith('0x5c19a95c'); + }); + }); + + describe('validation', () => { + it('should throw for invalid delegatee address', () => { + (() => new DelegateVotesBuilder().delegatee('notanaddress')).should.throw('Invalid delegatee address'); + }); + + it('should throw for negative nonce', () => { + (() => new DelegateVotesBuilder().nonce(-1)).should.throw('Nonce must be non-negative'); + }); + + it('should throw for zero or negative expiry', () => { + (() => new DelegateVotesBuilder().expiry(0)).should.throw('Expiry must be a positive unix timestamp'); + }); + + it('should throw for invalid v value', () => { + (() => + new DelegateVotesBuilder() + .delegatee(delegatee) + .nonce(nonce) + .expiry(expiry) + .signature({ v: 26, r, s })).should.throw('v must be 27 or 28'); + }); + + it('should throw for invalid r (wrong length)', () => { + (() => + new DelegateVotesBuilder() + .delegatee(delegatee) + .nonce(nonce) + .expiry(expiry) + .signature({ v, r: '0xabcd', s })).should.throw('r must be a 32-byte hex string'); + }); + + it('should throw for invalid s (wrong length)', () => { + (() => + new DelegateVotesBuilder() + .delegatee(delegatee) + .nonce(nonce) + .expiry(expiry) + .signature({ v, r, s: '0xabcd' })).should.throw('s must be a 32-byte hex string'); + }); + + it('should throw when building with missing fields', () => { + (() => new DelegateVotesBuilder().build()).should.throw( + 'Missing required fields: delegatee, nonce, expiry, and signature (v, r, s) are all required' + ); + }); + + it('should throw when signature is missing', () => { + (() => new DelegateVotesBuilder().delegatee(delegatee).nonce(nonce).expiry(expiry).build()).should.throw( + 'Missing required fields: delegatee, nonce, expiry, and signature (v, r, s) are all required' + ); + }); + }); +}); diff --git a/modules/abstract-eth/test/unit/messages/eip712/delegationFixtures.ts b/modules/abstract-eth/test/unit/messages/eip712/delegationFixtures.ts new file mode 100644 index 0000000000..096c8ccbf4 --- /dev/null +++ b/modules/abstract-eth/test/unit/messages/eip712/delegationFixtures.ts @@ -0,0 +1,40 @@ +// EIP-712 typed data for WLFI delegateBySig. +// Domain values sourced from on-chain eip712Domain() call on the WLFI contract +// (0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6). +export const wlfiDelegationFixture = { + input: { + payload: JSON.stringify({ + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Delegation: [ + { name: 'delegatee', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'expiry', type: 'uint256' }, + ], + }, + primaryType: 'Delegation', + domain: { + name: 'World Liberty Financial', + version: '2', + chainId: 1, + verifyingContract: '0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6', + }, + message: { + delegatee: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + nonce: 0, + expiry: 1893456000, + }, + }), + }, + expected: { + // Computed from TypedDataUtils against the WLFI domain + Delegation struct above + expectedSignableHex: + '19017844a586a62aa514206b0e72818ff6b0311fc83c3d08f7e93841ce41f305e3af2c7e43a12ac4ea2395cb5f431bc3f2e721db3dde29de35e88c89eed175615d89', + expectedSignableBase64: 'GQF4RKWGpiqlFCBrDnKBj/awMR/IPD0I9+k4Qc5B8wXjryx+Q6EqxOojlctfQxvD8uch2z3eKd416IyJ7tF1YV2J', + }, +}; diff --git a/modules/abstract-eth/test/unit/messages/eip712/delegationMessage.ts b/modules/abstract-eth/test/unit/messages/eip712/delegationMessage.ts new file mode 100644 index 0000000000..b665b284d3 --- /dev/null +++ b/modules/abstract-eth/test/unit/messages/eip712/delegationMessage.ts @@ -0,0 +1,31 @@ +import 'should'; +import { coins } from '@bitgo/statics'; +import { EIP712Message } from '../../../../src'; +import { wlfiDelegationFixture } from './delegationFixtures'; + +describe('EIP-712 ERC20Votes Delegation Message (WLFI delegateBySig)', () => { + const coinConfig = coins.get('eth'); + + it('should generate correct signable payload for WLFI delegation', async () => { + const message = new EIP712Message({ + ...wlfiDelegationFixture.input, + coinConfig, + }); + + const signablePayload = await message.getSignablePayload(); + signablePayload.toString('hex').should.equal(wlfiDelegationFixture.expected.expectedSignableHex); + }); + + it('should encode domain separator with WLFI name and version 2', async () => { + const message = new EIP712Message({ + ...wlfiDelegationFixture.input, + coinConfig, + }); + + const signablePayload = (await message.getSignablePayload()) as Buffer; + // Payload starts with 0x1901 + signablePayload.slice(0, 2).toString('hex').should.equal('1901'); + // Full payload is 2 + 32 + 32 = 66 bytes + signablePayload.length.should.equal(66); + }); +});