Skip to content

Commit

Permalink
feat: signable title escrow (Open-Attestation#111)
Browse files Browse the repository at this point in the history
* chore: rename lib to lib/external in solcover

* chore: set brackSpacing to true for prettier

* feat: beneficiary transfer signatory endorsement

* feat: erc165 support for TitleEscrowSignable
  • Loading branch information
superical committed Jul 19, 2022
1 parent f6a87ba commit ef22543
Show file tree
Hide file tree
Showing 11 changed files with 839 additions and 3 deletions.
3 changes: 2 additions & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2
"tabWidth": 2,
"bracketSpacing": true
}
2 changes: 1 addition & 1 deletion .solcover.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module.exports = {
skipFiles: [
'lib',
'lib/external',
'mocks'
],
istanbulReporter: ['lcov', 'text', 'text-summary', 'html']
Expand Down
94 changes: 94 additions & 0 deletions contracts/TitleEscrowSignable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

import "./TitleEscrow.sol";
import "./utils/SigHelper.sol";
import { BeneficiaryTransferEndorsement } from "./lib/TitleEscrowStructs.sol";
import "./interfaces/ITitleEscrowSignable.sol";

/// @notice This Title Escrow allows the holder to perform an off-chain endorsement of beneficiary transfers
/// @custom:experimental Note that this is currently an experimental feature. See readme for usage details.
contract TitleEscrowSignable is SigHelper, TitleEscrow, ITitleEscrowSignable {
string public constant name = "TradeTrust Title Escrow";

// BeneficiaryTransfer(address beneficiary,address holder,address nominee,address registry,uint256 tokenId,uint256 deadline,uint256 nonce)
bytes32 public constant BENEFICIARY_TRANSFER_TYPEHASH =
0xdc8ea80c045a9b675c73cb328c225cc3f099d01bd9b7820947ac10cba8661cf1;

function initialize(address _registry, uint256 _tokenId) public virtual override initializer {
__TitleEscrowSignable_init(_registry, _tokenId);
}

function __TitleEscrowSignable_init(address _registry, uint256 _tokenId) internal virtual onlyInitializing {
super.__TitleEscrow_init(_registry, _tokenId);
__SigHelper_init(name, "1");
}

function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
return super.supportsInterface(interfaceId) || interfaceId == type(ITitleEscrowSignable).interfaceId;
}

function transferBeneficiaryWithSig(BeneficiaryTransferEndorsement memory endorsement, Sig memory sig)
public
virtual
whenNotPaused
whenActive
onlyBeneficiary
whenHoldingToken
{
require(endorsement.deadline >= block.timestamp, "TE: Expired");
require(
endorsement.nominee != address(0) &&
endorsement.nominee != beneficiary &&
endorsement.holder == holder &&
endorsement.tokenId == tokenId &&
endorsement.registry == registry,
"TE: Invalid endorsement"
);

if (beneficiaryNominee != address(0)) {
require(endorsement.nominee == beneficiaryNominee, "TE: Nominee mismatch");
}

require(endorsement.beneficiary == beneficiary, "TE: Beneficiary mismatch");
require(_validateSig(_hash(endorsement), holder, sig), "TE: Invalid signature");

++nonces[holder];
_setBeneficiary(endorsement.nominee);
}

function cancelBeneficiaryTransfer(BeneficiaryTransferEndorsement memory endorsement)
public
virtual
whenNotPaused
whenActive
{
require(msg.sender == endorsement.holder, "TE: Caller not endorser");

bytes32 hash = _hash(endorsement);
_cancelHash(hash);

emit CancelBeneficiaryTransferEndorsement(hash, endorsement.holder, endorsement.tokenId);
}

function _hash(BeneficiaryTransferEndorsement memory endorsement) internal view returns (bytes32) {
return
keccak256(
abi.encode(
BENEFICIARY_TRANSFER_TYPEHASH,
endorsement.beneficiary,
endorsement.holder,
endorsement.nominee,
endorsement.registry,
endorsement.tokenId,
endorsement.deadline,
nonces[endorsement.holder]
)
);
}

function _setHolder(address newHolder) internal virtual override {
++nonces[holder];
super._setHolder(newHolder);
}
}
16 changes: 16 additions & 0 deletions contracts/interfaces/ITitleEscrowSignable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import "../utils/SigHelper.sol";
import "./ITitleEscrow.sol";
import { BeneficiaryTransferEndorsement } from "../lib/TitleEscrowStructs.sol";

