npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

npm version License: UNLICENSED


Table of Contents


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 instances

TitleFlow 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 ethers

Key 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 mainnet

Addresses 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 = ACTIVE

Phase 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());      // newOwner

Phase 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 = DESTROYED

Phase 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_EXTERNAL

Phase 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()); // newHolder

Phase 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()); // relayerAddress

Phase 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 = ACTIVE

Phase 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 = DESTROYED

Key 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()); // false

API 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       // 4

EIP-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 && foundryup

Start local node

anvil \
  --chain-id 31337 \
  --port 8545 \
  --accounts 20 \
  --balance 10000 \
  --gas-limit 30000000 \
  --code-size-limit 100000

Deploy contracts locally

# Copy env template and fill in Anvil test account addresses
cp .env.example .env

yarn hardhat run scripts/deploy.ts --network localhost

Addresses saved to deployments/localhost.json.

Run tests

yarn hardhat test

Deployment

# 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 mainnet

Required .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]