322 lines
13 KiB
JavaScript
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;
|
|
});
|
|
});
|
|
});
|