@credorelabs/credore-smart-contracts
v1.0.4
Published
Smart contracts for title documents : TitleFlow, TitleFlowFactory, TitleFlowRegistry, TitleFlowRelayFacet
Readme
@credorelabs/credore-smart-contracts
Gasless EIP-712 smart contract library for managing electronic Bills of Lading (eBL) title escrow operations on TradeTrust-compliant blockchain infrastructure.
Table of Contents
- Overview
- Architecture
- Prerequisites
- Installation
- Key Hierarchy
- Quick Start
- End-to-End Integration Guide
- Phase 1 — Deploy the System
- Phase 2 — Create a TitleFlow Instance
- Phase 3 — Mint Document Token
- Phase 4 — Register Escrow
- Phase 5 — Nominate
- Phase 6 — Transfer Beneficiary
- Phase 7 — Reject Transfer of Beneficiary
- Phase 8 — Transfer Holder
- Phase 9 — Reject Transfer of Holder
- Phase 10 — Transfer Owners
- Phase 11 — Reject Transfer of Owners
- Phase 12 — Return to Issuer
- Phase 13 — Shred
- Phase 14 — Lock for External Transfer (PINT Outbound)
- Phase 15 — Restore from External (PINT Inbound)
- Phase 16 — Endorse for External Transfer
- Phase 17 — Restore Endorse (PINT Beneficiary Restore)
- Phase 18 — Surrender for External Transfer
- Key Management
- Pause Mechanism
- API Reference
- Local Development
- Deployment
- Error Reference
Overview
This library provides smart contract bindings, EIP-712 signing utilities, and TypeScript types for the Credore title escrow system. It sits on top of the TradeTrust token registry and adds a gasless operation layer - the document owner never pays gas or touches the blockchain directly. All on-chain operations are submitted by an attorney or relayer on the owner's behalf, authorised by an off-chain EIP-712 signature.
Owner (HSM) Attorney (Backend) Blockchain
------------- ------------------ ----------
Sign EIP-712 --> Verify + Submit tx --> TitleFlow
(offline) (pays gas) (on-chain)Architecture
@tradetrust-tt/token-registry @credorelabs/credore-smart-contracts
----------------------------- ------------------------------------
TitleEscrowFactory TitleFlowFactory
TradeTrustToken (ERC-721 registry) └── TitleFlow (clone per owner)
TitleEscrow (per document) ├── registerEscrow()
├── beneficiary <── TitleFlow ├── nominate()
└── holder <── TitleFlow ├── transferBeneficiary()
├── rejectTransferBeneficiary()
├── transferHolder()
├── rejectTransferHolder()
├── transferOwners()
├── rejectTransferOwners()
├── returnToIssuer()
├── shred()
├── lockForExternalTransfer()
├── restoreFromExternal()
├── endorseForExternalTransfer()
├── restoreEndorse()
└── surrenderForExternalTransfer()
TitleFlowRegistry Global registry of all TitleFlow instancesTitleFlow is both the on-chain beneficiary and holder of every TitleEscrow it manages. The real-world owner controls it exclusively via EIP-712 signatures from an HSM.
Prerequisites
| Requirement | Version | |---|---| | Node.js | >= 22.x | | ethers.js | ^6.x | | @tradetrust-tt/token-registry | ^5.5.1 |
Installation
npm install @credorelabs/credore-smart-contracts @tradetrust-tt/token-registry ethers
# or
yarn add @credorelabs/credore-smart-contracts @tradetrust-tt/token-registry ethersKey Hierarchy
Every TitleFlow contract is initialised with five key holders.
GUARDIAN (cold HSM CRO / Legal)
├── Emergency rotate owner key guardianRecoverOwner()
├── Emergency rotate attorney key guardianRotateAttorney()
├── Emergency rotate relayer key guardianRotateRelayer()
├── Add / remove backup attorneys addBackupAttorney() / removeAttorney()
└── Unpause the contract unpause() (DEFAULT_ADMIN_ROLE)
OWNER (cold HSM document owner)
└── Signs all EIP-712 messages offline
(never submits transactions, never pays gas)
ATTORNEY PRIMARY (hot/warm HSM attorney backend)
├── Submits all daily transactions on-chain (ATTORNEY_ADMIN_ROLE)
├── Pays gas for all operations
└── Can pause the contract
ATTORNEY BACKUP (warm HSM standby)
└── Identical permissions to primary — activates immediately if primary fails
RELAYER (automated service — PINT interop)
└── Submits restoreFromExternal() and restoreEndorse() (RELAYER_ROLE)Quick Start
import {
TitleFlow__factory,
TitleFlowFactory__factory,
TitleFlowRegistry__factory,
ACTION_TYPES,
LIFECYCLE_TYPES,
ActionType,
Lifecycle,
titleFlowDomain,
ATTORNEY_ADMIN_ROLE,
RELAYER_ROLE,
GUARDIAN_ROLE,
FACTORY_ADMIN_ROLE,
} from "@credorelabs/credore-smart-contracts";
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://your-rpc-url");
const attorneyWallet = new ethers.Wallet(process.env.ATTORNEY_PRIVATE_KEY!, provider);
const ownerWallet = new ethers.Wallet(process.env.OWNER_PRIVATE_KEY!, provider);
// Attach to deployed TitleFlow
const titleFlow = TitleFlow__factory.connect(TITLE_FLOW_ADDRESS, attorneyWallet);
// Build EIP-712 domain for this TitleFlow
const { chainId } = await provider.getNetwork();
const domain = titleFlowDomain(chainId, TITLE_FLOW_ADDRESS);End-to-End Integration Guide
Phase 1 — Deploy the System
Deploy all four contracts in order. Use the provided deployment script:
# Set env vars first (see .env.example)
yarn hardhat run scripts/deploy.ts --network sepolia
yarn hardhat run scripts/deploy.ts --network polygon
yarn hardhat run scripts/deploy.ts --network mainnetAddresses are saved to deployments/<network>.json and exported from the package:
import { sepoliaDeployments, polygonDeployments, mainnetDeployments } from "@credorelabs/credore-smart-contracts";
const factoryAddress = sepoliaDeployments.contracts.TitleFlowFactory;
const registryAddress = sepoliaDeployments.contracts.TitleFlowRegistry;Or load at runtime:
import { loadDeployment } from "@credorelabs/credore-smart-contracts";
const dep = await loadDeployment("sepolia");
const factoryAddress = dep?.contracts.TitleFlowFactory;Phase 2 — Create a TitleFlow Instance
Each document owner gets their own dedicated TitleFlow contract deployed as a gas-efficient CREATE2 clone.
import { TitleFlowFactory__factory } from "@credorelabs/credore-smart-contracts";
const factory = TitleFlowFactory__factory.connect(factoryAddress, attorneyWallet);
// Predict address before deploying (deterministic)
const predictedAddress = await factory.predictAddress(
primaryAttorneyAddress,
backupAttorneyAddress,
guardianAddress,
ownerAddress,
relayerAddress,
);
console.log("Predicted TitleFlow address:", predictedAddress);
// Deploy the clone (requires FACTORY_ADMIN_ROLE)
const tx = await factory.create(
primaryAttorneyAddress,
backupAttorneyAddress,
guardianAddress,
ownerAddress,
relayerAddress,
);
const receipt = await tx.wait();
// Parse TitleFlowCreated event to get the actual address
const event = receipt?.logs
.map(l => { try { return factory.interface.parseLog(l as any); } catch { return null; } })
.find(e => e?.name === "TitleFlowCreated");
const titleFlowAddress = event?.args.titleFlow;
console.log("TitleFlow deployed at:", titleFlowAddress);Phase 3 — Mint Document Token
Mint the eBL document token on the TradeTrust registry. Both beneficiary and holder must be set to the TitleFlow address.
import { TradeTrustToken__factory } from "@tradetrust-tt/token-registry/contracts";
const registry = TradeTrustToken__factory.connect(registryAddress, attorneyWallet);
// The document hash uniquely identifies this eBL — use as tokenId
const tokenId = ethers.keccak256(ethers.toUtf8Bytes("BL-2025-001-UNIQUE-REF"));
await (await registry.mint(
titleFlowAddress, // beneficiary — TitleFlow, NOT the real owner
titleFlowAddress, // holder — TitleFlow, NOT the real owner
tokenId,
ethers.toUtf8Bytes("Initial issuance — Credore eBL Platform"),
)).wait();
// The TitleEscrow address is the ownerOf the NFT token
const titleEscrowAddress = await registry.ownerOf(BigInt(tokenId));
console.log("TitleEscrow:", titleEscrowAddress);Phase 4 — Register Escrow
After minting, the TitleEscrow must be registered with TitleFlow. The owner signs off-chain, the attorney submits on-chain.
import { TitleFlow__factory, LIFECYCLE_TYPES, titleFlowDomain } from "@credorelabs/credore-smart-contracts";
const titleFlow = TitleFlow__factory.connect(titleFlowAddress, attorneyWallet);
const domain = titleFlowDomain(chainId, titleFlowAddress);
const ownerAddress = await ownerWallet.getAddress();
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const exportNonce = await titleFlow.exportNonce(titleEscrowAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
// Owner signs off-chain (HSM in production)
const signature = await ownerWallet.signTypedData(domain, LIFECYCLE_TYPES, {
titleEscrow: titleEscrowAddress,
pintRef: ethers.ZeroHash,
nonce,
deadline,
exportDocumentHash: ethers.ZeroHash,
exportEnvelopeHash: ethers.ZeroHash,
lastBeneficiary: ethers.ZeroAddress,
newHolder: ethers.ZeroAddress,
destinationPlatformId: ethers.ZeroHash,
exportNonce,
});
// Attorney submits on-chain
await (await titleFlow.registerEscrow(
titleEscrowAddress,
nonce,
deadline,
exportNonce,
signature,
)).wait();
const lifecycle = await titleFlow.lifecycle(titleEscrowAddress);
console.log("Escrow registered — lifecycle:", lifecycle.toString()); // 1 = ACTIVEPhase 5 — Nominate
Nominate a new beneficiary. Required before transferBeneficiary when holder ≠ beneficiary.
import { ACTION_TYPES, ActionType } from "@credorelabs/credore-smart-contracts";
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const remarks = "Nominating new beneficiary";
// currentNominee must be non-zero — pass the existing nominee or any non-zero address
const currentNominee = await escrow.nominee();
const newNominee = "0xNEW_NOMINEE_ADDRESS";
// Owner signs off-chain
const signature = await ownerWallet.signTypedData(domain, ACTION_TYPES, {
titleEscrow: titleEscrowAddress,
beneficiary: ethers.ZeroAddress,
holder: ethers.ZeroAddress,
nominee: currentNominee,
newBeneficiary: ethers.ZeroAddress,
newHolder: ethers.ZeroAddress,
newNominee: newNominee,
remarkHash: ethers.keccak256(ethers.toUtf8Bytes(remarks)),
nonce,
action: ActionType.Nominate,
deadline,
});
// Attorney submits
await (await titleFlow.nominate(
titleEscrowAddress,
currentNominee,
newNominee,
ethers.toUtf8Bytes(remarks),
nonce,
deadline,
signature,
)).wait();
console.log("Nominee set to:", await escrow.nominee());Phase 6 — Transfer Beneficiary
Transfer the beneficiary role to the nominated address. nominate() must be called first when holder ≠ beneficiary.
const beneficiary = await escrow.beneficiary();
const holder = await escrow.holder();
const nominee = await escrow.nominee(); // must be non-zero
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const remarks = "Transferring beneficiary rights";
const signature = await ownerWallet.signTypedData(domain, ACTION_TYPES, {
titleEscrow: titleEscrowAddress,
beneficiary,
holder,
nominee,
newBeneficiary: nominee, // transfer to current nominee
newHolder: ethers.ZeroAddress,
newNominee: ethers.ZeroAddress,
remarkHash: ethers.keccak256(ethers.toUtf8Bytes(remarks)),
nonce,
action: ActionType.BeneficiaryTransfer,
deadline,
});
await (await titleFlow.transferBeneficiary(
titleEscrowAddress,
holder,
beneficiary,
nominee, // newBeneficiary
nominee, // nominee
ethers.toUtf8Bytes(remarks),
nonce,
deadline,
signature,
)).wait();
console.log("Beneficiary transferred to:", await escrow.beneficiary());Phase 7 — Reject Transfer of Beneficiary
Revert the beneficiary back to the previous address. Only valid when holder ≠ beneficiary — use rejectTransferOwners if they are the same party.
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const remarks = "Rejecting beneficiary transfer — incorrect party";
const signature = await ownerWallet.signTypedData(domain, ACTION_TYPES, {
titleEscrow: titleEscrowAddress,
beneficiary: ethers.ZeroAddress,
holder: ethers.ZeroAddress,
nominee: ethers.ZeroAddress,
newBeneficiary: ethers.ZeroAddress,
newHolder: ethers.ZeroAddress,
newNominee: ethers.ZeroAddress,
remarkHash: ethers.keccak256(ethers.toUtf8Bytes(remarks)),
nonce,
action: ActionType.RejectBeneficiary,
deadline,
});
await (await titleFlow.rejectTransferBeneficiary(
titleEscrowAddress,
ethers.toUtf8Bytes(remarks),
nonce,
deadline,
signature,
)).wait();
console.log("Beneficiary reverted to:", await escrow.beneficiary());Phase 8 — Transfer Holder
Transfer the holder role independently of the beneficiary.
const beneficiary = await escrow.beneficiary();
const holder = await escrow.holder();
const newHolder = "0xNEW_HOLDER_ADDRESS";
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const remarks = "Transferring holder rights";
const signature = await ownerWallet.signTypedData(domain, ACTION_TYPES, {
titleEscrow: titleEscrowAddress,
beneficiary,
holder,
nominee: ethers.ZeroAddress,
newBeneficiary: ethers.ZeroAddress,
newHolder,
newNominee: ethers.ZeroAddress,
remarkHash: ethers.keccak256(ethers.toUtf8Bytes(remarks)),
nonce,
action: ActionType.HolderTransfer,
deadline,
});
await (await titleFlow.transferHolder(
titleEscrowAddress,
holder,
beneficiary,
newHolder,
ethers.toUtf8Bytes(remarks),
nonce,
deadline,
signature,
)).wait();
console.log("Holder transferred to:", await escrow.holder());Phase 9 — Reject Transfer of Holder
Revert the holder to the previous address. Only valid when holder ≠ beneficiary.
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const remarks = "Rejecting holder transfer — wrong party nominated";
const signature = await ownerWallet.signTypedData(domain, ACTION_TYPES, {
titleEscrow: titleEscrowAddress,
beneficiary: ethers.ZeroAddress,
holder: ethers.ZeroAddress,
nominee: ethers.ZeroAddress,
newBeneficiary: ethers.ZeroAddress,
newHolder: ethers.ZeroAddress,
newNominee: ethers.ZeroAddress,
remarkHash: ethers.keccak256(ethers.toUtf8Bytes(remarks)),
nonce,
action: ActionType.RejectHolder,
deadline,
});
await (await titleFlow.rejectTransferHolder(
titleEscrowAddress,
ethers.toUtf8Bytes(remarks),
nonce,
deadline,
signature,
)).wait();
console.log("Holder reverted to:", await escrow.holder());Phase 10 — Transfer Owners
Transfer both beneficiary and holder in a single transaction.
const newOwner = "0xNEW_OWNER_ADDRESS";
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const remarks = "Full ownership transfer";
const signature = await ownerWallet.signTypedData(domain, ACTION_TYPES, {
titleEscrow: titleEscrowAddress,
beneficiary: ethers.ZeroAddress,
holder: ethers.ZeroAddress,
nominee: ethers.ZeroAddress,
newBeneficiary: ethers.ZeroAddress,
newHolder: newOwner,
newNominee: newOwner,
remarkHash: ethers.keccak256(ethers.toUtf8Bytes(remarks)),
nonce,
action: ActionType.OwnersTransfer,
deadline,
});
await (await titleFlow.transferOwners(
titleEscrowAddress,
newOwner, // nominee (new beneficiary)
newOwner, // newHolder
ethers.toUtf8Bytes(remarks),
nonce,
deadline,
signature,
)).wait();
console.log("beneficiary:", await escrow.beneficiary()); // newOwner
console.log("holder: ", await escrow.holder()); // newOwnerPhase 11 — Reject Transfer of Owners
Revert both beneficiary and holder simultaneously. Use when the same party holds both roles.
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const remarks = "Rejecting full ownership transfer — error in counterparty";
const signature = await ownerWallet.signTypedData(domain, ACTION_TYPES, {
titleEscrow: titleEscrowAddress,
beneficiary: ethers.ZeroAddress,
holder: ethers.ZeroAddress,
nominee: ethers.ZeroAddress,
newBeneficiary: ethers.ZeroAddress,
newHolder: ethers.ZeroAddress,
newNominee: ethers.ZeroAddress,
remarkHash: ethers.keccak256(ethers.toUtf8Bytes(remarks)),
nonce,
action: ActionType.RejectOwners,
deadline,
});
await (await titleFlow.rejectTransferOwners(
titleEscrowAddress,
ethers.toUtf8Bytes(remarks),
nonce,
deadline,
signature,
)).wait();
console.log("beneficiary reverted to:", await escrow.beneficiary());
console.log("holder reverted to: ", await escrow.holder());Phase 12 — Return to Issuer
Return the eBL to the issuing Token Registry. After returning, the registry admin burns the token.
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const remarks = "Returning eBL to issuer — transaction complete";
const signature = await ownerWallet.signTypedData(domain, ACTION_TYPES, {
titleEscrow: titleEscrowAddress,
beneficiary: ethers.ZeroAddress,
holder: ethers.ZeroAddress,
nominee: ethers.ZeroAddress,
newBeneficiary: ethers.ZeroAddress,
newHolder: ethers.ZeroAddress,
newNominee: ethers.ZeroAddress,
remarkHash: ethers.keccak256(ethers.toUtf8Bytes(remarks)),
nonce,
action: ActionType.ReturnToIssuer,
deadline,
});
await (await titleFlow.returnToIssuer(
titleEscrowAddress,
ethers.toUtf8Bytes(remarks),
nonce,
deadline,
signature,
)).wait();
console.log("eBL returned to issuer");
// Registry admin burns the document (requires ACCEPTER_ROLE)
const registry = TradeTrustToken__factory.connect(registryAddress, adminWallet);
await (await registry.burn(
BigInt(tokenId),
ethers.toUtf8Bytes("Accepting returned eBL"),
)).wait();Phase 13 — Shred
Permanently destroy the eBL document.
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const remarks = "Shredding eBL — goods delivered";
const signature = await ownerWallet.signTypedData(domain, ACTION_TYPES, {
titleEscrow: titleEscrowAddress,
beneficiary: ethers.ZeroAddress,
holder: ethers.ZeroAddress,
nominee: ethers.ZeroAddress,
newBeneficiary: ethers.ZeroAddress,
newHolder: ethers.ZeroAddress,
newNominee: ethers.ZeroAddress,
remarkHash: ethers.keccak256(ethers.toUtf8Bytes(remarks)),
nonce,
action: ActionType.Shred,
deadline,
});
await (await titleFlow.shred(
titleEscrowAddress,
ethers.toUtf8Bytes(remarks),
nonce,
deadline,
signature,
)).wait();
console.log("eBL shredded — lifecycle:", await titleFlow.lifecycle(titleEscrowAddress)); // 4 = DESTROYEDPhase 14 — Lock for External Transfer (PINT Outbound)
Lock the eBL for transfer to an external PINT platform. Transfers the holder to the relayer. Lifecycle becomes LOCKED_EXTERNAL — all relay operations are blocked until restored.
The EIP-712 struct uses newHolder = TitleFlow.relayer (the stored relayer address). Sign with newHolder = relayerAddress to match what the contract reconstructs.
const pintRef = ethers.keccak256(ethers.toUtf8Bytes("PINT-REF-2025-001"));
const exportDocumentHash = ethers.keccak256(ethers.toUtf8Bytes("EXPORT-DOC-HASH"));
const exportEnvelopeHash = ethers.keccak256(ethers.toUtf8Bytes("EXPORT-ENV-HASH"));
const destinationPlatform = ethers.keccak256(ethers.toUtf8Bytes("EXTERNAL-PLATFORM-ID"));
const relayerAddress = await titleFlow.relayer(); // stored relayer on TitleFlow
const lastBeneficiary = await escrow.beneficiary();
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const exportNonce = await titleFlow.exportNonce(titleEscrowAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
// Owner signs off-chain — newHolder MUST equal TitleFlow.relayer
const signature = await ownerWallet.signTypedData(domain, LIFECYCLE_TYPES, {
titleEscrow: titleEscrowAddress,
pintRef,
nonce,
deadline,
exportDocumentHash,
exportEnvelopeHash,
lastBeneficiary,
newHolder: relayerAddress, // must equal TitleFlow.relayer on-chain
destinationPlatformId: destinationPlatform,
exportNonce,
});
// Attorney submits
await (await titleFlow.lockForExternalTransfer(
titleEscrowAddress,
pintRef,
nonce,
deadline,
exportDocumentHash,
exportEnvelopeHash,
lastBeneficiary,
destinationPlatform,
exportNonce,
signature,
)).wait();
// After lock: holder = relayer, beneficiary = TitleFlow (unchanged)
console.log("Locked — holder:", await escrow.holder()); // relayerAddress
console.log("lifecycle:", await titleFlow.lifecycle(titleEscrowAddress)); // 2 = LOCKED_EXTERNALPhase 15 — Restore from External (PINT Inbound)
Restore an eBL from a PINT inbound transfer. Because restoreFromExternal calls transferOwners via _forwardCall, TitleFlow must be the holder at restore time. The relayer must first return the holder role directly on TitleEscrow.
import { TitleEscrow__factory } from "@tradetrust-tt/token-registry/contracts";
// Step A: Relayer returns holder to TitleFlow directly on TitleEscrow
// (relayer is the current on-chain holder after lock)
const escrowAsRelayer = TitleEscrow__factory.connect(titleEscrowAddress, relayerWallet);
await (await escrowAsRelayer.transferHolder(
titleFlowAddress,
ethers.toUtf8Bytes("PINT restore — return holder to TitleFlow"),
)).wait();
// Step B: Owner signs restoreFromExternal — relayer submits (RELAYER_ROLE required)
const newHolder = "0xNEW_HOLDER_ADDRESS";
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const exportNonce = await titleFlow.exportNonce(titleEscrowAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const signature = await ownerWallet.signTypedData(domain, LIFECYCLE_TYPES, {
titleEscrow: titleEscrowAddress,
pintRef, // same pintRef used in lock
nonce,
deadline,
exportDocumentHash, // same hashes used in lock
exportEnvelopeHash,
lastBeneficiary: ethers.ZeroAddress,
newHolder,
destinationPlatformId: ethers.ZeroHash,
exportNonce,
});
await (await titleFlow.connect(relayerWallet).restoreFromExternal(
titleEscrowAddress,
pintRef,
nonce,
deadline,
exportDocumentHash,
exportEnvelopeHash,
newHolder,
exportNonce,
signature,
)).wait();
console.log("Restored — lifecycle:", await titleFlow.lifecycle(titleEscrowAddress)); // 1 = ACTIVE
console.log("holder:", await escrow.holder()); // newHolderPhase 16 — Endorse for External Transfer
Transfer only the beneficial title (beneficiary) to the relayer for cross-platform endorsement. Holder remains as TitleFlow. The EIP-712 struct uses newHolder = TitleFlow.relayer.
const lastBeneficiary = await escrow.beneficiary();
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const exportNonce = await titleFlow.exportNonce(titleEscrowAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const signature = await ownerWallet.signTypedData(domain, LIFECYCLE_TYPES, {
titleEscrow: titleEscrowAddress,
pintRef,
nonce,
deadline,
exportDocumentHash,
exportEnvelopeHash,
lastBeneficiary,
newHolder: relayerAddress, // must equal TitleFlow.relayer
destinationPlatformId: destinationPlatform,
exportNonce,
});
await (await titleFlow.endorseForExternalTransfer(
titleEscrowAddress,
pintRef,
nonce,
deadline,
exportDocumentHash,
exportEnvelopeHash,
lastBeneficiary,
destinationPlatform,
exportNonce,
signature,
)).wait();
// After endorse: beneficiary = relayer, holder = TitleFlow (unchanged)
console.log("Endorsed — beneficiary:", await escrow.beneficiary()); // relayerAddressPhase 17 — Restore Endorse (PINT Beneficiary Restore)
Restore beneficial title after endorseForExternalTransfer. The relayer is the current beneficiary after the endorse.
Note: restoreEndorse calls nominate internally. The relayer must pre-nominate the new beneficiary directly on TitleEscrow before calling restoreEndorse, so that the internal nominate is skipped (nominee is already set). See TitleFlowLifecycle_restoreEndorse.sol for the modified implementation.
// Step A: Relayer nominates new beneficiary directly on TitleEscrow
// (relayer = beneficiary after endorse ✓)
const escrowAsRelayer = TitleEscrow__factory.connect(titleEscrowAddress, relayerWallet);
await (await escrowAsRelayer.nominate(
newBeneficiaryAddress,
ethers.toUtf8Bytes("Restore endorse — nominate new beneficiary"),
)).wait();
// Step B: Owner signs restoreEndorse — relayer submits (RELAYER_ROLE required)
// newHolder field in the EIP-712 struct carries the newBeneficiary value
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const exportNonce = await titleFlow.exportNonce(titleEscrowAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const signature = await ownerWallet.signTypedData(domain, LIFECYCLE_TYPES, {
titleEscrow: titleEscrowAddress,
pintRef,
nonce,
deadline,
exportDocumentHash,
exportEnvelopeHash,
lastBeneficiary: ethers.ZeroAddress,
newHolder: newBeneficiaryAddress, // = newBeneficiary in restoreEndorse
destinationPlatformId: ethers.ZeroHash,
exportNonce,
});
await (await titleFlow.connect(relayerWallet).restoreEndorse(
titleEscrowAddress,
pintRef,
nonce,
deadline,
exportDocumentHash,
exportEnvelopeHash,
newBeneficiaryAddress,
exportNonce,
signature,
)).wait();
console.log("Endorse restored — beneficiary:", await escrow.beneficiary()); // newBeneficiaryAddress
console.log("lifecycle:", await titleFlow.lifecycle(titleEscrowAddress)); // 1 = ACTIVEPhase 18 — Surrender for External Transfer
Transfer both beneficiary and holder to the relayer for cross-platform surrender. Use for PINT outbound when full ownership must transfer. After surrender, the relayer calls returnToIssuer directly and burns the token.
const lastBeneficiary = await escrow.beneficiary();
const nonce = await titleFlow.nonce(titleEscrowAddress, ownerAddress);
const exportNonce = await titleFlow.exportNonce(titleEscrowAddress);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const forAmendment = false; // true = surrender for amendment, false = surrender for delivery
const signature = await ownerWallet.signTypedData(domain, LIFECYCLE_TYPES, {
titleEscrow: titleEscrowAddress,
pintRef,
nonce,
deadline,
exportDocumentHash,
exportEnvelopeHash,
lastBeneficiary,
newHolder: relayerAddress, // must equal TitleFlow.relayer
destinationPlatformId: destinationPlatform,
exportNonce,
});
await (await titleFlow.surrenderForExternalTransfer(
titleEscrowAddress,
pintRef,
nonce,
deadline,
exportDocumentHash,
exportEnvelopeHash,
lastBeneficiary,
destinationPlatform,
exportNonce,
forAmendment,
signature,
)).wait();
// After surrender: beneficiary = relayer, holder = relayer
// Relayer can now call returnToIssuer + burn directly on TitleEscrow
const escrowAsRelayer = TitleEscrow__factory.connect(titleEscrowAddress, relayerWallet);
await (await escrowAsRelayer.returnToIssuer(
ethers.toUtf8Bytes("Surrender — returning to issuer"),
)).wait();
const registry = TradeTrustToken__factory.connect(registryAddress, relayerWallet);
await (await registry.burn(
BigInt(tokenId),
ethers.toUtf8Bytes("Surrender accepted — eBL destroyed"),
)).wait();
console.log("lifecycle:", await titleFlow.lifecycle(titleEscrowAddress)); // 4 = DESTROYEDKey Management
Planned Owner Rotation
Enforces a 48-hour timelock — the new owner must accept after the delay.
// Attorney proposes (starts 48hr timelock)
await (await titleFlow.connect(attorneyWallet).proposeOwnerRotation(newOwnerAddress)).wait();
// After 48 hours, new owner accepts from the new HSM
await (await titleFlow.connect(newOwnerWallet).acceptOwnerRotation()).wait();
console.log("Owner rotated to:", await titleFlow.owner());Planned Attorney Rotation
// Current attorney proposes
await (await titleFlow.connect(attorneyWallet).proposeAttorney(newAttorneyAddress)).wait();
// New attorney accepts — role transferred atomically
await (await titleFlow.connect(newAttorneyWallet).acceptAttorney()).wait();
console.log("Attorney rotated to:", await titleFlow.attorney());Emergency Recovery (Guardian)
Bypasses all timelocks. Requires GUARDIAN_ROLE.
// Emergency owner recovery
await (await titleFlow.connect(guardianWallet).guardianRecoverOwner(
newOwnerAddress,
"Primary HSM hardware failure — emergency recovery",
)).wait();
// Emergency attorney rotation
await (await titleFlow.connect(guardianWallet).guardianRotateAttorney(
newAttorneyAddress,
"Attorney infrastructure outage — switching to backup",
)).wait();
// Emergency relayer rotation
await (await titleFlow.connect(guardianWallet).guardianRotateRelayer(
newRelayerAddress,
"PINT service migration — rotating relayer",
)).wait();Add / Remove Backup Attorney
import { ATTORNEY_ADMIN_ROLE } from "@credorelabs/credore-smart-contracts";
// Guardian adds backup (DEFAULT_ADMIN_ROLE required)
await (await titleFlow.connect(guardianWallet).addBackupAttorney(backupAddress)).wait();
const hasRole = await titleFlow.hasRole(ATTORNEY_ADMIN_ROLE, backupAddress);
console.log("Backup attorney active:", hasRole); // true
// Guardian removes backup
await (await titleFlow.connect(guardianWallet).removeAttorney(backupAddress)).wait();Relayer Management
// Active relayer rotates to new address (RELAYER_ROLE required)
await (await titleFlow.connect(relayerWallet).setRelayer(newRelayerAddress)).wait();
// Guardian adds a backup relayer (DEFAULT_ADMIN_ROLE)
await (await titleFlow.connect(guardianWallet).addBackupRelayer(backupRelayerAddress)).wait();
// Guardian removes a relayer
await (await titleFlow.connect(guardianWallet).removeRelayer(oldRelayerAddress)).wait();Pause Mechanism
// Attorney pauses (ATTORNEY_ADMIN_ROLE)
await (await titleFlow.connect(attorneyWallet).pause(
"Regulatory freeze — all operations suspended",
)).wait();
console.log("Paused:", await titleFlow.paused()); // true
// Guardian unpauses (DEFAULT_ADMIN_ROLE)
await (await titleFlow.connect(guardianWallet).unpause()).wait();
console.log("Paused:", await titleFlow.paused()); // falseAPI Reference
Contracts and Types
import {
// TypeChain contract types
TitleFlow__factory,
TitleFlowFactory__factory,
TitleFlowRegistry__factory,
TitleFlowRelayFacet__factory,
// Interface types (type-only imports)
type TitleFlow,
type TitleFlowFactory,
type TitleFlowRegistry,
type TitleFlowRelayFacet,
type ITitleFlowAdmin,
type ITitleFlowLifecycle,
type ITitleFlowRelay,
type ITitleFlowStorage,
type ITitleFlowFactory,
type ITitleFlowRegistry,
} from "@credorelabs/credore-smart-contracts";Role Constants
Pre-computed keccak256 role hashes for use with hasRole() and event filtering.
import {
ATTORNEY_ADMIN_ROLE, // keccak256("ATTORNEY_ADMIN_ROLE")
RELAYER_ROLE, // keccak256("RELAYER_ROLE")
GUARDIAN_ROLE, // keccak256("GUARDIAN_ROLE")
FACTORY_ADMIN_ROLE, // keccak256("FACTORY_ADMIN_ROLE")
REGISTRAR_ROLE, // keccak256("REGISTRAR_ROLE")
} from "@credorelabs/credore-smart-contracts";
const isAttorney = await titleFlow.hasRole(ATTORNEY_ADMIN_ROLE, address);Enums
import { ActionType, Lifecycle } from "@credorelabs/credore-smart-contracts";
// ActionType — used in ACTION_TYPES EIP-712 signatures
ActionType.Nominate // 0
ActionType.BeneficiaryTransfer // 1
ActionType.HolderTransfer // 2
ActionType.OwnersTransfer // 3
ActionType.RejectBeneficiary // 4
ActionType.RejectHolder // 5
ActionType.RejectOwners // 6
ActionType.ReturnToIssuer // 7
ActionType.Shred // 8
ActionType.RejectSurrender // 9
// Lifecycle — returned by titleFlow.lifecycle(escrowAddress)
Lifecycle.UNREGISTERED // 0
Lifecycle.ACTIVE // 1
Lifecycle.LOCKED_EXTERNAL // 2
Lifecycle.SURRENDERED // 3
Lifecycle.DESTROYED // 4EIP-712 Type Definitions
import { ACTION_TYPES, LIFECYCLE_TYPES, titleFlowDomain } from "@credorelabs/credore-smart-contracts";
// Build domain for a deployed TitleFlow instance
const domain = titleFlowDomain(chainId, titleFlowAddress);
// { name: "TitleFlow", version: "1", chainId, verifyingContract: titleFlowAddress }
// Use with signer.signTypedData()
const sig = await ownerWallet.signTypedData(domain, ACTION_TYPES, value);
const sig = await ownerWallet.signTypedData(domain, LIFECYCLE_TYPES, value);ACTION_TYPES is used for: nominate, transferBeneficiary, transferHolder, transferOwners, all rejectTransfer*, returnToIssuer, shred.
LIFECYCLE_TYPES is used for: registerEscrow, lockForExternalTransfer, endorseForExternalTransfer, surrenderForExternalTransfer, restoreFromExternal, restoreEndorse.
TypeScript Interfaces
import type {
ActionSignParams, // Parameters for ACTION_TYPES signing
LifecycleSignParams, // Parameters for LIFECYCLE_TYPES signing
TitleFlowRecord, // Struct from TitleFlowRegistry.getRecord()
ExternalLockData, // Struct from _externalLocks (after lockForExternalTransfer)
EndorseLockData, // Struct from _endorseLocks (after endorseForExternalTransfer)
SurrenderLockData, // Struct from _surrenderLocks (after surrenderForExternalTransfer)
DeploymentRecord, // Shape of deployments/<network>.json
} from "@credorelabs/credore-smart-contracts";Helpers
import {
titleFlowDomain, // Build EIP-712 domain object
loadDeployment, // Runtime deployment loader (returns null if not deployed)
} from "@credorelabs/credore-smart-contracts";
// Load deployment at runtime (won't crash if file doesn't exist)
const dep = await loadDeployment("sepolia"); // "mainnet" | "sepolia" | "polygon" | "amoy"
if (dep) {
console.log("TitleFlowFactory:", dep.contracts.TitleFlowFactory);
}Deployments
import {
mainnetDeployments,
sepoliaDeployments,
polygonDeployments,
amoyDeployments,
} from "@credorelabs/credore-smart-contracts";
// Each object is typed as DeploymentRecord
const factory = sepoliaDeployments.contracts.TitleFlowFactory;
const registry = sepoliaDeployments.contracts.TitleFlowRegistry;
const chainId = sepoliaDeployments.chainId;Local Development
Prerequisites
Install Anvil (Foundry toolkit):
curl -L https://foundry.paradigm.xyz | bash && foundryupStart local node
anvil \
--chain-id 31337 \
--port 8545 \
--accounts 20 \
--balance 10000 \
--gas-limit 30000000 \
--code-size-limit 100000Deploy contracts locally
# Copy env template and fill in Anvil test account addresses
cp .env.example .env
yarn hardhat run scripts/deploy.ts --network localhostAddresses saved to deployments/localhost.json.
Run tests
yarn hardhat testDeployment
# Testnet
yarn hardhat run scripts/deploy.ts --network sepolia
yarn hardhat run scripts/deploy.ts --network amoy
# Mainnet
yarn hardhat run scripts/deploy.ts --network polygon
yarn hardhat run scripts/deploy.ts --network mainnetRequired .env variables — see .env.example:
| Variable | Description |
|---|---|
| DEPLOYER_PRIVATE_KEY | Account paying deployment gas |
| GUARDIAN_PRIVATE_KEY | Guardian account (for setFactory wiring on live networks) |
| GUARDIAN_ADDRESS | Guardian EOA address embedded in contracts |
| FACTORY_ADMIN_ADDRESS | Factory admin EOA embedded in contracts |
| ALCHEMY_KEY | Alchemy API key (or set per-network RPC URLs) |
| ETHERSCAN_API_KEY | For Ethereum contract verification |
| POLYGONSCAN_API_KEY | For Polygon contract verification |
Deployment Addresses
| Network | TitleFlowFactory | TitleFlowRegistry | Deployed | |---|---|---|---| | Mainnet | — | — | — | | Polygon | — | — | — | | Sepolia | — | — | — | | Amoy | — | — | — |
Error Reference
| Error | Cause | Fix |
|---|---|---|
| InvalidSigner | EIP-712 signature does not recover to the registered owner | Verify domain, ACTION_TYPES/LIFECYCLE_TYPES, and all field values match exactly what the contract reconstructs |
| InvalidNonce | Nonce already consumed or skipped | Read fresh nonce with titleFlow.nonce(escrow, owner) before every signing operation |
| SignatureExpired | block.timestamp > deadline | Build deadline from on-chain block time, not Date.now() — especially important in tests after time manipulation |
| InvalidEscrow | Escrow not registered or address is zero | Call registerEscrow before any title operations |
| AlreadyRegistered | registerEscrow called twice | Check titleFlow.registeredEscrow(escrowAddr) before registering |
| InvalidState | Operation not allowed in current lifecycle state | Check titleFlow.lifecycle(escrowAddr) — must be ACTIVE (1) for relay ops; only lifecycle functions work in LOCKED_EXTERNAL (2) |
| InvalidPintReference | pintRef is zero or does not match locked value | Never pass ZeroHash as pintRef for lock/restore operations |
| InvalidExportNonce | exportNonce does not match on-chain value | Read fresh titleFlow.exportNonce(escrowAddr) before each lifecycle call |
| InvalidSignatureLength | Signature is not exactly 65 bytes | Use signer.signTypedData() (EIP-712), not signer.signMessage() |
| EnforcedPause | Contract is paused | Wait for guardian to unpause; check titleFlow.paused() before submitting |
| AccessControlUnauthorizedAccount | Caller lacks required role | Attorney for daily ops; relayer for restoreFromExternal/restoreEndorse; guardian for emergency |
| CallerNotBeneficiary | TitleEscrow operation requires msg.sender == beneficiary but TitleFlow proxy is not | Check escrow.beneficiary() — TitleFlow must still be the beneficiary |
| CallerNotHolder | TitleEscrow operation requires msg.sender == holder but TitleFlow proxy is not | For direct escrow calls (relayer), check that the relayer is the current holder |
| DualRoleRejectionRequired | Used rejectTransferBeneficiary or rejectTransferHolder when the same address holds both roles | Use rejectTransferOwners when beneficiary and holder are the same address |
| InvalidTransferToZeroAddress | prevBeneficiary or prevHolder is zero during rejection | Rejection requires a prior transfer to revert — ensure transferBeneficiary/transferHolder/transferOwners was performed first |
| TargetNomineeAlreadyBeneficiary | nominate() called with _nominee == beneficiary | Nominee must differ from current beneficiary |
| NomineeAlreadyNominated | nominate() called with same nominee already set | Clear nominee first or use a different address |
| TokenNotReturnedToIssuer | shred() called while escrow still holds the token | Call returnToIssuer first, then burn via tokenRegistry.burn() |
Lifecycle State Machine
registerEscrow()
UNREGISTERED ──────────────────> ACTIVE
│
┌────────────────────────┤
│ nominate() │
│ transferBeneficiary() │
│ rejectTransfer*() │
│ transferHolder() │
│ transferOwners() │
└────────────────────────┤
│
returnToIssuer() ────────┼──> token owned by registry
│ registry.burn() → NFT → 0xdead
│
shred() ─────────────────┼──> DESTROYED (4)
│ lifecycle set by TitleFlowRelayFacet
│
lockForExternalTransfer()│
endorseForExternalTransfer()
surrenderForExternalTransfer()
▼
LOCKED_EXTERNAL (2)
(relay ops blocked)
│
restoreFromExternal() ───┤ (after relayer.transferHolder back to TitleFlow)
restoreEndorse() ────────┤ (after relayer.nominate directly on TitleEscrow)
rejectSurrender() ───────┘ (attorney rejects surrender)
│
▼
ACTIVE (1)License
UNLICENSED — proprietary software. All rights reserved by Credore (Trustless Private Limited).
Copyright & Legal Notice
Copyright © 2026 Credore (Trustless Private Limited). All rights reserved.
This software and associated documentation files (the "Software") are proprietary and confidential. No license is granted to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software. Any use requires explicit written permission from Credore (Trustless Private Limited).
For licensing inquiries: [email protected]
