Skip to content

Commit

Permalink
feat: verify contract signatures
Browse files Browse the repository at this point in the history
  • Loading branch information
iamacook committed Mar 10, 2025
1 parent d2a7a3b commit 657730b
Show file tree
Hide file tree
Showing 15 changed files with 1,242 additions and 84 deletions.
147 changes: 124 additions & 23 deletions src/domain/common/entities/safe-signature.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { faker } from '@faker-js/faker';
import { shuffle } from 'lodash';
import * as viem from 'viem';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
import { SafeSignature } from '@/domain/common/entities/safe-signature';
import { SignatureType } from '@/domain/common/entities/signature-type.entity';
import { getSignature } from '@/domain/common/utils/__tests__/signatures.builder';
import {
SIGNATURE_HEX_LENGTH,
DYNAMIC_PART_LENGTH_FIELD_HEX_LENGTH,
} from '@/domain/common/utils/signatures';

describe('SafeSignature', () => {
it('should create an instance', () => {
Expand All @@ -15,9 +20,33 @@ describe('SafeSignature', () => {
expect(() => new SafeSignature({ signature, hash })).not.toThrow();
});

it('should throw an error if the signature length is invalid', () => {
it('should throw if the signature does not start with 0x', () => {
const signature = faker.string
.hexadecimal({
length: 130,
})
.slice(2) as `0x${string}`;
const hash = faker.string.hexadecimal({ length: 66 }) as `0x${string}`;

expect(() => new SafeSignature({ signature, hash })).toThrow(
new Error('Invalid "0x" notated signature'),
);
});

it('should throw an error if the signature length is not even', () => {
const signature = faker.string.hexadecimal({
length: 130 + 1,
length: 129,
}) as `0x${string}`;
const hash = faker.string.hexadecimal({ length: 66 }) as `0x${string}`;

expect(() => new SafeSignature({ signature, hash })).toThrow(
new Error('Invalid hex bytes length'),
);
});

it('should throw if the signature length is less than 132', () => {
const signature = faker.string.hexadecimal({
length: 128,
}) as `0x${string}`;
const hash = faker.string.hexadecimal({ length: 66 }) as `0x${string}`;

Expand All @@ -26,6 +55,70 @@ describe('SafeSignature', () => {
);
});

it('should throw if a contract signature has insufficient bytes for the dynamic part length field', async () => {
const privateKey = generatePrivateKey();
const signer = privateKeyToAccount(privateKey);
const hash = faker.string.hexadecimal({ length: 66 }) as `0x${string}`;
const signature = await getSignature({
signer,
hash,
signatureType: SignatureType.ContractSignature,
});

expect(
() =>
new SafeSignature({
signature: signature.slice(
0,
SIGNATURE_HEX_LENGTH + DYNAMIC_PART_LENGTH_FIELD_HEX_LENGTH - 2,
) as `0x${string}`,
hash,
}),
).toThrow(new Error('Insufficient length for dynamic part length field'));
});

it('should throw if a contract signature has insufficient bytes for the dynamic part', async () => {
const privateKey = generatePrivateKey();
const signer = privateKeyToAccount(privateKey);
const hash = faker.string.hexadecimal({ length: 66 }) as `0x${string}`;
const signature = await getSignature({
signer,
hash,
signatureType: SignatureType.ContractSignature,
});

expect(
() =>
new SafeSignature({
signature: signature.slice(0, -2) as `0x${string}`,
hash,
}),
).toThrow(new Error('Insufficient length for dynamic part'));
});

it('should throw if providing a concatenated signature', async () => {
const privateKey = generatePrivateKey();
const signer = privateKeyToAccount(privateKey);
const hash = faker.string.hexadecimal({ length: 66 }) as `0x${string}`;
const signatures = await Promise.all(
shuffle(Object.values(SignatureType)).map((signatureType) => {
return getSignature({
signer,
hash,
signatureType,
});
}),
);

expect(
() =>
new SafeSignature({
signature: viem.concat(signatures),
hash,
}),
).toThrow('Concatenated signatures are not supported');
});

it('should return the r value', () => {
const signature = faker.string.hexadecimal({
length: 130,
Expand Down Expand Up @@ -69,44 +162,52 @@ describe('SafeSignature', () => {
});

describe('signatureType', () => {
it('should return ContractSignature if the v is 0', () => {
const signature = (faker.string.hexadecimal({
length: 128,
}) + '00') as `0x${string}`;
it('should return ContractSignature if the v is 0', async () => {
const hash = faker.string.hexadecimal({ length: 66 }) as `0x${string}`;
const signature = await getSignature({
signer: privateKeyToAccount(generatePrivateKey()),
hash,
signatureType: SignatureType.ContractSignature,
});

const safeSignature = new SafeSignature({ signature, hash });

expect(safeSignature.signatureType).toBe(SignatureType.ContractSignature);
});

it('should return ApprovedHash if the v is 1', () => {
const signature = (faker.string.hexadecimal({
length: 128,
}) + '01') as `0x${string}`;
it('should return ApprovedHash if the v is 1', async () => {
const hash = faker.string.hexadecimal({ length: 66 }) as `0x${string}`;
const signature = await getSignature({
signer: privateKeyToAccount(generatePrivateKey()),
hash,
signatureType: SignatureType.ApprovedHash,
});

const safeSignature = new SafeSignature({ signature, hash });

expect(safeSignature.signatureType).toBe(SignatureType.ApprovedHash);
});

it('should return EthSign if the v is greater than 30', () => {
const signature = (faker.string.hexadecimal({
length: 128,
}) + faker.helpers.arrayElement([31, 32]).toString(16)) as `0x${string}`;
it('should return EthSign if the v is greater than 30', async () => {
const hash = faker.string.hexadecimal({ length: 66 }) as `0x${string}`;
const signature = await getSignature({
signer: privateKeyToAccount(generatePrivateKey()),
hash,
signatureType: SignatureType.EthSign,
});

const safeSignature = new SafeSignature({ signature, hash });

expect(safeSignature.signatureType).toBe(SignatureType.EthSign);
});

it('should return Eoa if the v is not any of the above', () => {
const signature = (faker.string.hexadecimal({
length: 128,
}) + faker.helpers.arrayElement([27, 28]).toString(16)) as `0x${string}`;
it('should return Eoa if the v is not any of the above', async () => {
const hash = faker.string.hexadecimal({ length: 66 }) as `0x${string}`;
const signature = await getSignature({
signer: privateKeyToAccount(generatePrivateKey()),
hash,
signatureType: SignatureType.Eoa,
});

const safeSignature = new SafeSignature({ signature, hash });

Expand Down Expand Up @@ -134,7 +235,7 @@ describe('SafeSignature', () => {
);

it('should memoize the owner', async () => {
const encodeApiParametersSpy = jest.spyOn(viem, 'encodeAbiParameters');
const getAddressSpy = jest.spyOn(viem, 'getAddress');

const privateKey = generatePrivateKey();
const signer = privateKeyToAccount(privateKey);
Expand All @@ -153,10 +254,10 @@ describe('SafeSignature', () => {
const safeSignature = new SafeSignature({ signature, hash });

expect(safeSignature.owner).toBe(signer.address);
expect(encodeApiParametersSpy).toHaveBeenCalledTimes(1);
expect(getAddressSpy).toHaveBeenCalledTimes(1);

expect(safeSignature.owner).toBe(signer.address);
expect(encodeApiParametersSpy).toHaveBeenCalledTimes(1);
expect(getAddressSpy).toHaveBeenCalledTimes(1);

const newPrivateKey = generatePrivateKey();
const newSigner = privateKeyToAccount(newPrivateKey);
Expand All @@ -167,10 +268,10 @@ describe('SafeSignature', () => {
});

expect(safeSignature.owner).toBe(newSigner.address);
expect(encodeApiParametersSpy).toHaveBeenCalledTimes(2);
expect(getAddressSpy).toHaveBeenCalledTimes(2);

expect(safeSignature.owner).toBe(newSigner.address);
expect(encodeApiParametersSpy).toHaveBeenCalledTimes(2);
expect(getAddressSpy).toHaveBeenCalledTimes(2);
});
});
});
30 changes: 12 additions & 18 deletions src/domain/common/entities/safe-signature.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import { secp256k1 } from '@noble/curves/secp256k1';
import { memoize } from 'lodash';
import {
checksumAddress,
encodeAbiParameters,
hashMessage,
hexToBigInt,
parseAbiParameters,
} from 'viem';
import { getAddress, hashMessage } from 'viem';
import { publicKeyToAddress } from 'viem/utils';
import { SignatureType } from '@/domain/common/entities/signature-type.entity';
import { parseSignaturesByType } from '@/domain/common/utils/signatures';

export class SafeSignature {
public signature: `0x${string}`;
public hash: `0x${string}`;

constructor(args: { signature: `0x${string}`; hash: `0x${string}` }) {
if (args.signature.length !== 132) {
throw new Error('Invalid signature length');
const signatures = parseSignaturesByType(args.signature);

if (signatures.length !== 1) {
throw new Error('Concatenated signatures are not supported');
}

if (!signatures.includes(args.signature)) {
throw new Error('Invalid signature');
}

this.signature = args.signature;
Expand All @@ -32,7 +33,7 @@ export class SafeSignature {
}

get v(): number {
return parseInt(this.signature.slice(-2), 16);
return parseInt(this.signature.slice(130, 132), 16);
}

get signatureType(): SignatureType {
Expand All @@ -58,7 +59,7 @@ export class SafeSignature {
switch (this.signatureType) {
case SignatureType.ContractSignature:
case SignatureType.ApprovedHash: {
return uint256ToAddress(this.r);
return getAddress(`0x${this.r.slice(-40)}`);
}
case SignatureType.EthSign: {
// To differentiate signature types, eth_sign signatures have v value increased by 4
Expand Down Expand Up @@ -86,13 +87,6 @@ export class SafeSignature {
);
}

function uint256ToAddress(value: `0x${string}`): `0x${string}` {
const encoded = encodeAbiParameters(parseAbiParameters('uint256'), [
hexToBigInt(value),
]);
return checksumAddress(`0x${encoded.slice(-40)}`);
}

function recoverAddress(args: {
hash: `0x${string}`;
signature: `0x${string}`;
Expand Down
52 changes: 43 additions & 9 deletions src/domain/common/utils/__tests__/signatures.builder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { faker } from '@faker-js/faker';
import { isAddress, type PrivateKeyAccount } from 'viem';
import { SignatureType } from '@/domain/common/entities/signature-type.entity';
import type { PrivateKeyAccount } from 'viem';
import { DYNAMIC_PART_LENGTH_FIELD_HEX_LENGTH } from '@/domain/common/utils/signatures';

export async function getSignature(args: {
signer: PrivateKeyAccount;
Expand Down Expand Up @@ -28,28 +30,60 @@ export async function getSignature(args: {
}
}

function getApprovedHashSignature(owner: `0x${string}`): `0x${string}` {
export function getApprovedHashSignature(owner: `0x${string}`): `0x${string}` {
return ('0x000000000000000000000000' +
owner.slice(2) +
'0000000000000000000000000000000000000000000000000000000000000000' +
'01') as `0x${string}`;
}

function getContractSignature(owner: `0x${string}`): `0x${string}` {
return ('0x000000000000000000000000' +
owner.slice(2) +
'0000000000000000000000000000000000000000000000000000000000000000' +
'00') as `0x${string}`;
/**
* Builds a mock contract signature for a given owner, consisting of:
* - A static part: a padded verifier address, data pointer, and signature type
* - A dynamic part: a 32-byte length field followed by padded random hex data
*
* @param verifier - the verifier address as a 0x-prefixed hex string
* @returns a mock contract signature as a lower-cased hex string
*/
export function getContractSignature(verifier: `0x${string}`): `0x${string}` {
// For single-signature blob, the pointer is 65, left-padded to 32 bytes
const DATA_POINTER = (65).toString(16).padStart(64, '0');
const CONTRACT_SIGNATURE_TYPE = '00';

if (!isAddress(verifier)) {
throw new Error('Invalid verifier address');
}

// Verifier padded to 32 bytes
const paddedVerifier = verifier.slice(2).padStart(64, '0');
const staticPart = paddedVerifier + DATA_POINTER + CONTRACT_SIGNATURE_TYPE;

const byteLength = faker.number.int({ min: 1, max: 10 });

const lengthFieldHex = byteLength
.toString(16)
.padStart(DYNAMIC_PART_LENGTH_FIELD_HEX_LENGTH, '0');

// Dynamic data must be padded to 32 bytes
const paddedDynamicBytes = Math.ceil(byteLength / 32) * 32;

const dynamicPartHex = faker.string
.hexadecimal({ length: paddedDynamicBytes * 2 })
.slice(2);

const dynamicPart = lengthFieldHex + dynamicPartHex;

return `0x${(staticPart + dynamicPart).toLowerCase()}`;
}

async function getEoaSignature(args: {
export async function getEoaSignature(args: {
signer: PrivateKeyAccount;
hash: `0x${string}`;
}): Promise<`0x${string}`> {
return await args.signer.sign({ hash: args.hash });
}

async function getEthSignSignature(args: {
export async function getEthSignSignature(args: {
signer: PrivateKeyAccount;
hash: `0x${string}`;
}): Promise<`0x${string}`> {
Expand Down
Loading

0 comments on commit 657730b

Please sign in to comment.