Skip to content
This repository has been archived by the owner on Sep 9, 2024. It is now read-only.

Adds TimestampedHashRegistry contract #165

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 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
83 changes: 83 additions & 0 deletions contracts/utils/TimestampedHashRegistry.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
import "./interfaces/ITimestampedHashRegistry.sol";

// TODO: would SelfMulticall be useful?
contract TimestampedHashRegistry is Ownable, EIP712, ITimestampedHashRegistry {
using ECDSA for bytes32;
using EnumerableSet for EnumerableSet.AddressSet;

mapping(bytes32 => EnumerableSet.AddressSet) private _hashTypeToSigners;

mapping(bytes32 => SignedHash) public hashTypeToSignedHash;
acenolaza marked this conversation as resolved.
Show resolved Hide resolved

bytes32 private constant _SIGNED_HASH_TYPE_HASH =
keccak256(
"SignedHash(bytes32 hashType,bytes32 hash,uint256 timestamp)"
acenolaza marked this conversation as resolved.
Show resolved Hide resolved
);

constructor() EIP712("TimestampedHashRegistry", "1.0.0") {}

function setHashTypeSigners(
acenolaza marked this conversation as resolved.
Show resolved Hide resolved
bytes32 hashType,
address[] calldata signers
) external onlyOwner {
require(hashType != bytes32(0), "Hash is zero");
require(signers.length != 0, "Signers length is empty");
for (uint256 ind = 0; ind < signers.length; ind++) {
_hashTypeToSigners[hashType].add(signers[ind]);
acenolaza marked this conversation as resolved.
Show resolved Hide resolved
}
emit SetHashTypeSigners(hashType, signers);
}

function registerSignedHash(
bytes32 hashType,
SignedHash calldata signedHash,
acenolaza marked this conversation as resolved.
Show resolved Hide resolved
bytes[] calldata signatures
) external {
require(hashType != bytes32(0), "Hash type is zero");
EnumerableSet.AddressSet storage signers = _hashTypeToSigners[hashType];
require(signers.length() != 0, "Signers have not been set");
require(
signatures.length == signers.length(),
"Signatures length mismatch"
);
acenolaza marked this conversation as resolved.
Show resolved Hide resolved
for (uint256 ind = 0; ind < signatures.length; ind++) {
require(
signers.contains(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is kinda expensive. The DapiFallbackV1 version which expected the signatures to be ordered correctly was a good tradeoff imo.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw there's nothing preventing the caller from using the same signature multiple times

Copy link
Contributor Author

@acenolaza acenolaza Oct 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is kinda expensive. The DapiFallbackV1 version which expected the signatures to be ordered correctly was a good tradeoff imo.

I was really proud with achieving not having to rely on the caller sending the signatures in an expected order 😅

btw there's nothing preventing the caller from using the same signature multiple times

I'm confused by this. Wouldn't the signature check fail if a user sends a signature for a different root or timestamp? Or are you suggesting that I check the root parameter is different from a previously registered root for a hash type? Or that the timestamp is no older than a day or something?

Copy link
Member

@bbenligiray bbenligiray Oct 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean if I have one valid signature, I can repeat it signers.length times to create a signatures array that will pass this verification. Note that a signer create multiple valid signatures of the same hash (signature malleability), which means you can't avoid this by preventing signatures from being repeated. All in all, you will have to rely on the caller sending the signatures in the expected order (or sort it for them in the contract), which means the EnumerableSet is only good for having addSigner()/removeSigner() functions and you should probably avoid using contains() here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. Good catch 👏🏻

An alternative that I can think of is creating a new Enumerable.AddressSet for all addresses derived from the signatures. Then loop over the signers set and use contains on the derived addresses set to check if a signature for the signer is present. This would allow the caller to send the signatures in any order but it might be more expensive.

Copy link
Contributor Author

@acenolaza acenolaza Oct 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is making me uneasy tho 🤔 . If we start removing/adding signers then we might assume they are being added at the end of the array while it might be that they are added on an empty slot. My suggestion is to just use array if order matters or use EnumerableSet like this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think using any design other than Safe's is unnecessarily adventurous here. Keep the owners in an AddressSet, have setupSigners(), addSigner(), removeSigner() functions. Require signatures to be correctly ordered. The CI will ensure that our metadata matches the on-chain order anyway and we can use that to order the signatures accordingly (btw currently the manager-multisig CI doesn't ensure this for Safe or DapiFallbackV1, this needs to be fixed).

Copy link
Contributor Author

@acenolaza acenolaza Oct 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep the owners in an AddressSet

owners == signers, right?

have setupSigners(), addSigner(), removeSigner() functions

setupSigners() should completely override the elements in the set, like a reset? or should this be an external function that should only be called once for each hashType to initialize the set of signers (if signers have ever been set for the hashType then revert similar to Safe.sol setup()).

Require signatures to be correctly ordered.

removeSigner() would change the order of the set since EnumerableSet._remove() swaps the last element of the set with the one being removed 😕

(btw signatureSplit() only extracts the r,s,v parts from a signature at a specified position but this does not ensure the actual signature order against the previously set owners list, I probably didn't understand the reference tho 🤭 )

The CI will ensure that our metadata matches the on-chain order anyway and we can use that to order the signatures accordingly

Does this mean that after calling removeSigner() and the order of the signers changes on-chain we should expect an error in CI and then fix the signers order in our metadata?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

owners == signers, right?

Right

setupSigners() should completely override the elements in the set, like a reset? or should this be an external function that should only be called once for each hashType to initialize the set of signers (if signers have ever been set for the hashType then revert similar to Safe.sol setup()).

The latter

removeSigner() would change the order of the set since EnumerableSet._remove() swaps the last element of the set with the one being removed 😕

Not a problem. It's the users' (or maybe the manager-multisig frontend's) responsibility to update the metadata accordingly.

(btw signatureSplit() only extracts the r,s,v parts from a signature at a specified position but this does not ensure the actual signature order against the previously set owners list, I probably didn't understand the reference tho 🤭 )

I mean Safe also depends on sending the signatures in the correct order, sending them in an array or in serialized form doesn't matter

Does this mean that after calling removeSigner() and the order of the signers changes on-chain we should expect an error in CI and then fix the signers order in our metadata?

Either that or the frontend should have a "create tx to remove signer" button that you click that updates the metadata as expected and create the transaction at the same time. Once the transaction is executed the CI passes automatically.

_hashTypedDataV4(
keccak256(
abi.encode(
_SIGNED_HASH_TYPE_HASH,
hashType,
signedHash.hash,
signedHash.timestamp
)
)
).recover(signatures[ind])
),
"Signature mismatch"
);
}
hashTypeToSignedHash[hashType] = SignedHash(
signedHash.hash,
signedHash.timestamp
);
emit RegisteredSignedHash(
hashType,
signedHash.hash,
signedHash.timestamp,
signatures
acenolaza marked this conversation as resolved.
Show resolved Hide resolved
);
}

function getSigners(
bytes32 hashType
) external view returns (address[] memory signers) {
signers = _hashTypeToSigners[hashType].values();
}
}
37 changes: 37 additions & 0 deletions contracts/utils/interfaces/ITimestampedHashRegistry.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

interface ITimestampedHashRegistry {
event SetHashTypeSigners(bytes32 indexed hashType, address[] signers);

event RegisteredSignedHash(
bytes32 indexed hashType,
bytes32 hash,
uint256 timestamp,
bytes[] signatures
);

struct SignedHash {
bytes32 hash; // i.e. merkle tree root
uint256 timestamp;
}

function setHashTypeSigners(
bytes32 hashType,
address[] calldata signers
) external;

function registerSignedHash(
bytes32 hashType,
SignedHash calldata signedHash,
bytes[] calldata signatures
) external;

function getSigners(
bytes32 hashType
) external view returns (address[] memory signers);

function hashTypeToSignedHash(
bytes32 hashType
) external view returns (bytes32 hash, uint256 timestamp);
}
12 changes: 8 additions & 4 deletions test/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,18 @@ module.exports = {
[adminRole, ethers.utils.solidityKeccak256(['string'], [roleDescription])]
);
},
expiringMetaTxDomain: async (expiringMetaTxForwarder) => {
buildEIP712Domain: (name, chainId, verifyingContract) => {
return {
name: 'ExpiringMetaTxForwarder',
name,
version: '1.0.0',
chainId: (await expiringMetaTxForwarder.provider.getNetwork()).chainId,
verifyingContract: expiringMetaTxForwarder.address,
chainId,
verifyingContract,
};
},
expiringMetaTxDomain: async (expiringMetaTxForwarder) => {
const chainId = (await expiringMetaTxForwarder.provider.getNetwork()).chainId;
return module.exports.buildEIP712Domain('ExpiringMetaTxForwarder', chainId, expiringMetaTxForwarder.address);
},
expiringMetaTxTypes: () => {
return {
ExpiringMetaTx: [
Expand Down
251 changes: 251 additions & 0 deletions test/utils/TimestampedHashRegistry.sol.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
const hre = require('hardhat');
const helpers = require('@nomicfoundation/hardhat-network-helpers');
const { StandardMerkleTree } = require('@openzeppelin/merkle-tree');
const { expect } = require('chai');
const { generateRandomBytes32, generateRandomAddress, buildEIP712Domain } = require('../test-utils');

describe('TimestampedHashRegistry', function () {
const deploy = async () => {
const roleNames = [
'deployer',
'owner',
'dapiFallbackRootSigner1',
'dapiFallbackRootSigner2',
'dapiFallbackRootSigner3',
'airnode',
'randomPerson',
];
const accounts = await hre.ethers.getSigners();
const roles = roleNames.reduce((acc, roleName, index) => {
return { ...acc, [roleName]: accounts[index] };
}, {});

const TimestampedHashRegistry = await hre.ethers.getContractFactory('TimestampedHashRegistry', roles.deployer);
const timestampedHashRegistry = await TimestampedHashRegistry.deploy();
await timestampedHashRegistry.connect(roles.deployer).transferOwnership(roles.owner.address);

const dapiName = 'API3/USD';
const fallbackBeaconTemplateId = generateRandomBytes32();
const fallbackBeaconId = hre.ethers.utils.solidityKeccak256(
['address', 'bytes32'],
[roles.airnode.address, fallbackBeaconTemplateId]
);
const fallbackSponsorWalletAddress = generateRandomAddress();

const treeEntry = [hre.ethers.utils.formatBytes32String(dapiName), fallbackBeaconId, fallbackSponsorWalletAddress];
const treeValues = [
[generateRandomBytes32(), generateRandomBytes32(), generateRandomAddress()],
[generateRandomBytes32(), generateRandomBytes32(), generateRandomAddress()],
treeEntry,
[generateRandomBytes32(), generateRandomBytes32(), generateRandomAddress()],
[generateRandomBytes32(), generateRandomBytes32(), generateRandomAddress()],
];
const tree = StandardMerkleTree.of(treeValues, ['bytes32', 'bytes32', 'address']);
const root = tree.root;
const timestamp = Math.floor(Date.now() / 1000);
const chainId = (await timestampedHashRegistry.provider.getNetwork()).chainId;
const domain = buildEIP712Domain('TimestampedHashRegistry', chainId, timestampedHashRegistry.address);
const types = {
SignedHash: [
{ name: 'hashType', type: 'bytes32' },
{ name: 'hash', type: 'bytes32' },
{ name: 'timestamp', type: 'uint256' },
],
};
const dapiFallbackHashType = hre.ethers.utils.formatBytes32String('dAPI fallback root');
const values = {
hashType: dapiFallbackHashType,
hash: root,
timestamp,
};
const signatures = await Promise.all(
[roles.dapiFallbackRootSigner1, roles.dapiFallbackRootSigner2, roles.dapiFallbackRootSigner3].map(
async (rootSigner) => await rootSigner._signTypedData(domain, types, values)
)
);

return {
roles,
timestampedHashRegistry,
dapiName,
fallbackBeaconTemplateId,
fallbackBeaconId,
fallbackSponsorWalletAddress,
domain,
types,
dapiFallbackHashType,
root,
timestamp,
signatures,
};
};

describe('constructor', function () {
it('constructs', async function () {
const { roles, timestampedHashRegistry } = await helpers.loadFixture(deploy);
expect(await timestampedHashRegistry.owner()).to.equal(roles.owner.address);
});
});

describe('setHashTypeSigners', function () {
context('Sender is the owner', function () {
context('HashType is not zero', function () {
context('Signers is not empty', function () {
it('sets signers', async function () {
const { roles, timestampedHashRegistry, dapiFallbackHashType } = await helpers.loadFixture(deploy);
const signers = [
roles.dapiFallbackRootSigner1.address,
roles.dapiFallbackRootSigner2.address,
roles.dapiFallbackRootSigner3.address,
];
expect(await timestampedHashRegistry.getSigners(dapiFallbackHashType)).to.deep.equal([]);
await expect(timestampedHashRegistry.connect(roles.owner).setHashTypeSigners(dapiFallbackHashType, signers))
.to.emit(timestampedHashRegistry, 'SetHashTypeSigners')
.withArgs(dapiFallbackHashType, signers);
expect(await timestampedHashRegistry.getSigners(dapiFallbackHashType)).to.deep.equal(signers);
});
});
context('Signers is empty', function () {
it('reverts', async function () {
const { roles, timestampedHashRegistry } = await helpers.loadFixture(deploy);
await expect(
timestampedHashRegistry.connect(roles.owner).setHashTypeSigners(generateRandomBytes32(), [])
).to.be.revertedWith('Signers length is empty');
});
});
});
context('HashType is zero', function () {
it('reverts', async function () {
const { roles, timestampedHashRegistry } = await helpers.loadFixture(deploy);
await expect(
timestampedHashRegistry.connect(roles.owner).setHashTypeSigners(hre.ethers.constants.HashZero, [])
).to.be.revertedWith('Hash is zero');
});
});
});
context('Sender is not the owner', function () {
it('reverts', async function () {
const { roles, timestampedHashRegistry } = await helpers.loadFixture(deploy);
const rootSigners = [generateRandomAddress(), generateRandomAddress(), generateRandomAddress()];
await expect(
timestampedHashRegistry.connect(roles.randomPerson).setHashTypeSigners(generateRandomBytes32(), rootSigners)
).to.be.revertedWith('Ownable: caller is not the owner');
});
});
});

describe('registerSignedHash', function () {
context('HashType is not zero', function () {
context('Signers is not empty', function () {
context('Number of signatures is equal to number of signers', function () {
context('All signatures match', function () {
it('registers hash', async function () {
const { roles, timestampedHashRegistry, dapiFallbackHashType, root, timestamp, signatures } =
await helpers.loadFixture(deploy);
const signers = [
roles.dapiFallbackRootSigner1.address,
roles.dapiFallbackRootSigner2.address,
roles.dapiFallbackRootSigner3.address,
];
expect(await timestampedHashRegistry.hashTypeToSignedHash(dapiFallbackHashType)).to.contain(
hre.ethers.constants.HashZero,
0
);
await timestampedHashRegistry.connect(roles.owner).setHashTypeSigners(dapiFallbackHashType, signers);
await expect(
timestampedHashRegistry.registerSignedHash(dapiFallbackHashType, { hash: root, timestamp }, signatures)
)
.to.emit(timestampedHashRegistry, 'RegisteredSignedHash')
.withArgs(dapiFallbackHashType, root, timestamp, signatures);
expect(await timestampedHashRegistry.hashTypeToSignedHash(dapiFallbackHashType)).to.contain(
root,
timestamp
);
});
});
context('All signatures do not match', function () {
it('reverts', async function () {
const {
roles,
timestampedHashRegistry,
domain,
types,
dapiFallbackHashType,
root,
timestamp,
signatures,
} = await helpers.loadFixture(deploy);
const signers = [
roles.dapiFallbackRootSigner1.address,
roles.dapiFallbackRootSigner2.address,
roles.dapiFallbackRootSigner3.address,
];
await timestampedHashRegistry.connect(roles.owner).setHashTypeSigners(dapiFallbackHashType, signers);
// Signed by a different signer
await expect(
timestampedHashRegistry.registerSignedHash(dapiFallbackHashType, { hash: root, timestamp }, [
await roles.randomPerson._signTypedData(domain, types, {
hashType: dapiFallbackHashType,
hash: root,
timestamp,
}),
...signatures.slice(1),
])
).to.be.revertedWith('Signature mismatch');
// Signed a different root
await expect(
timestampedHashRegistry.registerSignedHash(dapiFallbackHashType, { hash: root, timestamp }, [
await roles.dapiFallbackRootSigner1._signTypedData(domain, types, {
hashType: dapiFallbackHashType,
hash: generateRandomBytes32(),
timestamp,
}),
...signatures.slice(1),
])
).to.be.revertedWith('Signature mismatch');
});
});
});
context('Number of signatures is not equal to number of signers', function () {
it('reverts', async function () {
const { roles, timestampedHashRegistry, dapiFallbackHashType, root, timestamp, signatures } =
await helpers.loadFixture(deploy);
const signers = [
roles.dapiFallbackRootSigner1.address,
roles.dapiFallbackRootSigner2.address,
roles.dapiFallbackRootSigner3.address,
];
await timestampedHashRegistry.connect(roles.owner).setHashTypeSigners(dapiFallbackHashType, signers);
await expect(
timestampedHashRegistry.registerSignedHash(
dapiFallbackHashType,
{ hash: root, timestamp },
signatures.slice(1)
)
).to.be.revertedWith('Signatures length mismatch');
});
});
});
context('Signers is empty', function () {
it('reverts', async function () {
const { timestampedHashRegistry, dapiFallbackHashType, root, timestamp } = await helpers.loadFixture(deploy);
await expect(
timestampedHashRegistry.registerSignedHash(dapiFallbackHashType, { hash: root, timestamp }, [])
).to.be.revertedWith('Signers have not been set');
});
});
});
context('HashType is zero', function () {
it('reverts', async function () {
const { timestampedHashRegistry, root, timestamp, signatures } = await helpers.loadFixture(deploy);
await expect(
timestampedHashRegistry.registerSignedHash(
hre.ethers.constants.HashZero,
{ hash: root, timestamp },
signatures
)
).to.be.revertedWith('Hash type is zero');
});
});
});
});
Loading