Files
openzeppelin-contracts/test/account/AccountMultiSigner.test.js
Ernesto García 1d9400e053 Add ERC7913 signers and utilities (#5659)
Co-authored-by: Hadrien Croubois <hadrien.croubois@gmail.com>
2025-06-05 09:22:26 -06:00

322 lines
13 KiB
JavaScript

const { ethers, entrypoint } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { getDomain } = require('../helpers/eip712');
const { ERC4337Helper } = require('../helpers/erc4337');
const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey, MultiERC7913SigningKey } = require('../helpers/signers');
const { MAX_UINT64 } = require('../helpers/constants');
const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior');
const { shouldBehaveLikeERC1271 } = require('../utils/cryptography/ERC1271.behavior');
const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior');
const { PackedUserOperation } = require('../helpers/eip712-types');
// Prepare signers in advance (RSA are long to initialize)
const signerECDSA1 = ethers.Wallet.createRandom();
const signerECDSA2 = ethers.Wallet.createRandom();
const signerECDSA3 = ethers.Wallet.createRandom();
const signerECDSA4 = ethers.Wallet.createRandom(); // Unauthorized signer
const signerP256 = new NonNativeSigner(P256SigningKey.random());
const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random());
// Minimal fixture common to the different signer verifiers
async function fixture() {
// EOAs and environment
const [beneficiary, other] = await ethers.getSigners();
const target = await ethers.deployContract('CallReceiverMock');
// ERC-7913 verifiers
const verifierP256 = await ethers.deployContract('ERC7913P256Verifier');
const verifierRSA = await ethers.deployContract('ERC7913RSAVerifier');
// ERC-4337 env
const helper = new ERC4337Helper();
await helper.wait();
const entrypointDomain = await getDomain(entrypoint.v08);
const domain = { name: 'AccountMultiSigner', version: '1', chainId: entrypointDomain.chainId }; // Missing verifyingContract
const makeMock = (signers, threshold) =>
helper.newAccount('$AccountMultiSignerMock', ['AccountMultiSigner', '1', signers, threshold]).then(mock => {
domain.verifyingContract = mock.address;
return mock;
});
// Sign user operations using MultiERC7913SigningKey
const signUserOp = function (userOp) {
return this.signer
.signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed)
.then(signature => Object.assign(userOp, { signature }));
};
const invalidSig = function () {
return this.signer.signMessage('invalid');
};
return {
helper,
verifierP256,
verifierRSA,
domain,
target,
beneficiary,
other,
makeMock,
signUserOp,
invalidSig,
};
}
describe('AccountMultiSigner', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('Multi ECDSA signers with threshold=1', function () {
beforeEach(async function () {
this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1]));
this.mock = await this.makeMock([signerECDSA1.address], 1);
});
shouldBehaveLikeAccountCore();
shouldBehaveLikeAccountHolder();
shouldBehaveLikeERC1271({ erc7739: true });
shouldBehaveLikeERC7821();
});
describe('Multi ECDSA signers with threshold=2', function () {
beforeEach(async function () {
this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2]));
this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address], 2);
});
shouldBehaveLikeAccountCore();
shouldBehaveLikeAccountHolder();
shouldBehaveLikeERC1271({ erc7739: true });
shouldBehaveLikeERC7821();
});
describe('Mixed signers with threshold=2', function () {
beforeEach(async function () {
// Create signers array with all three types
signerP256.bytes = ethers.concat([
this.verifierP256.target,
signerP256.signingKey.publicKey.qx,
signerP256.signingKey.publicKey.qy,
]);
signerRSA.bytes = ethers.concat([
this.verifierRSA.target,
ethers.AbiCoder.defaultAbiCoder().encode(
['bytes', 'bytes'],
[signerRSA.signingKey.publicKey.e, signerRSA.signingKey.publicKey.n],
),
]);
this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerP256, signerRSA]));
this.mock = await this.makeMock([signerECDSA1.address, signerP256.bytes, signerRSA.bytes], 2);
});
shouldBehaveLikeAccountCore();
shouldBehaveLikeAccountHolder();
shouldBehaveLikeERC1271({ erc7739: true });
shouldBehaveLikeERC7821();
});
describe('Signer management', function () {
beforeEach(async function () {
this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2]));
this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address], 1);
await this.mock.deploy();
});
it('can add signers', async function () {
const signers = [signerECDSA3.address];
// Successfully adds a signer
const signersArrayBefore = await this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress));
await expect(this.mock.$_addSigners(signers))
.to.emit(this.mock, 'ERC7913SignerAdded')
.withArgs(signerECDSA3.address);
const signersArrayAfter = await this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress));
expect(signersArrayAfter.length).to.equal(signersArrayBefore.length + 1);
expect(signersArrayAfter).to.include(ethers.getAddress(signerECDSA3.address));
// Reverts if the signer was already added
await expect(this.mock.$_addSigners(signers))
.to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913AlreadyExists')
.withArgs(...signers.map(s => s.toLowerCase()));
});
it('can remove signers', async function () {
const signers = [signerECDSA2.address];
// Successfully removes an already added signer
const signersArrayBefore = await this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress));
await expect(this.mock.$_removeSigners(signers))
.to.emit(this.mock, 'ERC7913SignerRemoved')
.withArgs(signerECDSA2.address);
const signersArrayAfter = await this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress));
expect(signersArrayAfter.length).to.equal(signersArrayBefore.length - 1);
expect(signersArrayAfter).to.not.include(ethers.getAddress(signerECDSA2.address));
// Reverts removing a signer if it doesn't exist
await expect(this.mock.$_removeSigners(signers))
.to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913NonexistentSigner')
.withArgs(...signers.map(s => s.toLowerCase()));
// Reverts if removing a signer makes the threshold unreachable
await expect(this.mock.$_removeSigners([signerECDSA1.address]))
.to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913UnreachableThreshold')
.withArgs(0, 1);
});
it('can change threshold', async function () {
// Reachable threshold is set
await expect(this.mock.$_setThreshold(2)).to.emit(this.mock, 'ERC7913ThresholdSet');
// Unreachable threshold reverts
await expect(this.mock.$_setThreshold(3)).to.revertedWithCustomError(
this.mock,
'MultiSignerERC7913UnreachableThreshold',
);
});
it('rejects invalid signer format', async function () {
const invalidSigner = '0x123456'; // Too short
await expect(this.mock.$_addSigners([invalidSigner]))
.to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913InvalidSigner')
.withArgs(invalidSigner);
});
it('can read signers and threshold', async function () {
await expect(
this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress)),
).to.eventually.have.deep.members([signerECDSA1.address, signerECDSA2.address]);
await expect(this.mock.threshold()).to.eventually.equal(1);
});
it('checks if an address is a signer', async function () {
// Should return true for authorized signers
await expect(this.mock.isSigner(signerECDSA1.address)).to.eventually.be.true;
await expect(this.mock.isSigner(signerECDSA2.address)).to.eventually.be.true;
// Should return false for unauthorized signers
await expect(this.mock.isSigner(signerECDSA3.address)).to.eventually.be.false;
await expect(this.mock.isSigner(signerECDSA4.address)).to.eventually.be.false;
});
});
describe('Signature validation', function () {
const TEST_MESSAGE = ethers.keccak256(ethers.toUtf8Bytes('Test message'));
beforeEach(async function () {
// Set up mock with authorized signers
this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address], 1);
await this.mock.deploy();
});
it('rejects signatures from unauthorized signers', async function () {
// Create signatures including an unauthorized signer
const authorizedSignature = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE));
const unauthorizedSignature = await signerECDSA4.signMessage(ethers.getBytes(TEST_MESSAGE));
// Prepare signers and signatures arrays
const signers = [
signerECDSA1.address,
signerECDSA4.address, // Unauthorized signer
].sort((a, b) => (ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1));
const signatures = signers.map(signer => {
if (signer === signerECDSA1.address) return authorizedSignature;
return unauthorizedSignature;
});
// Encode the multi-signature
const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]);
// Should fail because one signer is not authorized
await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false;
});
it('rejects invalid signatures from authorized signers', async function () {
// Create a valid signature and an invalid one from authorized signers
const validSignature = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE));
const invalidSignature = await signerECDSA2.signMessage(ethers.toUtf8Bytes('Different message')); // Wrong message
// Prepare signers and signatures arrays
const signers = [signerECDSA1.address, signerECDSA2.address].sort((a, b) =>
ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1,
);
const signatures = signers.map(signer => {
if (signer === signerECDSA1.address) return validSignature;
return invalidSignature;
});
// Encode the multi-signature
const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]);
// Should fail because one signature is invalid
await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false;
});
it('rejects signatures from unsorted signers', async function () {
// Create a valid signature and an invalid one from authorized signers
const validSignature1 = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE));
const validSignature2 = await signerECDSA2.signMessage(ethers.getBytes(TEST_MESSAGE));
// Prepare signers and signatures arrays
const signers = [signerECDSA1.address, signerECDSA2.address].sort((a, b) =>
ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1,
);
const unsortedSigners = signers.reverse();
const signatures = unsortedSigners.map(signer => {
if (signer === signerECDSA1.address) return validSignature1;
return validSignature2;
});
// Encode the multi-signature
const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(
['bytes[]', 'bytes[]'],
[unsortedSigners, signatures],
);
// Should fail because signers are not sorted
await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false;
});
it('rejects signatures when signers.length != signatures.length', async function () {
// Create a valid signature and an invalid one from authorized signers
const validSignature1 = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE));
// Prepare signers and signatures arrays
const signers = [signerECDSA1.address, signerECDSA2.address];
const signatures = [validSignature1];
// Encode the multi-signature
const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]);
// Should fail because signers and signatures arrays have different lengths
await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false;
});
it('rejects duplicated signers', async function () {
// Create a valid signature
const validSignature = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE));
// Prepare signers and signatures arrays
const signers = [signerECDSA1.address, signerECDSA1.address];
const signatures = [validSignature, validSignature];
// Encode the multi-signature
const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]);
// Should fail because of duplicated signers
await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false;
});
});
});