forked from Open-Attestation/token-registry
-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: signable title escrow (Open-Attestation#111)
* 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
Showing
11 changed files
with
839 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
{ | ||
"printWidth": 120, | ||
"tabWidth": 2 | ||
"tabWidth": 2, | ||
"bracketSpacing": true | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.