From 148db42d7f39a5680bdf4651e0d66268b0f00f1e Mon Sep 17 00:00:00 2001 From: nghaninn <43451336+nghaninn@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:53:21 +0800 Subject: [PATCH 1/3] fix: test package on beta --- package.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 568b747..fc2e250 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,10 @@ { "name": "master" }, + { + "name": "beta", + "prerelease": "beta" + }, { "name": "v4", "range": "4.x.x" @@ -14,8 +18,7 @@ "tagFormat": "v${version}" }, "files": [ - "dist", - "contracts" + "dist" ], "main": "dist/index.js", "types": "dist/types/index.d.ts", From 6fb59844bbc3519fa598e09a2365b5e7910be654 Mon Sep 17 00:00:00 2001 From: nghaninn <43451336+nghaninn@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:10:18 +0800 Subject: [PATCH 2/3] fix: test package export --- package.json | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index fc2e250..3be0ea2 100644 --- a/package.json +++ b/package.json @@ -32,14 +32,10 @@ "require": "./dist/index.js" }, "./contracts": { - "types": "./dist/contracts/index.js", - "import": "./dist/contracts/index.js", - "require": "./dist/contracts/index.js" + "default": "./dist/contracts/index.js" }, "./contracts/common": { - "types": "./dist/contracts/common.js", - "import": "./dist/contracts/common.js", - "require": "./dist/contracts/common.js" + "default": "./dist/contracts/common.js" } }, "scripts": { From 1595cbc3741f5e4ad9cdd6a1b2120abf7813a9af Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Fri, 7 Aug 2026 13:59:42 +0530 Subject: [PATCH 3/3] feat: add mintBlock and shredBlock to ObligationEscrow Include ObligationEscrow, TrustVCToken, and related factory/support code on top of beta, with mintBlock/shredBlock tracking for mint and shred lifecycle events. Co-authored-by: Cursor --- .env.sample | 3 +- .github/workflows/release.yml | 15 +- .gitignore | 1 + README.md | 186 +++- contracts/ObligationEscrow.sol | 487 +++++++++ contracts/ObligationEscrowFactory.sol | 46 + contracts/TrustVCToken.sol | 71 ++ contracts/interfaces/IObligationEscrow.sol | 185 ++++ .../interfaces/IObligationEscrowFactory.sol | 24 + contracts/interfaces/ITrustVCToken.sol | 15 + .../interfaces/ObligationEscrowErrors.sol | 46 + .../ObligationEscrowFactoryErrors.sol | 6 + contracts/interfaces/TrustVCTokenErrors.sol | 10 + .../ObligationEscrowFactoryCallerMock.sol | 10 + hardhat.config.ts | 76 +- package.json | 20 +- scripts/assertNoMocks.js | 47 + src/constants/contract-address.ts | 7 + src/constants/contract-interface-id.ts | 1 + src/constants/contract-interfaces.ts | 28 + .../compute-obligation-escrow-address.ts | 33 + src/utils/compute-title-escrow-address.ts | 4 +- src/utils/index.ts | 1 + tasks/deploy-escrow-factory.ts | 2 +- tasks/deploy-token-impl.ts | 2 +- tasks/helpers/deploy-contract.ts | 2 +- test/ObligationEscrow.test.ts | 970 ++++++++++++++++++ test/ObligationEscrowFactory.test.ts | 192 ++++ test/TrustVCToken.test.ts | 183 ++++ ...eploy-obligation-escrow-factory.fixture.ts | 9 + .../deploy-obligation-escrow.fixture.ts | 8 + test/fixtures/deploy-trustvc-token.fixture.ts | 26 + test/fixtures/index.ts | 3 + 33 files changed, 2673 insertions(+), 46 deletions(-) create mode 100644 contracts/ObligationEscrow.sol create mode 100644 contracts/ObligationEscrowFactory.sol create mode 100644 contracts/TrustVCToken.sol create mode 100644 contracts/interfaces/IObligationEscrow.sol create mode 100644 contracts/interfaces/IObligationEscrowFactory.sol create mode 100644 contracts/interfaces/ITrustVCToken.sol create mode 100644 contracts/interfaces/ObligationEscrowErrors.sol create mode 100644 contracts/interfaces/ObligationEscrowFactoryErrors.sol create mode 100644 contracts/interfaces/TrustVCTokenErrors.sol create mode 100644 contracts/mocks/ObligationEscrowFactoryCallerMock.sol create mode 100644 scripts/assertNoMocks.js create mode 100644 src/utils/compute-obligation-escrow-address.ts create mode 100644 test/ObligationEscrow.test.ts create mode 100644 test/ObligationEscrowFactory.test.ts create mode 100644 test/TrustVCToken.test.ts create mode 100644 test/fixtures/deploy-obligation-escrow-factory.fixture.ts create mode 100644 test/fixtures/deploy-obligation-escrow.fixture.ts create mode 100644 test/fixtures/deploy-trustvc-token.fixture.ts diff --git a/.env.sample b/.env.sample index 649919f..45a1d07 100644 --- a/.env.sample +++ b/.env.sample @@ -7,7 +7,8 @@ POLYGONSCAN_API_KEY= COINMARKETCAP_API_KEY= STABILITY_API_KEY= STABILITY_TESTNET_API_KEY= -ASTRONSCAN_API_KEY= +ASTRON_API_KEY= +ASTRON_TESTNET_API_KEY= # Deployer Private Key DEPLOYER_PK= diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1010aca..01cb4b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,18 +25,17 @@ jobs: runs-on: ubuntu-latest needs: [tests, linters] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v4 with: node-version: 18.x - run: npm ci - run: npm run build - - uses: codfish/semantic-release-action@v2 - id: semantic + - name: Semantic Release + run: npm run semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Output new release version - if: steps.semantic.outputs.new-release-published == 'true' - run: | - echo "πŸŽ‰New Release Published: ${{ steps.semantic.outputs.release-version }}" diff --git a/.gitignore b/.gitignore index f039b0c..f228cd8 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ yarn-error.log /coverage coverage.json .openzeppelin/ +.deps \ No newline at end of file diff --git a/README.md b/README.md index b7978b0..aade21d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

- TradeTrust Electronic Bill of Lading (eBL) + TradeTrust Electronic Title Records (eBL) & Obligation Titles (BoE)

@@ -14,8 +14,19 @@

-The Electronic Bill of Lading (eBL) is a digital document that can be used to prove the ownership of goods. It is a standardised document that is accepted by all major shipping lines and customs authorities. The [Token Registry](https://github.com/TradeTrust/token-registry) repository contains both the smart contract -code for token registry (in `/contracts`) as well as the node package for using this library (in `/src`). +This repository provides on-chain title custody for TradeTrust transferable records. It includes: + +- **Electronic Bill of Lading (eBL) / Electronic Title Record (ETR):** `TradeTrustToken` + `TitleEscrow` (+ `TitleEscrowFactory`) +- **Bill of Exchange (BoE) / Obligation titles:** `TrustVCToken` + `ObligationEscrow` (+ `ObligationEscrowFactory`) + +The [Token Registry](https://github.com/TradeTrust/token-registry) repository contains both the smart contract code (in `/contracts`) and the Node package for using this library (in `/src`). + +| Use case | Token registry | Escrow | Factory | +| -------- | -------------- | ------ | ------- | +| eBL / ETR | `TradeTrustToken` | `TitleEscrow` | `TitleEscrowFactory` | +| BoE / Obligation | `TrustVCToken` | `ObligationEscrow` | `ObligationEscrowFactory` | + +Both paths use a Soulbound Token (SBT) minted to a deterministic escrow clone that holds beneficiary/holder ownership. Obligation escrows add an on-chain status lifecycle (`Issued` β†’ `Accepted` / `Rejected` / `Discharged`) on top of the same custody model. ## Table of Contents @@ -32,6 +43,17 @@ code for token registry (in `/contracts`) as well as the node package for using - [Reject Transfers of Beneficiary/Holder](#reject-transfers-of-beneficiaryholder) - [Return ETR Document to Issuer](#return-etr-document-to-issuer) - [Accessing the Current Owners](#accessing-the-current-owners) + - [TrustVCToken](#trustvctoken) + - [Connect to existing obligation registry](#connect-to-existing-obligation-registry) + - [Issuing an Obligation Title](#issuing-an-obligation-title) + - [Restoring / Burning an Obligation Title](#restoring--burning-an-obligation-title) + - [Obligation Escrow](#obligation-escrow) + - [Connect to Obligation Escrow](#connect-to-obligation-escrow) + - [Status Lifecycle](#status-lifecycle) + - [Transfer of Beneficiary/Holder](#transfer-of-beneficiaryholder-1) + - [Reject Transfers of Beneficiary/Holder](#reject-transfers-of-beneficiaryholder-1) + - [Return Obligation Title to Issuer](#return-obligation-title-to-issuer) + - [Accessing Status and Owners](#accessing-status-and-owners) - [Provider \& Signer](#provider--signer) - [Roles and Access](#roles-and-access) - [Grant a role to a user](#grant-a-role-to-a-user) @@ -44,6 +66,7 @@ code for token registry (in `/contracts`) as well as the node package for using - [Using an existing Title Escrow Factory](#using-an-existing-title-escrow-factory) - [Title Escrow Factory](#title-escrow-factory) - [Deploy a new Title Escrow Factory](#deploy-a-new-title-escrow-factory) + - [Obligation Registry (TrustVCToken)](#obligation-registry-trustvctoken) - [Verification](#verification) - [Network Configuration](#network-configuration) - [Configuration](#configuration) @@ -180,6 +203,132 @@ const currentHolder = await connectedEscrow.holder(); const nominatedBeneficiary = await connectedEscrow.nominee(); ``` +## TrustVCToken + +`TrustVCToken` is the SBT registry for **obligation titles** (for example Bill of Exchange). It follows the same mint / restore / burn shape as `TradeTrustToken`, but uses `ObligationEscrowFactory` instead of `TitleEscrowFactory`. Status lifecycle (`accept` / `reject` / `discharge`) lives on the escrow, not on the token. + +> [!NOTE] +> `TrustVCToken` still exposes `titleEscrowFactory()` for compatibility with shared base contracts; that address is the obligation escrow factory. Prefer `obligationEscrowFactory()` when working with BoE titles. + +### Connect to existing obligation registry + +```ts +import { TrustVCToken__factory } from "@tradetrust-tt/token-registry/contracts"; + +const connectedObligationRegistry = TrustVCToken__factory.connect(obligationRegistryAddress, signer); +``` + +### Issuing an Obligation Title + +```ts +await connectedObligationRegistry.mint(beneficiaryAddress, holderAddress, tokenId, remarks); +``` + +Minting creates (or reuses) a deterministic `ObligationEscrow` clone and transfers the SBT into that escrow. On first receipt, the escrow sets beneficiary/holder and status to `Issued`. + +### Restoring / Burning an Obligation Title + +```ts +await connectedObligationRegistry.restore(tokenId, remarks); +await connectedObligationRegistry.burn(tokenId, remarks); +``` + +> [!NOTE] +> Reject and discharge on `ObligationEscrow` call `burnFromEscrow` on the registry (escrow-only). Issuer-side `burn` / `restore` follow the same registry roles as `TradeTrustToken`. + +## Obligation Escrow + +`ObligationEscrow` manages custody of an obligation title between a **`beneficiary`** and **`holder`**, and tracks status through the obligation lifecycle. + +During minting, `TrustVCToken` creates and assigns an `ObligationEscrow` as the owner of that token β€” the same pattern as `TitleEscrow` for eBL. + +> [!IMPORTANT] +> A `remark` field is used for contract operations (optional; pass `"0x"` when empty). Remark length is limited to **120** characters; encryption is recommended. + +### Connect to Obligation Escrow + +```ts +import { ObligationEscrow__factory, ObligationEscrowFactory__factory } from "@tradetrust-tt/token-registry/contracts"; +import { utils } from "@tradetrust-tt/token-registry"; + +// From a known escrow address +const connectedObligationEscrow = ObligationEscrow__factory.connect(obligationEscrowAddress, signer); + +// Or resolve the deterministic address from the factory +const factoryAddress = await connectedObligationRegistry.obligationEscrowFactory(); +const factory = ObligationEscrowFactory__factory.connect(factoryAddress, signer); +const escrowAddress = await factory.getEscrowAddress(obligationRegistryAddress, tokenId); + +// Off-chain prediction (same CREATE2 scheme as the factory) +const implementationAddress = await factory.implementation(); +const predicted = utils.computeObligationEscrowAddress({ + implementationAddress, + factoryAddress, + registryAddress: obligationRegistryAddress, + tokenId, +}); +``` + +### Status Lifecycle + +```solidity +enum Status { Issued, Accepted, Rejected, Discharged } + +function status() external view returns (Status); +function isRegistered() external view returns (bool); + +function accept(bytes calldata remark) external; // holder, from Issued β†’ Accepted +function reject(bytes calldata remark) external; // holder, from Issued β†’ Rejected (terminates + burns) +function discharge(bytes calldata remark) external; // beneficiary, from Accepted β†’ Discharged (terminates + burns) +``` + +| Action | Caller | From | To | Effect | +| ------ | ------ | ---- | -- | ------ | +| `accept` | holder | `Issued` | `Accepted` | Title remains active | +| `reject` | holder | `Issued` | `Rejected` | Terminates escrow and burns token | +| `discharge` | beneficiary | `Accepted` | `Discharged` | Terminates escrow and burns token | + +### Transfer of Beneficiary/Holder + +Transfer APIs match `TitleEscrow`: + +```solidity +function transferBeneficiary(address nominee, bytes calldata remark) external; +function transferHolder(address newHolder, bytes calldata remark) external; +function transferOwners(address nominee, address newHolder, bytes calldata remark) external; +function nominate(address nominee, bytes calldata remark) external; +``` + +### Reject Transfers of Beneficiary/Holder + +```solidity +function rejectTransferBeneficiary(bytes calldata _remark) external; +function rejectTransferHolder(bytes calldata _remark) external; +function rejectTransferOwners(bytes calldata _remark) external; +``` + +Rejection rules are the same as Title Escrow: reject as the next action after appointment, and use `rejectTransferOwners` when you are both beneficiary and holder. + +### Return Obligation Title to Issuer + +```solidity +function returnToIssuer(bytes calldata remark) external; +``` + +Requires both beneficiary and holder (when dual roles apply). Sets `terminationReason` to `ReturnToIssuer` after shred by the registry. + +### Accessing Status and Owners + +```ts +const currentStatus = await connectedObligationEscrow.status(); +const registered = await connectedObligationEscrow.isRegistered(); +const reason = await connectedObligationEscrow.terminationReason(); + +const currentBeneficiary = await connectedObligationEscrow.beneficiary(); +const currentHolder = await connectedObligationEscrow.holder(); +const nominatedBeneficiary = await connectedObligationEscrow.nominee(); +``` + ## Provider & Signer Different ways to get provider or signer: @@ -204,6 +353,10 @@ signerFromMnemonic.connect(provider); Roles are useful for granting users to access certain functions only. Currently, here are the designated roles meant for the different key operations. +> [!IMPORTANT] +> Role management applies to the **token registries** (`TradeTrustToken` and `TrustVCToken`), not to the escrows. +> `TitleEscrow` and `ObligationEscrow` control access via **beneficiary** / **holder** only. + | Role | Access | | -------------- | ------------------------------------------ | | `DefaultAdmin` | Able to perform all operations | @@ -211,6 +364,8 @@ Roles are useful for granting users to access certain functions only. Currently, | `AccepterRole` | Able to accept a token returned to issuer | | `RestorerRole` | Able to restore a token returned to issuer | +`TrustVCToken` inherits the same `RegistryAccess` roles as `TradeTrustToken`. Use `grantRole` / `revokeRole` / `setRoleAdmin` on either registry the same way. + A trusted user can be granted multiple roles by the admin user to perform different operations. The following functions can be called on the token contract by the admin user to grant and revoke roles to and from users. @@ -350,6 +505,26 @@ npx hardhat deploy:factory --network amoy πŸ‘† This will deploy a new Title Escrow factory on the _Amoyy_ network without verifying the contract. To verify the contract, pass in the `--verify` flag. +### Obligation Registry (TrustVCToken) + +Hardhat tasks `deploy:token` / `deploy:factory` currently target the eBL stack (`TradeTrustToken` / `TitleEscrowFactory`). + +For BoE, deploy `ObligationEscrowFactory` first, then `TrustVCToken` with that factory address: + +```ts +import { ethers } from "hardhat"; + +const escrowFactory = await (await ethers.getContractFactory("ObligationEscrowFactory")).deploy(); +await escrowFactory.waitForDeployment(); + +const token = await ( + await ethers.getContractFactory("TrustVCToken") +).deploy("My Obligation Registry", "MOR", await escrowFactory.getAddress()); +await token.waitForDeployment(); +``` + +The deployer becomes the default admin (same role model as `TradeTrustToken`). + ## Verification When verifying the contracts through either the Hardhat's verify plugin or passing the `--verify` flag to the deployment @@ -357,7 +532,8 @@ tasks (which internally uses the same plugin), you will need to include your cor - For Ethereum, set `ETHERSCAN_API_KEY`. - For Polygon, set `POLYGONSCAN_API_KEY`. -- For Astron, set `ASTRONSCAN_API_KEY`. +- For Astron, set `ASTRON_API_KEY`. +- For Astrontestnet, set `ASTRON_TESTNET_API_KEY`. ## Network Configuration @@ -372,6 +548,7 @@ Here's a list of network names currently pre-configured: - `stabilitytestnet` (Stability TestNet) - `stability` (Stability Global Trust Network) - `astron` (astron Network MainNet) +- `astrontestnet` (astron Network TestNet) > [!TIP] > πŸ’‘ You can configure existing and add other networks you wish to deploy to in the `hardhat.config.ts` file. @@ -391,6 +568,7 @@ POLYGONSCAN_API_KEY= COINMARKETCAP_API_KEY= STABILITY_API_KEY= ASTRONSCAN_API_KEY= +ASTRON_TESTNET_API_KEY= # Deployer Private Key DEPLOYER_PK= diff --git a/contracts/ObligationEscrow.sol b/contracts/ObligationEscrow.sol new file mode 100644 index 0000000..5b95b7f --- /dev/null +++ b/contracts/ObligationEscrow.sol @@ -0,0 +1,487 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { Initializable } from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import { Pausable } from "@openzeppelin/contracts/utils/Pausable.sol"; +import { IERC165 } from "@openzeppelin/contracts/interfaces/IERC165.sol"; +import { IObligationEscrow } from "./interfaces/IObligationEscrow.sol"; +import { ITrustVCToken } from "./interfaces/ITrustVCToken.sol"; +import { ITradeTrustToken } from "./interfaces/ITradeTrustToken.sol"; +import { ObligationEscrowErrors } from "./interfaces/ObligationEscrowErrors.sol"; + +/** + * @title ObligationEscrow + * @dev Title escrow contract for managing beneficiaries, holders, and obligation status lifecycle. + */ +contract ObligationEscrow is Initializable, IERC165, ObligationEscrowErrors, IObligationEscrow { + address public override registry; + uint256 public override tokenId; + + address public override beneficiary; + address public override holder; + + address public override prevBeneficiary; + address public override prevHolder; + + address public override nominee; + + bool public override active; + + bytes public remark; + + TerminationReason public override terminationReason; + + bool private _registered; + Status private _status; + + uint256 public override mintBlock; + uint256 public override shredBlock; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() initializer {} + + /** + * @dev Modifier to make a function callable only by the beneficiary. + */ + modifier onlyBeneficiary() { + if (msg.sender != beneficiary) { + revert CallerNotBeneficiary(); + } + _; + } + + /** + * @dev Modifier to make a function callable only by the holder. + */ + modifier onlyHolder() { + if (msg.sender != holder) { + revert CallerNotHolder(); + } + _; + } + + /** + * @dev Modifier to ensure the contract is holding the token. + */ + modifier whenHoldingToken() { + if (!_isHoldingToken()) { + revert TitleEscrowNotHoldingToken(); + } + _; + } + + /** + * @dev Modifier to ensure the registry is not paused. + */ + modifier whenNotPaused() { + bool paused = Pausable(registry).paused(); + if (paused) { + revert RegistryContractPaused(); + } + _; + } + + /** + * @dev Modifier to ensure the title escrow is active. + */ + modifier whenActive() { + if (!active) { + revert InactiveTitleEscrow(); + } + _; + } + + /** + * @dev Modifier to check if the bytes length is within the limit + */ + modifier remarkLengthLimit(bytes calldata _remark) { + if (_remark.length > 120) revert RemarkLengthExceeded(); + _; + } + + /** + * @notice Initializes the ObligationEscrow contract with the registry address and the tokenId + * @param _registry The address of the registry + * @param _tokenId The id of the token + */ + function initialize(address _registry, uint256 _tokenId) public virtual initializer { + __ObligationEscrow_init(_registry, _tokenId); + } + + /** + * @notice Initializes the ObligationEscrow contract with the registry address and the tokenId + */ + function __ObligationEscrow_init(address _registry, uint256 _tokenId) internal virtual onlyInitializing { + if (_registry == address(0) || _registry.code.length == 0) revert InvalidRegistry(_registry); + registry = _registry; + tokenId = _tokenId; + active = true; + } + + /** + * @dev See {ERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IObligationEscrow).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + /** + * @dev See {IObligationEscrow-status}. + */ + function status() external view override returns (Status) { + _assertRegistered(); + return _status; + } + + /** + * @dev See {IObligationEscrow-isRegistered}. + */ + function isRegistered() external view override returns (bool) { + return _registered; + } + + /** + * @dev See {IObligationEscrow-accept}. + */ + function accept(bytes calldata _remark) external override whenNotPaused whenActive remarkLengthLimit(_remark) { + _assertRegistered(); + if (msg.sender != holder) revert CallerNotHolder(); + _assertHoldingToken(); + _transition(Status.Issued, Status.Accepted); + remark = _remark; + emit StatusAccepted(tokenId, holder, _remark); + } + + /** + * @dev See {IObligationEscrow-reject}. + */ + function reject(bytes calldata _remark) external override whenNotPaused whenActive remarkLengthLimit(_remark) { + _assertRegistered(); + if (msg.sender != holder) revert CallerNotHolder(); + _assertHoldingToken(); + _transition(Status.Issued, Status.Rejected); + remark = _remark; + emit StatusRejected(tokenId, holder, _remark); + _terminateAndBurn(_remark, TerminationReason.Rejected); + } + + /** + * @dev See {IObligationEscrow-discharge}. + */ + function discharge(bytes calldata _remark) external override whenNotPaused whenActive remarkLengthLimit(_remark) { + _assertRegistered(); + if (msg.sender != beneficiary) revert CallerNotBeneficiary(); + _assertHoldingToken(); + _setStatus(Status.Accepted, Status.Discharged); + remark = _remark; + emit StatusDischarged(tokenId, beneficiary, _remark); + _terminateAndBurn(_remark, TerminationReason.Discharged); + } + + /** + * @dev See {IERC721Receiver-onERC721Received}. + */ + function onERC721Received( + address /* operator */, + address /* from */, + uint256 _tokenId, + bytes calldata data + ) external virtual override whenNotPaused whenActive returns (bytes4) { + if (_tokenId != tokenId) { + revert InvalidTokenId(_tokenId); + } + if (msg.sender != address(registry)) { + revert InvalidRegistry(msg.sender); + } + bool isMinting = false; + if (beneficiary == address(0) || holder == address(0)) { + if (data.length == 0) { + revert EmptyReceivingData(); + } + (address _beneficiary, address _holder, bytes memory _remark) = abi.decode(data, (address, address, bytes)); + if (_beneficiary == address(0) || _holder == address(0)) { + revert InvalidTokenTransferToZeroAddressOwners(_beneficiary, _holder); + } + if (_remark.length > 120) revert RemarkLengthExceeded(); + _setBeneficiary(_beneficiary, ""); + _setHolder(_holder, ""); + remark = _remark; + isMinting = true; + _registered = true; + _status = Status.Issued; + mintBlock = block.number; + emit StatusInitialized(tokenId, registry); + } else remark = data; + + emit TokenReceived(beneficiary, holder, isMinting, registry, tokenId, remark); + return bytes4(keccak256("onERC721Received(address,address,uint256,bytes)")); + } + + /** + * @dev See {IObligationEscrow-nominate}. + */ + function nominate( + address _nominee, + bytes calldata _remark + ) public virtual override whenNotPaused whenActive onlyBeneficiary whenHoldingToken remarkLengthLimit(_remark) { + if (beneficiary == _nominee) { + revert TargetNomineeAlreadyBeneficiary(); + } + if (nominee == _nominee) { + revert NomineeAlreadyNominated(); + } + prevBeneficiary = address(0); + if (beneficiary == holder) prevHolder = address(0); + remark = _remark; + + _setNominee(_nominee, _remark); + } + + /** + * @dev See {IObligationEscrow-transferBeneficiary}. + */ + function transferBeneficiary( + address _nominee, + bytes calldata _remark + ) public virtual override whenNotPaused whenActive onlyHolder whenHoldingToken remarkLengthLimit(_remark) { + if (_nominee == address(0)) { + revert InvalidTransferToZeroAddress(); + } + if (!(beneficiary == holder || nominee == _nominee)) { + revert InvalidNominee(); + } + prevHolder = address(0); + prevBeneficiary = beneficiary; + remark = _remark; + + _setBeneficiary(_nominee, _remark); + } + + /** + * @dev See {IObligationEscrow-transferHolder}. + */ + function transferHolder( + address newHolder, + bytes calldata _remark + ) public virtual override whenNotPaused whenActive onlyHolder whenHoldingToken remarkLengthLimit(_remark) { + if (newHolder == address(0)) { + revert InvalidTransferToZeroAddress(); + } + if (holder == newHolder) { + revert RecipientAlreadyHolder(); + } + if (beneficiary == holder) prevBeneficiary = address(0); + prevHolder = holder; + remark = _remark; + + _setHolder(newHolder, _remark); + } + + /** + * @dev See {IObligationEscrow-transferOwners}. + */ + function transferOwners(address _nominee, address newHolder, bytes calldata _remark) external virtual override { + transferBeneficiary(_nominee, _remark); + transferHolder(newHolder, _remark); + } + + /** + * @dev See {IObligationEscrow-rejectTransferBeneficiary}. + */ + function rejectTransferBeneficiary( + bytes calldata _remark + ) public virtual override whenNotPaused whenActive onlyBeneficiary whenHoldingToken remarkLengthLimit(_remark) { + if (prevBeneficiary == address(0)) { + revert InvalidTransferToZeroAddress(); + } + if (beneficiary == holder) { + revert DualRoleRejectionRequired(); + } + remark = _remark; + address from = beneficiary; + address to = prevBeneficiary; + + _setBeneficiary(to, _remark); + prevBeneficiary = address(0); + emit RejectTransferBeneficiary(from, to, registry, tokenId, _remark); + } + + /** + * @dev See {IObligationEscrow-rejectTransferHolder}. + */ + function rejectTransferHolder( + bytes calldata _remark + ) public virtual override whenNotPaused whenActive onlyHolder whenHoldingToken remarkLengthLimit(_remark) { + if (prevHolder == address(0)) { + revert InvalidTransferToZeroAddress(); + } + if (holder == beneficiary) { + revert DualRoleRejectionRequired(); + } + remark = _remark; + address from = holder; + address to = prevHolder; + + _setHolder(to, _remark); + prevHolder = address(0); + + emit RejectTransferHolder(from, to, registry, tokenId, _remark); + } + + /** + * @dev See {IObligationEscrow-rejectTransferOwners}. + */ + function rejectTransferOwners( + bytes calldata _remark + ) + external + virtual + override + whenNotPaused + whenActive + whenHoldingToken + onlyBeneficiary + onlyHolder + remarkLengthLimit(_remark) + { + if (prevBeneficiary == address(0) || prevHolder == address(0)) { + revert InvalidTransferToZeroAddress(); + } + remark = _remark; + address fromHolder = holder; + address toHolder = prevHolder; + address fromBeneficiary = beneficiary; + address toBeneficiary = prevBeneficiary; + _setBeneficiary(toBeneficiary, _remark); + _setHolder(toHolder, _remark); + prevBeneficiary = address(0); + prevHolder = address(0); + emit RejectTransferOwners(fromBeneficiary, toBeneficiary, fromHolder, toHolder, registry, tokenId, _remark); + } + + /** + * @dev See {IObligationEscrow-returnToIssuer}. + */ + function returnToIssuer( + bytes calldata _remark + ) + external + virtual + override + whenNotPaused + whenActive + onlyBeneficiary + onlyHolder + whenHoldingToken + remarkLengthLimit(_remark) + { + _setNominee(address(0), ""); + ITradeTrustToken(registry).transferFrom(address(this), registry, tokenId, ""); + remark = _remark; + prevBeneficiary = address(0); + prevHolder = address(0); + + emit ReturnToIssuer(msg.sender, registry, tokenId, _remark); + } + + /** + * @dev See {IObligationEscrow-shred}. + */ + function shred(bytes calldata _remark) external virtual override whenNotPaused whenActive remarkLengthLimit(_remark) { + if (_isHoldingToken()) { + revert TokenNotReturnedToIssuer(); + } + if (msg.sender != registry) { + revert InvalidRegistry(msg.sender); + } + _deactivate(_remark, TerminationReason.ReturnToIssuer); + } + + /** + * @dev See {IObligationEscrow-isHoldingToken}. + */ + function isHoldingToken() external view override returns (bool) { + return _isHoldingToken(); + } + + function _assertRegistered() private view { + if (!_registered) revert NotRegistered(); + } + + function _assertHoldingToken() private view { + if (!_isHoldingToken()) revert TitleEscrowNotHoldingToken(); + } + + function _transition(Status requiredStatus, Status newStatus) private { + if (beneficiary == holder) revert OwnerHolderMustDiffer(); + _setStatus(requiredStatus, newStatus); + } + + function _setStatus(Status requiredStatus, Status newStatus) private { + Status current = _status; + if (current != requiredStatus) revert InvalidStatusTransition(current, requiredStatus); + _status = newStatus; + } + + function _terminateAndBurn(bytes calldata _remark, TerminationReason reason) private { + _setNominee(address(0), ""); + if (_isHoldingToken()) { + ITradeTrustToken(registry).transferFrom(address(this), registry, tokenId, ""); + } + prevBeneficiary = address(0); + prevHolder = address(0); + _deactivate(_remark, reason); + ITrustVCToken(registry).burnFromEscrow(tokenId, _remark); + } + + function _deactivate(bytes calldata _remark, TerminationReason reason) private { + _setBeneficiary(address(0), ""); + _setHolder(address(0), ""); + active = false; + remark = _remark; + terminationReason = reason; + shredBlock = block.number; + emit Shred(registry, tokenId, reason, _remark); + } + + /** + * @notice Internal function to check if the contract is holding a token + * @return A boolean indicating whether the contract is holding a token + */ + function _isHoldingToken() internal view returns (bool) { + return ITradeTrustToken(registry).ownerOf(tokenId) == address(this); + } + + /** + * @notice Sets the nominee + * @param newNominee The address of the new nominee + */ + function _setNominee(address newNominee, bytes memory _remark) internal virtual { + emit Nomination(nominee, newNominee, registry, tokenId, _remark); + nominee = newNominee; + } + + /** + * @notice Sets the beneficiary + * @param newBeneficiary The address of the new beneficiary + */ + function _setBeneficiary(address newBeneficiary, bytes memory _remark) internal virtual { + emit BeneficiaryTransfer(beneficiary, newBeneficiary, registry, tokenId, _remark); + if (nominee != address(0)) _setNominee(address(0), ""); + beneficiary = newBeneficiary; + } + + /** + * @notice Sets the holder + * @param newHolder The address of the new holder + */ + function _setHolder(address newHolder, bytes memory _remark) internal virtual { + emit HolderTransfer(holder, newHolder, registry, tokenId, _remark); + holder = newHolder; + } + + /** + * @dev Storage gap for upgrades. + */ + uint256[42] private __gap; +} diff --git a/contracts/ObligationEscrowFactory.sol b/contracts/ObligationEscrowFactory.sol new file mode 100644 index 0000000..e1219ee --- /dev/null +++ b/contracts/ObligationEscrowFactory.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { Clones } from "@openzeppelin/contracts/proxy/Clones.sol"; +import { ObligationEscrow } from "./ObligationEscrow.sol"; +import { IObligationEscrowFactory } from "./interfaces/IObligationEscrowFactory.sol"; +import { ObligationEscrowFactoryErrors } from "./interfaces/ObligationEscrowFactoryErrors.sol"; + +/** + * @title ObligationEscrowFactory + * @notice Deploys non-upgradeable ObligationEscrow clones (EIP-1167), matching TitleEscrowFactory. + */ +contract ObligationEscrowFactory is IObligationEscrowFactory, ObligationEscrowFactoryErrors { + address public override implementation; + + /** + * @notice Creates a new ObligationEscrowFactory contract. + * @dev Sets `implementation` with the address of a newly created ObligationEscrow contract. + */ + constructor() { + implementation = address(new ObligationEscrow()); + } + + /** + * @dev See {IObligationEscrowFactory-create}. + */ + function create(uint256 tokenId) external override returns (address) { + if (tx.origin == msg.sender) { + revert CreateCallerNotContract(); + } + bytes32 salt = keccak256(abi.encodePacked(msg.sender, tokenId)); + address titleEscrow = Clones.cloneDeterministic(implementation, salt); + ObligationEscrow(titleEscrow).initialize(msg.sender, tokenId); + + emit ObligationEscrowCreated(titleEscrow, msg.sender, tokenId); + + return titleEscrow; + } + + /** + * @dev See {IObligationEscrowFactory-getEscrowAddress}. + */ + function getEscrowAddress(address tokenRegistry, uint256 tokenId) external view override returns (address) { + return Clones.predictDeterministicAddress(implementation, keccak256(abi.encodePacked(tokenRegistry, tokenId))); + } +} diff --git a/contracts/TrustVCToken.sol b/contracts/TrustVCToken.sol new file mode 100644 index 0000000..f5c2686 --- /dev/null +++ b/contracts/TrustVCToken.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { TradeTrustTokenBase, ITitleEscrowFactory } from "./base/TradeTrustTokenBase.sol"; +import { ITrustVCToken } from "./interfaces/ITrustVCToken.sol"; +import { TrustVCTokenErrors } from "./interfaces/TrustVCTokenErrors.sol"; + +/** + * @title TrustVCToken + * @notice SBT registry for obligation titles (classic TradeTrustToken deploy shape). + * @dev Status lifecycle lives on ObligationEscrow (same pattern as TradeTrustToken + TitleEscrowFactory). + */ +contract TrustVCToken is TradeTrustTokenBase, ITrustVCToken, TrustVCTokenErrors { + address internal immutable _obligationEscrowFactory; + uint256 internal immutable _genesis; + + /** + * @notice Creates a new TrustVCToken contract. + * @param name The name of the token. + * @param symbol The symbol of the token. + * @param obligationEscrowFactory_ The ObligationEscrowFactory address. + */ + constructor(string memory name, string memory symbol, address obligationEscrowFactory_) { + if (obligationEscrowFactory_ == address(0)) revert ZeroAddress(); + if (obligationEscrowFactory_.code.length == 0) revert InvalidObligationEscrowFactory(); + + _genesis = block.number; + _obligationEscrowFactory = obligationEscrowFactory_; + initialize(name, symbol, _msgSender()); + } + + /** + * @notice Initializes roles/admin (mirrors TradeTrustToken). + * @param name The name of the token. + * @param symbol The symbol of the token. + * @param admin The address of the admin. + */ + function initialize(string memory name, string memory symbol, address admin) internal initializer { + __TradeTrustTokenBase_init(name, symbol, admin); + } + + /** + * @dev See {ITradeTrustSBT-titleEscrowFactory}. + */ + function titleEscrowFactory() public view override returns (ITitleEscrowFactory) { + return ITitleEscrowFactory(_obligationEscrowFactory); + } + + /** + * @dev See {ITrustVCToken-obligationEscrowFactory}. + */ + function obligationEscrowFactory() external view override returns (address) { + return _obligationEscrowFactory; + } + + /** + * @dev See {ITradeTrustSBT-genesis}. + */ + function genesis() public view override returns (uint256) { + return _genesis; + } + + /** + * @dev See {ITrustVCToken-burnFromEscrow}. + */ + function burnFromEscrow(uint256 tokenId, bytes calldata remark) external override whenNotPaused remarkLengthLimit(remark) { + address escrow = titleEscrowFactory().getEscrowAddress(address(this), tokenId); + if (msg.sender != escrow) revert CallerNotEscrow(); + _registryTransferTo(BURN_ADDRESS, tokenId, remark); + } +} diff --git a/contracts/interfaces/IObligationEscrow.sol b/contracts/interfaces/IObligationEscrow.sol new file mode 100644 index 0000000..cfc3959 --- /dev/null +++ b/contracts/interfaces/IObligationEscrow.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { IERC721Receiver } from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; + +/** + * @title IObligationEscrow + * @notice Interface for ObligationEscrow: title custody, transfers, and status lifecycle. + */ +interface IObligationEscrow is IERC721Receiver { + enum Status { + Issued, + Accepted, + Rejected, + Discharged + } + + enum TerminationReason { + None, + ReturnToIssuer, + Rejected, + Discharged + } + + event StatusInitialized(uint256 indexed tokenId, address indexed registry); + + event StatusAccepted(uint256 indexed tokenId, address indexed holder, bytes remark); + + event StatusRejected(uint256 indexed tokenId, address indexed holder, bytes remark); + + event StatusDischarged(uint256 indexed tokenId, address indexed beneficiary, bytes remark); + + event TokenReceived( + address indexed beneficiary, + address indexed holder, + bool indexed isMinting, + address registry, + uint256 tokenId, + bytes remark + ); + event Nomination( + address indexed prevNominee, + address indexed nominee, + address registry, + uint256 tokenId, + bytes remark + ); + event BeneficiaryTransfer( + address indexed fromBeneficiary, + address indexed toBeneficiary, + address registry, + uint256 tokenId, + bytes remark + ); + event HolderTransfer( + address indexed fromHolder, + address indexed toHolder, + address registry, + uint256 tokenId, + bytes remark + ); + event ReturnToIssuer(address indexed caller, address registry, uint256 tokenId, bytes remark); + event Shred(address registry, uint256 tokenId, TerminationReason reason, bytes remark); + event RejectTransferOwners( + address indexed fromBeneficiary, + address indexed toBeneficiary, + address indexed fromHolder, + address toHolder, + address registry, + uint256 tokenId, + bytes remark + ); + event RejectTransferBeneficiary( + address indexed fromBeneficiary, + address indexed toBeneficiary, + address registry, + uint256 tokenId, + bytes remark + ); + event RejectTransferHolder( + address indexed fromHolder, + address indexed toHolder, + address registry, + uint256 tokenId, + bytes remark + ); + + function status() external view returns (Status); + + function isRegistered() external view returns (bool); + + function accept(bytes calldata remark) external; + + function reject(bytes calldata remark) external; + + function discharge(bytes calldata remark) external; + + /** + * @notice Allows the beneficiary to nominate a new beneficiary + * @dev The nominated beneficiary will need to be transferred by the holder to become the actual beneficiary + * @param nominee The address of the nominee + */ + function nominate(address nominee, bytes calldata remark) external; + + /** + * @notice Allows the holder to transfer the beneficiary role to the nominated beneficiary or to themselves + * @param nominee The address of the new beneficiary + */ + function transferBeneficiary(address nominee, bytes calldata remark) external; + + /** + * @notice Allows the holder to transfer their role to another address + * @param newHolder The address of the new holder + */ + function transferHolder(address newHolder, bytes calldata remark) external; + + /** + * @notice Allows for the simultaneous transfer of both beneficiary and holder roles + * @param nominee The address of the new beneficiary + * @param newHolder The address of the new holder + */ + function transferOwners(address nominee, address newHolder, bytes calldata remark) external; + + /** + * @notice Allows the new beneficiary to reject the nomination + * @param _remark The remark for the rejection + */ + function rejectTransferBeneficiary(bytes calldata _remark) external; + + /** + * @notice Allows the new holder to reject the transfer of the holder role + * @param _remark The remark for the rejection + */ + function rejectTransferHolder(bytes calldata _remark) external; + + /** + * @notice Allows the new beneficiary and holder to reject the transfer of both roles + * @param _remark The remark for the rejection + */ + function rejectTransferOwners(bytes calldata _remark) external; + + function beneficiary() external view returns (address); + + function holder() external view returns (address); + + function prevBeneficiary() external view returns (address); + + function prevHolder() external view returns (address); + + function active() external view returns (bool); + + function nominee() external view returns (address); + + function registry() external view returns (address); + + function tokenId() external view returns (uint256); + + function terminationReason() external view returns (TerminationReason); + + /** + * @notice Block number when the document was minted into this escrow + */ + function mintBlock() external view returns (uint256); + + /** + * @notice Block number when the escrow was shredded / deactivated (0 if still active) + */ + function shredBlock() external view returns (uint256); + + /** + * @notice Check if the TitleEscrow is currently holding a token + * @return A boolean indicating whether the contract is holding a token + */ + function isHoldingToken() external returns (bool); + + /** + * @notice Allows the beneficiary and holder to returnToIssuer the token back to the registry + */ + function returnToIssuer(bytes calldata remark) external; + + /** + * @notice Allows the registry to shred the TitleEscrow by marking it as inactive and reset the beneficiary and holder addresses + */ + function shred(bytes calldata remark) external; +} diff --git a/contracts/interfaces/IObligationEscrowFactory.sol b/contracts/interfaces/IObligationEscrowFactory.sol new file mode 100644 index 0000000..f01013b --- /dev/null +++ b/contracts/interfaces/IObligationEscrowFactory.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +interface IObligationEscrowFactory { + event ObligationEscrowCreated(address indexed titleEscrow, address indexed tokenRegistry, uint256 indexed tokenId); + + function implementation() external view returns (address); + + /** + * @notice Creates a new clone of the ObligationEscrow contract and initializes it. + * @dev The function will revert if it is called by an EOA. + * @param tokenId The ID of the token. + * @return The address of the newly created ObligationEscrow contract. + */ + function create(uint256 tokenId) external returns (address); + + /** + * @notice Returns the address of an ObligationEscrow contract for the provided token registry and token ID. + * @param tokenRegistry The address of the token registry. + * @param tokenId The ID of the token. + * @return The address of the ObligationEscrow contract. + */ + function getEscrowAddress(address tokenRegistry, uint256 tokenId) external view returns (address); +} diff --git a/contracts/interfaces/ITrustVCToken.sol b/contracts/interfaces/ITrustVCToken.sol new file mode 100644 index 0000000..2a6e176 --- /dev/null +++ b/contracts/interfaces/ITrustVCToken.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +/** + * @title ITrustVCToken + * @notice Thin SBT registry for obligation titles; lifecycle lives on ObligationEscrow. + */ +interface ITrustVCToken { + function obligationEscrowFactory() external view returns (address); + + /** + * @notice Burns a title after reject/discharge; callable only by the title's escrow. + */ + function burnFromEscrow(uint256 tokenId, bytes calldata remark) external; +} diff --git a/contracts/interfaces/ObligationEscrowErrors.sol b/contracts/interfaces/ObligationEscrowErrors.sol new file mode 100644 index 0000000..40c9cfd --- /dev/null +++ b/contracts/interfaces/ObligationEscrowErrors.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { IObligationEscrow } from "./IObligationEscrow.sol"; + +interface ObligationEscrowErrors { + error CallerNotBeneficiary(); + + error CallerNotHolder(); + + error TitleEscrowNotHoldingToken(); + + error RegistryContractPaused(); + + error InactiveTitleEscrow(); + + error InvalidTokenId(uint256 tokenId); + + error InvalidRegistry(address registry); + + error EmptyReceivingData(); + + error InvalidTokenTransferToZeroAddressOwners(address beneficiary, address holder); + + error TargetNomineeAlreadyBeneficiary(); + + error NomineeAlreadyNominated(); + + error InvalidTransferToZeroAddress(); + + error InvalidNominee(); + + error RecipientAlreadyHolder(); + + error TokenNotReturnedToIssuer(); + + error RemarkLengthExceeded(); + + error DualRoleRejectionRequired(); + + error NotRegistered(); + + error OwnerHolderMustDiffer(); + + error InvalidStatusTransition(IObligationEscrow.Status currentStatus, IObligationEscrow.Status requiredStatus); +} diff --git a/contracts/interfaces/ObligationEscrowFactoryErrors.sol b/contracts/interfaces/ObligationEscrowFactoryErrors.sol new file mode 100644 index 0000000..58c590e --- /dev/null +++ b/contracts/interfaces/ObligationEscrowFactoryErrors.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +interface ObligationEscrowFactoryErrors { + error CreateCallerNotContract(); +} diff --git a/contracts/interfaces/TrustVCTokenErrors.sol b/contracts/interfaces/TrustVCTokenErrors.sol new file mode 100644 index 0000000..2baa7e3 --- /dev/null +++ b/contracts/interfaces/TrustVCTokenErrors.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +interface TrustVCTokenErrors { + error ZeroAddress(); + + error InvalidObligationEscrowFactory(); + + error CallerNotEscrow(); +} diff --git a/contracts/mocks/ObligationEscrowFactoryCallerMock.sol b/contracts/mocks/ObligationEscrowFactoryCallerMock.sol new file mode 100644 index 0000000..7741704 --- /dev/null +++ b/contracts/mocks/ObligationEscrowFactoryCallerMock.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { IObligationEscrowFactory } from "../interfaces/IObligationEscrowFactory.sol"; + +contract ObligationEscrowFactoryCallerMock { + function callCreate(address obligationEscrowFactory, uint256 tokenId) public { + IObligationEscrowFactory(obligationEscrowFactory).create(tokenId); + } +} diff --git a/hardhat.config.ts b/hardhat.config.ts index 74baee8..305e8c4 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -18,7 +18,8 @@ const { POLYGONSCAN_API_KEY, STABILITY_API_KEY, STABILITY_TESTNET_API_KEY, - ASTRONSCAN_API_KEY, + ASTRON_API_KEY, + ASTRON_TESTNET_API_KEY, } = process.env; const IS_CI_ENV = process.env.NODE_ENV === "ci"; @@ -43,19 +44,22 @@ if (IS_CI_ENV) { const config: HardhatUserConfig = { solidity: { - compilers: [{ version: "0.8.22" }], - - settings: { - optimizer: { - enabled: true, - runs: 200, - }, - outputSelection: { - "*": { - "*": ["storageLayout"], + compilers: [ + { + version: "0.8.22", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + outputSelection: { + "*": { + "*": ["abi", "evm.bytecode", "evm.deployedBytecode", "storageLayout"], + }, + }, }, }, - }, + ], }, typechain: { outDir: "src/contracts", @@ -89,19 +93,36 @@ const config: HardhatUserConfig = { /** * Astron */ - astron: ASTRONSCAN_API_KEY!, + astron: ASTRON_API_KEY!, + /** + * Astron testnet + */ + astrontestnet: ASTRON_TESTNET_API_KEY!, }, - customChains: [{ + customChains: [ + { /** * Astron */ - network: "astron", - chainId: 1338, - urls: { - apiURL: "https://astronscanl2.bitfactory.cn/api", - browserURL: "https://astronscanl2.bitfactory.cn", - }, - }], + network: "astron", + chainId: 1338, + urls: { + apiURL: "https://astronscanl2.bitfactory.cn/api", + browserURL: "https://astronscanl2.bitfactory.cn", + }, + }, + { + /** + * Astron testnet + */ + network: "astrontestnet", + chainId: 21002, + urls: { + apiURL: "https://dev-astronscanl2.bitfactory.cn/api", + browserURL: "https://dev-astronscanl2.bitfactory.cn", + }, + }, + ], }, networks: { /** @@ -150,9 +171,16 @@ const config: HardhatUserConfig = { * Astron */ astron: { - ...networkConfig, - url: "https://astronlayer2.bitfactory.cn/rpc/", - }, + ...networkConfig, + url: `https://astronlayer2.bitfactory.cn/auth/${ASTRON_API_KEY}`, + }, + /** + * Astron testnet + */ + astrontestnet: { + ...networkConfig, + url: `https://dev-astronlayer2.bitfactory.cn/auth/${ASTRON_TESTNET_API_KEY}`, + }, /** * Development */ diff --git a/package.json b/package.json index 3be0ea2..79d0002 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,18 @@ "tagFormat": "v${version}" }, "files": [ - "dist" + "dist", + "contracts/base", + "contracts/interfaces", + "contracts/presets", + "contracts/utils", + "contracts/lib", + "contracts/TitleEscrow.sol", + "contracts/TitleEscrowFactory.sol", + "contracts/TradeTrustToken.sol", + "contracts/ObligationEscrow.sol", + "contracts/ObligationEscrowFactory.sol", + "contracts/TrustVCToken.sol" ], "main": "dist/index.js", "types": "dist/types/index.d.ts", @@ -43,9 +54,9 @@ "build:js:copy-src": "babel src -d dist --ignore src/**/*.spec.ts,src/**/*.test.ts -x .js,.ts,.tsx --copy-files", "build:js": "tsc && npm run build:js:copy-src", "build:js:copy-types": "mkdir -p ./dist/contracts && cp -a ./src/contracts/* ./dist/contracts", - "build:artifacts:copy": "mkdir -p ./dist/artifacts && cp -a ./artifacts/contracts/. ./dist/artifacts && rm -f ./dist/artifacts/**/*.dbg.json", + "build:artifacts:copy": "mkdir -p ./dist/artifacts && cp -a ./artifacts/contracts/. ./dist/artifacts && rm -f ./dist/artifacts/**/*.dbg.json && rm -rf ./dist/artifacts/contracts/mocks", "build:arrange": "rm -r ./dist/types/test && rm -r ./dist/types/tasks && rm -r ./dist/types/src/contracts && cp -a ./dist/types/src/* ./dist/types && rm -r ./dist/types/src", - "build": "npm run clean:build && npm run build:sol && npm run build:js && npm run build:js:copy-types && npm run build:artifacts:copy && npm run build:arrange", + "build": "npm run clean:build && npm run build:sol && npm run build:js && npm run build:js:copy-types && npm run build:artifacts:copy && npm run build:arrange && npm run ci:assert:no-mocks", "clean:build": "TS_NODE_TRANSPILE_ONLY=1 hardhat clean && rm -rf ./dist && rm -rf ./src/contracts", "commit": "git-cz", "commit:retry": "npm run commit -- --retry", @@ -60,7 +71,8 @@ "prepare": "npm run build", "semantic-release": "semantic-release", "precoverage": "npm run clean:build && npm run build:sol", - "coverage": "hardhat coverage" + "coverage": "hardhat coverage", + "ci:assert:no-mocks": "node scripts/assertNoMocks.js" }, "jest": { "globalSetup": "./jest/setup.ts", diff --git a/scripts/assertNoMocks.js b/scripts/assertNoMocks.js new file mode 100644 index 0000000..1b91a73 --- /dev/null +++ b/scripts/assertNoMocks.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/* + Ensures no mock artifacts are present in production build output. + Checks: + 1) dist/artifacts/contracts/mocks should not exist + 2) No files under dist/artifacts containing "/mocks/" +*/ +const fs = require("fs"); +const path = require("path"); + +const root = process.cwd(); +const artifactsDir = path.join(root, "dist", "artifacts", "contracts"); +const mocksDir = path.join(artifactsDir, "mocks"); + +let failed = false; +const errors = []; + +if (fs.existsSync(mocksDir)) { + failed = true; + errors.push(`Found mocks artifacts directory: ${path.relative(root, mocksDir)}`); +} + +function walk(dir) { + fs.readdirSync(dir, { withFileTypes: true }).forEach((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else { + const rel = path.relative(root, full); + if (rel.includes(`${path.sep}mocks${path.sep}`)) { + failed = true; + errors.push(`Found mock artifact in dist: ${rel}`); + } + } + }); +} + +if (fs.existsSync(artifactsDir)) { + walk(artifactsDir); +} + +if (failed) { + console.error(`\nERROR: Production artifacts contain mocks.\n${errors.map((e) => ` - ${e}`).join("\n")}`); + process.exit(1); +} else { + console.log("No mock artifacts found in dist."); +} diff --git a/src/constants/contract-address.ts b/src/constants/contract-address.ts index 5576e64..111ada4 100644 --- a/src/constants/contract-address.ts +++ b/src/constants/contract-address.ts @@ -8,6 +8,7 @@ const ChainId = { Stability: 101010, StabilityTestnet: 20180427, Astron: 1338, + AstronTestnet: 21002, }; export const contractAddress = { @@ -20,6 +21,8 @@ export const contractAddress = { [ChainId.XDCApothem]: "0xca70f36aeeda435c1048fd372bf286a41ac538be", [ChainId.Stability]: "0x96cc41e7007Dee20eB409586E2e8206d5053219B", [ChainId.StabilityTestnet]: "0xAac003619FA8C7008C73704a550aA2bF20951dbb", + [ChainId.Astron]: "0xa1fCAE62b69D85BfBb5B447f038123A1e4d8f6eC", + [ChainId.AstronTestnet]: "0x812A0E71c61A42C8d3d449BdfF51834f85686C73", }, Deployer: { [ChainId.Ethereum]: "0xEc6fD701743cEdf504873F5E5E807586f437A500", @@ -30,6 +33,8 @@ export const contractAddress = { [ChainId.XDC]: "0x9EFE3127e4F3f4e4CF83132F05BE11a097e0f9Ca", [ChainId.XDCApothem]: "0x20284AFe0B36545611dCF90Bb8128FC52e24247F", [ChainId.PolygonAmoy]: "0xfcafea839e576967b96ad1FBFB52b5CA26cd1D25", + [ChainId.Astron]: "0x79ED245fFecdAF8C87BFE35ccF6A7b9FE9024240", + [ChainId.AstronTestnet]: "0x1043B46Ad9072fB784492d94F8d25eF679B7AAf8", }, TokenImplementation: { [ChainId.Ethereum]: "0x1583A4Eb50Bdde67F44A506a16c6d90C1E0A46d3", @@ -40,5 +45,7 @@ export const contractAddress = { [ChainId.Stability]: "0x9093AD686C92572750d7399484131F0b3E02b62A", [ChainId.XDC]: "0xCdF35cA8e01f5693B23135C7a92B8FefeFd0bDd0", [ChainId.XDCApothem]: "0xcc72c1b3f7875fda22bcdb147d462d7da64efc55", + [ChainId.Astron]: "0x025aF5b49f1755C536d6965093C88d85A7Ec3300", + [ChainId.AstronTestnet]: "0xA0Da221B3cd3e863425E1F1D34B9307D295a9d03", }, }; diff --git a/src/constants/contract-interface-id.ts b/src/constants/contract-interface-id.ts index dbb8ee7..13ade22 100644 --- a/src/constants/contract-interface-id.ts +++ b/src/constants/contract-interface-id.ts @@ -6,6 +6,7 @@ export const contractInterfaceId = { TradeTrustTokenBurnable: computeInterfaceId(contractInterfaces.TradeTrustTokenBurnable), TradeTrustTokenRestorable: computeInterfaceId(contractInterfaces.TradeTrustTokenRestorable), TitleEscrow: computeInterfaceId(contractInterfaces.TitleEscrow), + ObligationEscrow: computeInterfaceId(contractInterfaces.ObligationEscrow), TitleEscrowSignable: computeInterfaceId(contractInterfaces.TitleEscrowSignable), TitleEscrowFactory: computeInterfaceId(contractInterfaces.TitleEscrowFactory), AccessControl: computeInterfaceId(contractInterfaces.AccessControl), diff --git a/src/constants/contract-interfaces.ts b/src/constants/contract-interfaces.ts index 5042059..8563295 100644 --- a/src/constants/contract-interfaces.ts +++ b/src/constants/contract-interfaces.ts @@ -23,6 +23,34 @@ export const contractInterfaces = { "returnToIssuer(bytes)", "shred(bytes)", ], + ObligationEscrow: [ + "status()", + "isRegistered()", + "accept(bytes)", + "reject(bytes)", + "discharge(bytes)", + "nominate(address,bytes)", + "transferBeneficiary(address,bytes)", + "transferHolder(address,bytes)", + "transferOwners(address,address,bytes)", + "rejectTransferBeneficiary(bytes)", + "rejectTransferHolder(bytes)", + "rejectTransferOwners(bytes)", + "beneficiary()", + "holder()", + "prevBeneficiary()", + "prevHolder()", + "active()", + "nominee()", + "registry()", + "tokenId()", + "terminationReason()", + "mintBlock()", + "shredBlock()", + "isHoldingToken()", + "returnToIssuer(bytes)", + "shred(bytes)", + ], TitleEscrowSignable: [ "transferBeneficiaryWithSig((address,address,address,address,uint256,uint256,uint256),(bytes32,bytes32,uint8))", "cancelBeneficiaryTransfer((address,address,address,address,uint256,uint256,uint256))", diff --git a/src/utils/compute-obligation-escrow-address.ts b/src/utils/compute-obligation-escrow-address.ts new file mode 100644 index 0000000..491d870 --- /dev/null +++ b/src/utils/compute-obligation-escrow-address.ts @@ -0,0 +1,33 @@ +import { ethers as packedEthers } from "ethers"; + +export interface ComputeObligationEscrowAddressParams { + implementationAddress: string; + factoryAddress: string; + registryAddress: string; + tokenId: string; +} + +const ethers = { ...packedEthers }; + +if (ethers.version.includes("/5")) { + (ethers as any).keccak256 = (ethers as any).utils.keccak256; + (ethers as any).solidityPackedKeccak256 = (ethers as any).utils.solidityKeccak256; + (ethers as any).solidityPacked = (ethers as any).utils.solidityPack; + (ethers as any).getCreate2Address = (ethers as any).utils.getCreate2Address; +} + +/** + * Computes the deterministic EIP-1167 clone address for an ObligationEscrow, + * mirroring ObligationEscrowFactory.getEscrowAddress (same scheme as TitleEscrowFactory). + */ +export const computeObligationEscrowAddress = (params: ComputeObligationEscrowAddressParams) => { + const { implementationAddress, factoryAddress, registryAddress, tokenId } = params; + const initCodeHash = (ethers as any).keccak256( + `0x3d602d80600a3d3981f3363d3d373d3d3d363d73${implementationAddress.substring(2)}5af43d82803e903d91602b57fd5bf3` + ); + const salt = (ethers as any).solidityPackedKeccak256( + ["bytes"], + [(ethers as any).solidityPacked(["address", "uint256"], [registryAddress, tokenId])] + ); + return (ethers as any).getCreate2Address(factoryAddress, salt, initCodeHash); +}; diff --git a/src/utils/compute-title-escrow-address.ts b/src/utils/compute-title-escrow-address.ts index d493486..301e6f6 100644 --- a/src/utils/compute-title-escrow-address.ts +++ b/src/utils/compute-title-escrow-address.ts @@ -1,6 +1,6 @@ import { ethers as packedEthers } from "ethers"; -interface Params { +export interface ComputeTitleEscrowAddressParams { implementationAddress: string; factoryAddress: string; registryAddress: string; @@ -19,7 +19,7 @@ if (ethers.version.includes("/5")) { /** * @deprecated not be used with W3C VC */ -export const computeTitleEscrowAddress = (params: Params) => { +export const computeTitleEscrowAddress = (params: ComputeTitleEscrowAddressParams) => { const { implementationAddress, factoryAddress, registryAddress, tokenId } = params; const initCodeHash = (ethers as any).keccak256( `0x3d602d80600a3d3981f3363d3d373d3d3d363d73${implementationAddress.substring(2)}5af43d82803e903d91602b57fd5bf3` diff --git a/src/utils/index.ts b/src/utils/index.ts index 7b82afa..c9b2c6c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from "./compute-title-escrow-address"; +export * from "./compute-obligation-escrow-address"; export * from "./encode-init-params"; export * from "./get-event-from-receipt"; export * from "./compute-interface-id"; diff --git a/tasks/deploy-escrow-factory.ts b/tasks/deploy-escrow-factory.ts index 59b8801..09cc094 100644 --- a/tasks/deploy-escrow-factory.ts +++ b/tasks/deploy-escrow-factory.ts @@ -22,7 +22,7 @@ task(TASK_DEPLOY_ESCROW_FACTORY) }); const factoryDeployTx = titleEscrowFactoryContract.deploymentTransaction(); console.log(`[Transaction] Pending ${factoryDeployTx?.hash}`); - await titleEscrowFactoryContract.deploymentTransaction(); + await titleEscrowFactoryContract.waitForDeployment(); const factoryAddress = titleEscrowFactoryContract.target; console.log(`[Status] Deployed to ${factoryAddress}`); diff --git a/tasks/deploy-token-impl.ts b/tasks/deploy-token-impl.ts index 5162cb4..5338f92 100644 --- a/tasks/deploy-token-impl.ts +++ b/tasks/deploy-token-impl.ts @@ -34,7 +34,7 @@ task(TASK_DEPLOY_TOKEN_IMPL) }); } - console.log(`[Status] βœ… Completed deploying token implementation at ${registryImplContract.address}`); + console.log(`[Status] βœ… Completed deploying token implementation at ${registryImplContract.target}`); } catch (err: any) { console.log("[Status] ❌ An error occurred while deploying token implementation"); console.error(err.error?.message ?? err.message); diff --git a/tasks/helpers/deploy-contract.ts b/tasks/helpers/deploy-contract.ts index 936d195..6413a0d 100644 --- a/tasks/helpers/deploy-contract.ts +++ b/tasks/helpers/deploy-contract.ts @@ -20,7 +20,7 @@ export const deployContract = async ({ console.log(`[Transaction] Pending ${tx?.hash}`); await contract.deploymentTransaction(); - console.log(`[Address] Deployed to ${contract.address}`); + console.log(`[Address] Deployed to ${contract.target}`); return contract; }; diff --git a/test/ObligationEscrow.test.ts b/test/ObligationEscrow.test.ts new file mode 100644 index 0000000..1925fe8 --- /dev/null +++ b/test/ObligationEscrow.test.ts @@ -0,0 +1,970 @@ +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { ObligationEscrow, ObligationEscrowFactory, TrustVCToken } from "@tradetrust/contracts"; +import { Contract, Signer } from "ethers"; +import faker from "faker"; +import { ethers } from "hardhat"; +import { expect } from "."; +import { contractInterfaceId, defaultAddress } from "../src/constants"; +import { deployObligationEscrowFixture, deployTrustVCTokenFixture } from "./fixtures"; +import { deployImplProxy } from "./fixtures/deploy-impl-proxy.fixture"; +import { getTestUsers, impersonateAccount, TestUsers, txnHexRemarks } from "./helpers"; + +const BURN_ADDRESS = "0x000000000000000000000000000000000000dEaD"; + +const Status = { + Issued: 0, + Accepted: 1, + Rejected: 2, + Discharged: 3, +} as const; + +const TerminationReason = { + None: 0, + ReturnToIssuer: 1, + Rejected: 2, + Discharged: 3, +} as const; + +describe("ObligationEscrow", async () => { + let users: TestUsers; + let tokenId: string; + const exceededLengthRemark = ethers.hexlify(ethers.randomBytes(121)); + + // eslint-disable-next-line no-undef + before(async () => { + users = await getTestUsers(); + }); + + beforeEach(async () => { + tokenId = faker.datatype.hexaDecimal(64); + }); + + const getEscrowContract = async (obligationToken: TrustVCToken, id: string): Promise => { + const factoryAddr = await obligationToken.obligationEscrowFactory(); + const factory = (await ethers.getContractFactory("ObligationEscrowFactory")).attach( + factoryAddr + ) as unknown as ObligationEscrowFactory; + const escrowAddress = await factory.getEscrowAddress(await obligationToken.getAddress(), id); + return (await ethers.getContractFactory("ObligationEscrow")).attach(escrowAddress) as unknown as ObligationEscrow; + }; + + const mint = async ( + obligationToken: TrustVCToken, + beneficiary: SignerWithAddress, + holder: SignerWithAddress, + id: string + ): Promise => { + await ( + await obligationToken + .connect(users.carrier) + .mint(beneficiary.address, holder.address, id, txnHexRemarks.mintRemark) + ).wait(); + return getEscrowContract(obligationToken, id); + }; + + const mintWithReceipt = async ( + obligationToken: TrustVCToken, + beneficiary: SignerWithAddress, + holder: SignerWithAddress, + id: string + ): Promise<{ escrow: ObligationEscrow; mintBlockNumber: number }> => { + const receipt = await ( + await obligationToken + .connect(users.carrier) + .mint(beneficiary.address, holder.address, id, txnHexRemarks.mintRemark) + ).wait(); + return { + escrow: await getEscrowContract(obligationToken, id), + mintBlockNumber: receipt!.blockNumber, + }; + }; + + describe("ERC165 Support", () => { + let obligationToken: TrustVCToken; + let escrow: ObligationEscrow; + + // eslint-disable-next-line no-undef + before(async () => { + ({ obligationToken } = await deployTrustVCTokenFixture({ deployer: users.carrier })); + }); + + beforeEach(async () => { + escrow = await mint(obligationToken, users.beneficiary, users.holder, tokenId); + }); + + it("should support IObligationEscrow interface", async () => { + expect(await escrow.supportsInterface(contractInterfaceId.ObligationEscrow)).to.be.true; + }); + }); + + describe("General Behaviours", () => { + let implContract: ObligationEscrow; + + // eslint-disable-next-line no-undef + before(async () => { + implContract = await deployObligationEscrowFixture({ deployer: users.carrier }); + }); + + it("should not allow initialising the implementation directly", async () => { + const tx = implContract.initialize(defaultAddress.Zero, tokenId); + await expect(tx).to.be.revertedWithCustomError(implContract, "InvalidInitialization"); + }); + }); + + describe("Initialisation (fresh clone)", () => { + let implContract: ObligationEscrow; + let cloneContract: ObligationEscrow; + let registryAddress: string; + + // eslint-disable-next-line no-undef + before(async () => { + implContract = await deployObligationEscrowFixture({ deployer: users.carrier }); + }); + + beforeEach(async () => { + const { obligationToken } = await deployTrustVCTokenFixture({ deployer: users.carrier }); + registryAddress = await obligationToken.getAddress(); + cloneContract = await deployImplProxy({ + implementation: implContract as ObligationEscrow & Contract, + deployer: users.carrier, + }); + await cloneContract.initialize(registryAddress, tokenId); + }); + + it("should set the correct registry address", async () => { + expect(await cloneContract.registry()).to.equal(registryAddress); + }); + + it("should set active to true", async () => { + expect(await cloneContract.active()).to.be.true; + }); + + it("should set the correct token ID", async () => { + expect(await cloneContract.tokenId()).to.equal(tokenId); + }); + + it("should keep beneficiary/holder/nominee/prevBeneficiary/prevHolder as zero address", async () => { + expect(await cloneContract.beneficiary()).to.equal(defaultAddress.Zero); + expect(await cloneContract.holder()).to.equal(defaultAddress.Zero); + expect(await cloneContract.nominee()).to.equal(defaultAddress.Zero); + expect(await cloneContract.prevBeneficiary()).to.equal(defaultAddress.Zero); + expect(await cloneContract.prevHolder()).to.equal(defaultAddress.Zero); + }); + + it("should set terminationReason to None", async () => { + expect(await cloneContract.terminationReason()).to.equal(TerminationReason.None); + }); + + it("should leave mintBlock and shredBlock at zero before mint", async () => { + expect(await cloneContract.mintBlock()).to.equal(0); + expect(await cloneContract.shredBlock()).to.equal(0); + }); + + it("should not be registered and revert status()", async () => { + expect(await cloneContract.isRegistered()).to.be.false; + await expect(cloneContract.status()).to.be.revertedWithCustomError(cloneContract, "NotRegistered"); + }); + + it("should not allow re-initialisation", async () => { + const tx = cloneContract.initialize(registryAddress, tokenId); + await expect(tx).to.be.revertedWithCustomError(cloneContract, "InvalidInitialization"); + }); + }); + + describe("IERC721Receiver Behaviour", () => { + let implContractSrc: ObligationEscrow; + let fakeRegistry: TrustVCToken; + let fakeRegistryWallet: Signer; + let implContract: ObligationEscrow; + let fakeAddress: string; + + // eslint-disable-next-line no-undef + before(async () => { + implContractSrc = await deployObligationEscrowFixture({ deployer: users.carrier }); + }); + + beforeEach(async () => { + ({ obligationToken: fakeRegistry } = await deployTrustVCTokenFixture({ deployer: users.carrier })); + fakeRegistryWallet = await impersonateAccount({ address: await fakeRegistry.getAddress() }); + fakeAddress = ethers.getAddress(faker.finance.ethereumAddress()); + + implContract = await deployImplProxy({ + implementation: implContractSrc as ObligationEscrow & Contract, + deployer: users.carrier, + }); + await implContract.initialize(await fakeRegistry.getAddress(), tokenId); + }); + + it("should revert for the wrong tokenId", async () => { + const wrongTokenId = faker.datatype.hexaDecimal(64); + const tx = implContract + .connect(fakeRegistryWallet) + .onERC721Received(fakeAddress, fakeAddress, wrongTokenId, "0x00"); + + await expect(tx).to.be.revertedWithCustomError(implContract, "InvalidTokenId").withArgs(wrongTokenId); + }); + + it("should revert when caller is not the registry", async () => { + const [notRegistry] = users.others; + const tx = implContract.connect(notRegistry).onERC721Received(fakeAddress, fakeAddress, tokenId, "0x00"); + + await expect(tx).to.be.revertedWithCustomError(implContract, "InvalidRegistry").withArgs(notRegistry.address); + }); + + describe("Minting receive", () => { + let data: string; + + beforeEach(async () => { + data = new ethers.AbiCoder().encode( + ["address", "address", "bytes"], + [users.beneficiary.address, users.holder.address, txnHexRemarks.mintRemark] + ); + }); + + it("should set beneficiary and holder from data", async () => { + await implContract.connect(fakeRegistryWallet).onERC721Received(fakeAddress, fakeAddress, tokenId, data); + + expect(await implContract.beneficiary()).to.equal(users.beneficiary.address); + expect(await implContract.holder()).to.equal(users.holder.address); + }); + + it("should register the escrow and set status to Issued", async () => { + const tx = implContract.connect(fakeRegistryWallet).onERC721Received(fakeAddress, fakeAddress, tokenId, data); + const receipt = await (await tx).wait(); + + expect(await implContract.isRegistered()).to.be.true; + expect(await implContract.status()).to.equal(Status.Issued); + expect(await implContract.mintBlock()).to.equal(receipt!.blockNumber); + expect(await implContract.shredBlock()).to.equal(0); + await expect(tx) + .to.emit(implContract, "StatusInitialized") + .withArgs(tokenId, await fakeRegistry.getAddress()); + }); + + it("should emit TokenReceived with isMinting true", async () => { + const tx = implContract.connect(fakeRegistryWallet).onERC721Received(fakeAddress, fakeAddress, tokenId, data); + + await expect(tx) + .to.emit(implContract, "TokenReceived") + .withArgs( + users.beneficiary.address, + users.holder.address, + true, + await fakeRegistry.getAddress(), + tokenId, + txnHexRemarks.mintRemark + ); + }); + + it("should emit BeneficiaryTransfer and HolderTransfer events", async () => { + const tx = implContract.connect(fakeRegistryWallet).onERC721Received(fakeAddress, fakeAddress, tokenId, data); + + await expect(tx) + .to.emit(implContract, "BeneficiaryTransfer") + .withArgs(defaultAddress.Zero, users.beneficiary.address, await fakeRegistry.getAddress(), tokenId, "0x"); + await expect(tx) + .to.emit(implContract, "HolderTransfer") + .withArgs(defaultAddress.Zero, users.holder.address, await fakeRegistry.getAddress(), tokenId, "0x"); + }); + + it("should revert on empty data", async () => { + const tx = implContract.connect(fakeRegistryWallet).onERC721Received(fakeAddress, fakeAddress, tokenId, "0x"); + + await expect(tx).to.be.revertedWithCustomError(implContract, "EmptyReceivingData"); + }); + + it("should revert when beneficiary is zero address", async () => { + const zeroBeneficiaryData = new ethers.AbiCoder().encode( + ["address", "address", "bytes"], + [defaultAddress.Zero, users.holder.address, txnHexRemarks.mintRemark] + ); + const tx = implContract + .connect(fakeRegistryWallet) + .onERC721Received(fakeAddress, fakeAddress, tokenId, zeroBeneficiaryData); + + await expect(tx) + .to.be.revertedWithCustomError(implContract, "InvalidTokenTransferToZeroAddressOwners") + .withArgs(defaultAddress.Zero, users.holder.address); + }); + + it("should revert when holder is zero address", async () => { + const zeroHolderData = new ethers.AbiCoder().encode( + ["address", "address", "bytes"], + [users.beneficiary.address, defaultAddress.Zero, txnHexRemarks.mintRemark] + ); + const tx = implContract + .connect(fakeRegistryWallet) + .onERC721Received(fakeAddress, fakeAddress, tokenId, zeroHolderData); + + await expect(tx) + .to.be.revertedWithCustomError(implContract, "InvalidTokenTransferToZeroAddressOwners") + .withArgs(users.beneficiary.address, defaultAddress.Zero); + }); + }); + + describe("After minting receive", () => { + beforeEach(async () => { + const data = new ethers.AbiCoder().encode( + ["address", "address", "bytes"], + [users.beneficiary.address, users.holder.address, txnHexRemarks.mintRemark] + ); + await implContract.connect(fakeRegistryWallet).onERC721Received(fakeAddress, fakeAddress, tokenId, data); + }); + + it("should accept subsequent receive without data and emit isMinting false", async () => { + const tx = implContract.connect(fakeRegistryWallet).onERC721Received(fakeAddress, fakeAddress, tokenId, "0x"); + + await expect(tx) + .to.emit(implContract, "TokenReceived") + .withArgs( + users.beneficiary.address, + users.holder.address, + false, + await fakeRegistry.getAddress(), + tokenId, + "0x" + ); + }); + }); + }); + + describe("Operational behaviours (via a real TrustVCToken)", () => { + let obligationToken: TrustVCToken; + let escrow: ObligationEscrow; + + // eslint-disable-next-line no-undef + before(async () => { + ({ obligationToken } = await deployTrustVCTokenFixture({ deployer: users.carrier })); + }); + + describe("Status lifecycle (accept/reject/discharge)", () => { + beforeEach(async () => { + escrow = await mint(obligationToken, users.beneficiary, users.holder, tokenId); + }); + + it("should start Issued and registered right after mint", async () => { + expect(await escrow.status()).to.equal(Status.Issued); + expect(await escrow.isRegistered()).to.be.true; + }); + + it("should record mintBlock on mint and leave shredBlock at zero", async () => { + const { escrow: mintedEscrow, mintBlockNumber } = await mintWithReceipt( + obligationToken, + users.beneficiary, + users.holder, + faker.datatype.hexaDecimal(64) + ); + expect(await mintedEscrow.mintBlock()).to.equal(mintBlockNumber); + expect(await mintedEscrow.shredBlock()).to.equal(0); + }); + + it("should not allow remark length to exceed limit on accept/reject/discharge", async () => { + await expect(escrow.connect(users.holder).accept(exceededLengthRemark)).to.be.revertedWithCustomError( + escrow, + "RemarkLengthExceeded" + ); + await expect(escrow.connect(users.holder).reject(exceededLengthRemark)).to.be.revertedWithCustomError( + escrow, + "RemarkLengthExceeded" + ); + }); + + it("should allow the holder to accept an Issued title", async () => { + const tx = escrow.connect(users.holder).accept(txnHexRemarks.mintRemark); + + await expect(tx) + .to.emit(escrow, "StatusAccepted") + .withArgs(tokenId, users.holder.address, txnHexRemarks.mintRemark); + expect(await escrow.status()).to.equal(Status.Accepted); + expect(await escrow.active()).to.be.true; + }); + + it("should not allow non-holder to accept", async () => { + const tx = escrow.connect(users.beneficiary).accept(txnHexRemarks.mintRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "CallerNotHolder"); + }); + + it("should not allow accepting a title that is not Issued", async () => { + await escrow.connect(users.holder).accept(txnHexRemarks.mintRemark); + const tx = escrow.connect(users.holder).accept(txnHexRemarks.mintRemark); + await expect(tx) + .to.be.revertedWithCustomError(escrow, "InvalidStatusTransition") + .withArgs(Status.Accepted, Status.Issued); + }); + + it("should allow the holder to reject an Issued title and auto-shred it", async () => { + const mintBlock = await escrow.mintBlock(); + const tx = escrow.connect(users.holder).reject(txnHexRemarks.mintRemark); + const receipt = await (await tx).wait(); + + await expect(tx) + .to.emit(escrow, "StatusRejected") + .withArgs(tokenId, users.holder.address, txnHexRemarks.mintRemark); + await expect(tx) + .to.emit(escrow, "Shred") + .withArgs(await obligationToken.getAddress(), tokenId, TerminationReason.Rejected, txnHexRemarks.mintRemark); + expect(await escrow.status()).to.equal(Status.Rejected); + expect(await escrow.active()).to.be.false; + expect(await escrow.terminationReason()).to.equal(TerminationReason.Rejected); + expect(await escrow.beneficiary()).to.equal(defaultAddress.Zero); + expect(await escrow.holder()).to.equal(defaultAddress.Zero); + expect(await obligationToken.ownerOf(tokenId)).to.equal(BURN_ADDRESS); + expect(await escrow.shredBlock()).to.equal(receipt!.blockNumber); + expect(await escrow.mintBlock()).to.equal(mintBlock); + }); + + it("should not allow non-holder to reject", async () => { + const tx = escrow.connect(users.beneficiary).reject(txnHexRemarks.mintRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "CallerNotHolder"); + }); + + it("should not allow rejecting a title that is not Issued", async () => { + await escrow.connect(users.holder).accept(txnHexRemarks.mintRemark); + const tx = escrow.connect(users.holder).reject(txnHexRemarks.mintRemark); + await expect(tx) + .to.be.revertedWithCustomError(escrow, "InvalidStatusTransition") + .withArgs(Status.Accepted, Status.Issued); + }); + + it("should not allow discharging a title that is not Accepted", async () => { + const tx = escrow.connect(users.beneficiary).discharge(txnHexRemarks.mintRemark); + await expect(tx) + .to.be.revertedWithCustomError(escrow, "InvalidStatusTransition") + .withArgs(Status.Issued, Status.Accepted); + }); + + it("should allow the beneficiary to discharge an Accepted title and auto-shred it", async () => { + const mintBlock = await escrow.mintBlock(); + await escrow.connect(users.holder).accept(txnHexRemarks.mintRemark); + const tx = escrow.connect(users.beneficiary).discharge(txnHexRemarks.mintRemark); + const receipt = await (await tx).wait(); + + await expect(tx) + .to.emit(escrow, "StatusDischarged") + .withArgs(tokenId, users.beneficiary.address, txnHexRemarks.mintRemark); + expect(await escrow.status()).to.equal(Status.Discharged); + expect(await escrow.active()).to.be.false; + expect(await escrow.terminationReason()).to.equal(TerminationReason.Discharged); + expect(await obligationToken.ownerOf(tokenId)).to.equal(BURN_ADDRESS); + expect(await escrow.shredBlock()).to.equal(receipt!.blockNumber); + expect(await escrow.mintBlock()).to.equal(mintBlock); + }); + + it("should not allow non-beneficiary to discharge", async () => { + await escrow.connect(users.holder).accept(txnHexRemarks.mintRemark); + const tx = escrow.connect(users.holder).discharge(txnHexRemarks.mintRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "CallerNotBeneficiary"); + }); + + it("should revert accept/reject when beneficiary equals holder", async () => { + const dualTokenId = faker.datatype.hexaDecimal(64); + const dualEscrow = await mint(obligationToken, users.beneficiary, users.beneficiary, dualTokenId); + + await expect( + dualEscrow.connect(users.beneficiary).accept(txnHexRemarks.mintRemark) + ).to.be.revertedWithCustomError(dualEscrow, "OwnerHolderMustDiffer"); + await expect( + dualEscrow.connect(users.beneficiary).reject(txnHexRemarks.mintRemark) + ).to.be.revertedWithCustomError(dualEscrow, "OwnerHolderMustDiffer"); + }); + + it("should allow the beneficiary to discharge when beneficiary equals holder (only beneficiary check)", async () => { + await escrow.connect(users.holder).accept(txnHexRemarks.mintRemark); + await escrow.connect(users.holder).transferHolder(users.beneficiary.address, txnHexRemarks.mintRemark); + + const tx = escrow.connect(users.beneficiary).discharge(txnHexRemarks.mintRemark); + await expect(tx).to.not.be.reverted; + expect(await escrow.status()).to.equal(Status.Discharged); + }); + + it("should revert accept while the registry is paused", async () => { + await obligationToken.connect(users.carrier).pause(txnHexRemarks.pauseRemark); + + await expect(escrow.connect(users.holder).accept(txnHexRemarks.mintRemark)).to.be.revertedWithCustomError( + escrow, + "RegistryContractPaused" + ); + + await obligationToken.connect(users.carrier).unpause(txnHexRemarks.unPauseRemark); + }); + + it("should revert reject while the registry is paused", async () => { + await obligationToken.connect(users.carrier).pause(txnHexRemarks.pauseRemark); + + await expect(escrow.connect(users.holder).reject(txnHexRemarks.mintRemark)).to.be.revertedWithCustomError( + escrow, + "RegistryContractPaused" + ); + + await obligationToken.connect(users.carrier).unpause(txnHexRemarks.unPauseRemark); + }); + + it("should revert discharge while the registry is paused", async () => { + await escrow.connect(users.holder).accept(txnHexRemarks.mintRemark); + await obligationToken.connect(users.carrier).pause(txnHexRemarks.pauseRemark); + + await expect( + escrow.connect(users.beneficiary).discharge(txnHexRemarks.mintRemark) + ).to.be.revertedWithCustomError(escrow, "RegistryContractPaused"); + + await obligationToken.connect(users.carrier).unpause(txnHexRemarks.unPauseRemark); + }); + }); + + describe("Nomination", () => { + let beneficiaryNominee: SignerWithAddress; + + beforeEach(async () => { + escrow = await mint(obligationToken, users.beneficiary, users.holder, tokenId); + [beneficiaryNominee] = users.others; + }); + + it("should not allow remark length to exceed limit", async () => { + const tx = escrow.connect(users.beneficiary).nominate(beneficiaryNominee.address, exceededLengthRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "RemarkLengthExceeded"); + }); + + it("should allow beneficiary to nominate a new beneficiary", async () => { + await escrow.connect(users.beneficiary).nominate(beneficiaryNominee.address, txnHexRemarks.nominateRemark); + expect(await escrow.nominee()).to.equal(beneficiaryNominee.address); + }); + + it("should not allow non-beneficiary to nominate", async () => { + const tx = escrow.connect(users.holder).nominate(beneficiaryNominee.address, txnHexRemarks.nominateRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "CallerNotBeneficiary"); + }); + + it("should not allow nominating the current beneficiary", async () => { + const tx = escrow.connect(users.beneficiary).nominate(users.beneficiary.address, txnHexRemarks.nominateRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "TargetNomineeAlreadyBeneficiary"); + }); + + it("should not allow nominating the same nominee twice", async () => { + await escrow.connect(users.beneficiary).nominate(beneficiaryNominee.address, txnHexRemarks.nominateRemark); + const tx = escrow.connect(users.beneficiary).nominate(beneficiaryNominee.address, txnHexRemarks.nominateRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "NomineeAlreadyNominated"); + }); + + it("should emit Nomination event", async () => { + const tx = escrow.connect(users.beneficiary).nominate(beneficiaryNominee.address, txnHexRemarks.nominateRemark); + await expect(tx) + .to.emit(escrow, "Nomination") + .withArgs( + defaultAddress.Zero, + beneficiaryNominee.address, + await obligationToken.getAddress(), + tokenId, + txnHexRemarks.nominateRemark + ); + }); + + it("should not allow nominating when escrow is not holding token", async () => { + const dualTokenId = faker.datatype.hexaDecimal(64); + const dualEscrow = await mint(obligationToken, users.beneficiary, users.beneficiary, dualTokenId); + await dualEscrow.connect(users.beneficiary).returnToIssuer(txnHexRemarks.returnToIssuerRemark); + + const tx = dualEscrow + .connect(users.beneficiary) + .nominate(beneficiaryNominee.address, txnHexRemarks.nominateRemark); + await expect(tx).to.be.revertedWithCustomError(dualEscrow, "TitleEscrowNotHoldingToken"); + }); + }); + + describe("Transfer beneficiary / holder / owners", () => { + let beneficiaryNominee: SignerWithAddress; + let newHolder: SignerWithAddress; + + beforeEach(async () => { + escrow = await mint(obligationToken, users.beneficiary, users.holder, tokenId); + [beneficiaryNominee, newHolder] = users.others; + }); + + it("should allow holder to transfer to a nominated beneficiary", async () => { + await escrow.connect(users.beneficiary).nominate(beneficiaryNominee.address, txnHexRemarks.nominateRemark); + await escrow + .connect(users.holder) + .transferBeneficiary(beneficiaryNominee.address, txnHexRemarks.beneficiaryTransferRemark); + + expect(await escrow.beneficiary()).to.equal(beneficiaryNominee.address); + expect(await escrow.nominee()).to.equal(defaultAddress.Zero); + }); + + it("should not allow non-holder to transfer beneficiary", async () => { + await escrow.connect(users.beneficiary).nominate(beneficiaryNominee.address, txnHexRemarks.nominateRemark); + const tx = escrow + .connect(users.beneficiary) + .transferBeneficiary(beneficiaryNominee.address, txnHexRemarks.beneficiaryTransferRemark); + + await expect(tx).to.be.revertedWithCustomError(escrow, "CallerNotHolder"); + }); + + it("should not allow transferring beneficiary to zero address", async () => { + const tx = escrow + .connect(users.holder) + .transferBeneficiary(defaultAddress.Zero, txnHexRemarks.beneficiaryTransferRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "InvalidTransferToZeroAddress"); + }); + + it("should not allow transferring beneficiary to a non-nominated address", async () => { + const tx = escrow + .connect(users.holder) + .transferBeneficiary(beneficiaryNominee.address, txnHexRemarks.beneficiaryTransferRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "InvalidNominee"); + }); + + it("should emit BeneficiaryTransfer event", async () => { + await escrow.connect(users.beneficiary).nominate(beneficiaryNominee.address, txnHexRemarks.nominateRemark); + const tx = escrow + .connect(users.holder) + .transferBeneficiary(beneficiaryNominee.address, txnHexRemarks.beneficiaryTransferRemark); + + await expect(tx) + .to.emit(escrow, "BeneficiaryTransfer") + .withArgs( + users.beneficiary.address, + beneficiaryNominee.address, + await obligationToken.getAddress(), + tokenId, + txnHexRemarks.beneficiaryTransferRemark + ); + }); + + it("should allow holder to transfer to a new holder", async () => { + await escrow.connect(users.holder).transferHolder(newHolder.address, txnHexRemarks.holderTransferRemark); + + expect(await escrow.holder()).to.equal(newHolder.address); + expect(await escrow.prevHolder()).to.equal(users.holder.address); + }); + + it("should not allow non-holder to transfer holder", async () => { + const tx = escrow + .connect(users.beneficiary) + .transferHolder(newHolder.address, txnHexRemarks.holderTransferRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "CallerNotHolder"); + }); + + it("should not allow transferring holder to zero address", async () => { + const tx = escrow.connect(users.holder).transferHolder(defaultAddress.Zero, txnHexRemarks.holderTransferRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "InvalidTransferToZeroAddress"); + }); + + it("should not allow transferring holder to the current holder", async () => { + const tx = escrow + .connect(users.holder) + .transferHolder(users.holder.address, txnHexRemarks.holderTransferRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "RecipientAlreadyHolder"); + }); + + it("should emit HolderTransfer event", async () => { + const tx = escrow.connect(users.holder).transferHolder(newHolder.address, txnHexRemarks.holderTransferRemark); + await expect(tx) + .to.emit(escrow, "HolderTransfer") + .withArgs( + users.holder.address, + newHolder.address, + await obligationToken.getAddress(), + tokenId, + txnHexRemarks.holderTransferRemark + ); + }); + + it("should transfer both beneficiary and holder via transferOwners", async () => { + await escrow.connect(users.beneficiary).nominate(beneficiaryNominee.address, txnHexRemarks.nominateRemark); + await escrow + .connect(users.holder) + .transferOwners(beneficiaryNominee.address, newHolder.address, txnHexRemarks.transferOwnersRemark); + + expect(await escrow.beneficiary()).to.equal(beneficiaryNominee.address); + expect(await escrow.holder()).to.equal(newHolder.address); + }); + + it("should revert transferOwners when caller is not holder", async () => { + const tx = escrow + .connect(users.beneficiary) + .transferOwners(beneficiaryNominee.address, newHolder.address, txnHexRemarks.transferOwnersRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "CallerNotHolder"); + }); + }); + + describe("Reject transfer", () => { + beforeEach(async () => { + escrow = await mint(obligationToken, users.beneficiary, users.holder, tokenId); + }); + + it("should allow beneficiary to reject a pending beneficiary transfer", async () => { + const [newBeneficiary] = users.others; + await escrow.connect(users.beneficiary).nominate(newBeneficiary.address, txnHexRemarks.nominateRemark); + await escrow + .connect(users.holder) + .transferBeneficiary(newBeneficiary.address, txnHexRemarks.beneficiaryTransferRemark); + + const tx = escrow.connect(newBeneficiary).rejectTransferBeneficiary(txnHexRemarks.rejectTransferRemark); + + await expect(tx) + .to.emit(escrow, "RejectTransferBeneficiary") + .withArgs( + newBeneficiary.address, + users.beneficiary.address, + await obligationToken.getAddress(), + tokenId, + txnHexRemarks.rejectTransferRemark + ); + expect(await escrow.beneficiary()).to.equal(users.beneficiary.address); + }); + + it("should revert rejectTransferBeneficiary when there is no pending transfer", async () => { + const tx = escrow.connect(users.beneficiary).rejectTransferBeneficiary(txnHexRemarks.rejectTransferRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "InvalidTransferToZeroAddress"); + }); + + it("should allow holder to reject a pending holder transfer", async () => { + const [newHolder] = users.others; + await escrow.connect(users.holder).transferHolder(newHolder.address, txnHexRemarks.holderTransferRemark); + + const tx = escrow.connect(newHolder).rejectTransferHolder(txnHexRemarks.rejectTransferRemark); + + await expect(tx) + .to.emit(escrow, "RejectTransferHolder") + .withArgs( + newHolder.address, + users.holder.address, + await obligationToken.getAddress(), + tokenId, + txnHexRemarks.rejectTransferRemark + ); + expect(await escrow.holder()).to.equal(users.holder.address); + }); + + it("should revert rejectTransferHolder when there is no pending transfer", async () => { + const tx = escrow.connect(users.holder).rejectTransferHolder(txnHexRemarks.rejectTransferRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "InvalidTransferToZeroAddress"); + }); + + it("should revert rejectTransferHolder/Beneficiary with DualRoleRejectionRequired when beneficiary==holder", async () => { + const dualTokenId = faker.datatype.hexaDecimal(64); + const dualEscrow = await mint(obligationToken, users.beneficiary, users.beneficiary, dualTokenId); + const [newOwner] = users.others; + await dualEscrow + .connect(users.beneficiary) + .transferOwners(newOwner.address, newOwner.address, txnHexRemarks.transferOwnersRemark); + + const txHolder = dualEscrow.connect(newOwner).rejectTransferHolder(txnHexRemarks.rejectTransferRemark); + await expect(txHolder).to.be.revertedWithCustomError(dualEscrow, "DualRoleRejectionRequired"); + + const txBeneficiary = dualEscrow + .connect(newOwner) + .rejectTransferBeneficiary(txnHexRemarks.rejectTransferRemark); + await expect(txBeneficiary).to.be.revertedWithCustomError(dualEscrow, "DualRoleRejectionRequired"); + }); + + it("should allow dual-role owner to rejectTransferOwners", async () => { + const dualTokenId = faker.datatype.hexaDecimal(64); + const dualEscrow = await mint(obligationToken, users.beneficiary, users.beneficiary, dualTokenId); + const [newOwner] = users.others; + await dualEscrow + .connect(users.beneficiary) + .transferOwners(newOwner.address, newOwner.address, txnHexRemarks.transferOwnersRemark); + + const tx = dualEscrow.connect(newOwner).rejectTransferOwners(txnHexRemarks.rejectTransferRemark); + + await expect(tx) + .to.emit(dualEscrow, "RejectTransferOwners") + .withArgs( + newOwner.address, + users.beneficiary.address, + newOwner.address, + users.beneficiary.address, + await obligationToken.getAddress(), + dualTokenId, + txnHexRemarks.rejectTransferRemark + ); + expect(await dualEscrow.beneficiary()).to.equal(users.beneficiary.address); + expect(await dualEscrow.holder()).to.equal(users.beneficiary.address); + }); + + it("should revert rejectTransferOwners when there is no pending transfer", async () => { + const dualTokenId = faker.datatype.hexaDecimal(64); + const dualEscrow = await mint(obligationToken, users.beneficiary, users.beneficiary, dualTokenId); + + const tx = dualEscrow.connect(users.beneficiary).rejectTransferOwners(txnHexRemarks.rejectTransferRemark); + await expect(tx).to.be.revertedWithCustomError(dualEscrow, "InvalidTransferToZeroAddress"); + }); + + it("should revert rejectTransferOwners when caller is not the dual-role owner", async () => { + const dualTokenId = faker.datatype.hexaDecimal(64); + const dualEscrow = await mint(obligationToken, users.beneficiary, users.beneficiary, dualTokenId); + const [newOwner] = users.others; + await dualEscrow + .connect(users.beneficiary) + .transferOwners(newOwner.address, newOwner.address, txnHexRemarks.transferOwnersRemark); + + const tx = dualEscrow.connect(users.holder).rejectTransferOwners(txnHexRemarks.rejectTransferRemark); + await expect(tx).to.be.revertedWithCustomError(dualEscrow, "CallerNotBeneficiary"); + }); + }); + + describe("returnToIssuer / shred (classic ETR path)", () => { + let dualEscrow: ObligationEscrow; + + beforeEach(async () => { + escrow = await mint(obligationToken, users.beneficiary, users.holder, tokenId); + + const dualTokenId = faker.datatype.hexaDecimal(64); + dualEscrow = await mint(obligationToken, users.beneficiary, users.beneficiary, dualTokenId); + }); + + it("should revert returnToIssuer when beneficiary != holder", async () => { + // onlyBeneficiary passes (caller is beneficiary) then onlyHolder fails, since roles differ. + const tx = escrow.connect(users.beneficiary).returnToIssuer(txnHexRemarks.returnToIssuerRemark); + await expect(tx).to.be.revertedWithCustomError(escrow, "CallerNotHolder"); + }); + + it("should allow dual-role owner to returnToIssuer", async () => { + const tx = dualEscrow.connect(users.beneficiary).returnToIssuer(txnHexRemarks.returnToIssuerRemark); + + await expect(tx) + .to.emit(dualEscrow, "ReturnToIssuer") + .withArgs( + users.beneficiary.address, + await obligationToken.getAddress(), + await dualEscrow.tokenId(), + txnHexRemarks.returnToIssuerRemark + ); + expect(await dualEscrow.isHoldingToken()).to.be.false; + expect(await dualEscrow.active()).to.be.true; + }); + + it("should revert shred if escrow is still holding the token", async () => { + const tx = dualEscrow.connect(users.carrier).shred(txnHexRemarks.burnRemark); + await expect(tx).to.be.revertedWithCustomError(dualEscrow, "TokenNotReturnedToIssuer"); + }); + + it("should revert shred when caller is not registry", async () => { + await dualEscrow.connect(users.beneficiary).returnToIssuer(txnHexRemarks.returnToIssuerRemark); + + const tx = dualEscrow.connect(users.beneficiary).shred(txnHexRemarks.burnRemark); + await expect(tx).to.be.revertedWithCustomError(dualEscrow, "InvalidRegistry"); + }); + + it("should shred via burn() and set terminationReason to ReturnToIssuer", async () => { + const mintBlock = await dualEscrow.mintBlock(); + await dualEscrow.connect(users.beneficiary).returnToIssuer(txnHexRemarks.returnToIssuerRemark); + + const tx = obligationToken.connect(users.carrier).burn(await dualEscrow.tokenId(), txnHexRemarks.burnRemark); + const receipt = await (await tx).wait(); + + await expect(tx) + .to.emit(dualEscrow, "Shred") + .withArgs( + await obligationToken.getAddress(), + await dualEscrow.tokenId(), + TerminationReason.ReturnToIssuer, + txnHexRemarks.burnRemark + ); + expect(await dualEscrow.active()).to.be.false; + expect(await dualEscrow.terminationReason()).to.equal(TerminationReason.ReturnToIssuer); + expect(await dualEscrow.beneficiary()).to.equal(defaultAddress.Zero); + expect(await dualEscrow.holder()).to.equal(defaultAddress.Zero); + expect(await dualEscrow.shredBlock()).to.equal(receipt!.blockNumber); + expect(await dualEscrow.mintBlock()).to.equal(mintBlock); + }); + }); + + describe("isHoldingToken", () => { + beforeEach(async () => { + escrow = await mint(obligationToken, users.beneficiary, users.holder, tokenId); + }); + + it("should return true while holding the token", async () => { + expect(await escrow.isHoldingToken()).to.be.true; + }); + + it("should return false after returnToIssuer", async () => { + const dualTokenId = faker.datatype.hexaDecimal(64); + const dualEscrow = await mint(obligationToken, users.beneficiary, users.beneficiary, dualTokenId); + await dualEscrow.connect(users.beneficiary).returnToIssuer(txnHexRemarks.returnToIssuerRemark); + + expect(await dualEscrow.isHoldingToken()).to.be.false; + }); + }); + + describe("Inactive escrow guard", () => { + let inactiveEscrow: ObligationEscrow; + + beforeEach(async () => { + inactiveEscrow = await mint(obligationToken, users.beneficiary, users.holder, tokenId); + await inactiveEscrow.connect(users.holder).reject(txnHexRemarks.mintRemark); + }); + + it("should revert nominate", async () => { + const tx = inactiveEscrow + .connect(users.beneficiary) + .nominate(users.carrier.address, txnHexRemarks.nominateRemark); + await expect(tx).to.be.revertedWithCustomError(inactiveEscrow, "InactiveTitleEscrow"); + }); + + it("should revert transferBeneficiary/transferHolder/transferOwners", async () => { + await expect( + inactiveEscrow + .connect(users.holder) + .transferBeneficiary(users.carrier.address, txnHexRemarks.beneficiaryTransferRemark) + ).to.be.revertedWithCustomError(inactiveEscrow, "InactiveTitleEscrow"); + await expect( + inactiveEscrow.connect(users.holder).transferHolder(users.carrier.address, txnHexRemarks.holderTransferRemark) + ).to.be.revertedWithCustomError(inactiveEscrow, "InactiveTitleEscrow"); + await expect( + inactiveEscrow + .connect(users.holder) + .transferOwners(users.carrier.address, users.carrier.address, txnHexRemarks.transferOwnersRemark) + ).to.be.revertedWithCustomError(inactiveEscrow, "InactiveTitleEscrow"); + }); + + it("should revert accept/reject/discharge once inactive", async () => { + await expect( + inactiveEscrow.connect(users.holder).accept(txnHexRemarks.mintRemark) + ).to.be.revertedWithCustomError(inactiveEscrow, "InactiveTitleEscrow"); + await expect( + inactiveEscrow.connect(users.holder).reject(txnHexRemarks.mintRemark) + ).to.be.revertedWithCustomError(inactiveEscrow, "InactiveTitleEscrow"); + await expect( + inactiveEscrow.connect(users.beneficiary).discharge(txnHexRemarks.mintRemark) + ).to.be.revertedWithCustomError(inactiveEscrow, "InactiveTitleEscrow"); + }); + + it("should revert returnToIssuer and shred", async () => { + await expect( + inactiveEscrow.connect(users.beneficiary).returnToIssuer(txnHexRemarks.returnToIssuerRemark) + ).to.be.revertedWithCustomError(inactiveEscrow, "InactiveTitleEscrow"); + await expect( + inactiveEscrow.connect(users.carrier).shred(txnHexRemarks.burnRemark) + ).to.be.revertedWithCustomError(inactiveEscrow, "InactiveTitleEscrow"); + }); + + it("should not revert isHoldingToken", async () => { + expect(await inactiveEscrow.isHoldingToken()).to.be.false; + }); + }); + + describe("Pause interplay", () => { + beforeEach(async () => { + escrow = await mint(obligationToken, users.beneficiary, users.holder, tokenId); + await obligationToken.connect(users.carrier).pause(txnHexRemarks.pauseRemark); + }); + + afterEach(async () => { + if (await obligationToken.paused()) { + await obligationToken.connect(users.carrier).unpause(txnHexRemarks.unPauseRemark); + } + }); + + it("should revert nominate/transferHolder/transferBeneficiary/transferOwners while paused", async () => { + await expect( + escrow.connect(users.beneficiary).nominate(users.carrier.address, txnHexRemarks.nominateRemark) + ).to.be.revertedWithCustomError(escrow, "RegistryContractPaused"); + await expect( + escrow.connect(users.holder).transferHolder(users.carrier.address, txnHexRemarks.holderTransferRemark) + ).to.be.revertedWithCustomError(escrow, "RegistryContractPaused"); + }); + }); + }); +}); diff --git a/test/ObligationEscrowFactory.test.ts b/test/ObligationEscrowFactory.test.ts new file mode 100644 index 0000000..6539132 --- /dev/null +++ b/test/ObligationEscrowFactory.test.ts @@ -0,0 +1,192 @@ +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { ObligationEscrow, ObligationEscrowFactory, ObligationEscrowFactoryCallerMock } from "@tradetrust/contracts"; +import { ContractTransactionResponse, TransactionReceipt } from "ethers"; +import faker from "faker"; +import { ethers } from "hardhat"; +import { expect } from "."; +import { contractInterfaceId, defaultAddress } from "../src/constants"; +import { computeObligationEscrowAddress, getEventFromReceipt } from "../src/utils"; +import { deployObligationEscrowFactoryFixture } from "./fixtures"; +import { createDeployFixtureRunner, getTestUsers, TestUsers, txnHexRemarks } from "./helpers"; + +describe("ObligationEscrowFactory", async () => { + let users: TestUsers; + + let obligationEscrowFactory: ObligationEscrowFactory; + + let deployFixturesRunner: () => Promise<[ObligationEscrowFactory]>; + + // eslint-disable-next-line no-undef + before(async () => { + users = await getTestUsers(); + + deployFixturesRunner = async () => + createDeployFixtureRunner(deployObligationEscrowFactoryFixture({ deployer: users.carrier })); + }); + + beforeEach(async () => { + [obligationEscrowFactory] = await loadFixture(deployFixturesRunner); + }); + + describe("Implementation", () => { + let implAddr: string; + let obligationEscrowContract: ObligationEscrow; + + beforeEach(async () => { + implAddr = await obligationEscrowFactory.implementation(); + obligationEscrowContract = (await ethers.getContractFactory("ObligationEscrow")).attach( + implAddr + ) as unknown as ObligationEscrow; + }); + + it("should have an implementation", async () => { + expect(implAddr).to.not.equal(defaultAddress.Zero); + }); + + it("should have the correct obligation escrow implementation", async () => { + const interfaceId = contractInterfaceId.ObligationEscrow; + + const res = await obligationEscrowContract.supportsInterface(interfaceId); + + expect(res).to.be.true; + }); + + it("should have zero-value storage on the raw implementation", async () => { + const [registry, beneficiary, holder, tokenId] = await Promise.all([ + obligationEscrowContract.registry(), + obligationEscrowContract.beneficiary(), + obligationEscrowContract.holder(), + obligationEscrowContract.tokenId(), + ]); + + expect(registry).to.equal(defaultAddress.Zero); + expect(beneficiary).to.equal(defaultAddress.Zero); + expect(holder).to.equal(defaultAddress.Zero); + expect(tokenId).to.equal(0n); + }); + + it("should not allow initialising implementation externally", async () => { + const [punk] = users.others; + const badAddress = faker.finance.ethereumAddress(); + + const tx = obligationEscrowContract.connect(punk).initialize(badAddress, "123"); + + await expect(tx).to.be.revertedWithCustomError(obligationEscrowContract, "InvalidInitialization"); + }); + + it("should not allow calling shred on implementation", async () => { + const [punk] = users.others; + + const tx = obligationEscrowContract.connect(punk).shred(txnHexRemarks.burnRemark); + + await expect(tx).to.be.reverted; + }); + }); + + describe("Create Obligation Escrow Contract", () => { + let tokenId: string; + let obligationEscrowFactoryCallerMock: ObligationEscrowFactoryCallerMock; + + beforeEach(async () => { + tokenId = faker.datatype.hexaDecimal(64); + + obligationEscrowFactoryCallerMock = (await ( + await ethers.getContractFactory("ObligationEscrowFactoryCallerMock") + ).deploy()) as unknown as ObligationEscrowFactoryCallerMock; + }); + + describe("Create Caller", () => { + it("should revert when calls create from an EOA", async () => { + const [eoa] = users.others; + + const tx = obligationEscrowFactory.connect(eoa).create(tokenId); + + await expect(tx).to.be.revertedWithCustomError(obligationEscrowFactory, "CreateCallerNotContract"); + }); + + it("should call create successfully from a contract", async () => { + const tx = obligationEscrowFactoryCallerMock + .connect(users.carrier) + .callCreate(obligationEscrowFactory.target, tokenId); + + await expect(tx).to.not.be.reverted; + }); + }); + + describe("Create Obligation Escrow Behaviours", () => { + let obligationEscrowFactoryCreateTx: ContractTransactionResponse; + let obligationEscrowContract: ObligationEscrow; + + beforeEach(async () => { + const [signer] = users.others; + obligationEscrowFactoryCreateTx = await obligationEscrowFactoryCallerMock + .connect(signer) + .callCreate(obligationEscrowFactory.target, tokenId); + const obligationEscrowAddress = getEventFromReceipt( + (await obligationEscrowFactoryCreateTx.wait()) as TransactionReceipt, + "ObligationEscrowCreated", + obligationEscrowFactory.interface + ).args.titleEscrow; + obligationEscrowContract = (await ethers.getContractFactory("ObligationEscrow")).attach( + obligationEscrowAddress + ) as unknown as ObligationEscrow; + }); + + it("should create with the correct token registry address", async () => { + const registryAddress = await obligationEscrowContract.registry(); + const createCallerAddress = obligationEscrowFactoryCallerMock.target; + + expect(registryAddress).to.equal(createCallerAddress); + }); + + it("should emit ObligationEscrowCreated event", async () => { + const createCallerAddress = obligationEscrowFactoryCallerMock.target; + + await expect(obligationEscrowFactoryCreateTx) + .to.emit(obligationEscrowFactory, "ObligationEscrowCreated") + .withArgs(obligationEscrowContract.target, createCallerAddress, tokenId); + }); + }); + }); + + describe("Compute Obligation Escrow address", () => { + it("should return the correct obligation escrow address", async () => { + const fakeRegistryAddress = faker.finance.ethereumAddress(); + const tokenId = faker.datatype.hexaDecimal(64); + const implementationAddress = await obligationEscrowFactory.implementation(); + + const expectedAddress = computeObligationEscrowAddress({ + implementationAddress, + registryAddress: fakeRegistryAddress, + tokenId, + factoryAddress: obligationEscrowFactory.target as string, + }); + + const res = await obligationEscrowFactory.getEscrowAddress(fakeRegistryAddress, tokenId); + + expect(res).to.equal(expectedAddress); + }); + + it("should return different addresses for different token IDs", async () => { + const fakeRegistryAddress = faker.finance.ethereumAddress(); + const tokenIdA = faker.datatype.hexaDecimal(64); + const tokenIdB = faker.datatype.hexaDecimal(64); + + const addrA = await obligationEscrowFactory.getEscrowAddress(fakeRegistryAddress, tokenIdA); + const addrB = await obligationEscrowFactory.getEscrowAddress(fakeRegistryAddress, tokenIdB); + + expect(addrA).to.not.equal(addrB); + }); + + it("should return different addresses for different registries", async () => { + const tokenId = faker.datatype.hexaDecimal(64); + const registryA = faker.finance.ethereumAddress(); + const registryB = faker.finance.ethereumAddress(); + + const addrA = await obligationEscrowFactory.getEscrowAddress(registryA, tokenId); + const addrB = await obligationEscrowFactory.getEscrowAddress(registryB, tokenId); + + expect(addrA).to.not.equal(addrB); + }); + }); +}); diff --git a/test/TrustVCToken.test.ts b/test/TrustVCToken.test.ts new file mode 100644 index 0000000..fc3eb8a --- /dev/null +++ b/test/TrustVCToken.test.ts @@ -0,0 +1,183 @@ +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { ObligationEscrow, ObligationEscrowFactory, TrustVCToken } from "@tradetrust/contracts"; +import { expect } from "chai"; +import faker from "faker"; +import { ethers } from "hardhat"; +import { defaultAddress, roleHash } from "../src/constants"; +import { deployTrustVCTokenFixture } from "./fixtures"; +import { getTestUsers, impersonateAccount, TestUsers, txnHexRemarks } from "./helpers"; + +const Status = { + Issued: 0, + Accepted: 1, + Rejected: 2, + Discharged: 3, +} as const; + +const BURN_ADDRESS = "0x000000000000000000000000000000000000dEaD"; + +describe("TrustVCToken", () => { + let users: TestUsers; + let obligationToken: TrustVCToken; + let tokenId: string; + + const getEscrow = async (id: string): Promise => { + const factoryAddr = await obligationToken.obligationEscrowFactory(); + const factory = await ethers.getContractAt("ObligationEscrowFactory", factoryAddr); + const escrowAddress = await factory.getEscrowAddress(await obligationToken.getAddress(), id); + return (await ethers.getContractAt("ObligationEscrow", escrowAddress)) as unknown as ObligationEscrow; + }; + + const mint = async (beneficiary: SignerWithAddress, holder: SignerWithAddress, id: string) => { + await ( + await obligationToken + .connect(users.carrier) + .mint(beneficiary.address, holder.address, id, txnHexRemarks.mintRemark) + ).wait(); + return getEscrow(id); + }; + + // eslint-disable-next-line no-undef + before(async () => { + users = await getTestUsers(); + }); + + beforeEach(async () => { + ({ obligationToken } = await deployTrustVCTokenFixture({ deployer: users.carrier })); + tokenId = faker.datatype.hexaDecimal(64); + }); + + it("should mint titles on the token and run accept/reject/discharge lifecycle on escrow", async () => { + const escrow = await mint(users.beneficiary, users.holder, tokenId); + + expect(await escrow.status()).to.equal(Status.Issued); + expect(await escrow.isRegistered()).to.be.true; + expect(await escrow.beneficiary()).to.equal(users.beneficiary.address); + expect(await escrow.holder()).to.equal(users.holder.address); + expect(await obligationToken.ownerOf(tokenId)).to.equal(await escrow.getAddress()); + + await escrow.connect(users.holder).accept(txnHexRemarks.mintRemark); + expect(await escrow.status()).to.equal(Status.Accepted); + + const rejectTokenId = faker.datatype.hexaDecimal(64); + const rejectEscrow = await mint(users.beneficiary, users.holder, rejectTokenId); + await rejectEscrow.connect(users.holder).reject(txnHexRemarks.mintRemark); + expect(await rejectEscrow.status()).to.equal(Status.Rejected); + expect(await rejectEscrow.active()).to.be.false; + expect(await obligationToken.ownerOf(rejectTokenId)).to.equal(BURN_ADDRESS); + + const dischargeTokenId = faker.datatype.hexaDecimal(64); + const dischargeEscrow = await mint(users.beneficiary, users.holder, dischargeTokenId); + await dischargeEscrow.connect(users.holder).accept(txnHexRemarks.mintRemark); + await dischargeEscrow.connect(users.beneficiary).discharge(txnHexRemarks.mintRemark); + expect(await dischargeEscrow.status()).to.equal(Status.Discharged); + expect(await dischargeEscrow.active()).to.be.false; + expect(await obligationToken.ownerOf(dischargeTokenId)).to.equal(BURN_ADDRESS); + }); + + it("should revert mint while paused", async () => { + await obligationToken.connect(users.carrier).pause(txnHexRemarks.pauseRemark); + + const tx = obligationToken + .connect(users.carrier) + .mint(users.beneficiary.address, users.holder.address, tokenId, txnHexRemarks.mintRemark); + + await expect(tx).to.be.revertedWithCustomError(obligationToken, "EnforcedPause"); + }); + + it("should revert minting the same tokenId twice", async () => { + await mint(users.beneficiary, users.holder, tokenId); + + const tx = obligationToken + .connect(users.carrier) + .mint(users.beneficiary.address, users.holder.address, tokenId, txnHexRemarks.mintRemark); + + await expect(tx).to.be.revertedWithCustomError(obligationToken, "TokenExists"); + }); + + it("should not require beneficiary and holder to differ at mint (unchanged from classic ETR)", async () => { + const escrow = await mint(users.beneficiary, users.beneficiary, tokenId); + + expect(await escrow.beneficiary()).to.equal(users.beneficiary.address); + expect(await escrow.holder()).to.equal(users.beneficiary.address); + }); + + it("should set the deployer as default admin", async () => { + expect(await obligationToken.hasRole(roleHash.DefaultAdmin, users.carrier.address)).to.be.true; + }); + + describe("Reads", () => { + it("should return the correct obligationEscrowFactory address", async () => { + const escrowFactoryAddress = await obligationToken.obligationEscrowFactory(); + expect(escrowFactoryAddress).to.not.equal(defaultAddress.Zero); + + const escrowFactory = (await ethers.getContractAt( + "ObligationEscrowFactory", + escrowFactoryAddress + )) as unknown as ObligationEscrowFactory; + expect(await escrowFactory.implementation()).to.not.equal(defaultAddress.Zero); + }); + + it("should return the same address for titleEscrowFactory() and obligationEscrowFactory()", async () => { + expect(await obligationToken.titleEscrowFactory()).to.equal(await obligationToken.obligationEscrowFactory()); + }); + + it("should return the initialisation block as genesis", async () => { + const deployTx = obligationToken.deploymentTransaction(); + const receipt = await deployTx!.wait(); + + expect(await obligationToken.genesis()).to.equal(receipt!.blockNumber); + }); + }); + + describe("Constructor validation", () => { + it("should revert with ZeroAddress when obligationEscrowFactory is the zero address", async () => { + const TrustVCTokenFactory = await ethers.getContractFactory("TrustVCToken"); + + await expect( + TrustVCTokenFactory.connect(users.carrier).deploy("Test", "TST", defaultAddress.Zero) + ).to.be.revertedWithCustomError(TrustVCTokenFactory, "ZeroAddress"); + }); + + it("should revert with InvalidObligationEscrowFactory when the address has no code", async () => { + const TrustVCTokenFactory = await ethers.getContractFactory("TrustVCToken"); + const [eoa] = users.others; + + await expect( + TrustVCTokenFactory.connect(users.carrier).deploy("Test", "TST", eoa.address) + ).to.be.revertedWithCustomError(TrustVCTokenFactory, "InvalidObligationEscrowFactory"); + }); + }); + + describe("burnFromEscrow", () => { + it("should revert with CallerNotEscrow when called by an address that is not the title's escrow", async () => { + await mint(users.beneficiary, users.holder, tokenId); + const [notEscrow] = users.others; + + const tx = obligationToken.connect(notEscrow).burnFromEscrow(tokenId, txnHexRemarks.burnRemark); + + await expect(tx).to.be.revertedWithCustomError(obligationToken, "CallerNotEscrow"); + }); + + it("should revert while paused, even when called by the correct escrow", async () => { + await mint(users.beneficiary, users.holder, tokenId); + await obligationToken.connect(users.carrier).pause(txnHexRemarks.pauseRemark); + + const escrow = await getEscrow(tokenId); + const escrowWallet = await impersonateAccount({ address: await escrow.getAddress() }); + + const tx = obligationToken.connect(escrowWallet).burnFromEscrow(tokenId, txnHexRemarks.burnRemark); + await expect(tx).to.be.revertedWithCustomError(obligationToken, "EnforcedPause"); + }); + + it("should not allow remark length to exceed limit, even when called by the correct escrow", async () => { + await mint(users.beneficiary, users.holder, tokenId); + const escrow = await getEscrow(tokenId); + const escrowWallet = await impersonateAccount({ address: await escrow.getAddress() }); + const exceededLengthRemark = ethers.hexlify(ethers.randomBytes(121)); + + const tx = obligationToken.connect(escrowWallet).burnFromEscrow(tokenId, exceededLengthRemark); + await expect(tx).to.be.revertedWithCustomError(obligationToken, "RemarkLengthExceeded"); + }); + }); +}); diff --git a/test/fixtures/deploy-obligation-escrow-factory.fixture.ts b/test/fixtures/deploy-obligation-escrow-factory.fixture.ts new file mode 100644 index 0000000..ad3a6c0 --- /dev/null +++ b/test/fixtures/deploy-obligation-escrow-factory.fixture.ts @@ -0,0 +1,9 @@ +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { Signer } from "ethers"; +import { ethers } from "hardhat"; +import { ObligationEscrowFactory } from "@tradetrust/contracts"; + +export const deployObligationEscrowFactoryFixture = async ({ deployer }: { deployer: SignerWithAddress | Signer }) => { + const escrowFactory = await ethers.getContractFactory("ObligationEscrowFactory"); + return (await escrowFactory.connect(deployer).deploy()) as unknown as ObligationEscrowFactory; +}; diff --git a/test/fixtures/deploy-obligation-escrow.fixture.ts b/test/fixtures/deploy-obligation-escrow.fixture.ts new file mode 100644 index 0000000..2b9428d --- /dev/null +++ b/test/fixtures/deploy-obligation-escrow.fixture.ts @@ -0,0 +1,8 @@ +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { ethers } from "hardhat"; +import { ObligationEscrow } from "@tradetrust/contracts"; + +export const deployObligationEscrowFixture = async ({ deployer }: { deployer: SignerWithAddress }) => { + const obligationEscrowFactory = await ethers.getContractFactory("ObligationEscrow"); + return (await obligationEscrowFactory.connect(deployer).deploy()) as unknown as ObligationEscrow; +}; diff --git a/test/fixtures/deploy-trustvc-token.fixture.ts b/test/fixtures/deploy-trustvc-token.fixture.ts new file mode 100644 index 0000000..e543266 --- /dev/null +++ b/test/fixtures/deploy-trustvc-token.fixture.ts @@ -0,0 +1,26 @@ +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { ObligationEscrowFactory, TrustVCToken } from "@tradetrust/contracts"; +import { Signer } from "ethers"; +import { ethers } from "hardhat"; +import { deployObligationEscrowFactoryFixture } from "./deploy-obligation-escrow-factory.fixture"; + +export const deployTrustVCTokenFixture = async ({ + deployer, + name = "Obligation Registry", + symbol = "STR", +}: { + deployer: SignerWithAddress | Signer; + name?: string; + symbol?: string; +}): Promise<{ + obligationEscrowFactory: ObligationEscrowFactory; + obligationToken: TrustVCToken; +}> => { + const obligationEscrowFactory = await deployObligationEscrowFactoryFixture({ deployer }); + + const obligationToken = (await (await ethers.getContractFactory("TrustVCToken")) + .connect(deployer) + .deploy(name, symbol, await obligationEscrowFactory.getAddress())) as unknown as TrustVCToken; + + return { obligationEscrowFactory, obligationToken }; +}; diff --git a/test/fixtures/index.ts b/test/fixtures/index.ts index 10630f8..6f5335f 100644 --- a/test/fixtures/index.ts +++ b/test/fixtures/index.ts @@ -6,3 +6,6 @@ export { deployTradeTrustTokenStandardFixture } from "./deploy-registry-impl.fix export { deployTDocDeployerFixture } from "./deploy-impl-deployer.fixture"; export type { DeployTokenFixtureRunner } from "./deploy-token-fixture-runner"; export { deployTitleEscrowMockFixture } from "./deploy-title-escrow-mock.fixture"; +export { deployTrustVCTokenFixture } from "./deploy-trustvc-token.fixture"; +export { deployObligationEscrowFactoryFixture } from "./deploy-obligation-escrow-factory.fixture"; +export { deployObligationEscrowFixture } from "./deploy-obligation-escrow.fixture";