interface ITitleEscrowSignable is ITitleEscrow {
event CancelBeneficiaryTransferEndorsement(bytes32 indexed hash, address indexed endorser, uint256 indexed tokenId);

function transferBeneficiaryWithSig(BeneficiaryTransferEndorsement memory endorsement, SigHelper.Sig memory sig)
external;

function cancelBeneficiaryTransfer(BeneficiaryTransferEndorsement memory endorsement) external;
}
17 changes: 17 additions & 0 deletions contracts/lib/TitleEscrowStructs.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

/**
@dev BeneficiaryTransferEndorsement represents the endorsement details.
The beneficiary is the transfer proposer, holder is the endorser, nominee is the endorsed nominee, registry is
the token registry, tokenId is the token id, deadline is the expiry in seconds and nonce is the holder's nonce.
*/
struct BeneficiaryTransferEndorsement {
address beneficiary;
address holder;
address nominee;
address registry;
uint256 tokenId;
uint256 deadline;
uint256 nonce;
}
26 changes: 26 additions & 0 deletions contracts/mocks/SigHelperMock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

import "../utils/SigHelper.sol";

contract SigHelperMock is SigHelper {
constructor(string memory name) {
__SigHelper_init(name, "1");
}

function __SigHelper_initInternal(string memory name, string memory version) public {
super.__SigHelper_init(name, version);
}

function validateSigInternal(
bytes32 hash,
address signer,
Sig memory sig
) public view returns (bool) {
return super._validateSig(hash, signer, sig);
}

function cancelHashInternal(bytes32 hash) public {
super._cancelHash(hash);
}
}
46 changes: 46 additions & 0 deletions contracts/utils/SigHelper.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/utils/cryptography/ECDSAUpgradeable.sol";

abstract contract SigHelper {
using ECDSAUpgradeable for bytes32;

bytes32 public DOMAIN_SEPARATOR;
mapping(address => uint256) public nonces;
mapping(bytes32 => bool) public cancelled;

struct Sig {
bytes32 r;
bytes32 s;
uint8 v;
}

function __SigHelper_init(string memory name, string memory version) internal {
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes(name)),
keccak256(bytes(version)),
block.chainid,
address(this)
)
);
}

function _validateSig(
bytes32 hash,
address signer,
Sig memory sig
) internal view virtual returns (bool) {
require(!cancelled[hash], "Cancelled");
bytes32 digest = DOMAIN_SEPARATOR.toTypedDataHash(hash);
address rSigner = digest.recover(abi.encodePacked(sig.r, sig.s, sig.v));
return rSigner != address(0) && rSigner == signer;
}

function _cancelHash(bytes32 hash) internal virtual {
require(!cancelled[hash], "Cancelled");
cancelled[hash] = true;
}
}
3 changes: 2 additions & 1 deletion src/constants/contract-interface-id.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { computeInterfaceId } from "../utils/compute-interface-id";
import { computeInterfaceId } from "../utils";
import { contractInterfaces } from "./contract-interfaces";

export const contractInterfaceId = {
TradeTrustERC721: computeInterfaceId(contractInterfaces.TradeTrustERC721),
TitleEscrow: computeInterfaceId(contractInterfaces.TitleEscrow),
TitleEscrowSignable: computeInterfaceId(contractInterfaces.TitleEscrowSignable),
TitleEscrowFactory: computeInterfaceId(contractInterfaces.TitleEscrowFactory),
AccessControl: computeInterfaceId(contractInterfaces.AccessControl),
ERC721: computeInterfaceId(contractInterfaces.ERC721),
Expand Down
4 changes: 4 additions & 0 deletions src/constants/contract-interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export const contractInterfaces = {
"surrender()",
"shred()",
],
TitleEscrowSignable: [
"transferBeneficiaryWithSig((address,address,address,address,uint256,uint256,uint256),(bytes32,bytes32,uint8))",
"cancelBeneficiaryTransfer((address,address,address,address,uint256,uint256,uint256))",
],
TitleEscrowFactory: ["create(address,address,uint256)", "getAddress(address,uint256)"],
AccessControl: [
"hasRole(bytes32,address)",
Expand Down
145 changes: 145 additions & 0 deletions test/SigHelper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/* eslint-disable no-underscore-dangle */
import { ethers } from "hardhat";
import faker from "faker";
import { SigHelperMock } from "@tradetrust/contracts";
import { Signature } from "ethers";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { expect, assert } from ".";
import { getTestUsers, TestUsers } from "./helpers";

describe("SigHelper", async () => {
let users: TestUsers;
let sender: SignerWithAddress;
let deployer: SignerWithAddress;

let sigHelperMock: SigHelperMock;

const domainName = "Test Name";
let domain: Record<string, any>;

beforeEach(async () => {
users = await getTestUsers();
sender = users.carrier;
[deployer] = users.others;

sigHelperMock = (await (await ethers.getContractFactory("SigHelperMock"))
.connect(deployer)
.deploy(domainName)) as SigHelperMock;
sigHelperMock = sigHelperMock.connect(sender);

const chainId = await sender.getChainId();
domain = {
name: domainName,
version: "1",
chainId,
verifyingContract: sigHelperMock.address,
};
});

describe("Initialisation", () => {
it("should initialise the domain separator correctly", async () => {
const hashDomain = ethers.utils._TypedDataEncoder.hashDomain(domain);
await sigHelperMock.__SigHelper_initInternal(domainName, "1");

const res = await sigHelperMock.DOMAIN_SEPARATOR();

expect(res).to.equal(hashDomain);
});
});

describe("Cancellation", () => {
let fakeHash: string;

beforeEach(async () => {
fakeHash = ethers.utils.keccak256(ethers.utils.randomBytes(32));
});

it("should cancel successfully", async () => {
const initStatus = await sigHelperMock.cancelled(fakeHash);
assert(!initStatus, "Initial status should be false");

await sigHelperMock.cancelHashInternal(fakeHash);

const status = await sigHelperMock.cancelled(fakeHash);

expect(status).to.be.true;
});

it("should not cancel an already cancelled hash", async () => {
await sigHelperMock.cancelHashInternal(fakeHash);
const initStatus = await sigHelperMock.cancelled(fakeHash);
assert(initStatus, "Initial status should be true");

const tx = sigHelperMock.cancelHashInternal(fakeHash);

await expect(tx).to.be.rejectedWith(/cancelled/i);
});
});

describe("Validation", () => {
let hashStruct: string;
let sigHash: string;
let sig: Signature;
let fakeData: {
types: Record<string, { name: string; type: string }[]>;
domain: typeof domain;
message: Record<string, any>;
};

beforeEach(async () => {
fakeData = {
types: {
Endorsement: [
{ name: "beneficiary", type: "address" },
{ name: "deadline", type: "uint256" },
],
},
domain,
message: {
beneficiary: faker.finance.ethereumAddress(),
deadline: Date.now(),
},
};

hashStruct = ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
["bytes32", "address", "uint256"],
[
ethers.utils.id("Endorsement(address beneficiary,uint256 deadline)"),
fakeData.message.beneficiary,
fakeData.message.deadline,
]
)
);

sigHash = await sender._signTypedData(fakeData.domain, fakeData.types, fakeData.message);
sig = ethers.utils.splitSignature(sigHash);
});

it("should return true for a valid signature", async () => {
const res = await sigHelperMock.validateSigInternal(hashStruct, sender.address, sig);

expect(res).to.be.true;
});

it("should return false for an invalid signature", async () => {
sigHash = await sender._signTypedData(fakeData.domain, fakeData.types, {
beneficiary: faker.finance.ethereumAddress(),
deadline: Date.now(),
});
sig = ethers.utils.splitSignature(sigHash);

const res = await sigHelperMock.validateSigInternal(hashStruct, sender.address, sig);

expect(res).to.be.false;
});

it("should return false if hash has been cancelled", async () => {
await sigHelperMock.cancelHashInternal(hashStruct);

const tx = sigHelperMock.validateSigInternal(hashStruct, sender.address, sig);

await expect(tx).to.be.rejectedWith("Cancelled");
});
});
});

0 comments on commit ef22543

Please sign in to comment.