Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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();
}
}
1 change: 1 addition & 0 deletions modules/abstract-eth/src/lib/transferBuilders/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './baseNFTTransferBuilder';
export * from './delegateVotesBuilder';
export * from './transferBuilderERC1155';
export * from './transferBuilderERC721';
4 changes: 4 additions & 0 deletions modules/abstract-eth/src/lib/walletUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
103 changes: 103 additions & 0 deletions modules/abstract-eth/test/unit/delegateVotesBuilder.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
});
Original file line number Diff line number Diff line change
@@ -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',
},
};
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading