diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 13416db..21f9011 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,3 +60,5 @@ jobs: cache: 'npm' - run: npm ci - run: npm run build + env: + NODE_OPTIONS: --max-old-space-size=8192 diff --git a/README.md b/README.md index fc2b368..c894a79 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## About -TrustVC is a comprehensive wrapper library designed to simplify the signing and verification processes for TrustVC W3C [Verifiable Credentials (VC)](https://github.com/TrustVC/w3c) and OpenAttestation Verifiable Documents (VD), including OpenCert Verifiable Documents, adhering to the W3C [VC](https://www.w3.org/TR/vc-data-model/) Data Model v2.0 (W3C Standard). It ensures compatibility and interoperability for Verifiable Credentials while supporting OpenAttestation [Verifiable Documents (VD)](https://github.com/Open-Attestation/open-attestation) v6.9.5. TrustVC seamlessly integrates functionalities for handling W3C Verifiable Credentials and OpenAttestation Verifiable Documents, leveraging existing TradeTrust libraries and smart contracts for [Token Registry](https://github.com/TradeTrust/token-registry) (V4 and V5). For W3C credentials it supports both [`did:web`](https://w3c-ccg.github.io/did-method-web/) (**recommended for production**; host a DID document on a domain you control, which acts as the trust anchor) and [`did:key`](https://w3c-ccg.github.io/did-key-spec/) (self-certifying, no hosting required; best for ad-hoc or ephemeral issuers, but requires an out-of-band trust binding) issuers across the `ecdsa-sd-2023` and `bbs-2023` cryptosuites. Additionally, it includes essential utility functions for strings, networks, and chains, making it a versatile tool for developers working with decentralized identity and verifiable data solutions. +TrustVC is a comprehensive wrapper library designed to simplify the signing and verification processes for TrustVC W3C [Verifiable Credentials (VC)](https://github.com/TrustVC/w3c) and OpenAttestation Verifiable Documents (VD), including OpenCert Verifiable Documents, adhering to the W3C [VC](https://www.w3.org/TR/vc-data-model/) Data Model v2.0 (W3C Standard). It ensures compatibility and interoperability for Verifiable Credentials while supporting OpenAttestation [Verifiable Documents (VD)](https://github.com/Open-Attestation/open-attestation) v6.9.5. TrustVC seamlessly integrates functionalities for handling W3C Verifiable Credentials and OpenAttestation Verifiable Documents, leveraging existing TradeTrust libraries and smart contracts for [Token Registry](https://github.com/TradeTrust/token-registry) (V4 and V5) and for **Obligation Registry** (electronic Bill of Exchange / BoE flows via `TrustVCToken` + `ObligationEscrow`). For W3C credentials it supports both [`did:web`](https://w3c-ccg.github.io/did-method-web/) (**recommended for production**; host a DID document on a domain you control, which acts as the trust anchor) and [`did:key`](https://w3c-ccg.github.io/did-key-spec/) (self-certifying, no hosting required; best for ad-hoc or ephemeral issuers, but requires an out-of-band trust binding) issuers across the `ecdsa-sd-2023` and `bbs-2023` cryptosuites. Additionally, it includes essential utility functions for strings, networks, and chains, making it a versatile tool for developers working with decentralized identity and verifiable data solutions. ## Table of Contents @@ -18,6 +18,8 @@ TrustVC is a comprehensive wrapper library designed to simplify the signing and - [b) TrustVC W3C Signing (signW3C)](#b-trustvc-w3c-signing-signw3c) - [3. **Deriving (Selective Disclosure)**](#3-deriving-selective-disclosure) - [4. **Verifying**](#4-verifying) + - [`verifyDocument` (ETR, BoE, and general VCs)](#verifydocument-etr-boe-and-general-vcs) + - [Obligation / BoE (`verifyDocument` + optional wrapper)](#obligation--boe-verifydocument--optional-wrapper) - [5. **Encryption**](#5-encryption) - [6. **Decryption**](#6-decryption) - [7. **TradeTrust Token Registry**](#7-tradetrust-token-registry) @@ -25,9 +27,16 @@ TrustVC is a comprehensive wrapper library designed to simplify the signing and - [TradeTrustToken](#tradetrusttoken) - [a) Token Registry v4](#a-token-registry-v4) - [b) Token Registry V5](#b-token-registry-v5) + - [c) Obligation Registry (BoE)](#c-obligation-registry-boe) + - [Lifecycle](#lifecycle) + - [SDK usage](#sdk-usage) + - [Status & verification](#status--verification) + - [Endorsement chain](#endorsement-chain) + - [Low-level contracts](#low-level-contracts) - [8. **Document Builder**](#8-document-builder) - [9. **Document Store**](#9-document-store) - [10. **Transaction Cancel**](#10-transaction-cancel) + - [Obligation Registry user guide](#obligation-registry-user-guide) ## Installation @@ -38,8 +47,12 @@ TrustVC is a comprehensive wrapper library designed to simplify the signing and npm install npm run build npm run test +npm run test:e2e ``` +- `npm run test` — Vitest unit tests +- `npm run test:e2e` — Hardhat e2e (classic token-registry + obligation/BOE). See [`src/__tests__/e2e/README.md`](src/__tests__/e2e/README.md). + ## Functions ### 1. **Wrapping** @@ -344,7 +357,19 @@ const derivationResult = await deriveW3C(signedDocument, { ### 4. **Verifying** -> TrustVC simplifies the verification process with a single function that supports W3C Verifiable Credentials (VCs) and OpenAttestation Verifiable Documents (VDs), including OpenCert Verifiable Documents. Whether you're working with W3C standards or OpenAttestation standards, TrustVC handles the verification seamlessly. For ECDSA-SD-2023 and BBS-2023 signed documents, which normally require derivation before verification, TrustVC automatically handles this process internally - if a document is not derived, the `verifyDocument` function will automatically derive and verify the document in a single step. +> TrustVC uses a **unified** `verifyDocument` pipeline for W3C credentials and OpenAttestation documents. Document-status checks are selected from the credential: +> +> | Document status fragment | Credential signal | Use for | +> |--------------------------|-------------------|---------| +> | TransferableRecords | `credentialStatus.tokenRegistry` | ETR / Title Escrow, classic transferable records | +> | ObligationRecords | `credentialStatus.obligationRegistry` | electronic Bill of Exchange (BoE) | +> | BitstringStatusList / other | `credentialStatus` type / URL | Revocable verifiable documents | +> +> Classic ETR documents get ObligationRecords **SKIPPED**; BoE documents get TransferableRecords **SKIPPED**. Pass `rpcProviderUrl` or `provider` when on-chain checks are required. +> +> For ECDSA-SD-2023 and BBS-2023 signed documents that normally require derivation before verification, the pipeline derives automatically when needed. + +#### `verifyDocument` (ETR, BoE, and general VCs) ```ts import { verifyDocument } from '@trustvc/trustvc'; @@ -385,6 +410,39 @@ const signedDocument = { const resultFragments = await verifyDocument(signedDocument); ``` +#### Obligation / BoE (`verifyDocument` + optional wrapper) + +Use `verifyDocument` for BoE credentials (ObligationRecords fragment). The `verifyObligationDocument` wrapper in `obligation-registry-functions` delegates to the same pipeline and returns `{ valid, fragments }`: + +```ts +import { + verifyDocument, + verifyObligationDocument, + getObligationDocumentStatus, +} from '@trustvc/trustvc'; + +const fragments = await verifyDocument(signedBoeVc, { + rpcProviderUrl: 'https://rpc.sepolia.org', + // or provider, +}); + +// Or use the convenience wrapper: +const { valid, fragments } = await verifyObligationDocument(signedBoeVc, { + rpcProviderUrl: 'https://rpc.sepolia.org', +}); + +const status = getObligationDocumentStatus(fragments); +// { obligationRegistry, status, terminationReason } | null +``` + +**ObligationRecords** fragment outcomes: + +- Valid minted BoE → `VALID` +- Classic ETR (`tokenRegistry` only) → `SKIPPED` +- Invalid obligation (e.g. not minted / wrong registry) → `INVALID` + +Full SDK flow (deploy → mint → lifecycle) is in [§7c Obligation Registry (BoE)](#c-obligation-registry-boe). + --- ### 5. **Encryption** @@ -737,8 +795,298 @@ function rejectTransferOwners(bytes calldata _remark) external; For more information on Token Registry and Title Escrow contracts **version v5**, please visit the readme of [TradeTrust Token Registry V5](https://github.com/TradeTrust/token-registry/blob/master/README.md) +#### c) Obligation Registry (BoE) + +> **New:** Obligation Registry supports electronic Bill of Exchange (BoE) style documents. It mirrors the classic Transferable Records / Title Escrow pattern, but uses **`TrustVCToken`** + **`ObligationEscrow`** (v5-style only — there is no Obligation v4 path). +> +> Prefer the high-level SDK in `@trustvc/trustvc/obligation-registry-functions` for deploy, mint, lifecycle, transfers, return-to-issuer, status reads, and document verification. Low-level Typechain factories are available from `@trustvc/trustvc/obligation-registry`. + +#### Lifecycle + +Deploy factory + registry + │ + ▼ + Mint ──► Issued (DocumentStatus.Issued) + │ + ├──────────────► Reject (holder) ──► Rejected (terminal) + │ + ▼ + Accept (holder) ──► Accepted + │ + ├──────────────► Discharge (beneficiary) ──► Discharged (terminal) + │ + ├──────────────► Nominate / transfers / reject-transfers + │ + ▼ + Consolidate dual role (beneficiary == holder) + │ + ▼ + Return to issuer ──► token held by registry + │ + ├──────────────► rejectReturned (restore to escrow) + └──────────────► acceptReturned (burn / shred) +``` + +**Role rules** + +- **Accept / document reject** require `beneficiary != holder`. +- **Discharge** requires `msg.sender == beneficiary`. +- **returnToIssuer** requires dual role (`beneficiary == holder`). After accept, typically `transferHolder` (or nominate + `transferBeneficiary`) so one wallet holds both roles before return. +- `terminationReason` is set on reject / discharge / shred (burn), **not** on `returnToIssuer` alone. + +**Status enums** (exported from `@trustvc/trustvc/obligation-registry-functions`): + +| Enum | Values | +|------|--------| +| `DocumentStatus` | `Issued=0`, `Accepted=1`, `Rejected=2`, `Discharged=3` | +| `ObligationEscrowTerminationReason` | `None=0`, `ReturnToIssuer=1`, `Rejected=2`, `Discharged=3` | +| `ObligationStatusAction` | `{ ACCEPT, REJECT, DISCHARGE }` — escrow write methods that advance status | + +Remarks are encrypted when you pass `options.id` (same pattern as Token Registry v5). Omit remarks or pass an empty string when not needed; the SDK encodes them as `0x…`. + +#### SDK usage + +```ts +import { + deployObligationEscrowFactory, + deployObligationRegistry, + mintObligationRegistry, + acceptObligationRegistry, + rejectObligationRegistry, + dischargeObligationRegistry, + nominateObligationRegistry, + transferHolderObligationRegistry, + transferBeneficiaryObligationRegistry, + transferOwnersObligationRegistry, + rejectTransferHolderObligationRegistry, + rejectTransferBeneficiaryObligationRegistry, + rejectTransferOwnersObligationRegistry, + returnToIssuerObligationRegistry, + acceptReturnedObligationRegistry, + rejectReturnedObligationRegistry, + getObligationRegistryStatus, + getObligationEscrowTerminationReason, + DocumentStatus, + ObligationStatusAction, +} from '@trustvc/trustvc/obligation-registry-functions'; +``` + +##### Deploy + +```ts +const { obligationEscrowFactoryAddress } = await deployObligationEscrowFactory(signer, { + chainId, +}); +const { obligationRegistry } = await deployObligationRegistry('My BoE Registry', 'BOE', signer, { + escrowFactoryAddress: obligationEscrowFactoryAddress, + chainId, +}); +``` + +You can omit `escrowFactoryAddress` to deploy a new factory as part of `deployObligationRegistry`. + +##### Mint → accept (or reject) + +```ts +const txOptions = { chainId, id: encryptionKeyId }; // id encrypts remarks + +await mintObligationRegistry( + { obligationRegistry }, + issuerSigner, + { + beneficiaryAddress, + holderAddress, + tokenId, + remarks: 'issued', + }, + txOptions, +); + +// Holder accepts while roles are still split +await acceptObligationRegistry( + { obligationRegistry }, + holderSigner, + { tokenId, remarks: 'accepted' }, + txOptions, +); + +// Alternatively, holder rejects the issued document (terminal) +// await rejectObligationRegistry({ obligationRegistry }, holderSigner, { tokenId }, txOptions); +``` + +`ObligationStatusAction.ACCEPT` / `.REJECT` / `.DISCHARGE` are the string method names used by the lifecycle helpers (`'accept' | 'reject' | 'discharge'`). + +##### Transfers & nominations + +Escrow helpers accept either `{ titleEscrowAddress }` **or** `{ obligationRegistry, tokenId }` (the SDK resolves the escrow address). + +```ts +await nominateObligationRegistry( + { obligationRegistry, tokenId }, + holderSigner, + { newBeneficiaryAddress, remarks: 'nominate' }, + txOptions, +); + +await transferHolderObligationRegistry( + { obligationRegistry, tokenId }, + holderSigner, + { holderAddress: newHolderAddress, remarks: 'transfer holder' }, + txOptions, +); + +await transferBeneficiaryObligationRegistry( + { obligationRegistry, tokenId }, + holderSigner, + { newBeneficiaryAddress, remarks: 'transfer beneficiary' }, + txOptions, +); + +await transferOwnersObligationRegistry( + { obligationRegistry, tokenId }, + dualRoleSigner, + { newBeneficiaryAddress, newHolderAddress, remarks: 'transfer both' }, + txOptions, +); +``` + +##### Reject transfers + +Same appointment rules as Title Escrow v5: reject as the next action after being appointed; use `rejectTransferOwners` when both roles were transferred together. + +```ts +await rejectTransferHolderObligationRegistry( + { obligationRegistry, tokenId }, + newHolderSigner, + { remarks: 'reject holder transfer' }, + txOptions, +); +await rejectTransferBeneficiaryObligationRegistry( + { obligationRegistry, tokenId }, + newBeneficiarySigner, + { remarks: 'reject beneficiary transfer' }, + txOptions, +); +await rejectTransferOwnersObligationRegistry( + { obligationRegistry, tokenId }, + dualAppointeeSigner, + { remarks: 'reject both' }, + txOptions, +); +``` + +##### Return to issuer / restore / burn + +```ts +// Requires beneficiary == holder +await returnToIssuerObligationRegistry( + { obligationRegistry, tokenId }, + dualRoleSigner, + { remarks: 'return' }, + txOptions, +); + +// Issuer restores to escrow +await rejectReturnedObligationRegistry( + { obligationRegistry }, + issuerSigner, + { tokenId, remarks: 'restore' }, + txOptions, +); + +// Or issuer accepts (burns / shreds) the returned token +await acceptReturnedObligationRegistry( + { obligationRegistry }, + issuerSigner, + { tokenId, remarks: 'burn' }, + txOptions, +); +``` + +##### Discharge (terminal, beneficiary) + +```ts +await dischargeObligationRegistry( + { obligationRegistry }, + beneficiarySigner, + { tokenId, remarks: 'discharged' }, + txOptions, +); +``` + +#### Status & verification + +```ts +const status = await getObligationRegistryStatus( + { obligationRegistry }, + signer, + { tokenId }, +); +// DocumentStatus.Issued | Accepted | Rejected | Discharged + +const reason = await getObligationEscrowTerminationReason( + { obligationRegistry }, + signer, + { tokenId }, +); + +import { + verifyDocument, + getObligationDocumentStatus, +} from '@trustvc/trustvc'; + +const fragments = await verifyDocument(signedVc, { + rpcProviderUrl: 'https://…', + // or provider, +}); +const enriched = getObligationDocumentStatus(fragments); +// { obligationRegistry, status, terminationReason } | null +``` + +See [§4 Verifying → Obligation / BoE](#obligation--boe-verifydocument--optional-wrapper) for the unified `verifyDocument` pipeline and ObligationRecords fragment outcomes. + +#### Endorsement chain + +```ts +import { fetchEndorsementChain } from '@trustvc/trustvc'; + +// Works for classic ETR (Title Escrow) and BoE (ObligationEscrow). +// Pass the registry address as tokenRegistryAddress (tokenRegistry or obligationRegistry). +const chain = await fetchEndorsementChain( + obligationRegistry, + String(tokenId), + provider, + encryptionKeyId, // optional — decrypts remarks + obligationEscrowAddress, // optional — skip lookup + { maxBlockRange: 10, rpcConcurrency: 3 }, // optional — BoE RPC tuning +); +``` + +For BoE, events include `STATUS_*`, `TRANSFER_*`, `RETURNED_TO_ISSUER`, and related lifecycle entries. Classic ETR uses V4/V5 Title Escrow paths when applicable. + +#### Low-level contracts + +```ts +import { obligationRegistryContracts } from '@trustvc/trustvc/obligation-registry'; + +const token = obligationRegistryContracts.TrustVCToken__factory.connect( + obligationRegistry, + signer, +); +const escrow = obligationRegistryContracts.ObligationEscrow__factory.connect( + escrowAddress, + signer, +); +``` + +Also exported: `obligationRegistryRoleHash`, `obligationRegistrySupportInterfaceIds`, and receipt helpers. + +For local e2e coverage of these flows, see [`src/__tests__/e2e/README.md`](src/__tests__/e2e/README.md). + ### 8. **Document Builder** -> The `DocumentBuilder` class helps build and manage W3C Verifiable Credentials (VCs) with credential status features, implementing the **W3C VC Data Model 2.0** specification. It supports creating documents with two types of credential statuses: `transferableRecords` and `verifiableDocument`. It can sign the document using modern cryptographic signature schemes including **ECDSA-SD-2023** (default) and **BBS-2023**, verify its signature, and serialize the document to a JSON format. Additionally, it allows for configuration of document rendering methods and expiration dates. +> The `DocumentBuilder` class helps build and manage W3C Verifiable Credentials (VCs) with credential status features, implementing the **W3C VC Data Model 2.0** specification. It supports creating documents with credential statuses for classic transferable records (`tokenRegistry`), obligation records (`obligationRegistry`), and revocable verifiable documents. It can sign the document using modern cryptographic signature schemes including **ECDSA-SD-2023** (default) and **BBS-2023**, verify its signature, and serialize the document to a JSON format. Additionally, it allows for configuration of document rendering methods and expiration dates. +> +> For electronic Bill of Exchange / Obligation Registry documents, use the same `DocumentBuilder` with `credentialStatus({ obligationRegistry, ... })` — see [§4](#obligation--boe-verifydocument--optional-wrapper) and [§7c](#c-obligation-registry-boe). #### Usage @@ -768,7 +1116,7 @@ builder.credentialSubject({ ##### Configure Credential Status You can configure the credential status as either `transferableRecords` or `verifiableDocument`. -**Transferable Records** +**Transferable Records (classic Token Registry)** ```ts builder.credentialStatus({ // Refers to the supported network. @@ -780,9 +1128,38 @@ builder.credentialStatus({ }); ``` +Verify with `verifyDocument` (TransferableRecords fragment). + +**Obligation Records (BoE / Obligation Registry)** + +Use the same `DocumentBuilder` with `obligationRegistry` (not `tokenRegistry`): + +```ts +import { DocumentBuilder } from '@trustvc/trustvc'; + +const boeBuilder = new DocumentBuilder({ + '@context': 'https://trustvc.io/context/bill-of-exchange.json', +}).credentialSubject({ + electronicDocumentIdentifier: 'urn:uuid:e6f4b2a1-9c3d-4e8f-a7b0-1d2e3f4a5b6c', + referenceNumber: 'BOE-2026-00147', +}); + +boeBuilder.credentialStatus({ + chain: 'amoy', + chainId: 80002, + obligationRegistry: '0x1234567890abcdef...', + rpcProviderUrl: 'https://rpc-amoy.polygon.technology', +}); +``` + +This attaches the obligation-records JSON-LD context and a `TransferableRecords`-typed `credentialStatus` carrying `obligationRegistry`. On-chain minting is separate — use `mintObligationRegistry` from `@trustvc/trustvc/obligation-registry-functions` (see [§7c](#c-obligation-registry-boe)). + +Verify BoE documents with `verifyDocument` — see [§4](#obligation--boe-verifydocument--optional-wrapper). + > ⚠️ **Disclaimer:** -> This builder **does not mint** documents on-chain. If you're using `transferableRecords`, you'll need to mint the document. -> [See the minting guide here](https://docs.tradetrust.io/docs/how-tos/credential-status#2-minting-the-credential) +> These builders **do not mint** documents on-chain. Mint separately. +> Classic ETR minting: [TradeTrust minting guide](https://docs.tradetrust.io/docs/how-tos/credential-status#2-minting-the-credential). +> Obligation minting: `mintObligationRegistry` in [§7c](#c-obligation-registry-boe). **Verifiable Document** @@ -1203,3 +1580,86 @@ const replacementHash2 = await cancelTransaction(signer, { gasPrice: '25000000000', // 25 gwei in wei }); ``` + +## Obligation Registry user guide + +Quick integrator reference for electronic Bill of Exchange (BoE) / Obligation Registry. API detail is in [§7c](#c-obligation-registry-boe), [§4 verify](#obligation--boe-verifydocument--optional-wrapper), and [§8 Document Builder](#8-document-builder). + +### Classic vs Obligation + +| Use case | Stack | Credential status | Verify API | +|----------|--------|-------------------|------------| +| eBL / classic transferable record | Token Registry + Title Escrow | `tokenRegistry` | `verifyDocument` | +| electronic Bill of Exchange | Obligation Registry + ObligationEscrow | `obligationRegistry` | `verifyDocument` (ObligationRecords fragment) | + +Do **not** mix stacks — wrong credential fields or mint helpers will fail or skip checks. + +### End-to-end flow + +``` +1. Deploy ObligationEscrowFactory + TrustVCToken (obligation registry) +2. Build BoE VC with DocumentBuilder + credentialStatus.obligationRegistry +3. Sign the VC +4. Mint tokenId on the registry (beneficiary + holder) +5. Holder accept / reject → optional transfers / discharge / return +6. Verify with verifyDocument (or verifyObligationDocument wrapper) +``` + +Builders **do not mint**. Signing produces the credential; `mintObligationRegistry` puts the token on-chain. + +### BoE credential subject + +Use context `https://trustvc.io/context/bill-of-exchange.json` with finalized BoE form fields (`electronicDocumentIdentifier`, `referenceNumber`, `amountInFigures` / `amountInWords`, `tenor`, `payee`, `drawer` / `drawee`, etc.). + +Sample: [`w3c` package `obligation-credential-subject.sample.json`](https://github.com/TrustVC/w3c/blob/main/packages/w3c-context/samples/obligation-credential-subject.sample.json). + +```ts +import { DocumentBuilder } from '@trustvc/trustvc'; + +const boeBuilder = new DocumentBuilder({ + '@context': 'https://trustvc.io/context/bill-of-exchange.json', +}).credentialSubject({ + electronicDocumentIdentifier: 'urn:uuid:e6f4b2a1-9c3d-4e8f-a7b0-1d2e3f4a5b6c', + referenceNumber: 'BOE-2026-00147', + // ... other finalized BoE form fields +}); + +boeBuilder.credentialStatus({ + chain: 'amoy', + chainId: 80002, + obligationRegistry: '0x…', + rpcProviderUrl: 'https://rpc-amoy.polygon.technology', +}); +``` + +### Imports cheat sheet + +| Concern | Import path | +|---------|-------------| +| Deploy / mint / lifecycle / transfers | `@trustvc/trustvc/obligation-registry-functions` | +| Document builder | `@trustvc/trustvc` → `DocumentBuilder` + `obligationRegistry` | +| Verify | `@trustvc/trustvc` → `verifyDocument` (or `verifyObligationDocument` wrapper) | +| Obligation status from fragments | `@trustvc/trustvc` → `getObligationDocumentStatus` | +| Endorsement chain | `@trustvc/trustvc` → `fetchEndorsementChain` | +| Typechain factories | `@trustvc/trustvc/obligation-registry` | + +### CLI + +[`trustvc-cli`](https://github.com/TrustVC/trustvc-cli) exposes `obligation-registry` and `obligation-escrow` for on-chain BoE flows. Use **`trustvc verify`** for both ETR and BoE documents (auto-detects ObligationRecords). See the CLI README [Obligation Registry user guide](https://github.com/TrustVC/trustvc-cli#obligation-registry-user-guide). + +### E2E tests + +```bash +npm run test:e2e +``` + +Coverage: [`src/__tests__/e2e/README.md`](src/__tests__/e2e/README.md). + +### Things to know (not bugs) + +1. **Same verify API** — ETR and BoE both use `verifyDocument`; the active document-status fragment depends on `tokenRegistry` vs `obligationRegistry`. +2. **Same builder** — BoE uses `DocumentBuilder` with `obligationRegistry` (not `tokenRegistry`). +3. **Accept / reject need split roles** — `beneficiary != holder`. +4. **Return needs dual role** — `beneficiary == holder`. +5. **`terminationReason` is not set by return alone** — set on reject / discharge / burn. +6. **Builders do not mint** — mint separately with `mintObligationRegistry`. diff --git a/package-lock.json b/package-lock.json index 9d0b104..9c12c01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,15 +12,15 @@ "@tradetrust-tt/dnsprove": "^2.18.0", "@tradetrust-tt/ethers-aws-kms-signer": "^2.1.4", "@tradetrust-tt/token-registry-v4": "npm:@tradetrust-tt/token-registry@^4.16.0", - "@tradetrust-tt/token-registry-v5": "npm:@tradetrust-tt/token-registry@^5.5.0", + "@tradetrust-tt/token-registry-v5": "npm:@tradetrust-tt/token-registry@^5.6.0-beta.1", "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", - "@trustvc/w3c": "^2.2.0", - "@trustvc/w3c-context": "^2.2.0", - "@trustvc/w3c-credential-status": "^2.2.0", - "@trustvc/w3c-issuer": "^2.2.0", - "@trustvc/w3c-vc": "^2.2.0", + "@trustvc/w3c": "^2.4.1", + "@trustvc/w3c-context": "^2.4.0", + "@trustvc/w3c-credential-status": "^2.4.0", + "@trustvc/w3c-issuer": "^2.3.0", + "@trustvc/w3c-vc": "^2.4.1", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", @@ -1310,59 +1310,34 @@ "node": ">=18" } }, - "node_modules/@digitalbazaar/bbs-2023-cryptosuite/node_modules/@digitalbazaar/bls12-381-multikey": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@digitalbazaar/bls12-381-multikey/-/bls12-381-multikey-2.1.0.tgz", - "integrity": "sha512-JelU85fNhvHl2/mqRdmrtrE2ZQJ0//+UwI0l/YFmvsOr6YN2GuKPzdkfXjpm7f3UvnBqz5f8QKFTb9mVa7mVVg==", + "node_modules/@digitalbazaar/bbs-signatures": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@digitalbazaar/bbs-signatures/-/bbs-signatures-3.1.0.tgz", + "integrity": "sha512-wx86l/PFOaRcoLBPmzwpF9Oo4uYJrm4uq/B1rHX5OHD15NakmUINfRr8NAGDG356GeTBDGZkMsBFgNj1x0dc+g==", "license": "BSD-3-Clause", "dependencies": { - "@digitalbazaar/bbs-signatures": "^3.0.0", - "@noble/curves": "^1.3.0", - "base58-universal": "^2.0.0", - "base64url-universal": "^2.0.0", - "cborg": "^4.2.0" + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0" }, "engines": { "node": ">=18" } }, - "node_modules/@digitalbazaar/bbs-2023-cryptosuite/node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "node_modules/@digitalbazaar/bbs-signatures/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", "license": "MIT", "dependencies": { - "@noble/hashes": "1.8.0" + "@noble/hashes": "2.2.0" }, "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, - "node_modules/@digitalbazaar/bbs-2023-cryptosuite/node_modules/cborg": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", - "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", - "license": "Apache-2.0", - "bin": { - "cborg": "lib/bin.js" - } - }, - "node_modules/@digitalbazaar/bbs-signatures": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@digitalbazaar/bbs-signatures/-/bbs-signatures-3.1.0.tgz", - "integrity": "sha512-wx86l/PFOaRcoLBPmzwpF9Oo4uYJrm4uq/B1rHX5OHD15NakmUINfRr8NAGDG356GeTBDGZkMsBFgNj1x0dc+g==", - "license": "BSD-3-Clause", - "dependencies": { - "@noble/curves": "^2.2.0", - "@noble/hashes": "^2.2.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@digitalbazaar/bbs-signatures/node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", @@ -1375,6 +1350,22 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@digitalbazaar/bls12-381-multikey": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@digitalbazaar/bls12-381-multikey/-/bls12-381-multikey-2.1.0.tgz", + "integrity": "sha512-JelU85fNhvHl2/mqRdmrtrE2ZQJ0//+UwI0l/YFmvsOr6YN2GuKPzdkfXjpm7f3UvnBqz5f8QKFTb9mVa7mVVg==", + "license": "BSD-3-Clause", + "dependencies": { + "@digitalbazaar/bbs-signatures": "^3.0.0", + "@noble/curves": "^1.3.0", + "base58-universal": "^2.0.0", + "base64url-universal": "^2.0.0", + "cborg": "^4.2.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@digitalbazaar/data-integrity": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@digitalbazaar/data-integrity/-/data-integrity-2.5.0.tgz", @@ -1466,9 +1457,9 @@ } }, "node_modules/@digitalbazaar/di-sd-primitives/node_modules/undici": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", - "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -1487,6 +1478,90 @@ "node": ">=18" } }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/-/ecdsa-rdfc-2019-cryptosuite-1.3.0.tgz", + "integrity": "sha512-Rhg++GnGWHJ29QyWTFW0tRqd/uGLADIsLVEq10zEIAY7D9ScoSLtyYzwZ/zBueBTXHXrtDUEL5SfrSvcKTLFyg==", + "license": "BSD-3-Clause", + "dependencies": { + "@digitalbazaar/ecdsa-multikey": "^1.6.0", + "jsonld": "^9.0.0", + "rdf-canonize": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/node_modules/@digitalbazaar/http-client": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-4.3.0.tgz", + "integrity": "sha512-6lMpxpt9BOmqHKGs9Xm6DP4LlZTBFer/ZjHvP3FcW3IaUWYIWC7dw5RFZnvw4fP57kAVcm1dp3IF+Y50qhBvAw==", + "license": "BSD-3-Clause", + "dependencies": { + "ky": "^1.14.2", + "undici": "^6.23.0" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/node_modules/canonicalize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.1.0.tgz", + "integrity": "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + } + }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/node_modules/jsonld": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/jsonld/-/jsonld-9.0.0.tgz", + "integrity": "sha512-pjMIdkXfC1T2wrX9B9i2uXhGdyCmgec3qgMht+TDj+S0qX3bjWMQUfL7NeqEhuRTi8G5ESzmL9uGlST7nzSEWg==", + "license": "BSD-3-Clause", + "dependencies": { + "@digitalbazaar/http-client": "^4.2.0", + "canonicalize": "^2.1.0", + "lru-cache": "^6.0.0", + "rdf-canonize": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/node_modules/ky": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", + "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/node_modules/rdf-canonize": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/rdf-canonize/-/rdf-canonize-5.0.0.tgz", + "integrity": "sha512-g8OUrgMXAR9ys/ZuJVfBr05sPPoMA7nHIVs8VEvg9QwM5W4GR2qSFEEHjsyHF1eWlBaf8Ev40WNjQFQ+nJTO3w==", + "license": "BSD-3-Clause", + "dependencies": { + "setimmediate": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@digitalbazaar/ecdsa-sd-2023-cryptosuite": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@digitalbazaar/ecdsa-sd-2023-cryptosuite/-/ecdsa-sd-2023-cryptosuite-3.4.1.tgz", @@ -1504,15 +1579,6 @@ "node": ">=18" } }, - "node_modules/@digitalbazaar/ecdsa-sd-2023-cryptosuite/node_modules/cborg": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", - "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", - "license": "Apache-2.0", - "bin": { - "cborg": "lib/bin.js" - } - }, "node_modules/@digitalbazaar/http-client": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-3.4.1.tgz", @@ -3692,27 +3758,15 @@ } }, "node_modules/@noble/curves": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", - "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.2.0" - }, - "engines": { - "node": ">= 20.19.0" + "@noble/hashes": "1.8.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "license": "MIT", "engines": { - "node": ">= 20.19.0" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -6560,9 +6614,9 @@ }, "node_modules/@tradetrust-tt/token-registry-v5": { "name": "@tradetrust-tt/token-registry", - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@tradetrust-tt/token-registry/-/token-registry-5.5.1.tgz", - "integrity": "sha512-yxOdjRLSI0fPptFbicYpnb/fn4Ngbi/LuGDCQKL0vb7PLCvhT8DLpiAFbFs0cc+QAb439lnZjhPg+UbWQHufRA==", + "version": "5.6.0-beta.1", + "resolved": "https://registry.npmjs.org/@tradetrust-tt/token-registry/-/token-registry-5.6.0-beta.1.tgz", + "integrity": "sha512-EtxZw/WnvGQMb/MB243eHy4ejSTN8bkV7I7Y9f19xk9v/mOojFL74C/tD1zjsZUUjiQi9rzLRYlzSUNbZBN2gg==", "license": "Apache-2.0", "dependencies": { "ethers": "^6.13.4" @@ -6831,24 +6885,24 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c/-/w3c-2.2.0.tgz", - "integrity": "sha512-2WhAoYZW7JLt9H1OrT1qcwTL0g6LymMVg9uiZ3xxlaRgYzOmEqClN3Pq6apo1kRZTzJMHcKWECKADkHGa0Jgqw==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@trustvc/w3c/-/w3c-2.4.1.tgz", + "integrity": "sha512-yCyztSAbialRn9VYvXrHUE4v13q4g/BHUN4XUctzF5lrN3WsHKSgUpFUflocLdp1h5fxvJTbqyVoMSh5SDLwQw==", "license": "Apache-2.0", "dependencies": { - "@trustvc/w3c-context": "^2.2.0", - "@trustvc/w3c-credential-status": "^2.2.0", - "@trustvc/w3c-issuer": "^2.2.0", - "@trustvc/w3c-vc": "^2.2.0" + "@trustvc/w3c-context": "^2.4.0", + "@trustvc/w3c-credential-status": "^2.4.0", + "@trustvc/w3c-issuer": "^2.3.0", + "@trustvc/w3c-vc": "^2.4.1" }, "engines": { "node": ">=18.x" } }, "node_modules/@trustvc/w3c-context": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-context/-/w3c-context-2.2.0.tgz", - "integrity": "sha512-p9mtIWZ1v1hhqiGLJ5Fu+2PK9ClIRsdo04vgCVC8BxhIjwUU7ZHb95sYF1E8Ay9pP2BRyFujBdoaYXHH8n5v4A==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-context/-/w3c-context-2.4.0.tgz", + "integrity": "sha512-OCRfqZfTyEZ2Lpd5RPBl36DwvaKv3qV1PgZOEA9hrnEtHMWxwS5iSdv+xFYK8pZIoeheFZKymfgcB5LL7UBQRw==", "license": "Apache-2.0", "dependencies": { "did-resolver": "^4.1.0", @@ -6865,13 +6919,13 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c-credential-status": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-credential-status/-/w3c-credential-status-2.2.0.tgz", - "integrity": "sha512-lfgnvAUSwdi5hWnuf+wqTkpPTYxmZyZ8kdzVPRQeKWqb0ysdWN+n32ROoNpQAFSPqYlSL0pLWuI/vg35WuhnEA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-credential-status/-/w3c-credential-status-2.4.0.tgz", + "integrity": "sha512-fiMGtfOmq1x9e+vd89ZKqguYtmW2w2k2hxINkno+mDNHdt5CV8tfTH5EbL1fY0oPiuXM5SK+dV6iSBTIWGPFog==", "license": "Apache-2.0", "dependencies": { - "@trustvc/w3c-context": "^2.2.0", - "@trustvc/w3c-issuer": "^2.2.0", + "@trustvc/w3c-context": "^2.4.0", + "@trustvc/w3c-issuer": "^2.3.0", "base64url-universal": "^2.0.0", "pako": "^2.1.0" }, @@ -6880,9 +6934,9 @@ } }, "node_modules/@trustvc/w3c-issuer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-issuer/-/w3c-issuer-2.2.0.tgz", - "integrity": "sha512-o5XWh52c3KeNqrrIpSvjPt+3zwZ/wwh2hlGOst6PZXVzS9nMab+jUwhs52d+HBhe2r8BL4Z81sdMGA8YAEnk6Q==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-issuer/-/w3c-issuer-2.3.0.tgz", + "integrity": "sha512-J/Rlae2s/ihkF0q6OmBofmzdRjDI98ED9lRFIB6Uh2WaEl8eZP7+FoStip+t1Piy6ZbO/6MwaS0QCw/6N6viMA==", "license": "Apache-2.0", "dependencies": { "@digitalbazaar/bls12-381-multikey": "^2.1.0", @@ -6897,46 +6951,6 @@ "node": ">=18.x" } }, - "node_modules/@trustvc/w3c-issuer/node_modules/@digitalbazaar/bls12-381-multikey": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@digitalbazaar/bls12-381-multikey/-/bls12-381-multikey-2.1.0.tgz", - "integrity": "sha512-JelU85fNhvHl2/mqRdmrtrE2ZQJ0//+UwI0l/YFmvsOr6YN2GuKPzdkfXjpm7f3UvnBqz5f8QKFTb9mVa7mVVg==", - "license": "BSD-3-Clause", - "dependencies": { - "@digitalbazaar/bbs-signatures": "^3.0.0", - "@noble/curves": "^1.3.0", - "base58-universal": "^2.0.0", - "base64url-universal": "^2.0.0", - "cborg": "^4.2.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@trustvc/w3c-issuer/node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@trustvc/w3c-issuer/node_modules/cborg": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", - "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", - "license": "Apache-2.0", - "bin": { - "cborg": "lib/bin.js" - } - }, "node_modules/@trustvc/w3c-issuer/node_modules/did-resolver": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/did-resolver/-/did-resolver-4.1.0.tgz", @@ -6944,19 +6958,20 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c-vc": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-vc/-/w3c-vc-2.2.0.tgz", - "integrity": "sha512-QAfoEgNndi2X+V0Nz9nBGiUk4Ko0XUFVLn0BY6qJK8GHJndMMCEcZ14PlaHyKG8nIossQeKZbsBwnavm2jRFdg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-vc/-/w3c-vc-2.4.1.tgz", + "integrity": "sha512-f/pqBu70epEYVmtWvKvlhCd2F5QkV885rCcbfa9KZq9xYbOzeBueIF7P3gSdv3ILAiMCQTxvpL7hd8I8hj+M1A==", "license": "Apache-2.0", "dependencies": { "@digitalbazaar/bbs-2023-cryptosuite": "^2.0.1", "@digitalbazaar/bls12-381-multikey": "^2.1.0", "@digitalbazaar/data-integrity": "^2.5.0", "@digitalbazaar/ecdsa-multikey": "^1.8.0", + "@digitalbazaar/ecdsa-rdfc-2019-cryptosuite": "^1.3.0", "@digitalbazaar/ecdsa-sd-2023-cryptosuite": "^3.4.1", "@mattrglobal/jsonld-signatures-bbs": "^1.2.0", - "@trustvc/w3c-credential-status": "^2.2.0", - "@trustvc/w3c-issuer": "^2.2.0", + "@trustvc/w3c-credential-status": "^2.4.0", + "@trustvc/w3c-issuer": "^2.3.0", "base64url-universal": "^2.0.0", "cbor": "^9.0.2", "did-resolver": "^4.1.0", @@ -6972,46 +6987,6 @@ "jsonld": "^6.0.0" } }, - "node_modules/@trustvc/w3c-vc/node_modules/@digitalbazaar/bls12-381-multikey": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@digitalbazaar/bls12-381-multikey/-/bls12-381-multikey-2.1.0.tgz", - "integrity": "sha512-JelU85fNhvHl2/mqRdmrtrE2ZQJ0//+UwI0l/YFmvsOr6YN2GuKPzdkfXjpm7f3UvnBqz5f8QKFTb9mVa7mVVg==", - "license": "BSD-3-Clause", - "dependencies": { - "@digitalbazaar/bbs-signatures": "^3.0.0", - "@noble/curves": "^1.3.0", - "base58-universal": "^2.0.0", - "base64url-universal": "^2.0.0", - "cborg": "^4.2.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@trustvc/w3c-vc/node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@trustvc/w3c-vc/node_modules/cborg": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", - "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", - "license": "Apache-2.0", - "bin": { - "cborg": "lib/bin.js" - } - }, "node_modules/@trustvc/w3c-vc/node_modules/did-resolver": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/did-resolver/-/did-resolver-4.1.0.tgz", @@ -8676,6 +8651,15 @@ "node": ">=16" } }, + "node_modules/cborg": { + "version": "4.5.8", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", + "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", + "license": "Apache-2.0", + "bin": { + "cborg": "lib/bin.js" + } + }, "node_modules/chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -14696,9 +14680,9 @@ } }, "node_modules/jsonld-signatures/node_modules/undici": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", - "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/package.json b/package.json index 1886eb0..ced2ac9 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,21 @@ "import": "./dist/esm/token-registry-v5/contracts.js", "require": "./dist/cjs/token-registry-v5/contracts.js" }, + "./obligation-registry": { + "types": "./dist/types/obligation-registry/index.d.ts", + "import": "./dist/esm/obligation-registry/index.js", + "require": "./dist/cjs/obligation-registry/index.js" + }, + "./obligation-registry/contracts": { + "types": "./dist/types/obligation-registry/contracts.d.ts", + "import": "./dist/esm/obligation-registry/contracts.js", + "require": "./dist/cjs/obligation-registry/contracts.js" + }, + "./obligation-registry-functions": { + "types": "./dist/types/obligation-registry-functions/index.d.ts", + "import": "./dist/esm/obligation-registry-functions/index.js", + "require": "./dist/cjs/obligation-registry-functions/index.js" + }, "./utils": { "types": "./dist/types/utils/index.d.ts", "import": "./dist/esm/utils/index.js", @@ -88,6 +103,16 @@ "import": "./dist/esm/verify/fragments/index.js", "require": "./dist/cjs/verify/fragments/index.js" }, + "./verify-obligation": { + "types": "./dist/types/verify-obligation/index.d.ts", + "import": "./dist/esm/verify-obligation/index.js", + "require": "./dist/cjs/verify-obligation/index.js" + }, + "./verify-obligation/fragments": { + "types": "./dist/types/verify-obligation/fragments/index.d.ts", + "import": "./dist/esm/verify-obligation/fragments/index.js", + "require": "./dist/cjs/verify-obligation/fragments/index.js" + }, "./w3c": { "types": "./dist/types/w3c/index.d.ts", "import": "./dist/esm/w3c/index.js", @@ -118,15 +143,15 @@ "@tradetrust-tt/dnsprove": "^2.18.0", "@tradetrust-tt/ethers-aws-kms-signer": "^2.1.4", "@tradetrust-tt/token-registry-v4": "npm:@tradetrust-tt/token-registry@^4.16.0", - "@tradetrust-tt/token-registry-v5": "npm:@tradetrust-tt/token-registry@^5.5.0", + "@tradetrust-tt/token-registry-v5": "npm:@tradetrust-tt/token-registry@^5.6.0-beta.1", "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", - "@trustvc/w3c": "^2.2.0", - "@trustvc/w3c-context": "^2.2.0", - "@trustvc/w3c-credential-status": "^2.2.0", - "@trustvc/w3c-issuer": "^2.2.0", - "@trustvc/w3c-vc": "^2.2.0", + "@trustvc/w3c": "^2.4.1", + "@trustvc/w3c-context": "^2.4.0", + "@trustvc/w3c-credential-status": "^2.4.0", + "@trustvc/w3c-issuer": "^2.3.0", + "@trustvc/w3c-vc": "^2.4.1", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", diff --git a/src/__tests__/core/documentBuilder.obligation.test.ts b/src/__tests__/core/documentBuilder.obligation.test.ts new file mode 100644 index 0000000..75a9a99 --- /dev/null +++ b/src/__tests__/core/documentBuilder.obligation.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { TR_CONTEXT_URL } from '@trustvc/w3c-context'; +import { DocumentBuilder } from '../../core/documentBuilder'; + +describe('DocumentBuilder (obligation records)', () => { + let documentBuilder: DocumentBuilder; + + beforeEach(() => { + documentBuilder = new DocumentBuilder({ + '@context': 'https://trustvc.io/context/bill-of-lading.json', + }).credentialSubject({ type: ['BillOfLading'] }); + }); + + it('should configure obligationRecords correctly', () => { + documentBuilder.credentialStatus({ + chain: 'amoy', + chainId: 80002, + obligationRegistry: '0x71D28767662cB233F887aD2Bb65d048d760bA694', + rpcProviderUrl: 'https://rpc-amoy.polygon.technology', + }); + expect(documentBuilder).toBeDefined(); + expect(documentBuilder.toString()).toContain(TR_CONTEXT_URL); + }); +}); diff --git a/src/__tests__/core/documentBuilder.test.ts b/src/__tests__/core/documentBuilder.test.ts index 831e616..fe04738 100644 --- a/src/__tests__/core/documentBuilder.test.ts +++ b/src/__tests__/core/documentBuilder.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { DocumentBuilder } from '../../core/documentBuilder'; import { Bbs2023PrivateKeyPair, @@ -148,7 +148,7 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { index: 10, }), ).toThrow( - 'Configuration Error: Do not mix transferable records and verifiable document properties.', + 'Configuration Error: Do not mix transferable records, obligation records, and verifiable document properties.', ); }); @@ -162,6 +162,16 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { }); describe('Sign, Derive and Verify', () => { + beforeEach(() => { + vi.spyOn(DocumentBuilder.prototype as never, 'verifyTokenRegistry').mockResolvedValue( + undefined, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should sign, derive and verify the document successfully for transferableRecords using ECDSA', async () => { documentBuilder.credentialStatus({ chain: 'amoy', @@ -235,6 +245,10 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { }); describe('Error Handling', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('Should throw error if document builder initialized with data model v1.1 context', () => { expect( () => new DocumentBuilder({ '@context': ['https://www.w3.org/2018/credentials/v1'] }), @@ -242,6 +256,9 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { }); it('Should throw an error when trying to verify without deriving using ECDSA', async () => { + vi.spyOn(DocumentBuilder.prototype as never, 'verifyTokenRegistry').mockResolvedValue( + undefined, + ); documentBuilder.credentialStatus({ chain: 'amoy', chainId: 80002, @@ -256,6 +273,9 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { }); it('Should throw an error when trying to verify without deriving using BBS2023', async () => { + vi.spyOn(DocumentBuilder.prototype as never, 'verifyTokenRegistry').mockResolvedValue( + undefined, + ); documentBuilder.credentialStatus({ chain: 'amoy', chainId: 80002, @@ -282,6 +302,7 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { }); it('should throw an error when unable to verify token registry', async () => { + vi.restoreAllMocks(); documentBuilder.credentialStatus({ chain: 'amoy', chainId: 80002, diff --git a/src/__tests__/core/verify.pol.test.ts b/src/__tests__/core/verify.pol.test.ts index 8722190..0b51a81 100644 --- a/src/__tests__/core/verify.pol.test.ts +++ b/src/__tests__/core/verify.pol.test.ts @@ -36,8 +36,11 @@ describe('Polygon (POL) network support', () => { describe('W3C_TRANSFERABLE_RECORD_POL fixture structure', () => { it('should have chain POL and chainId 137 in credentialStatus', () => { - expect(W3C_TRANSFERABLE_RECORD_POL.credentialStatus.tokenNetwork.chain).toBe('POL'); - expect(W3C_TRANSFERABLE_RECORD_POL.credentialStatus.tokenNetwork.chainId).toBe(137); + const credentialStatus = W3C_TRANSFERABLE_RECORD_POL.credentialStatus as unknown as { + tokenNetwork: { chain: string; chainId: number }; + }; + expect(credentialStatus.tokenNetwork.chain).toBe('POL'); + expect(credentialStatus.tokenNetwork.chainId).toBe(137); }); it('should have a DataIntegrityProof with ecdsa-sd-2023 cryptosuite', () => { diff --git a/src/__tests__/core/verify.rpc-resilience.test.ts b/src/__tests__/core/verify.rpc-resilience.test.ts new file mode 100644 index 0000000..d55d520 --- /dev/null +++ b/src/__tests__/core/verify.rpc-resilience.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, it, vi } from 'vitest'; +import { verifyDocument } from '../..'; +import * as transferableRecordsUtils from '../../verify/fragments/document-status/transferableRecords/utils'; +import { + W3C_TRANSFERABLE_RECORD, + WRAPPED_DOCUMENT_DID_TOKEN_REGISTRY_V3, +} from '../fixtures/fixtures'; +import { W3CCredentialStatusCode } from '../../verify/fragments/document-status/w3cCredentialStatus'; + +/** + * Extra verify coverage that mocks / soft-asserts around flaky public Amoy RPC. + * Keeps src/__tests__/core/verify.test.ts aligned with main (live RPC snapshots). + */ +const providerUrl = 'https://rpc-amoy.polygon.technology'; + +describe.concurrent('W3C verify (RPC resilience)', () => { + describe.concurrent('W3C_TRANSFERABLE_RECORD mocked mint checks', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it( + 'should return VALID status for TransferableRecords when mint check is mocked', + { timeout: 300000 }, + async ({ expect }) => { + vi.spyOn(transferableRecordsUtils, 'isTokenMintedOnRegistry').mockResolvedValue({ + minted: true, + address: '0x6c2a002A5833a100f38458c50F11E71Aa1A342c6', + }); + expect( + await verifyDocument(W3C_TRANSFERABLE_RECORD as any, { rpcProviderUrl: providerUrl }), + ).toMatchInlineSnapshot(` + [ + { + "data": true, + "name": "W3CSignatureIntegrity", + "status": "VALID", + "type": "DOCUMENT_INTEGRITY", + }, + { + "name": "EcdsaW3CSignatureIntegrity", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document either has no proof or proof type is not 'DataIntegrityProof' or proof cryptosuite is not 'ecdsa-sd-2023'.", + }, + "status": "SKIPPED", + "type": "DOCUMENT_INTEGRITY", + }, + { + "name": "Bbs2023W3CSignatureIntegrity", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document either has no proof or proof type is not 'DataIntegrityProof' or proof cryptosuite is not 'bbs-2023'.", + }, + "status": "SKIPPED", + "type": "DOCUMENT_INTEGRITY", + }, + { + "name": "W3CCredentialStatus", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document does not have a valid credentialStatus or type.", + }, + "status": "SKIPPED", + "type": "DOCUMENT_STATUS", + }, + { + "data": { + "tokenRegistry": "0x6c2a002A5833a100f38458c50F11E71Aa1A342c6", + }, + "name": "TransferableRecords", + "status": "VALID", + "type": "DOCUMENT_STATUS", + }, + { + "name": "ObligationRecords", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document does not have ObligationRecords status", + }, + "status": "SKIPPED", + "type": "DOCUMENT_STATUS", + }, + { + "name": "W3CEmptyCredentialStatus", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document contains a credentialStatus.", + }, + "status": "SKIPPED", + "type": "DOCUMENT_STATUS", + }, + { + "data": true, + "name": "W3CIssuerIdentity", + "status": "VALID", + "type": "ISSUER_IDENTITY", + }, + ] + `); + }, + ); + + it('should return INVALID status for TransferableRecords when mint check is mocked as not minted', async ({ + expect, + }) => { + vi.spyOn(transferableRecordsUtils, 'isTokenMintedOnRegistry').mockResolvedValue({ + minted: false, + address: '0x6c2a002A5833a100f38458c50F11E71Aa1A342c6', + reason: { + code: W3CCredentialStatusCode.DOCUMENT_NOT_ISSUED, + codeString: 'DOCUMENT_NOT_MINTED', + message: 'Document has not been issued under token registry', + }, + }); + const tampered: any = { + ...W3C_TRANSFERABLE_RECORD, + credentialStatus: { + ...W3C_TRANSFERABLE_RECORD.credentialStatus, + tokenId: '123', + }, + }; + expect(await verifyDocument(tampered, { rpcProviderUrl: providerUrl })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'TransferableRecords', + reason: { + code: W3CCredentialStatusCode.DOCUMENT_NOT_ISSUED, + codeString: 'DOCUMENT_NOT_MINTED', + message: 'Document has not been issued under token registry', + }, + status: 'INVALID', + type: 'DOCUMENT_STATUS', + }), + ]), + ); + }); + }); +}); + +describe.concurrent('V3 verify (RPC resilience)', () => { + it( + 'should verify a DID_TOKEN_REGISTRY document with soft asserts for flaky Amoy RPC', + { timeout: 300000 }, + async ({ expect }) => { + const fragments = await verifyDocument(WRAPPED_DOCUMENT_DID_TOKEN_REGISTRY_V3, { + rpcProviderUrl: providerUrl, + }); + + expect(fragments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'OpenAttestationHash', + status: 'VALID', + type: 'DOCUMENT_INTEGRITY', + }), + expect.objectContaining({ + name: 'OpenAttestationEthereumDocumentStoreStatus', + status: 'SKIPPED', + type: 'DOCUMENT_STATUS', + }), + expect.objectContaining({ + name: 'OpenAttestationDidSignedDocumentStatus', + status: 'SKIPPED', + type: 'DOCUMENT_STATUS', + }), + expect.objectContaining({ + name: 'OpenAttestationDnsDidIdentityProof', + status: 'SKIPPED', + type: 'ISSUER_IDENTITY', + }), + expect.objectContaining({ + name: 'OpencertsRegistryVerifier', + status: 'SKIPPED', + type: 'ISSUER_IDENTITY', + }), + ]), + ); + + // Token registry / DNS-TXT can ERROR when public Amoy RPC is flaky. + const tokenRegistryFragment = fragments.find( + (fragment) => fragment.name === 'OpenAttestationEthereumTokenRegistryStatus', + ); + expect(tokenRegistryFragment).toBeDefined(); + expect(['VALID', 'ERROR']).toContain(tokenRegistryFragment?.status); + + const dnsTxtFragment = fragments.find( + (fragment) => fragment.name === 'OpenAttestationDnsTxtIdentityProof', + ); + expect(dnsTxtFragment).toBeDefined(); + expect(['VALID', 'ERROR']).toContain(dnsTxtFragment?.status); + }, + ); +}); diff --git a/src/__tests__/core/verify.test.ts b/src/__tests__/core/verify.test.ts index 424c8af..48040d5 100644 --- a/src/__tests__/core/verify.test.ts +++ b/src/__tests__/core/verify.test.ts @@ -72,6 +72,16 @@ describe.concurrent('W3C verify', () => { "status": "SKIPPED", "type": "DOCUMENT_STATUS", }, + { + "name": "ObligationRecords", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document does not have ObligationRecords status", + }, + "status": "SKIPPED", + "type": "DOCUMENT_STATUS", + }, { "name": "W3CEmptyCredentialStatus", "reason": { @@ -341,6 +351,16 @@ describe.concurrent('W3C verify', () => { "status": "VALID", "type": "DOCUMENT_STATUS", }, + { + "name": "ObligationRecords", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document does not have ObligationRecords status", + }, + "status": "SKIPPED", + "type": "DOCUMENT_STATUS", + }, { "name": "W3CEmptyCredentialStatus", "reason": { diff --git a/src/__tests__/e2e/README.md b/src/__tests__/e2e/README.md new file mode 100644 index 0000000..7566e18 --- /dev/null +++ b/src/__tests__/e2e/README.md @@ -0,0 +1,100 @@ +# TrustVC E2E Tests + +Hardhat end-to-end tests for token-registry and obligation-registry SDK flows. These are **not** run by Vitest (`npm run test`); Vitest explicitly excludes `src/__tests__/e2e/**`. + +## How to run + +From the `trustvc` package root: + +```bash +npm run test:e2e +``` + +This starts a local Hardhat node (`e2e:node` on `http://127.0.0.1:8545`), waits for the port, then runs: + +```bash +npx hardhat test src/__tests__/e2e/**/*.test.ts --network hardhat +``` + +You can also run the steps separately: + +```bash +npm run e2e:node # terminal 1 — local chain, chainId 1337 +npm run e2e:test # terminal 2 — after the node is up +``` + +Mocha timeout is 60s (see `hardhat.config.ts`). Individual suites raise timeouts where deploy/history needs more time. + +## Matrix + +| Suite family | ethers | Contracts | +|--------------|--------|-----------| +| Classic token-registry | v5, v6 | TitleEscrow / TradeTrustToken **v4** and **v5** (reject-transfer is v5-only) | +| Obligation (BOE) | v5, v6 | Obligation registry only (**v5-style**; no TitleEscrow v4) | + +Shared fixtures live in [`fixtures.ts`](./fixtures.ts). Obligation helpers live in [`obligationUtils.ts`](./obligationUtils.ts). + +## Classic suites + +| File | Coverage | +|------|----------| +| [`token-registry-functions/transfer.e2e.test.ts`](./token-registry-functions/transfer.e2e.test.ts) | mint, nominate, transferHolder / Beneficiary / Owners | +| [`token-registry-functions/rejectTransfer.e2e.test.ts`](./token-registry-functions/rejectTransfer.e2e.test.ts) | rejectTransferHolder / Beneficiary / Owners | +| [`token-registry-functions/returnToken.e2e.test.ts`](./token-registry-functions/returnToken.e2e.test.ts) | returnToIssuer (+ related reject paths) | + +## Obligation suites + +| File | Coverage | +|------|----------| +| [`obligation-registry-functions/statusLifecycle.e2e.test.ts`](./obligation-registry-functions/statusLifecycle.e2e.test.ts) | deploy, mint → Issued, accept, document reject, discharge, status / termination readers | +| [`obligation-registry-functions/transfer.e2e.test.ts`](./obligation-registry-functions/transfer.e2e.test.ts) | nominate, transferHolder / Beneficiary / Owners | +| [`obligation-registry-functions/rejectTransfer.e2e.test.ts`](./obligation-registry-functions/rejectTransfer.e2e.test.ts) | rejectTransferHolder / Beneficiary / Owners | +| [`obligation-registry-functions/returnToken.e2e.test.ts`](./obligation-registry-functions/returnToken.e2e.test.ts) | returnToIssuer, rejectReturned (restore), acceptReturned (burn) | +| [`obligation-registry-functions/endorsementChain.e2e.test.ts`](./obligation-registry-functions/endorsementChain.e2e.test.ts) | `fetchEndorsementChain` (BoE / ObligationEscrow path) event order | + +### Obligation lifecycle (checklist) + +``` +Deploy factory + registry + │ + ▼ + Mint ──► Issued + │ + ├──────────────► Reject (holder) ──► Rejected (terminal) + │ + ▼ + Accept (holder) ──► Accepted + │ + ├──────────────► Discharge (beneficiary) ──► Discharged (terminal) + │ + ├──────────────► Nominate / transfers / reject-transfers + │ + ▼ + Consolidate dual role (beneficiary == holder) + │ + ▼ + Return to issuer ──► token held by registry + │ + ├──────────────► rejectReturned (restore) + └──────────────► acceptReturned (burn / shred) +``` + +### BOE role rules (important for writing tests) + +- Accept / document reject require `beneficiary != holder` (`OwnerHolderMustDiffer`). Discharge requires only `msg.sender == beneficiary`. +- **returnToIssuer** requires dual role (`beneficiary == holder`). +- Typical path after accept before return: `transferHolder` (or nominate + `transferBeneficiary`) so one wallet holds both roles. +- `terminationReason` is set on reject / discharge / shred (burn), **not** on `returnToIssuer` alone. + +### Endorsement chain + +`fetchEndorsementChain` is asserted for a mini obligation lifecycle (`STATUS_*`, `TRANSFER_*`, `RETURNED_TO_ISSUER`). The unified function auto-routes to the ObligationEscrow path when the escrow implements the obligation interface. + +## Notes + +- Suites call `hardhat_reset` in `before` — expect sequential describe blocks, not parallel file workers fighting one chain. +- Ethers v5 and v6 use **different** Hardhat account key sets in `fixtures.ts` to avoid nonce collisions. +- Obligation deploy uses SDK `deployObligationEscrowFactory` + `deployObligationRegistry`. +- Do not leave Mocha `describe.only` / `it.only` in any e2e file — `.only` is global and skips every other suite. +- To run **only** obligation suites (with `npm run e2e:node` already up): + `npx hardhat test src/__tests__/e2e/obligation-registry-functions/*.e2e.test.ts --network hardhat` diff --git a/src/__tests__/e2e/obligation-registry-functions/endorsementChain.e2e.test.ts b/src/__tests__/e2e/obligation-registry-functions/endorsementChain.e2e.test.ts new file mode 100644 index 0000000..5d9df8a --- /dev/null +++ b/src/__tests__/e2e/obligation-registry-functions/endorsementChain.e2e.test.ts @@ -0,0 +1,138 @@ +import { expect } from 'chai'; +import { Signer } from 'ethers'; +import { fetchEndorsementChain } from '../../../core/endorsement-chain'; +import { + acceptObligationRegistry, + returnToIssuerObligationRegistry, + transferHolderObligationRegistry, +} from '../../../obligation-registry-functions'; +import { getSignersV5, getSignersV6, providerV5 } from '../fixtures'; +import { + delay, + defaultTxOptions, + deployObligationFixture, + mintIssuedToken, + waitTx, + obligationProviders, + resetHardhatChain, +} from '../obligationUtils'; + +obligationProviders.forEach(({ ethersVersion }) => { + describe(`Obligation endorsement-chain E2E - ethers ${ethersVersion}`, function () { + let owner: any; + + let holder: any; + + let beneficiary: any; + let obligationRegistry: string; + + let readProvider: any; + + before(async function () { + this.timeout(120000); + await resetHardhatChain(); + + const signers = ethersVersion === 'v5' ? await getSignersV5(4) : await getSignersV6(4); + [owner, holder, beneficiary] = signers; + readProvider = ethersVersion === 'v5' ? providerV5 : owner.provider; + + const deployed = await deployObligationFixture(owner); + obligationRegistry = deployed.obligationRegistry; + await delay(); + }); + + it('builds ordered chain for mint → accept → transferHolder → return', async function () { + this.timeout(120000); + const tokenId = '0'; + + // Accept requires split roles; return requires dual — consolidate via transferHolder. + const { escrowAddress } = await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + remarks: 'chain mint', + }); + + const acceptTx = await acceptObligationRegistry( + { obligationRegistry }, + holder as Signer, + { tokenId, remarks: 'chain accept' }, + defaultTxOptions(), + ); + await waitTx(acceptTx); + + const transferHolderTx = await transferHolderObligationRegistry( + { obligationRegistry, tokenId }, + holder as Signer, + { holderAddress: beneficiary.address, remarks: 'chain consolidate dual' }, + defaultTxOptions(), + ); + await waitTx(transferHolderTx); + + const returnTx = await returnToIssuerObligationRegistry( + { obligationRegistry, tokenId }, + beneficiary as Signer, + { remarks: 'chain return' }, + defaultTxOptions(), + ); + await waitTx(returnTx); + + const chain = await fetchEndorsementChain( + obligationRegistry, + tokenId, + readProvider, + 'test-encryption-key', + escrowAddress, + ); + + expect(chain.length).to.be.greaterThan(0); + const types = chain.map((event) => event.type); + + // Mint tx merges TokenReceived + StatusInitialized (+ role sets) into one event. + expect(types.some((t) => t === 'INITIAL' || t === 'STATUS_INITIALIZED')).to.equal(true); + expect(types).to.include('STATUS_ACCEPTED'); + expect(types).to.include('TRANSFER_HOLDER'); + expect(types).to.include('RETURNED_TO_ISSUER'); + + const mintIdx = types.findIndex((t) => t === 'INITIAL' || t === 'STATUS_INITIALIZED'); + const acceptIdx = types.indexOf('STATUS_ACCEPTED'); + const returnIdx = types.indexOf('RETURNED_TO_ISSUER'); + expect(mintIdx).to.be.lessThan(acceptIdx); + expect(acceptIdx).to.be.lessThan(returnIdx); + + for (const event of chain) { + expect(event.transactionHash).to.match(/^0x[a-fA-F0-9]+$/); + expect(event.timestamp).to.be.a('number'); + } + }); + + it('works with default RPC options', async function () { + this.timeout(60000); + const tokenId = '1'; + + await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + }); + + const acceptTx = await acceptObligationRegistry( + { obligationRegistry }, + holder as Signer, + { tokenId, remarks: 'accept defaults' }, + defaultTxOptions(), + ); + await waitTx(acceptTx); + + const chain = await fetchEndorsementChain(obligationRegistry, tokenId, readProvider); + + const types = chain.map((event) => event.type); + expect(types.some((t) => t === 'INITIAL' || t === 'STATUS_INITIALIZED')).to.equal(true); + expect(types).to.include('STATUS_ACCEPTED'); + }); + }); +}); diff --git a/src/__tests__/e2e/obligation-registry-functions/rejectTransfer.e2e.test.ts b/src/__tests__/e2e/obligation-registry-functions/rejectTransfer.e2e.test.ts new file mode 100644 index 0000000..8d07429 --- /dev/null +++ b/src/__tests__/e2e/obligation-registry-functions/rejectTransfer.e2e.test.ts @@ -0,0 +1,204 @@ +import { expect } from 'chai'; +import { Signer } from 'ethers'; +import { + acceptObligationRegistry, + nominateObligationRegistry, + rejectTransferBeneficiaryObligationRegistry, + rejectTransferHolderObligationRegistry, + rejectTransferOwnersObligationRegistry, + transferBeneficiaryObligationRegistry, + transferHolderObligationRegistry, + transferOwnersObligationRegistry, +} from '../../../obligation-registry-functions'; +import { getSignersV5, getSignersV6 } from '../fixtures'; +import { + attachObligationEscrow, + delay, + defaultTxOptions, + deployObligationFixture, + mintIssuedToken, + MintIssuedTokenResult, + waitTx, + obligationProviders, + resetHardhatChain, +} from '../obligationUtils'; + +obligationProviders.forEach(({ ethersVersion }) => { + describe(`Obligation reject-transfer E2E - ethers ${ethersVersion}`, function () { + let owner: any; + + let holder: any; + + let beneficiary: any; + + let newHolder: any; + + let newBeneficiary: any; + let obligationRegistry: string; + let nextTokenId = 0; + + const allocateTokenId = () => { + const id = String(nextTokenId); + nextTokenId += 1; + return id; + }; + + /** + * Mint split roles then accept (accept requires beneficiary != holder). + * @param {string} tokenId - Token ID to mint and accept. + * @returns {Promise} Mint result including escrow address. + */ + const mintAccepted = async (tokenId: string) => { + const minted = await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + }); + const acceptTx = await acceptObligationRegistry( + { obligationRegistry }, + holder as Signer, + { tokenId, remarks: 'accept for reject-transfer' }, + defaultTxOptions(), + ); + await waitTx(acceptTx); + return minted; + }; + + before(async function () { + this.timeout(120000); + await resetHardhatChain(); + + const signers = ethersVersion === 'v5' ? await getSignersV5(6) : await getSignersV6(6); + [owner, holder, beneficiary, newHolder, newBeneficiary] = signers; + + const deployed = await deployObligationFixture(owner); + obligationRegistry = deployed.obligationRegistry; + await delay(); + }); + + describe('rejectTransferHolder', function () { + it('new holder rejects pending holder transfer', async function () { + const tokenId = allocateTokenId(); + const { escrowAddress } = await mintAccepted(tokenId); + + const transferTx = await transferHolderObligationRegistry( + { obligationRegistry, tokenId }, + holder as Signer, + { holderAddress: newHolder.address, remarks: 'pending holder' }, + defaultTxOptions(), + ); + await waitTx(transferTx); + + const rejectTx = await rejectTransferHolderObligationRegistry( + { obligationRegistry, tokenId }, + newHolder as Signer, + { remarks: 'reject holder transfer' }, + defaultTxOptions(), + ); + await waitTx(rejectTx); + + const escrow = attachObligationEscrow(escrowAddress, ethersVersion, owner); + expect(await escrow.holder()).to.equal(holder.address); + }); + }); + + describe('rejectTransferBeneficiary', function () { + it('new beneficiary rejects pending beneficiary transfer', async function () { + const tokenId = allocateTokenId(); + const { escrowAddress } = await mintAccepted(tokenId); + + const nominateTx = await nominateObligationRegistry( + { obligationRegistry, tokenId }, + beneficiary as Signer, + { newBeneficiaryAddress: newBeneficiary.address, remarks: 'nominate' }, + defaultTxOptions(), + ); + await waitTx(nominateTx); + + const transferTx = await transferBeneficiaryObligationRegistry( + { obligationRegistry, tokenId }, + holder as Signer, + { newBeneficiaryAddress: newBeneficiary.address, remarks: 'confirm' }, + defaultTxOptions(), + ); + await waitTx(transferTx); + + const rejectTx = await rejectTransferBeneficiaryObligationRegistry( + { obligationRegistry, tokenId }, + newBeneficiary as Signer, + { remarks: 'reject beneficiary transfer' }, + defaultTxOptions(), + ); + await waitTx(rejectTx); + + const escrow = attachObligationEscrow(escrowAddress, ethersVersion, owner); + expect(await escrow.beneficiary()).to.equal(beneficiary.address); + }); + }); + + describe('rejectTransferOwners', function () { + it('dual-role recipient rejects owners transfer', async function () { + // Dual mint (no accept) — transferOwners requires dual role; accept forbids it. + const tokenId = allocateTokenId(); + const { escrowAddress } = await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: holder.address, + holderAddress: holder.address, + tokenId, + }); + + const transferTx = await transferOwnersObligationRegistry( + { obligationRegistry, tokenId }, + holder as Signer, + { + newBeneficiaryAddress: newHolder.address, + newHolderAddress: newHolder.address, + remarks: 'transfer owners dual', + }, + defaultTxOptions(), + ); + await waitTx(transferTx); + + const rejectTx = await rejectTransferOwnersObligationRegistry( + { obligationRegistry, tokenId }, + newHolder as Signer, + { remarks: 'reject owners' }, + defaultTxOptions(), + ); + await waitTx(rejectTx); + + const escrow = attachObligationEscrow(escrowAddress, ethersVersion, owner); + expect(await escrow.holder()).to.equal(holder.address); + expect(await escrow.beneficiary()).to.equal(holder.address); + }); + + it('fails when there is no pending owners transfer', async function () { + const tokenId = allocateTokenId(); + await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: holder.address, + holderAddress: holder.address, + tokenId, + }); + + try { + await rejectTransferOwnersObligationRegistry( + { obligationRegistry, tokenId }, + holder as Signer, + { remarks: 'nothing to reject' }, + defaultTxOptions(), + ); + expect.fail('Expected rejectTransferOwners with no pending transfer to fail'); + } catch (error: unknown) { + expect((error as Error).message).to.equal( + 'Pre-check (callStatic) for rejectTransferOwners failed', + ); + } + }); + }); + }); +}); diff --git a/src/__tests__/e2e/obligation-registry-functions/returnToken.e2e.test.ts b/src/__tests__/e2e/obligation-registry-functions/returnToken.e2e.test.ts new file mode 100644 index 0000000..5457158 --- /dev/null +++ b/src/__tests__/e2e/obligation-registry-functions/returnToken.e2e.test.ts @@ -0,0 +1,243 @@ +import { expect } from 'chai'; +import { Signer } from 'ethers'; +import { + acceptObligationRegistry, + acceptReturnedObligationRegistry, + getObligationEscrowTerminationReason, + rejectReturnedObligationRegistry, + returnToIssuerObligationRegistry, + transferHolderObligationRegistry, + ObligationEscrowTerminationReason, +} from '../../../obligation-registry-functions'; +import { getSignersV5, getSignersV6 } from '../fixtures'; +import { + attachObligationEscrow, + attachTrustVCToken, + delay, + defaultTxOptions, + deployObligationFixture, + mintIssuedToken, + MintIssuedTokenResult, + waitTx, + obligationProviders, + resetHardhatChain, +} from '../obligationUtils'; + +obligationProviders.forEach(({ ethersVersion }) => { + describe(`Obligation return-to-issuer E2E - ethers ${ethersVersion}`, function () { + let owner: any; + + let holder: any; + + let beneficiary: any; + let obligationRegistry: string; + let nextTokenId = 0; + + const allocateTokenId = () => { + const id = String(nextTokenId); + nextTokenId += 1; + return id; + }; + + /** + * Accept requires split roles; returnToIssuer requires dual role. + * Flow: mint split → accept → transferHolder to beneficiary → dual beneficiary returns. + * @param {string} tokenId - Token ID to mint, accept, and consolidate. + * @returns {Promise} Mint result including escrow address. + */ + const mintAcceptedThenConsolidateDual = async (tokenId: string) => { + const minted = await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + }); + const acceptTx = await acceptObligationRegistry( + { obligationRegistry }, + holder as Signer, + { tokenId, remarks: 'accept for return' }, + defaultTxOptions(), + ); + await waitTx(acceptTx); + + const consolidateTx = await transferHolderObligationRegistry( + { obligationRegistry, tokenId }, + holder as Signer, + { holderAddress: beneficiary.address, remarks: 'consolidate dual role' }, + defaultTxOptions(), + ); + await waitTx(consolidateTx); + + return minted; + }; + + before(async function () { + this.timeout(120000); + await resetHardhatChain(); + + const signers = ethersVersion === 'v5' ? await getSignersV5(4) : await getSignersV6(4); + [owner, holder, beneficiary] = signers; + + const deployed = await deployObligationFixture(owner); + obligationRegistry = deployed.obligationRegistry; + await delay(); + }); + + describe('returnToIssuer', function () { + it('dual-role returns token to registry (Issued, no accept)', async function () { + const tokenId = allocateTokenId(); + await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: beneficiary.address, + tokenId, + }); + + const tx = await returnToIssuerObligationRegistry( + { obligationRegistry, tokenId }, + beneficiary as Signer, + { remarks: 'return to issuer' }, + defaultTxOptions(), + ); + await waitTx(tx); + + const token = attachTrustVCToken(obligationRegistry, ethersVersion, owner); + expect(await token.ownerOf(tokenId)).to.equal(obligationRegistry); + + // terminationReason is set on shred/burn, not on return alone + const reason = await getObligationEscrowTerminationReason( + { obligationRegistry }, + owner as Signer, + { tokenId }, + ); + expect(reason).to.equal(ObligationEscrowTerminationReason.None); + }); + + it('dual-role returns after accept + consolidate', async function () { + const tokenId = allocateTokenId(); + await mintAcceptedThenConsolidateDual(tokenId); + + const tx = await returnToIssuerObligationRegistry( + { obligationRegistry, tokenId }, + beneficiary as Signer, + { remarks: 'return after accept' }, + defaultTxOptions(), + ); + await waitTx(tx); + + const token = attachTrustVCToken(obligationRegistry, ethersVersion, owner); + expect(await token.ownerOf(tokenId)).to.equal(obligationRegistry); + }); + }); + + describe('rejectReturned (restore)', function () { + it('registry owner restores a returned token', async function () { + const tokenId = allocateTokenId(); + const { escrowAddress } = await mintAcceptedThenConsolidateDual(tokenId); + + const returnTx = await returnToIssuerObligationRegistry( + { obligationRegistry, tokenId }, + beneficiary as Signer, + { remarks: 'return then restore' }, + defaultTxOptions(), + ); + await waitTx(returnTx); + + const restoreTx = await rejectReturnedObligationRegistry( + { obligationRegistry }, + owner as Signer, + { tokenId, remarks: 'restore' }, + defaultTxOptions(), + ); + await waitTx(restoreTx); + + const token = attachTrustVCToken(obligationRegistry, ethersVersion, owner); + expect(await token.ownerOf(tokenId)).to.equal(escrowAddress); + + const escrow = attachObligationEscrow(escrowAddress, ethersVersion, owner); + expect(await escrow.active()).to.equal(true); + + const reason = await getObligationEscrowTerminationReason( + { obligationRegistry }, + owner as Signer, + { tokenId }, + ); + expect(reason).to.equal(ObligationEscrowTerminationReason.None); + }); + }); + + describe('acceptReturned (burn)', function () { + it('registry owner burns a returned token', async function () { + const tokenId = allocateTokenId(); + const { escrowAddress } = await mintAcceptedThenConsolidateDual(tokenId); + + const returnTx = await returnToIssuerObligationRegistry( + { obligationRegistry, tokenId }, + beneficiary as Signer, + { remarks: 'return then burn' }, + defaultTxOptions(), + ); + await waitTx(returnTx); + + const burnTx = await acceptReturnedObligationRegistry( + { obligationRegistry }, + owner as Signer, + { tokenId, remarks: 'burn' }, + defaultTxOptions(), + ); + await waitTx(burnTx); + + const token = attachTrustVCToken(obligationRegistry, ethersVersion, owner); + // TrustVCToken soft-burns to the sentinel address (ownerOf still resolves). + const BURN_ADDRESS = '0x000000000000000000000000000000000000dEaD'; + expect((await token.ownerOf(tokenId)).toLowerCase()).to.equal(BURN_ADDRESS.toLowerCase()); + + const escrow = attachObligationEscrow(escrowAddress, ethersVersion, owner); + expect(await escrow.active()).to.equal(false); + + const reason = await getObligationEscrowTerminationReason( + { obligationRegistry }, + owner as Signer, + { tokenId }, + ); + expect(reason).to.equal(ObligationEscrowTerminationReason.ReturnToIssuer); + }); + }); + + describe('error handling', function () { + it('split roles cannot returnToIssuer', async function () { + const tokenId = allocateTokenId(); + await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + }); + const acceptTx = await acceptObligationRegistry( + { obligationRegistry }, + holder as Signer, + { tokenId, remarks: 'accept split roles' }, + defaultTxOptions(), + ); + await waitTx(acceptTx); + + try { + await returnToIssuerObligationRegistry( + { obligationRegistry, tokenId }, + holder as Signer, + { remarks: 'not dual role' }, + defaultTxOptions(), + ); + expect.fail('Expected returnToIssuer without dual role to fail'); + } catch (error: unknown) { + expect((error as Error).message).to.equal( + 'Pre-check (callStatic) for returnToIssuer failed', + ); + } + }); + }); + }); +}); diff --git a/src/__tests__/e2e/obligation-registry-functions/statusLifecycle.e2e.test.ts b/src/__tests__/e2e/obligation-registry-functions/statusLifecycle.e2e.test.ts new file mode 100644 index 0000000..2334d06 --- /dev/null +++ b/src/__tests__/e2e/obligation-registry-functions/statusLifecycle.e2e.test.ts @@ -0,0 +1,320 @@ +import { expect } from 'chai'; +import { Signer } from 'ethers'; +import { CHAIN_ID } from '../../../utils'; +import { + acceptObligationRegistry, + dischargeObligationRegistry, + getObligationEscrowTerminationReason, + getObligationRegistryStatus, + isObligationRegistryRegistered, + mintObligationRegistry, + rejectObligationRegistry, + DocumentStatus, + ObligationEscrowTerminationReason, +} from '../../../obligation-registry-functions'; +import { getSignersV5, getSignersV6 } from '../fixtures'; +import { + attachObligationEscrow, + delay, + defaultTxOptions, + deployObligationFixture, + mintIssuedToken, + waitTx, + obligationProviders, + resetHardhatChain, +} from '../obligationUtils'; + +obligationProviders.forEach(({ ethersVersion }) => { + describe(`Obligation status lifecycle E2E - ethers ${ethersVersion}`, function () { + let owner: any; + + let holder: any; + + let beneficiary: any; + let obligationRegistry: string; + let nextTokenId = 0; + + const allocateTokenId = () => { + const id = String(nextTokenId); + nextTokenId += 1; + return id; + }; + + before(async function () { + this.timeout(120000); + await resetHardhatChain(); + + const signers = ethersVersion === 'v5' ? await getSignersV5(4) : await getSignersV6(4); + [owner, holder, beneficiary] = signers; + + const deployed = await deployObligationFixture(owner); + obligationRegistry = deployed.obligationRegistry; + expect(obligationRegistry).to.match(/^0x[a-fA-F0-9]{40}$/); + await delay(); + }); + + describe('mint + status readers', function () { + it('mints to Issued and reports registered', async function () { + const tokenId = allocateTokenId(); + const { escrowAddress } = await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + remarks: 'mint with remarks', + }); + + expect(escrowAddress).to.match(/^0x[a-fA-F0-9]{40}$/); + + const status = await getObligationRegistryStatus({ obligationRegistry }, owner as Signer, { + tokenId, + }); + expect(status).to.equal(DocumentStatus.Issued); + + const registered = await isObligationRegistryRegistered( + { obligationRegistry }, + owner as Signer, + { tokenId }, + ); + expect(registered).to.equal(true); + + const reason = await getObligationEscrowTerminationReason( + { obligationRegistry }, + owner as Signer, + { tokenId }, + ); + expect(reason).to.equal(ObligationEscrowTerminationReason.None); + + const escrow = attachObligationEscrow(escrowAddress, ethersVersion, owner); + expect(await escrow.active()).to.equal(true); + }); + + it('fails duplicate token mint', async function () { + const tokenId = allocateTokenId(); + await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + }); + + try { + await mintObligationRegistry( + { obligationRegistry }, + owner as Signer, + { + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + remarks: 'duplicate', + }, + defaultTxOptions(), + ); + expect.fail('Expected duplicate mint to fail'); + } catch (error: unknown) { + expect((error as Error).message).to.equal('Pre-check (callStatic) for mint failed'); + } + }); + + it('cannot accept when beneficiary equals holder', async function () { + const tokenId = allocateTokenId(); + await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: holder.address, + holderAddress: holder.address, + tokenId, + }); + + try { + await acceptObligationRegistry( + { obligationRegistry }, + holder as Signer, + { tokenId, remarks: 'dual role' }, + defaultTxOptions(), + ); + expect.fail('Expected accept with dual role to fail'); + } catch (error: unknown) { + expect((error as Error).message).to.equal('Pre-check (callStatic) for accept failed'); + } + }); + }); + + describe('accept', function () { + it('holder transitions Issued → Accepted when roles differ', async function () { + const tokenId = allocateTokenId(); + await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + }); + + const tx = await acceptObligationRegistry( + { obligationRegistry }, + holder as Signer, + { tokenId, remarks: 'accepted' }, + defaultTxOptions(), + ); + await waitTx(tx); + + const status = await getObligationRegistryStatus({ obligationRegistry }, owner as Signer, { + tokenId, + }); + expect(status).to.equal(DocumentStatus.Accepted); + }); + + it('non-holder cannot accept', async function () { + const tokenId = allocateTokenId(); + await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + }); + + try { + await acceptObligationRegistry( + { obligationRegistry }, + beneficiary as Signer, + { tokenId, remarks: 'not holder' }, + defaultTxOptions(), + ); + expect.fail('Expected accept by non-holder to fail'); + } catch (error: unknown) { + expect((error as Error).message).to.equal('Pre-check (callStatic) for accept failed'); + } + }); + }); + + describe('reject (document)', function () { + it('holder transitions Issued → Rejected and terminates', async function () { + const tokenId = allocateTokenId(); + const { escrowAddress } = await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + }); + + const tx = await rejectObligationRegistry( + { obligationRegistry }, + holder as Signer, + { tokenId, remarks: 'rejected' }, + defaultTxOptions(), + ); + await waitTx(tx); + + const status = await getObligationRegistryStatus({ obligationRegistry }, owner as Signer, { + tokenId, + }); + expect(status).to.equal(DocumentStatus.Rejected); + + const reason = await getObligationEscrowTerminationReason( + { obligationRegistry }, + owner as Signer, + { tokenId }, + ); + expect(reason).to.equal(ObligationEscrowTerminationReason.Rejected); + + const escrow = attachObligationEscrow(escrowAddress, ethersVersion, owner); + expect(await escrow.active()).to.equal(false); + }); + }); + + describe('discharge', function () { + it('beneficiary discharges Accepted → Discharged', async function () { + const tokenId = allocateTokenId(); + const { escrowAddress } = await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + }); + + const acceptTx = await acceptObligationRegistry( + { obligationRegistry }, + holder as Signer, + { tokenId, remarks: 'accepted for discharge' }, + defaultTxOptions(), + ); + await waitTx(acceptTx); + + const tx = await dischargeObligationRegistry( + { obligationRegistry }, + beneficiary as Signer, + { tokenId, remarks: 'discharged' }, + defaultTxOptions(), + ); + await waitTx(tx); + + const status = await getObligationRegistryStatus({ obligationRegistry }, owner as Signer, { + tokenId, + }); + expect(status).to.equal(DocumentStatus.Discharged); + + const reason = await getObligationEscrowTerminationReason( + { obligationRegistry }, + owner as Signer, + { tokenId }, + ); + expect(reason).to.equal(ObligationEscrowTerminationReason.Discharged); + + const escrow = attachObligationEscrow(escrowAddress, ethersVersion, owner); + expect(await escrow.active()).to.equal(false); + }); + + it('cannot discharge while still Issued', async function () { + const tokenId = allocateTokenId(); + await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + }); + + try { + await dischargeObligationRegistry( + { obligationRegistry }, + beneficiary as Signer, + { tokenId, remarks: 'too early' }, + defaultTxOptions(), + ); + expect.fail('Expected discharge from Issued to fail'); + } catch (error: unknown) { + expect((error as Error).message).to.equal('Pre-check (callStatic) for discharge failed'); + } + }); + }); + + describe('remarks without encryption id', function () { + it('mints with empty remarks and no encryption id', async function () { + const tokenId = allocateTokenId(); + const tx = await mintObligationRegistry( + { obligationRegistry }, + owner as Signer, + { + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + remarks: '', + }, + { chainId: CHAIN_ID.local }, + ); + await waitTx(tx); + + const status = await getObligationRegistryStatus({ obligationRegistry }, owner as Signer, { + tokenId, + }); + expect(status).to.equal(DocumentStatus.Issued); + }); + }); + }); +}); diff --git a/src/__tests__/e2e/obligation-registry-functions/transfer.e2e.test.ts b/src/__tests__/e2e/obligation-registry-functions/transfer.e2e.test.ts new file mode 100644 index 0000000..824496e --- /dev/null +++ b/src/__tests__/e2e/obligation-registry-functions/transfer.e2e.test.ts @@ -0,0 +1,214 @@ +import { expect } from 'chai'; +import { Signer } from 'ethers'; +import { + acceptObligationRegistry, + nominateObligationRegistry, + transferBeneficiaryObligationRegistry, + transferHolderObligationRegistry, + transferOwnersObligationRegistry, +} from '../../../obligation-registry-functions'; +import { getSignersV5, getSignersV6 } from '../fixtures'; +import { + attachObligationEscrow, + delay, + defaultTxOptions, + deployObligationFixture, + mintIssuedToken, + MintIssuedTokenResult, + waitTx, + obligationProviders, + resetHardhatChain, +} from '../obligationUtils'; + +obligationProviders.forEach(({ ethersVersion }) => { + describe(`Obligation transfer E2E - ethers ${ethersVersion}`, function () { + let owner: any; + + let holder: any; + + let beneficiary: any; + + let newHolder: any; + + let newBeneficiary: any; + let obligationRegistry: string; + let nextTokenId = 0; + + const allocateTokenId = () => { + const id = String(nextTokenId); + nextTokenId += 1; + return id; + }; + + /** + * Mint split roles then accept (accept requires beneficiary != holder). + * @param {string} tokenId - Token ID to mint and accept. + * @returns {Promise} Mint result including escrow address. + */ + const mintAccepted = async (tokenId: string) => { + const minted = await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: beneficiary.address, + holderAddress: holder.address, + tokenId, + }); + const acceptTx = await acceptObligationRegistry( + { obligationRegistry }, + holder as Signer, + { tokenId, remarks: 'accept for transfer tests' }, + defaultTxOptions(), + ); + await waitTx(acceptTx); + return minted; + }; + + before(async function () { + this.timeout(120000); + await resetHardhatChain(); + + const signers = ethersVersion === 'v5' ? await getSignersV5(6) : await getSignersV6(6); + [owner, holder, beneficiary, newHolder, newBeneficiary] = signers; + + const deployed = await deployObligationFixture(owner); + obligationRegistry = deployed.obligationRegistry; + await delay(); + }); + + describe('transferHolder', function () { + it('holder transfers holder role', async function () { + const tokenId = allocateTokenId(); + const { escrowAddress } = await mintAccepted(tokenId); + + const tx = await transferHolderObligationRegistry( + { obligationRegistry, tokenId }, + holder as Signer, + { holderAddress: newHolder.address, remarks: 'transfer holder' }, + defaultTxOptions(), + ); + await waitTx(tx); + + const escrow = attachObligationEscrow(escrowAddress, ethersVersion, owner); + expect(await escrow.holder()).to.equal(newHolder.address); + expect(await escrow.beneficiary()).to.equal(beneficiary.address); + }); + + it('non-holder cannot transfer holder', async function () { + const tokenId = allocateTokenId(); + await mintAccepted(tokenId); + + try { + await transferHolderObligationRegistry( + { obligationRegistry, tokenId }, + beneficiary as Signer, + { holderAddress: newHolder.address, remarks: 'unauthorized' }, + defaultTxOptions(), + ); + expect.fail('Expected unauthorized transferHolder to fail'); + } catch (error: unknown) { + expect((error as Error).message).to.equal( + 'Pre-check (callStatic) for transferHolder failed', + ); + } + }); + }); + + describe('nominate + transferBeneficiary', function () { + it('beneficiary nominates and holder confirms', async function () { + const tokenId = allocateTokenId(); + const { escrowAddress } = await mintAccepted(tokenId); + + const nominateTx = await nominateObligationRegistry( + { obligationRegistry, tokenId }, + beneficiary as Signer, + { newBeneficiaryAddress: newBeneficiary.address, remarks: 'nominate' }, + defaultTxOptions(), + ); + await waitTx(nominateTx); + + const escrow = attachObligationEscrow(escrowAddress, ethersVersion, owner); + expect(await escrow.nominee()).to.equal(newBeneficiary.address); + + const transferTx = await transferBeneficiaryObligationRegistry( + { obligationRegistry, tokenId }, + holder as Signer, + { newBeneficiaryAddress: newBeneficiary.address, remarks: 'confirm beneficiary' }, + defaultTxOptions(), + ); + await waitTx(transferTx); + + expect(await escrow.beneficiary()).to.equal(newBeneficiary.address); + }); + + it('non-beneficiary cannot nominate', async function () { + const tokenId = allocateTokenId(); + await mintAccepted(tokenId); + + try { + await nominateObligationRegistry( + { obligationRegistry, tokenId }, + holder as Signer, + { newBeneficiaryAddress: newBeneficiary.address, remarks: 'unauthorized' }, + defaultTxOptions(), + ); + expect.fail('Expected unauthorized nominate to fail'); + } catch (error: unknown) { + expect((error as Error).message).to.equal('Pre-check (callStatic) for nominate failed'); + } + }); + }); + + describe('transferOwners', function () { + it('dual-role holder transfers both roles (Issued, no accept needed)', async function () { + // Dual-role cannot accept; transfers still work while active/Issued. + const tokenId = allocateTokenId(); + const { escrowAddress } = await mintIssuedToken({ + obligationRegistry, + owner, + beneficiaryAddress: holder.address, + holderAddress: holder.address, + tokenId, + }); + + const tx = await transferOwnersObligationRegistry( + { obligationRegistry, tokenId }, + holder as Signer, + { + newBeneficiaryAddress: newBeneficiary.address, + newHolderAddress: newHolder.address, + remarks: 'transfer owners', + }, + defaultTxOptions(), + ); + await waitTx(tx); + + const escrow = attachObligationEscrow(escrowAddress, ethersVersion, owner); + expect(await escrow.beneficiary()).to.equal(newBeneficiary.address); + expect(await escrow.holder()).to.equal(newHolder.address); + }); + + it('fails when caller is not dual-role', async function () { + const tokenId = allocateTokenId(); + await mintAccepted(tokenId); + + try { + await transferOwnersObligationRegistry( + { obligationRegistry, tokenId }, + holder as Signer, + { + newBeneficiaryAddress: newBeneficiary.address, + newHolderAddress: newHolder.address, + remarks: 'not dual role', + }, + defaultTxOptions(), + ); + expect.fail('Expected transferOwners without dual role to fail'); + } catch (error: unknown) { + expect((error as Error).message).to.equal( + 'Pre-check (callStatic) for transferOwners failed', + ); + } + }); + }); + }); +}); diff --git a/src/__tests__/e2e/obligationUtils.ts b/src/__tests__/e2e/obligationUtils.ts new file mode 100644 index 0000000..03a1279 --- /dev/null +++ b/src/__tests__/e2e/obligationUtils.ts @@ -0,0 +1,163 @@ +import { ethers, Signer } from 'ethers'; +import { ethers as ethersV6, ContractRunner } from 'ethersV6'; +import { network } from 'hardhat'; +import { CHAIN_ID } from '../../utils'; +import { + deployObligationEscrowFactory, + deployObligationRegistry, +} from '../../deploy/obligation-registry'; +import { mintObligationRegistry } from '../../obligation-registry-functions/mint'; +import { getObligationEscrowAddress } from '../../obligation-registry-functions/utils'; +import { obligationRegistryContracts } from '../../obligation-registry'; +import { providerV5, providerV6 } from './fixtures'; + +export const OBLIGATION_ENCRYPTION_ID = 'test-encryption-key'; + +export type ObligationEthersVersion = 'v5' | 'v6'; + +export type ObligationProviderInfo = { + Provider: typeof providerV5 | typeof providerV6; + ethersVersion: ObligationEthersVersion; +}; + +export const obligationProviders: ObligationProviderInfo[] = [ + { Provider: providerV5, ethersVersion: 'v5' }, + { Provider: providerV6, ethersVersion: 'v6' }, +]; + +export const delay = (ms = 500): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export type DeployObligationFixtureResult = { + obligationRegistry: string; + obligationEscrowFactoryAddress: string; +}; + +/** + * Deploys ObligationEscrowFactory + TrustVCToken via SDK (classic TitleEscrowFactory + token path). + * @param {Signer | ethersV6.Signer} owner - Signer that deploys the contracts. + * @param {{ chainId?: CHAIN_ID }} [options] - Deployment options. + * @param {CHAIN_ID} [options.chainId] - Target chain ID (defaults to local Hardhat). + * @returns {Promise} Deployed registry and factory addresses. + */ +export const deployObligationFixture = async ( + owner: Signer | ethersV6.Signer, + options: { chainId?: CHAIN_ID } = {}, +): Promise => { + const chainId = options.chainId ?? CHAIN_ID.local; + const { obligationEscrowFactoryAddress } = await deployObligationEscrowFactory(owner, { + chainId, + }); + await delay(1000); + const deployed = await deployObligationRegistry('Test Obligation Registry', 'TOR', owner, { + escrowFactoryAddress: obligationEscrowFactoryAddress, + chainId, + }); + return { + obligationRegistry: deployed.obligationRegistry, + obligationEscrowFactoryAddress: deployed.obligationEscrowFactoryAddress, + }; +}; + +/** Reset local Hardhat state and pause so JsonRpc providers drop stale nonces. */ +export const resetHardhatChain = async (): Promise => { + await network.provider.send('evm_setAutomine', [true]); + await network.provider.send('hardhat_reset'); + await delay(1500); +}; + +export const attachObligationEscrow = ( + address: string, + ethersVersion: ObligationEthersVersion, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer: any, +) => { + const abi = obligationRegistryContracts.ObligationEscrow__factory.abi; + return ethersVersion === 'v5' + ? new ethers.Contract(address, abi, signer as Signer) + : new ethersV6.Contract(address, abi, signer as ContractRunner); +}; + +export const attachTrustVCToken = ( + address: string, + ethersVersion: ObligationEthersVersion, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer: any, +) => { + const abi = obligationRegistryContracts.TrustVCToken__factory.abi; + return ethersVersion === 'v5' + ? new ethers.Contract(address, abi, signer as Signer) + : new ethersV6.Contract(address, abi, signer as ContractRunner); +}; + +export type MintIssuedTokenParams = { + obligationRegistry: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + owner: any; + beneficiaryAddress: string; + holderAddress: string; + tokenId: string | number; + remarks?: string; + encryptionId?: string; +}; + +export type MintIssuedTokenResult = { + tokenId: string; + escrowAddress: string; +}; + +/** + * Mints an Issued obligation token and resolves its ObligationEscrow address. + * @param {MintIssuedTokenParams} params - Mint parameters including registry, roles, and token ID. + * @returns {Promise} Minted token ID and escrow address. + */ +export const mintIssuedToken = async ( + params: MintIssuedTokenParams, +): Promise => { + const { + obligationRegistry, + owner, + beneficiaryAddress, + holderAddress, + tokenId, + remarks = 'e2e mint', + encryptionId = OBLIGATION_ENCRYPTION_ID, + } = params; + + const tx = await mintObligationRegistry( + { obligationRegistry }, + owner, + { + beneficiaryAddress, + holderAddress, + tokenId, + remarks, + }, + { chainId: CHAIN_ID.local, id: encryptionId }, + ); + await tx.wait(); + await delay(500); + + const escrowAddress = await getObligationEscrowAddress( + obligationRegistry, + tokenId, + owner.provider, + ); + + return { tokenId: String(tokenId), escrowAddress }; +}; + +/** + * Wait for a tx and pause briefly so ethers v6 nonce tracking stays consistent. + * @param {{ wait: () => Promise }} tx - Transaction-like object. + * @param {() => Promise} tx.wait - Resolves when the transaction is mined. + * @returns {Promise} Resolves after confirmation and a short delay. + */ +export const waitTx = async (tx: { wait: () => Promise }): Promise => { + await tx.wait(); + await delay(500); +}; + +export const defaultTxOptions = (encryptionId = OBLIGATION_ENCRYPTION_ID) => ({ + chainId: CHAIN_ID.local, + id: encryptionId, +}); diff --git a/src/__tests__/obligation-registry-functions/verify.test.ts b/src/__tests__/obligation-registry-functions/verify.test.ts new file mode 100644 index 0000000..2916b31 --- /dev/null +++ b/src/__tests__/obligation-registry-functions/verify.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from 'vitest'; +import { OBLIGATION_RECORDS_NAME } from '../../verify/fragments'; + +const verifyDocumentMock = vi.fn(); + +vi.mock('../../core/verify', () => ({ + verifyDocument: (...args: unknown[]) => verifyDocumentMock(...args), +})); + +import { + verifyObligationDocument, + getObligationDocumentStatus, +} from '../../obligation-registry-functions/verify'; + +const OBLIGATION_REGISTRY = '0xObligationRegistryAddress'; + +describe('verifyObligationDocument', () => { + it('runs the unified verify pipeline and reports valid when every fragment is VALID', async () => { + const fragments = [ + { name: 'W3CSignatureIntegrity', type: 'DOCUMENT_INTEGRITY', status: 'VALID' }, + { + name: OBLIGATION_RECORDS_NAME, + type: 'DOCUMENT_STATUS', + status: 'VALID', + data: { obligationRegistry: OBLIGATION_REGISTRY, status: 1, terminationReason: 0 }, + }, + { name: 'W3CIssuerIdentity', type: 'ISSUER_IDENTITY', status: 'VALID' }, + ]; + verifyDocumentMock.mockResolvedValue(fragments); + + const document = { credentialStatus: { obligationRegistry: OBLIGATION_REGISTRY } }; + const result = await verifyObligationDocument(document, { + rpcProviderUrl: 'http://localhost:8545', + }); + + expect(verifyDocumentMock).toHaveBeenCalledWith(document, { + rpcProviderUrl: 'http://localhost:8545', + }); + expect(result.valid).toBe(true); + expect(result.fragments).toBe(fragments); + }); + + it('reports invalid when any fragment is not VALID', async () => { + const fragments = [ + { name: 'W3CSignatureIntegrity', type: 'DOCUMENT_INTEGRITY', status: 'VALID' }, + { name: OBLIGATION_RECORDS_NAME, type: 'DOCUMENT_STATUS', status: 'INVALID', data: {} }, + ]; + verifyDocumentMock.mockResolvedValue(fragments); + + const result = await verifyObligationDocument({}); + + expect(result.valid).toBe(false); + }); +}); + +describe('getObligationDocumentStatus', () => { + it('extracts status and terminationReason from a VALID ObligationRecords fragment', () => { + const fragments = [ + { + name: OBLIGATION_RECORDS_NAME, + type: 'DOCUMENT_STATUS', + status: 'VALID', + data: { obligationRegistry: OBLIGATION_REGISTRY, status: 1, terminationReason: 0 }, + }, + ]; + + expect(getObligationDocumentStatus(fragments as never)).toEqual({ + obligationRegistry: OBLIGATION_REGISTRY, + status: 1, + terminationReason: 0, + }); + }); + + it('returns null when the ObligationRecords fragment is not VALID', () => { + const fragments = [ + { name: OBLIGATION_RECORDS_NAME, type: 'DOCUMENT_STATUS', status: 'INVALID', data: {} }, + ]; + + expect(getObligationDocumentStatus(fragments as never)).toBeNull(); + }); + + it('returns null when ObligationRecords was SKIPPED (e.g. classic ETR document)', () => { + const fragments = [ + { + name: OBLIGATION_RECORDS_NAME, + type: 'DOCUMENT_STATUS', + status: 'SKIPPED', + reason: { code: 0, codeString: 'SKIPPED', message: 'skipped' }, + }, + ]; + + expect(getObligationDocumentStatus(fragments as never)).toBeNull(); + }); +}); diff --git a/src/__tests__/utils/documents/obligation.test.ts b/src/__tests__/utils/documents/obligation.test.ts new file mode 100644 index 0000000..039edab --- /dev/null +++ b/src/__tests__/utils/documents/obligation.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + getObligationRegistryAddress, + getTokenRegistryAddress, + isObligationRecord, + isTransferableRecord, +} from '../../../utils'; +import { + W3C_TRANSFERABLE_RECORD, + WRAPPED_DOCUMENT_DID_TOKEN_REGISTRY_V3, +} from '../../fixtures/fixtures'; + +const W3C_OBLIGATION_RECORD = { + ...W3C_TRANSFERABLE_RECORD, + credentialStatus: { + ...W3C_TRANSFERABLE_RECORD.credentialStatus, + tokenRegistry: undefined, + obligationRegistry: '0x71D28767662cB233F887aD2Bb65d048d760bA694', + }, +} as any; + +describe.concurrent('documents (obligation records)', () => { + describe.concurrent('isTransferableRecord', () => { + it('isTransferableRecord - INVALID W3C VC Obligation Record Document', () => { + const transferableRecord = isTransferableRecord(W3C_OBLIGATION_RECORD); + expect(transferableRecord).toBe(false); + }); + }); + + describe.concurrent('isObligationRecord', () => { + it('isObligationRecord - VALID W3C VC Obligation Record Document', () => { + const obligationRecord = isObligationRecord(W3C_OBLIGATION_RECORD); + expect(obligationRecord).toBe(true); + }); + + it('isObligationRecord - INVALID W3C VC Transferable Record Document', () => { + const obligationRecord = isObligationRecord(W3C_TRANSFERABLE_RECORD); + expect(obligationRecord).toBe(false); + }); + + it('isObligationRecord - INVALID OA document', () => { + const obligationRecord = isObligationRecord(WRAPPED_DOCUMENT_DID_TOKEN_REGISTRY_V3 as any); + expect(obligationRecord).toBe(false); + }); + }); + + describe.concurrent('getTokenRegistryAddress', () => { + it('getTokenRegistryAddress - Obligation document returns undefined', () => { + const tokenRegistryAddress = getTokenRegistryAddress(W3C_OBLIGATION_RECORD); + expect(tokenRegistryAddress).toBe(undefined); + }); + }); + + describe.concurrent('getObligationRegistryAddress', () => { + it('getObligationRegistryAddress - VALID W3C VC Obligation Record Document', () => { + const obligationRegistryAddress = getObligationRegistryAddress(W3C_OBLIGATION_RECORD); + expect(obligationRegistryAddress).toBe('0x71D28767662cB233F887aD2Bb65d048d760bA694'); + }); + + it('getObligationRegistryAddress - Transferable Record returns undefined', () => { + const obligationRegistryAddress = getObligationRegistryAddress(W3C_TRANSFERABLE_RECORD); + expect(obligationRegistryAddress).toBe(undefined); + }); + }); +}); diff --git a/src/__tests__/verify-obligation/obligationRecordUtils.test.ts b/src/__tests__/verify-obligation/obligationRecordUtils.test.ts new file mode 100644 index 0000000..805e952 --- /dev/null +++ b/src/__tests__/verify-obligation/obligationRecordUtils.test.ts @@ -0,0 +1,88 @@ +import { TradeTrustToken__factory } from '@tradetrust-tt/token-registry-v4/contracts'; +import { describe, expect, it, vi } from 'vitest'; +import { ObligationRecordsStatusCode } from '../../verify-obligation/fragments/document-status/obligationRecords/obligationRecordVerifier.types'; +import { + decodeObligationRegistryError, + isTokenMintedOnObligationRegistry, +} from '../../verify-obligation/fragments/document-status/obligationRecords/utils'; + +describe('decodeObligationRegistryError', () => { + it('classifies nonexistent token by selector even when an unrelated message is present', () => { + expect( + decodeObligationRegistryError({ + message: 'call revert exception', + data: '0x7e2732890000000000000000000000000000000000000000000000000000000000000001', + }), + ).toBe('Document has not been issued under obligation registry'); + }); + + it('classifies nonexistent token by message phrase', () => { + expect( + decodeObligationRegistryError({ + message: 'ERC721: owner query for nonexistent token', + }), + ).toBe('Document has not been issued under obligation registry'); + }); + + it('classifies nonexistent token by selector when message is missing', () => { + expect( + decodeObligationRegistryError({ + data: '0x7e273289abcdef', + }), + ).toBe('Document has not been issued under obligation registry'); + }); +}); + +describe('isTokenMintedOnObligationRegistry', () => { + const obligationRegistry = '0x71D28767662cB233F887aD2Bb65d048d760bA694'; + const tokenId = '0xabc'; + + it('rejects when provider chain ID does not match the credential chain ID', async () => { + const provider = { + _isProvider: true, + getNetwork: vi.fn().mockResolvedValue({ chainId: 137 }), + }; + + const result = await isTokenMintedOnObligationRegistry({ + obligationRegistry, + tokenId, + provider: provider as never, + chainId: 80002, + }); + + expect(result).toEqual({ + minted: false, + address: obligationRegistry, + reason: { + code: ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + codeString: 'UNRECOGNIZED_DOCUMENT', + message: + "Provider network chain ID (137) does not match credential's declared chain ID (80002)", + }, + }); + expect(provider.getNetwork).toHaveBeenCalledOnce(); + }); + + it('normalizes string chain IDs before comparing provider network', async () => { + const ownerOf = vi.fn().mockResolvedValue('0x0000000000000000000000000000000000000001'); + const connect = vi.spyOn(TradeTrustToken__factory, 'connect').mockReturnValue({ + ownerOf, + } as never); + const provider = { + _isProvider: true, + getNetwork: vi.fn().mockResolvedValue({ chainId: 80002 }), + }; + + const result = await isTokenMintedOnObligationRegistry({ + obligationRegistry, + tokenId, + provider: provider as never, + chainId: '80002', + }); + + expect(provider.getNetwork).toHaveBeenCalledOnce(); + expect(connect).toHaveBeenCalledWith(obligationRegistry, provider); + expect(ownerOf).toHaveBeenCalledWith(tokenId); + expect(result).toEqual({ minted: true, address: obligationRegistry }); + }); +}); diff --git a/src/__tests__/verify-obligation/obligationRecordVerifier.test.ts b/src/__tests__/verify-obligation/obligationRecordVerifier.test.ts new file mode 100644 index 0000000..b50e792 --- /dev/null +++ b/src/__tests__/verify-obligation/obligationRecordVerifier.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { credentialStatusObligationRecordVerifier } from '../../verify-obligation/fragments/document-status/obligationRecords/obligationRecordVerifier'; +import { ObligationRecordsStatusCode } from '../../verify-obligation/fragments/document-status/obligationRecords/obligationRecordVerifier.types'; +import * as obligationUtils from '../../verify-obligation/fragments/document-status/obligationRecords/utils'; + +const provider = {} as never; +const obligationRegistry = '0x71D28767662cB233F887aD2Bb65d048d760bA694'; + +const baseDocument = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + issuer: 'did:web:trustvc.github.io:did:1', + credentialSubject: { id: 'did:example:123' }, + proof: { type: 'DataIntegrityProof' }, + credentialStatus: { + type: 'TransferableRecords', + tokenNetwork: { chain: 'amoy', chainId: 80002 }, + obligationRegistry, + tokenId: '1234', + }, +} as any; + +describe('credentialStatusObligationRecordVerifier', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('tests true for obligation documents and false for classic ETR documents', () => { + expect(credentialStatusObligationRecordVerifier.test(baseDocument, { provider })).toBe(true); + expect( + credentialStatusObligationRecordVerifier.test( + { + ...baseDocument, + credentialStatus: { + ...baseDocument.credentialStatus, + obligationRegistry: undefined, + tokenRegistry: '0x6c2a002A5833a100f38458c50F11E71Aa1A342c6', + }, + } as any, + { provider }, + ), + ).toBe(false); + }); + + it('rejects empty credentialStatus arrays in test and verify', async () => { + const emptyStatusDocument = { ...baseDocument, credentialStatus: [] } as any; + + expect(credentialStatusObligationRecordVerifier.test(emptyStatusDocument, { provider })).toBe( + false, + ); + + await expect( + credentialStatusObligationRecordVerifier.verify(emptyStatusDocument, { provider }), + ).resolves.toEqual({ + name: 'ObligationRecords', + type: 'DOCUMENT_STATUS', + status: 'ERROR', + reason: { + code: ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + codeString: 'UNRECOGNIZED_DOCUMENT', + message: "Document's credentialStatus is empty", + }, + }); + }); + + it('returns VALID with enrichment for a minted obligation document', async () => { + vi.spyOn(obligationUtils, 'isTokenMintedOnObligationRegistry').mockResolvedValue({ + minted: true, + address: obligationRegistry, + }); + vi.spyOn(obligationUtils, 'getObligationEscrowEnrichment').mockResolvedValue({ + status: 1, + terminationReason: 0, + }); + + await expect( + credentialStatusObligationRecordVerifier.verify(baseDocument, { provider }), + ).resolves.toEqual({ + name: 'ObligationRecords', + type: 'DOCUMENT_STATUS', + status: 'VALID', + data: { + obligationRegistry, + status: 1, + terminationReason: 0, + }, + }); + }); + + it('skips escrow enrichment when provider chain ID mismatches credential chain ID', async () => { + vi.spyOn(obligationUtils, 'isTokenMintedOnObligationRegistry').mockResolvedValue({ + minted: false, + address: obligationRegistry, + reason: { + code: ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + codeString: 'UNRECOGNIZED_DOCUMENT', + message: + "Provider network chain ID (137) does not match credential's declared chain ID (80002)", + }, + }); + const enrichmentSpy = vi.spyOn(obligationUtils, 'getObligationEscrowEnrichment'); + + await expect( + credentialStatusObligationRecordVerifier.verify(baseDocument, { provider }), + ).resolves.toEqual({ + name: 'ObligationRecords', + type: 'DOCUMENT_STATUS', + status: 'INVALID', + data: { obligationRegistry }, + reason: { + code: ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + codeString: 'UNRECOGNIZED_DOCUMENT', + message: + "Provider network chain ID (137) does not match credential's declared chain ID (80002)", + }, + }); + expect(enrichmentSpy).not.toHaveBeenCalled(); + }); + + it('returns INVALID when token is not minted', async () => { + vi.spyOn(obligationUtils, 'isTokenMintedOnObligationRegistry').mockResolvedValue({ + minted: false, + address: obligationRegistry, + reason: { + code: ObligationRecordsStatusCode.DOCUMENT_NOT_MINTED, + codeString: 'DOCUMENT_NOT_MINTED', + message: 'Document has not been issued under obligation registry', + }, + }); + + await expect( + credentialStatusObligationRecordVerifier.verify(baseDocument, { provider }), + ).resolves.toEqual({ + name: 'ObligationRecords', + type: 'DOCUMENT_STATUS', + status: 'INVALID', + data: { + obligationRegistry, + }, + reason: { + code: ObligationRecordsStatusCode.DOCUMENT_NOT_MINTED, + codeString: 'DOCUMENT_NOT_MINTED', + message: 'Document has not been issued under obligation registry', + }, + }); + }); + + it('returns ERROR when both tokenRegistry and obligationRegistry are present', async () => { + await expect( + credentialStatusObligationRecordVerifier.verify( + { + ...baseDocument, + credentialStatus: { + ...baseDocument.credentialStatus, + tokenRegistry: '0x6c2a002A5833a100f38458c50F11E71Aa1A342c6', + }, + } as any, + { provider }, + ), + ).resolves.toEqual({ + name: 'ObligationRecords', + type: 'DOCUMENT_STATUS', + status: 'ERROR', + reason: { + code: ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + codeString: 'UNRECOGNIZED_DOCUMENT', + message: + "Document's credentialStatus must not include both tokenRegistry and obligationRegistry", + }, + }); + }); + + it('returns ERROR when tokenId is missing', async () => { + await expect( + credentialStatusObligationRecordVerifier.verify( + { + ...baseDocument, + credentialStatus: { + ...baseDocument.credentialStatus, + tokenId: undefined, + }, + } as any, + { provider }, + ), + ).resolves.toEqual({ + name: 'ObligationRecords', + type: 'DOCUMENT_STATUS', + status: 'ERROR', + reason: { + code: ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + codeString: 'UNRECOGNIZED_DOCUMENT', + message: "Document's credentialStatus does not have tokenId", + }, + }); + }); +}); diff --git a/src/__tests__/verify-obligation/w3cIssuerIdentity.test.ts b/src/__tests__/verify-obligation/w3cIssuerIdentity.test.ts new file mode 100644 index 0000000..300dd09 --- /dev/null +++ b/src/__tests__/verify-obligation/w3cIssuerIdentity.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest'; +import { w3cIssuerIdentity } from '../../verify-obligation/fragments/issuer-identity/w3cIssuerIdentity'; + +const baseDocument = { + issuer: 'did:web:example.com', + proof: { + verificationMethod: 'did:web:example.com#keys-1', + }, +} as never; + +describe('w3cIssuerIdentity (verify-obligation)', () => { + it('returns INVALID when the document loader resolves without a DID document', async () => { + const result = await w3cIssuerIdentity.verify(baseDocument, { + documentLoader: vi.fn().mockResolvedValue({ document: null }), + } as never); + + expect(result).toEqual({ + type: 'ISSUER_IDENTITY', + name: 'W3CIssuerIdentity', + data: false, + reason: { message: 'The DID cannot be resolved.' }, + status: 'INVALID', + }); + }); + + it('returns ERROR when the document loader throws a network error', async () => { + const result = await w3cIssuerIdentity.verify(baseDocument, { + documentLoader: vi.fn().mockRejectedValue(new Error('network request failed')), + } as never); + + expect(result).toEqual({ + type: 'ISSUER_IDENTITY', + name: 'W3CIssuerIdentity', + status: 'ERROR', + reason: { message: 'network request failed' }, + }); + }); + + it('returns VALID when the document loader returns a DID document', async () => { + const result = await w3cIssuerIdentity.verify(baseDocument, { + documentLoader: vi.fn().mockResolvedValue({ document: { id: 'did:web:example.com' } }), + } as never); + + expect(result).toEqual({ + type: 'ISSUER_IDENTITY', + name: 'W3CIssuerIdentity', + data: true, + status: 'VALID', + }); + }); + + it('returns INVALID for malformed did:key identifiers', async () => { + const result = await w3cIssuerIdentity.verify( + { + issuer: 'did:key:not-a-valid-key', + proof: { verificationMethod: 'did:key:not-a-valid-key#keys-1' }, + } as never, + {} as never, + ); + + expect(result).toEqual({ + type: 'ISSUER_IDENTITY', + name: 'W3CIssuerIdentity', + data: false, + reason: { message: 'The DID cannot be resolved.' }, + status: 'INVALID', + }); + }); +}); diff --git a/src/__tests__/verify/obligationRecordUtils.test.ts b/src/__tests__/verify/obligationRecordUtils.test.ts new file mode 100644 index 0000000..34bd3c6 --- /dev/null +++ b/src/__tests__/verify/obligationRecordUtils.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { decodeObligationRegistryError } from '../../verify/fragments/document-status/obligationRecords/utils'; + +describe('decodeObligationRegistryError', () => { + it('classifies nonexistent token by selector even when an unrelated message is present', () => { + expect( + decodeObligationRegistryError({ + message: 'call revert exception', + data: '0x7e2732890000000000000000000000000000000000000000000000000000000000000001', + }), + ).toBe('Document has not been issued under obligation registry'); + }); + + it('classifies nonexistent token by message phrase', () => { + expect( + decodeObligationRegistryError({ + message: 'ERC721: owner query for nonexistent token', + }), + ).toBe('Document has not been issued under obligation registry'); + }); + + it('classifies nonexistent token by selector when message is missing', () => { + expect( + decodeObligationRegistryError({ + data: '0x7e273289abcdef', + }), + ).toBe('Document has not been issued under obligation registry'); + }); +}); diff --git a/src/__tests__/verify/obligationRecordVerifier.test.ts b/src/__tests__/verify/obligationRecordVerifier.test.ts new file mode 100644 index 0000000..03619b2 --- /dev/null +++ b/src/__tests__/verify/obligationRecordVerifier.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { credentialStatusObligationRecordVerifier } from '../../verify/fragments/document-status/obligationRecords/obligationRecordVerifier'; +import { ObligationRecordsStatusCode } from '../../verify/fragments/document-status/obligationRecords/obligationRecordVerifier.types'; +import * as obligationUtils from '../../verify/fragments/document-status/obligationRecords/utils'; + +const provider = {} as never; +const obligationRegistry = '0x71D28767662cB233F887aD2Bb65d048d760bA694'; + +const baseDocument = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + issuer: 'did:web:trustvc.github.io:did:1', + credentialSubject: { id: 'did:example:123' }, + proof: { type: 'DataIntegrityProof' }, + credentialStatus: { + type: 'TransferableRecords', + tokenNetwork: { chain: 'amoy', chainId: 80002 }, + obligationRegistry, + tokenId: '1234', + }, +} as any; + +describe('credentialStatusObligationRecordVerifier', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('tests true for obligation documents and false for classic ETR documents', () => { + expect(credentialStatusObligationRecordVerifier.test(baseDocument, { provider })).toBe(true); + expect( + credentialStatusObligationRecordVerifier.test( + { + ...baseDocument, + credentialStatus: { + ...baseDocument.credentialStatus, + obligationRegistry: undefined, + tokenRegistry: '0x6c2a002A5833a100f38458c50F11E71Aa1A342c6', + }, + } as any, + { provider }, + ), + ).toBe(false); + }); + + it('rejects empty credentialStatus arrays in test and verify', async () => { + const emptyStatusDocument = { ...baseDocument, credentialStatus: [] } as any; + + expect(credentialStatusObligationRecordVerifier.test(emptyStatusDocument, { provider })).toBe( + false, + ); + + await expect( + credentialStatusObligationRecordVerifier.verify(emptyStatusDocument, { provider }), + ).resolves.toEqual({ + name: 'ObligationRecords', + type: 'DOCUMENT_STATUS', + status: 'ERROR', + reason: { + code: ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + codeString: 'UNRECOGNIZED_DOCUMENT', + message: "Document's credentialStatus is empty", + }, + }); + }); + + it('returns VALID with enrichment for a minted obligation document', async () => { + vi.spyOn(obligationUtils, 'isTokenMintedOnObligationRegistry').mockResolvedValue({ + minted: true, + address: obligationRegistry, + }); + vi.spyOn(obligationUtils, 'getObligationEscrowEnrichment').mockResolvedValue({ + status: 1, + terminationReason: 0, + }); + + await expect( + credentialStatusObligationRecordVerifier.verify(baseDocument, { provider }), + ).resolves.toEqual({ + name: 'ObligationRecords', + type: 'DOCUMENT_STATUS', + status: 'VALID', + data: { + obligationRegistry, + status: 1, + terminationReason: 0, + }, + }); + }); + + it('returns INVALID when token is not minted', async () => { + vi.spyOn(obligationUtils, 'isTokenMintedOnObligationRegistry').mockResolvedValue({ + minted: false, + address: obligationRegistry, + reason: { + code: ObligationRecordsStatusCode.DOCUMENT_NOT_MINTED, + codeString: 'DOCUMENT_NOT_MINTED', + message: 'Document has not been issued under obligation registry', + }, + }); + + await expect( + credentialStatusObligationRecordVerifier.verify(baseDocument, { provider }), + ).resolves.toEqual({ + name: 'ObligationRecords', + type: 'DOCUMENT_STATUS', + status: 'INVALID', + data: { + obligationRegistry, + }, + reason: { + code: ObligationRecordsStatusCode.DOCUMENT_NOT_MINTED, + codeString: 'DOCUMENT_NOT_MINTED', + message: 'Document has not been issued under obligation registry', + }, + }); + }); + + it('returns ERROR when both tokenRegistry and obligationRegistry are present', async () => { + await expect( + credentialStatusObligationRecordVerifier.verify( + { + ...baseDocument, + credentialStatus: { + ...baseDocument.credentialStatus, + tokenRegistry: '0x6c2a002A5833a100f38458c50F11E71Aa1A342c6', + }, + } as any, + { provider }, + ), + ).resolves.toEqual({ + name: 'ObligationRecords', + type: 'DOCUMENT_STATUS', + status: 'ERROR', + reason: { + code: ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + codeString: 'UNRECOGNIZED_DOCUMENT', + message: + "Document's credentialStatus must not include both tokenRegistry and obligationRegistry", + }, + }); + }); + + it('returns ERROR when tokenId is missing', async () => { + await expect( + credentialStatusObligationRecordVerifier.verify( + { + ...baseDocument, + credentialStatus: { + ...baseDocument.credentialStatus, + tokenId: undefined, + }, + } as any, + { provider }, + ), + ).resolves.toEqual({ + name: 'ObligationRecords', + type: 'DOCUMENT_STATUS', + status: 'ERROR', + reason: { + code: ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + codeString: 'UNRECOGNIZED_DOCUMENT', + message: "Document's credentialStatus does not have tokenId", + }, + }); + }); +}); diff --git a/src/core/documentBuilder.ts b/src/core/documentBuilder.ts index db7b5ff..e20422e 100644 --- a/src/core/documentBuilder.ts +++ b/src/core/documentBuilder.ts @@ -1,6 +1,10 @@ import { PrivateKeyPair } from '@trustvc/w3c-issuer'; import { deriveW3C, signW3C, verifyW3CSignature } from '../w3c'; -import { assertCredentialStatus, assertTransferableRecords } from '@trustvc/w3c-credential-status'; +import { + assertCredentialStatus, + assertObligationRecords, + assertTransferableRecords, +} from '@trustvc/w3c-credential-status'; import { CredentialStatus, CryptoSuiteName, @@ -51,6 +55,16 @@ export interface W3CTransferableRecordsConfig { rpcProviderUrl: string; } +/** + * Configuration for W3C Obligation Records (BoE / Obligation Registry). + */ +export interface W3CObligationRecordsConfig { + chain: string; + chainId: number; + obligationRegistry: string; + rpcProviderUrl: string; +} + /** * Configuration for the rendering method used in a Verifiable Credential document. * @property {string} id - A unique identifier for the rendering method, typically a URL or URI. @@ -88,7 +102,11 @@ export interface SignOptions { export class DocumentBuilder { private document: Partial; // Holds the document to be built and signed. private documentType: string = 'w3c'; // Default to W3C - private selectedStatusType: 'transferableRecords' | 'verifiableDocument' | null = null; // Tracks selected status type. + private selectedStatusType: + | 'transferableRecords' + | 'obligationRecords' + | 'verifiableDocument' + | null = null; // Tracks selected status type. private statusConfig: Partial = {}; // Configuration for the credential status. private rpcProviderUrl: string; // Holds the RPC provider URL for verifying token registry. private requiredFields: string[] = ['credentialSubject']; // Required fields that must be present in the document. @@ -112,14 +130,17 @@ export class DocumentBuilder { } // Configures the credential status of the document based on the provided type (Transferable Records or Verifiable Document). - credentialStatus(config: W3CTransferableRecordsConfig | W3CVerifiableDocumentConfig) { + credentialStatus( + config: W3CTransferableRecordsConfig | W3CObligationRecordsConfig | W3CVerifiableDocumentConfig, + ) { if (this.isSigned) throw new Error('Configuration Error: Document is already signed.'); const isTransferable = this.isTransferableRecordsConfig(config); + const isObligation = this.isObligationRecordsConfig(config); const isVerifiable = this.isVerifiableDocumentConfig(config); - if (isTransferable && isVerifiable) { + if ([isTransferable, isObligation, isVerifiable].filter(Boolean).length > 1) { throw new Error( - 'Configuration Error: Do not mix transferable records and verifiable document properties.', + 'Configuration Error: Do not mix transferable records, obligation records, and verifiable document properties.', ); } @@ -132,6 +153,15 @@ export class DocumentBuilder { }; this.rpcProviderUrl = config.rpcProviderUrl; this.addContext(TR_CONTEXT_URL); // Add transferable records context to document. + } else if (isObligation) { + this.selectedStatusType = 'obligationRecords'; + this.statusConfig = { + type: 'TransferableRecords', + tokenNetwork: { chain: config.chain, chainId: config.chainId }, + obligationRegistry: config.obligationRegistry, + }; + this.rpcProviderUrl = config.rpcProviderUrl; + this.addContext(TR_CONTEXT_URL); } else if (isVerifiable) { this.selectedStatusType = 'verifiableDocument'; this.statusConfig = { @@ -203,6 +233,9 @@ export class DocumentBuilder { } else if (this.selectedStatusType === 'transferableRecords') { assertTransferableRecords(this.document.credentialStatus, 'sign'); await this.verifyTokenRegistry(); // Verify that the token registry supports the required interface. + } else if (this.selectedStatusType === 'obligationRecords') { + assertObligationRecords(this.document.credentialStatus, 'sign'); + await this.verifyObligationRegistry(); } this.document.issuer = this.document.issuer || privateKey.id.split('#')[0]; // Set the issuer of the document. @@ -253,10 +286,15 @@ export class DocumentBuilder { // Type guard for transferable records configuration private isTransferableRecordsConfig( - config: Partial, + config: Partial & { + rpcProviderUrl?: string; + chain?: string; + chainId?: number; + tokenRegistry?: string; + }, ): config is W3CTransferableRecordsConfig { return ( - config && + Boolean(config) && typeof config.tokenRegistry === 'string' && typeof config.chain === 'string' && typeof config.chainId === 'number' && @@ -264,6 +302,23 @@ export class DocumentBuilder { ); } + private isObligationRecordsConfig( + config: Partial & { + rpcProviderUrl?: string; + chain?: string; + chainId?: number; + obligationRegistry?: string; + }, + ): config is W3CObligationRecordsConfig { + return ( + Boolean(config) && + typeof config.obligationRegistry === 'string' && + typeof config.chain === 'string' && + typeof config.chainId === 'number' && + typeof config.rpcProviderUrl === 'string' + ); + } + // Type guard for verifiable document configuration private isVerifiableDocumentConfig( config: Partial, @@ -306,6 +361,46 @@ export class DocumentBuilder { } } + private async verifyObligationRegistry() { + const chainId = this.document.credentialStatus.tokenNetwork + .chainId as keyof typeof SUPPORTED_CHAINS; + if (!(chainId in SUPPORTED_CHAINS)) { + throw new Error(`Unsupported Chain: Chain ID ${chainId} is not supported.`); + } + + try { + const provider = new ethers.providers.JsonRpcProvider(this.rpcProviderUrl); + const registryAddress = (this.statusConfig as { obligationRegistry?: string }) + .obligationRegistry; + if (!registryAddress) { + throw new Error('Configuration Error: Missing obligationRegistry for interface check.'); + } + const isV4Supported = await this.supportsInterfaceOnRegistry( + v4Contracts.TradeTrustToken__factory, + constantsV4.contractInterfaceId.TradeTrustTokenMintable, + registryAddress, + provider, + ); + const isV5Supported = await this.supportsInterfaceOnRegistry( + v5Contracts.TradeTrustToken__factory, + constantsV5.contractInterfaceId.TradeTrustTokenMintable, + registryAddress, + provider, + ); + if (!isV4Supported && !isV5Supported) + throw new Error('Obligation registry version is not supported.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.message === 'Obligation registry version is not supported.') { + throw error; + } else { + throw new Error( + `Network Error: Unable to verify obligation registry. Please check the RPC URL or obligation registry address.`, + ); + } + } + } + // Private helper method to verify that the token registry supports the required interface for transferable records. private async verifyTokenRegistry() { const chainId = this.document.credentialStatus.tokenNetwork @@ -316,14 +411,16 @@ export class DocumentBuilder { try { const provider = new ethers.providers.JsonRpcProvider(this.rpcProviderUrl); - const isV4Supported = await this.supportsInterface( + const isV4Supported = await this.supportsInterfaceOnRegistry( v4Contracts.TradeTrustToken__factory, constantsV4.contractInterfaceId.TradeTrustTokenMintable, + this.statusConfig.tokenRegistry as string, provider, ); - const isV5Supported = await this.supportsInterface( + const isV5Supported = await this.supportsInterfaceOnRegistry( v5Contracts.TradeTrustToken__factory, constantsV5.contractInterfaceId.TradeTrustTokenMintable, + this.statusConfig.tokenRegistry as string, provider, ); if (!isV4Supported && !isV5Supported) @@ -340,15 +437,15 @@ export class DocumentBuilder { } } - // Private helper method to check if a contract supports a specific interface ID. - private async supportsInterface( + private async supportsInterfaceOnRegistry( contractFactory: | typeof v4Contracts.TradeTrustToken__factory | typeof v5Contracts.TradeTrustToken__factory, interfaceId: string, + registryAddress: string, provider: ethers.providers.JsonRpcProvider, ) { - const contract = contractFactory.connect(this.statusConfig.tokenRegistry, provider); + const contract = contractFactory.connect(registryAddress, provider); return contract.supportsInterface(interfaceId); } } diff --git a/src/core/endorsement-chain/types.ts b/src/core/endorsement-chain/types.ts index e13d430..cc75fff 100644 --- a/src/core/endorsement-chain/types.ts +++ b/src/core/endorsement-chain/types.ts @@ -15,18 +15,6 @@ export type TradeTrustTokenEventType = | 'TRANSFER_TO_WALLET' | 'INVALID'; -export type TransferEventType = TokenTransferEventType | TitleEscrowTransferEventType; - -export interface TransferBaseEvent { - type: TransferEventType; - transactionIndex: number; - holder?: string; - owner?: string; - transactionHash: string; - blockNumber: number; - remark?: string; -} - export type TokenTransferEventType = | 'INITIAL' | 'SURRENDERED' // V4 @@ -36,10 +24,6 @@ export type TokenTransferEventType = | 'SURRENDER_ACCEPTED' // V4 | 'RETURN_TO_ISSUER_ACCEPTED'; // V5 -export interface TitleEscrowTransferEvent extends TransferBaseEvent { - type: TitleEscrowTransferEventType; -} - export type TitleEscrowTransferEventType = | 'TRANSFER_BENEFICIARY' | 'TRANSFER_HOLDER' @@ -48,6 +32,32 @@ export type TitleEscrowTransferEventType = | 'REJECT_TRANSFER_HOLDER' // V5 | 'REJECT_TRANSFER_OWNERS'; // V5 +/** Obligation escrow status lifecycle events (returned by unified fetchEndorsementChain). */ +export type ObligationStatusEventType = + | 'STATUS_INITIALIZED' + | 'STATUS_ACCEPTED' + | 'STATUS_REJECTED' + | 'STATUS_DISCHARGED'; + +export type TransferEventType = + | TokenTransferEventType + | TitleEscrowTransferEventType + | ObligationStatusEventType; + +export interface TransferBaseEvent { + type: TransferEventType; + transactionIndex: number; + holder?: string; + owner?: string; + transactionHash: string; + blockNumber: number; + remark?: string; +} + +export interface TitleEscrowTransferEvent extends TransferBaseEvent { + type: TitleEscrowTransferEventType; +} + export interface TokenTransferEvent extends TransferBaseEvent { type: TokenTransferEventType; from: string; diff --git a/src/core/endorsement-chain/useEndorsementChain.ts b/src/core/endorsement-chain/useEndorsementChain.ts index 5d8695d..8a42895 100644 --- a/src/core/endorsement-chain/useEndorsementChain.ts +++ b/src/core/endorsement-chain/useEndorsementChain.ts @@ -3,6 +3,13 @@ import { ethers as ethersV6 } from 'ethersV6'; import { supportInterfaceIds as supportInterfaceIdsV4 } from '../../token-registry-v4/supportInterfaceIds'; import { supportInterfaceIds as supportInterfaceIdsV5 } from '../../token-registry-v5/supportInterfaceIds'; import { getEthersContractFromProvider } from '../../utils/ethers'; +import { supportInterfaceIds as obligationSupportInterfaceIds } from '../../obligation-registry/supportInterfaceIds'; +import { fetchObligationEscrowTransfers } from '../obligation-endorsement-chain/fetchObligationEscrowTransfers'; +import { + mergeObligationTransfers, + ObligationEndorsementChainRpcOptions, +} from '../obligation-endorsement-chain/helpers'; +import { getObligationEndorsementChain } from '../obligation-endorsement-chain/retrieveObligationEndorsementChain'; import { decrypt } from '../decrypt'; import { fetchEscrowTransfersV4, @@ -14,6 +21,9 @@ import { getEndorsementChain } from '../endorsement-chain/retrieveEndorsementCha import { EndorsementChain, TransferBaseEvent } from '../endorsement-chain/types'; import { Provider } from '@ethersproject/abstract-provider'; +/** Optional RPC tuning for obligation endorsement-chain fetches (ignored on classic ETR paths). */ +export type FetchEndorsementChainRpcOptions = ObligationEndorsementChainRpcOptions; + export const TitleEscrowInterface = { V4: supportInterfaceIdsV4.TitleEscrow, V5: supportInterfaceIdsV5.TitleEscrow, @@ -199,6 +209,7 @@ export const fetchEndorsementChain = async ( provider: Provider | ethersV6.Provider, keyId?: string, titleEscrowAddress?: string, + rpcOptions?: FetchEndorsementChainRpcOptions, ): Promise => { if (!tokenRegistryAddress || !tokenId || !provider) { throw new Error('Missing required dependencies'); @@ -206,7 +217,7 @@ export const fetchEndorsementChain = async ( const resolvedTitleEscrowAddress = titleEscrowAddress ?? (await getTitleEscrowAddress(tokenRegistryAddress, tokenId, provider)); - const [isV4, isV5] = await Promise.all([ + const [isV4, isV5TitleEscrow, isObligationEscrow] = await Promise.all([ isTitleEscrowVersion({ titleEscrowAddress: resolvedTitleEscrowAddress, versionInterface: TitleEscrowInterface.V4, @@ -217,11 +228,13 @@ export const fetchEndorsementChain = async ( versionInterface: TitleEscrowInterface.V5, provider, }), + checkSupportsInterface( + resolvedTitleEscrowAddress, + obligationSupportInterfaceIds.ObligationEscrow, + provider, + ), ]); - - if (!isV4 && !isV5) { - throw new Error('Only Token Registry V4/V5 is supported'); - } + const isV5 = isV5TitleEscrow || isObligationEscrow; let transferEvents: TransferBaseEvent[] = []; @@ -233,12 +246,25 @@ export const fetchEndorsementChain = async ( transferEvents = mergeTransfersV4([...titleEscrowLogs, ...tokenLogs]); } else if (isV5) { + if (isObligationEscrow) { + return fetchObligationEndorsementChainEvents( + provider, + tokenRegistryAddress, + tokenId, + keyId, + titleEscrowAddress, + rpcOptions, + ); + } + const titleEscrowLogs = await fetchEscrowTransfersV5( provider, resolvedTitleEscrowAddress, tokenRegistryAddress, ); transferEvents = mergeTransfersV5(titleEscrowLogs); + } else { + throw new Error('Only Token Registry V4/V5 is supported'); } const endorsementChain = await getEndorsementChain(provider, transferEvents); @@ -250,3 +276,39 @@ export const fetchEndorsementChain = async ( remark: event?.remark?.slice(2) ? decrypt(event.remark.slice(2), keyId ?? '') : '', })); }; + +const fetchObligationEndorsementChainEvents = async ( + provider: Provider | ethersV6.Provider, + tokenRegistryAddress: string, + tokenId: string, + keyId?: string, + titleEscrowAddress?: string, + rpcOptions?: FetchEndorsementChainRpcOptions, +): Promise => { + const escrowAddress = + titleEscrowAddress ?? + (await getTitleEscrowAddress(tokenRegistryAddress, tokenId, provider, { + titleEscrowVersion: 'v5', + })); + const { transfers, statusEvents } = await fetchObligationEscrowTransfers( + provider, + tokenRegistryAddress, + tokenId, + escrowAddress, + rpcOptions, + ); + const merged = mergeObligationTransfers([...transfers, ...statusEvents]); + const chain = await getObligationEndorsementChain(provider, merged, rpcOptions); + if (!keyId) return chain as EndorsementChain; + return chain.map((event) => { + if (!event.remark || event.remark === '0x' || event.remark === '') { + return { ...event, remark: '' }; + } + try { + const remarkHex = event.remark.startsWith('0x') ? event.remark.slice(2) : event.remark; + return { ...event, remark: decrypt(remarkHex, keyId) }; + } catch { + return event; + } + }) as EndorsementChain; +}; diff --git a/src/core/obligation-endorsement-chain/fetchObligationEscrowTransfers.ts b/src/core/obligation-endorsement-chain/fetchObligationEscrowTransfers.ts new file mode 100644 index 0000000..7936886 --- /dev/null +++ b/src/core/obligation-endorsement-chain/fetchObligationEscrowTransfers.ts @@ -0,0 +1,289 @@ +import { ethers } from 'ethers'; +import { ethers as ethersV6 } from 'ethersV6'; +import { Provider } from '@ethersproject/abstract-provider'; +import { obligationRegistryContracts } from '../../obligation-registry'; +import { getEthersContractFromProvider } from '../../utils/ethers'; +import { findObligationMintBlock } from './findObligationMintBlock'; +import { + getLogsInBlockRange, + ObligationEndorsementChainRpcOptions, + resolveObligationEndorsementChainRpcOptions, +} from './helpers'; +import { + ObligationParsedLog, + ObligationStatusEvent, + ObligationStatusEventType, + ObligationTitleEscrowTransferEvent, + ObligationTokenTransferEvent, + ObligationTokenTransferEventType, + ObligationTransferBaseEvent, +} from './types'; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +type StatusLog = { + transactionHash: string; + blockNumber: number; + transactionIndex: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args?: any; +}; + +const STATUS_EVENT_TYPES: Record = { + StatusInitialized: 'STATUS_INITIALIZED', + StatusAccepted: 'STATUS_ACCEPTED', + StatusRejected: 'STATUS_REJECTED', + StatusDischarged: 'STATUS_DISCHARGED', +}; + +const mapStatusLog = (log: StatusLog, type: ObligationStatusEventType): ObligationStatusEvent => { + const args = log.args ?? {}; + const remark = + args.remark !== undefined && args.remark !== null ? String(args.remark) : undefined; + + if (type === 'STATUS_INITIALIZED') { + return { + type, + transactionHash: log.transactionHash, + blockNumber: log.blockNumber, + transactionIndex: log.transactionIndex, + remark: remark || '', + }; + } + + if (type === 'STATUS_DISCHARGED') { + return { + type, + transactionHash: log.transactionHash, + blockNumber: log.blockNumber, + transactionIndex: log.transactionIndex, + owner: args.beneficiary ?? '', + holder: args.beneficiary ?? '', + remark: remark || '', + }; + } + + return { + type, + transactionHash: log.transactionHash, + blockNumber: log.blockNumber, + transactionIndex: log.transactionIndex, + holder: args.holder ?? '', + owner: args.holder ?? '', + remark: remark || '', + }; +}; + +const getParsedLogs = ( + logs: ethers.providers.Log[] | ethersV6.Log[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + escrow: any, +): ObligationParsedLog[] => { + const parsed: ObligationParsedLog[] = []; + for (const log of logs) { + if (log.blockNumber === undefined || log.blockNumber === null) { + throw new Error('Block number not present'); + } + try { + const decoded = escrow.interface.parseLog(log); + if (!decoded) continue; + parsed.push({ + ...log, + ...decoded, + }); + } catch { + // Ignore logs that do not match ObligationEscrow ABI. + } + } + return parsed; +}; + +function identifyTokenReceivedType(event: ObligationParsedLog): ObligationTokenTransferEventType { + if (event.args.isMinting) { + return 'INITIAL'; + } + return 'RETURN_TO_ISSUER_REJECTED'; +} + +function tokenIdMatches(args: { tokenId?: unknown }, tokenId: string): boolean { + if (args.tokenId === undefined || args.tokenId === null) return true; + try { + return BigInt(String(args.tokenId)).toString() === BigInt(tokenId).toString(); + } catch { + return String(args.tokenId) === tokenId; + } +} + +function mapTransferEvent( + event: ObligationParsedLog, + titleEscrowAddress: string, + tokenRegistryAddress: string, +): ObligationTransferBaseEvent | undefined { + if (event?.name === 'HolderTransfer') { + return { + type: 'TRANSFER_HOLDER', + blockNumber: event.blockNumber, + holder: event.args.toHolder, + transactionHash: event.transactionHash, + transactionIndex: event.transactionIndex, + remark: event.args?.remark, + } as ObligationTitleEscrowTransferEvent; + } + if (event?.name === 'BeneficiaryTransfer') { + return { + type: 'TRANSFER_BENEFICIARY', + owner: event.args.toBeneficiary, + blockNumber: event.blockNumber, + transactionHash: event.transactionHash, + transactionIndex: event.transactionIndex, + remark: event.args?.remark, + } as ObligationTitleEscrowTransferEvent; + } + if (event?.name === 'TokenReceived') { + const type = identifyTokenReceivedType(event); + return { + type, + from: type === 'INITIAL' ? ZERO_ADDRESS : tokenRegistryAddress, + to: titleEscrowAddress, + blockNumber: event.blockNumber, + transactionHash: event.transactionHash, + transactionIndex: event.transactionIndex, + remark: event.args?.remark, + } as ObligationTokenTransferEvent; + } + if (event?.name === 'ReturnToIssuer') { + return { + type: 'RETURNED_TO_ISSUER', + blockNumber: event.blockNumber, + from: titleEscrowAddress, + to: tokenRegistryAddress, + transactionHash: event.transactionHash, + transactionIndex: event.transactionIndex, + remark: event.args?.remark, + } as ObligationTokenTransferEvent; + } + if (event?.name === 'RejectTransferOwners') { + return { + type: 'REJECT_TRANSFER_OWNERS', + blockNumber: event.blockNumber, + transactionHash: event.transactionHash, + transactionIndex: event.transactionIndex, + remark: event.args?.remark, + } as ObligationTitleEscrowTransferEvent; + } + if (event?.name === 'RejectTransferBeneficiary') { + return { + type: 'REJECT_TRANSFER_BENEFICIARY', + blockNumber: event.blockNumber, + transactionHash: event.transactionHash, + transactionIndex: event.transactionIndex, + remark: event.args?.remark, + } as ObligationTitleEscrowTransferEvent; + } + if (event?.name === 'RejectTransferHolder') { + return { + type: 'REJECT_TRANSFER_HOLDER', + blockNumber: event.blockNumber, + transactionHash: event.transactionHash, + transactionIndex: event.transactionIndex, + remark: event.args?.remark, + } as ObligationTitleEscrowTransferEvent; + } + if (event?.name === 'Shred') { + return { + type: 'RETURN_TO_ISSUER_ACCEPTED', + blockNumber: event.blockNumber, + from: tokenRegistryAddress, + to: '0x00000000000000000000000000000000000dead', + transactionHash: event.transactionHash, + transactionIndex: event.transactionIndex, + remark: event.args?.remark, + } as ObligationTokenTransferEvent; + } + return undefined; +} + +function classifyEscrowLogs( + parsedLogs: ObligationParsedLog[], + titleEscrowAddress: string, + tokenRegistryAddress: string, + tokenId: string, +): { transfers: ObligationTransferBaseEvent[]; statusEvents: ObligationStatusEvent[] } { + const transfers: ObligationTransferBaseEvent[] = []; + const statusEvents: ObligationStatusEvent[] = []; + + for (const event of parsedLogs) { + const statusType = STATUS_EVENT_TYPES[event.name]; + if (statusType) { + if (!tokenIdMatches(event.args as { tokenId?: unknown }, tokenId)) continue; + statusEvents.push( + mapStatusLog( + { + transactionHash: event.transactionHash, + blockNumber: event.blockNumber, + transactionIndex: event.transactionIndex, + args: event.args, + }, + statusType, + ), + ); + continue; + } + + const transfer = mapTransferEvent(event, titleEscrowAddress, tokenRegistryAddress); + if (transfer) transfers.push(transfer); + } + + return { transfers, statusEvents }; +} + +/** + * Obligation endorsement events: mint-block on the registry, then escrow logs in + * [mintBlock, latest] only (chunked only if Free-tier range limits apply). + * @param {Provider | ethersV6.Provider} provider - Ethereum JSON-RPC provider. + * @param {string} obligationRegistryAddress - Obligation registry contract address. + * @param {string} tokenId - Token ID to fetch events for. + * @param {string} titleEscrowAddress - Obligation escrow contract address. + * @param {ObligationEndorsementChainRpcOptions} [rpcOptions] - RPC chunking and concurrency options. + * @returns {Promise<{ transfers: ObligationTransferBaseEvent[]; statusEvents: ObligationStatusEvent[] }>} Parsed transfer and status events. + */ +export const fetchObligationEscrowTransfers = async ( + provider: Provider | ethersV6.Provider, + obligationRegistryAddress: string, + tokenId: string, + titleEscrowAddress: string, + rpcOptions?: ObligationEndorsementChainRpcOptions, +): Promise<{ + transfers: ObligationTransferBaseEvent[]; + statusEvents: ObligationStatusEvent[]; +}> => { + const { maxBlockRange, rpcConcurrency } = resolveObligationEndorsementChainRpcOptions(rpcOptions); + const mintBlock = await findObligationMintBlock( + provider, + obligationRegistryAddress, + tokenId, + titleEscrowAddress, + rpcOptions, + ); + const toBlock = await provider.getBlockNumber(); + + const Contract = getEthersContractFromProvider(provider); + const escrow = new Contract( + titleEscrowAddress, + obligationRegistryContracts.ObligationEscrow__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider as any, + ); + + const rawLogs = await getLogsInBlockRange( + provider, + { address: titleEscrowAddress }, + mintBlock, + toBlock, + maxBlockRange, + { rpcConcurrency }, + ); + const parsedLogs = getParsedLogs(rawLogs, escrow); + + return classifyEscrowLogs(parsedLogs, titleEscrowAddress, obligationRegistryAddress, tokenId); +}; diff --git a/src/core/obligation-endorsement-chain/findObligationMintBlock.ts b/src/core/obligation-endorsement-chain/findObligationMintBlock.ts new file mode 100644 index 0000000..deef679 --- /dev/null +++ b/src/core/obligation-endorsement-chain/findObligationMintBlock.ts @@ -0,0 +1,184 @@ +import { ethers as ethersV6 } from 'ethersV6'; +import { Provider } from '@ethersproject/abstract-provider'; +import { obligationRegistryContracts } from '../../obligation-registry'; +import { getEthersContractFromProvider } from '../../utils/ethers'; +import { + getLogsInBlockRange, + isEthGetLogsRangeError, + ObligationEndorsementChainRpcOptions, + resolveObligationEndorsementChainRpcOptions, +} from './helpers'; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +const normalizeTokenId = (tokenId: string): string | bigint => { + try { + return BigInt(tokenId); + } catch { + return tokenId; + } +}; + +/** + * Resolve filter topics for ethers v5 (sync `.topics`) and v6 (deferred filter). + * @param {unknown} filter - Event filter from an ethers v5 or v6 contract. + * @returns {Promise} Resolved topic array, if available. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const resolveFilterTopics = async (filter: any): Promise => { + if (typeof filter?.getTopicFilter === 'function') { + try { + return (await filter.getTopicFilter()) as unknown[] | undefined; + } catch { + return undefined; + } + } + if (filter?.topics) return filter.topics as unknown[]; + try { + const resolved = await filter; + return resolved?.topics as unknown[] | undefined; + } catch { + return undefined; + } +}; + +/** + * Topic-filtered mint Transfer on the obligation registry (from = 0x0, tokenId indexed). + * Starts from registry `genesis()` when available. Prefer escrow address scan when known + * (Alchemy Free-friendly). Falls back to registry Transfer, factory created, then + * StatusInitialized topics. + * @param {Provider | ethersV6.Provider} provider - Ethereum JSON-RPC provider. + * @param {string} obligationRegistryAddress - Obligation registry contract address. + * @param {string} tokenId - Token ID to locate the mint block for. + * @param {string} [titleEscrowAddress] - Known escrow address for address-only log scan. + * @param {ObligationEndorsementChainRpcOptions} [rpcOptions] - RPC chunking and concurrency options. + * @returns {Promise} Block number of the mint (or earliest genesis) event. + */ +export const findObligationMintBlock = async ( + provider: Provider | ethersV6.Provider, + obligationRegistryAddress: string, + tokenId: string, + titleEscrowAddress?: string, + rpcOptions?: ObligationEndorsementChainRpcOptions, +): Promise => { + const { maxBlockRange, rpcConcurrency } = resolveObligationEndorsementChainRpcOptions(rpcOptions); + const Contract = getEthersContractFromProvider(provider); + const token = new Contract( + obligationRegistryAddress, + obligationRegistryContracts.TrustVCToken__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider as any, + ); + + const latest = await provider.getBlockNumber(); + let genesisBlock = 0; + try { + genesisBlock = Number(await token.genesis()); + } catch { + genesisBlock = 0; + } + const fromBlock = Number.isFinite(genesisBlock) ? Math.max(0, genesisBlock) : 0; + const tokenIdValue = normalizeTokenId(tokenId); + + // Prefer escrow address scan when known — no topics, stops at mint/status-init. + if (titleEscrowAddress) { + const escrow = new Contract( + titleEscrowAddress, + obligationRegistryContracts.ObligationEscrow__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider as any, + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isGenesisChunk = (chunkLogs: any[]) => + chunkLogs.some((log) => { + try { + const decoded = escrow.interface.parseLog(log); + if (!decoded) return false; + if (decoded.name === 'StatusInitialized') return true; + if (decoded.name === 'TokenReceived' && decoded.args?.isMinting) return true; + return false; + } catch { + return false; + } + }); + + const escrowLogs = await getLogsInBlockRange( + provider, + { address: titleEscrowAddress }, + fromBlock, + latest, + maxBlockRange, + { shouldStop: isGenesisChunk, rpcConcurrency }, + ); + if (escrowLogs.length > 0) { + return Math.min(...escrowLogs.map((l: { blockNumber: number }) => l.blockNumber)); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mintFilter: any = token.filters.Transfer(ZERO_ADDRESS, null, tokenIdValue); + try { + const logs = await token.queryFilter(mintFilter, fromBlock, latest); + if (logs.length > 0) { + return Math.min(...logs.map((l: { blockNumber: number }) => l.blockNumber)); + } + } catch (err) { + if (!isEthGetLogsRangeError(err)) throw err; + const topics = await resolveFilterTopics(mintFilter); + const logs = await getLogsInBlockRange( + provider, + { + address: obligationRegistryAddress, + topics, + }, + fromBlock, + latest, + maxBlockRange, + { newestFirstUntilHit: true, rpcConcurrency }, + ); + if (logs.length > 0) { + return Math.min(...logs.map((l: { blockNumber: number }) => l.blockNumber)); + } + } + + // Fallback: factory ObligationEscrowCreated(..., tokenId) + const factoryAddress: string = await token.titleEscrowFactory(); + const factory = new Contract( + factoryAddress, + obligationRegistryContracts.ObligationEscrowFactory__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider as any, + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const createdFilter: any = factory.filters.ObligationEscrowCreated( + null, + obligationRegistryAddress, + tokenIdValue, + ); + + try { + const created = await factory.queryFilter(createdFilter, fromBlock, latest); + if (created.length > 0) { + return Math.min(...created.map((l: { blockNumber: number }) => l.blockNumber)); + } + } catch (err) { + if (!isEthGetLogsRangeError(err)) throw err; + const topics = await resolveFilterTopics(createdFilter); + const logs = await getLogsInBlockRange( + provider, + { + address: factoryAddress, + topics, + }, + fromBlock, + latest, + maxBlockRange, + { newestFirstUntilHit: true, rpcConcurrency }, + ); + if (logs.length > 0) { + return Math.min(...logs.map((l: { blockNumber: number }) => l.blockNumber)); + } + } + + throw new Error('Unminted Title Escrow'); +}; diff --git a/src/core/obligation-endorsement-chain/helpers.ts b/src/core/obligation-endorsement-chain/helpers.ts new file mode 100644 index 0000000..6a87f5d --- /dev/null +++ b/src/core/obligation-endorsement-chain/helpers.ts @@ -0,0 +1,278 @@ +import { ethers as ethersV6 } from 'ethersV6'; +import { Dictionary, groupBy } from 'lodash'; +import { Provider } from '@ethersproject/abstract-provider'; +import { ObligationTransferBaseEvent, ObligationTransferEventType } from './types'; + +/** Default max parallel RPC calls for obligation endorsement-chain log / block fetches. */ +export const ENDORSEMENT_CHAIN_RPC_CONCURRENCY = 3; + +/** Alchemy Free tier eth_getLogs max span. */ +export const DEFAULT_ETH_GETLOGS_MAX_BLOCK_RANGE = 10; + +/** + * Tunable RPC / eth_getLogs options for obligation endorsement history. + * Pass these from the app (e.g. Vite `.env` → options); SDK uses the constants above when omitted. + */ +export type ObligationEndorsementChainRpcOptions = { + /** Max blocks per eth_getLogs chunk (Alchemy Free = 10). */ + maxBlockRange?: number; + /** Max parallel RPC workers for chunked getLogs / getBlock. */ + rpcConcurrency?: number; +}; + +export const resolveObligationEndorsementChainRpcOptions = ( + options?: ObligationEndorsementChainRpcOptions, +): Required => ({ + maxBlockRange: options?.maxBlockRange ?? DEFAULT_ETH_GETLOGS_MAX_BLOCK_RANGE, + rpcConcurrency: options?.rpcConcurrency ?? ENDORSEMENT_CHAIN_RPC_CONCURRENCY, +}); + +/** + * Maps `items` through an async `fn` with at most `concurrency` in-flight promises. + * Preserves input order in the result. + * @param {unknown[]} items - Items to map. + * @param {Function} fn - Async mapper invoked per item. + * @param {number} [concurrency] - Max in-flight promises (defaults to endorsement-chain RPC concurrency). + * @returns {Promise} Mapped results in input order. + */ +export const mapWithConcurrency = async ( + items: readonly T[], + fn: (item: T, index: number) => Promise, + concurrency: number = ENDORSEMENT_CHAIN_RPC_CONCURRENCY, +): Promise => { + if (items.length === 0) return []; + const limit = Math.max(1, concurrency); + const results: R[] = new Array(items.length); + let nextIndex = 0; + + const worker = async () => { + while (true) { + const current = nextIndex; + nextIndex += 1; + if (current >= items.length) return; + results[current] = await fn(items[current], current); + } + }; + + const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker()); + await Promise.all(workers); + return results; +}; + +/** + * Dedupes eth_getBlock by block number within a single endorsement-chain fetch. + * @param {Provider | ethersV6.Provider} provider - Ethereum JSON-RPC provider. + * @returns {(blockNumber: number) => Promise} Cached block-timestamp fetcher. + */ +export const createFetchEventTimeCache = ( + provider: Provider | ethersV6.Provider, +): ((blockNumber: number) => Promise) => { + const cache = new Map>(); + return (blockNumber: number) => { + let pending = cache.get(blockNumber); + if (!pending) { + pending = fetchObligationEventTime(blockNumber, provider); + cache.set(blockNumber, pending); + } + return pending; + }; +}; + +export const fetchObligationEventTime = async ( + blockNumber: number, + provider: Provider | ethersV6.Provider, +): Promise => { + const msecToSec = 1000; + const eventTimestamp = (await provider.getBlock(blockNumber))!.timestamp * msecToSec; + return eventTimestamp; +}; + +export const isEthGetLogsRangeError = (err: unknown): boolean => { + // ethers v6 often surfaces only "server response 400 Bad Request" on `.message` + // while the Free-tier range text lives in `info.responseBody`. + const parts: string[] = []; + if (err && typeof err === 'object') { + const e = err as { + message?: string; + shortMessage?: string; + info?: { responseBody?: string }; + error?: { message?: string }; + data?: { message?: string }; + }; + if (e.message) parts.push(e.message); + if (e.shortMessage) parts.push(e.shortMessage); + if (e.info?.responseBody) parts.push(e.info.responseBody); + if (e.error?.message) parts.push(e.error.message); + if (e.data?.message) parts.push(e.data.message); + } else { + parts.push(String(err)); + } + return /block range|10 block|Free tier|query returned more than|eth_getLogs|-32600/i.test( + parts.join(' '), + ); +}; + +export type GetLogsFilter = { + address: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + topics?: any[]; +}; + +export type GetLogsInBlockRangeOptions = { + newestFirstUntilHit?: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + shouldStop?: (chunkLogs: any[]) => boolean; + /** Parallel workers when fetching all chunks (ignored for newestFirst / shouldStop scans). */ + rpcConcurrency?: number; +}; + +/** + * Fetches logs for a known [fromBlock, toBlock] span. + * Tries one eth_getLogs first; on Free-tier range errors, chunks by maxBlockRange. + * @param {Provider | ethersV6.Provider} provider - Ethereum JSON-RPC provider. + * @param {GetLogsFilter} filter - Address and optional topic filter. + * @param {number} fromBlock - Start block (inclusive). + * @param {number} toBlock - End block (inclusive). + * @param {number} [maxBlockRange] - Max blocks per eth_getLogs chunk. + * @param {GetLogsInBlockRangeOptions} [options] - Scan direction, early-stop, and concurrency options. + * @returns {Promise} Matching event logs across the block range. + */ +export const getLogsInBlockRange = async ( + provider: Provider | ethersV6.Provider, + filter: GetLogsFilter, + fromBlock: number, + toBlock: number, + maxBlockRange: number = DEFAULT_ETH_GETLOGS_MAX_BLOCK_RANGE, + options?: GetLogsInBlockRangeOptions, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise => { + if (toBlock < fromBlock) return []; + + const base = { + address: filter.address, + ...(filter.topics ? { topics: filter.topics } : {}), + }; + + try { + return await provider.getLogs({ + ...base, + fromBlock, + toBlock, + }); + } catch (err) { + if (!isEthGetLogsRangeError(err)) throw err; + } + + const range = Math.max(1, maxBlockRange); + const windows: { start: number; end: number }[] = []; + for (let start = fromBlock; start <= toBlock; start += range) { + windows.push({ start, end: Math.min(start + range - 1, toBlock) }); + } + + if (options?.newestFirstUntilHit || options?.shouldStop) { + for (let i = windows.length - 1; i >= 0; i -= 1) { + const { start, end } = windows[i]; + const chunk = await provider.getLogs({ + ...base, + fromBlock: start, + toBlock: end, + }); + if (options.shouldStop) { + if (options.shouldStop(chunk)) return chunk; + continue; + } + if (chunk.length > 0) return chunk; + } + return []; + } + + const concurrency = options?.rpcConcurrency ?? ENDORSEMENT_CHAIN_RPC_CONCURRENCY; + const chunks = await mapWithConcurrency( + windows, + async ({ start, end }) => + provider.getLogs({ + ...base, + fromBlock: start, + toBlock: end, + }), + concurrency, + ); + return chunks.flat(); +}; + +const getHolderOwner = ( + events: ObligationTransferBaseEvent[], +): { owner: string; holder: string } => { + let owner = ''; + let holder = ''; + for (const event of events) { + owner = event.owner || owner; + holder = event.holder || holder; + } + return { owner, holder }; +}; + +const identifyEventTypeFromLogs = ( + groupedEvents: ObligationTransferBaseEvent[], +): ObligationTransferEventType => { + for (const event of groupedEvents) { + if ( + [ + 'INITIAL', + 'RETURNED_TO_ISSUER', + 'RETURN_TO_ISSUER_ACCEPTED', + 'RETURN_TO_ISSUER_REJECTED', + ].includes(event.type) || + event.type.startsWith('REJECT_') || + event.type.startsWith('STATUS_') + ) { + return event.type; + } + } + + const isTransferHolder = groupedEvents.some((event) => event.type === 'TRANSFER_HOLDER'); + const isTransferBeneficiary = groupedEvents.some( + (event) => event.type === 'TRANSFER_BENEFICIARY', + ); + + if (isTransferHolder && isTransferBeneficiary) { + return 'TRANSFER_OWNERS'; + } else if (isTransferHolder) { + return 'TRANSFER_HOLDER'; + } else if (isTransferBeneficiary) { + return 'TRANSFER_BENEFICIARY'; + } + + throw new Error('Unable to identify event type'); +}; + +export const mergeObligationTransfers = ( + transferEvents: ObligationTransferBaseEvent[], +): ObligationTransferBaseEvent[] => { + const groupedEventsDict: Dictionary = groupBy( + transferEvents, + 'transactionHash', + ); + const transactionHashValues = Object.values(groupedEventsDict); + return transactionHashValues.flatMap((groupedEvents) => { + if (groupedEvents.length === 1) return groupedEvents; + if (groupedEvents.length > 1) { + const { owner, holder } = getHolderOwner(groupedEvents); + const type = identifyEventTypeFromLogs(groupedEvents); + const base = groupedEvents.find((event) => event.type === type) ?? groupedEvents[0]; + return [{ ...base, owner, holder, type }]; + } + throw new Error('Invalid hash, update your configuration'); + }); +}; + +export const sortObligationLogChain = ( + logChain: ObligationTransferBaseEvent[], +): ObligationTransferBaseEvent[] => { + return logChain.sort((a, b) => { + if (a.blockNumber !== b.blockNumber) { + return a.blockNumber - b.blockNumber; + } + return a.transactionIndex - b.transactionIndex; + }); +}; diff --git a/src/core/obligation-endorsement-chain/index.ts b/src/core/obligation-endorsement-chain/index.ts new file mode 100644 index 0000000..a0d83c1 --- /dev/null +++ b/src/core/obligation-endorsement-chain/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './helpers'; +export * from './findObligationMintBlock'; +export * from './fetchObligationEscrowTransfers'; +export * from './retrieveObligationEndorsementChain'; +export * from './useObligationEndorsementChain'; diff --git a/src/core/obligation-endorsement-chain/retrieveObligationEndorsementChain.ts b/src/core/obligation-endorsement-chain/retrieveObligationEndorsementChain.ts new file mode 100644 index 0000000..c529f3d --- /dev/null +++ b/src/core/obligation-endorsement-chain/retrieveObligationEndorsementChain.ts @@ -0,0 +1,82 @@ +import { ethers as ethersV6 } from 'ethersV6'; +import { Provider } from '@ethersproject/abstract-provider'; +import { + createFetchEventTimeCache, + mapWithConcurrency, + ObligationEndorsementChainRpcOptions, + resolveObligationEndorsementChainRpcOptions, + sortObligationLogChain, +} from './helpers'; +import { + ObligationEndorsementChain, + ObligationTransferBaseEvent, + ObligationTransferEvent, +} from './types'; + +/** + * Adds timestamps and carries previous beneficiary/holder across events, + * including STATUS_* lifecycle events (does not wipe owner/holder on status). + * @param {Provider | ethersV6.Provider} provider - Ethereum JSON-RPC provider. + * @param {ObligationTransferBaseEvent[]} logChain - Raw transfer and status events. + * @param {ObligationEndorsementChainRpcOptions} [rpcOptions] - RPC concurrency options for block fetches. + * @returns {Promise} Enriched endorsement chain with timestamps. + */ +export const getObligationEndorsementChain = async ( + provider: Provider | ethersV6.Provider, + logChain: ObligationTransferBaseEvent[], + rpcOptions?: ObligationEndorsementChainRpcOptions, +): Promise => { + const { rpcConcurrency } = resolveObligationEndorsementChainRpcOptions(rpcOptions); + const historyChain: ObligationEndorsementChain = []; + sortObligationLogChain(logChain); + let previousBeneficiary = ''; + let previousHolder = ''; + + const fetchTime = createFetchEventTimeCache(provider); + const timestamps = await mapWithConcurrency( + logChain, + (log) => fetchTime(log.blockNumber), + rpcConcurrency, + ); + + logChain.forEach((log, index) => { + const timestamp = timestamps[index]; + const transactionDetails = { + type: log.type, + transactionHash: log.transactionHash, + transactionIndex: log.transactionIndex, + blockNumber: log.blockNumber, + owner: log.owner || previousBeneficiary, + holder: log.holder || previousHolder, + timestamp, + remark: log?.remark || '', + } as ObligationTransferEvent; + + if ( + log.type === 'TRANSFER_OWNERS' || + log.type === 'TRANSFER_BENEFICIARY' || + log.type === 'TRANSFER_HOLDER' || + log.type === 'INITIAL' + ) { + historyChain.push(transactionDetails); + previousHolder = transactionDetails.holder; + previousBeneficiary = transactionDetails.owner; + } else if (log.type === 'RETURN_TO_ISSUER_ACCEPTED') { + previousHolder = ''; + previousBeneficiary = ''; + historyChain.push(transactionDetails); + } else if ( + log.type === 'RETURNED_TO_ISSUER' || + log.type === 'RETURN_TO_ISSUER_REJECTED' || + log.type.startsWith('STATUS_') || + log.type.startsWith('REJECT_') + ) { + historyChain.push(transactionDetails); + if (log.owner) previousBeneficiary = transactionDetails.owner; + if (log.holder) previousHolder = transactionDetails.holder; + } else { + historyChain.push(transactionDetails); + } + }); + return historyChain; +}; diff --git a/src/core/obligation-endorsement-chain/types.ts b/src/core/obligation-endorsement-chain/types.ts new file mode 100644 index 0000000..0ac19e7 --- /dev/null +++ b/src/core/obligation-endorsement-chain/types.ts @@ -0,0 +1,81 @@ +import type { Event } from 'ethers'; +import { EventFragment, Result } from 'ethers/lib/utils'; + +export type ObligationStatusEventType = + | 'STATUS_INITIALIZED' + | 'STATUS_ACCEPTED' + | 'STATUS_REJECTED' + | 'STATUS_DISCHARGED'; + +export type ObligationTokenTransferEventType = + | 'INITIAL' + | 'RETURNED_TO_ISSUER' + | 'RETURN_TO_ISSUER_REJECTED' + | 'RETURN_TO_ISSUER_ACCEPTED'; + +export type ObligationTitleEscrowTransferEventType = + | 'TRANSFER_BENEFICIARY' + | 'TRANSFER_HOLDER' + | 'TRANSFER_OWNERS' + | 'REJECT_TRANSFER_BENEFICIARY' + | 'REJECT_TRANSFER_HOLDER' + | 'REJECT_TRANSFER_OWNERS'; + +export type ObligationTransferEventType = + | ObligationTokenTransferEventType + | ObligationTitleEscrowTransferEventType + | ObligationStatusEventType; + +export interface ObligationTransferBaseEvent { + type: ObligationTransferEventType; + transactionIndex: number; + holder?: string; + owner?: string; + transactionHash: string; + blockNumber: number; + remark?: string; +} + +export interface ObligationStatusEvent extends ObligationTransferBaseEvent { + type: ObligationStatusEventType; +} + +export interface ObligationTitleEscrowTransferEvent extends ObligationTransferBaseEvent { + type: ObligationTitleEscrowTransferEventType; +} + +export interface ObligationTokenTransferEvent extends ObligationTransferBaseEvent { + type: ObligationTokenTransferEventType; + from: string; + to: string; +} + +export interface ObligationTransferEvent extends ObligationTransferBaseEvent { + timestamp: number; + holder: string; + owner: string; +} + +export type ObligationEndorsementChain = ObligationTransferEvent[]; + +export interface ObligationParsedLog { + eventFragment: EventFragment; + name: string; + signature: string; + topic: string; + args: Result; + blockNumber: number; + blockHash: string; + transactionIndex: number; + removed: boolean; + logIndex: number; + transactionHash: string; + address: string; + data: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface ObligationTypedEvent = any, TArgsObject = any> + extends Event { + args: TArgsArray & TArgsObject; +} diff --git a/src/core/obligation-endorsement-chain/useObligationEndorsementChain.ts b/src/core/obligation-endorsement-chain/useObligationEndorsementChain.ts new file mode 100644 index 0000000..b560da3 --- /dev/null +++ b/src/core/obligation-endorsement-chain/useObligationEndorsementChain.ts @@ -0,0 +1,69 @@ +import { ethers as ethersV6 } from 'ethersV6'; +import { Provider } from '@ethersproject/abstract-provider'; +import { decrypt } from '../decrypt'; +import { getTitleEscrowAddress } from '../endorsement-chain'; +import { fetchObligationEscrowTransfers } from './fetchObligationEscrowTransfers'; +import { mergeObligationTransfers, ObligationEndorsementChainRpcOptions } from './helpers'; +import { getObligationEndorsementChain } from './retrieveObligationEndorsementChain'; +import { ObligationEndorsementChain } from './types'; + +export type FetchObligationEndorsementChainOptions = ObligationEndorsementChainRpcOptions & { + keyId?: string; + titleEscrowAddress?: string; +}; + +/** + * Fetches the obligation (BOE) endorsement / activity chain for a minted title. + * Separate from classic `fetchEndorsementChain` — does not touch ETR V4/V5 paths. + * + * Override RPC defaults via `options.maxBlockRange` / `options.rpcConcurrency` + * (e.g. from your app `.env`). Defaults: 10-block chunks, concurrency 3. + * @param {string} obligationRegistryAddress - Obligation registry contract address. + * @param {string} tokenId - Token ID to fetch the chain for. + * @param {Provider | ethersV6.Provider} provider - Ethereum JSON-RPC provider. + * @param {FetchObligationEndorsementChainOptions} [options] - Escrow override, decryption key, and RPC options. + * @returns {Promise} Full endorsement chain, with remarks decrypted when `keyId` is set. + */ +export const fetchObligationEndorsementChain = async ( + obligationRegistryAddress: string, + tokenId: string, + provider: Provider | ethersV6.Provider, + options: FetchObligationEndorsementChainOptions = {}, +): Promise => { + const { keyId, titleEscrowAddress, maxBlockRange, rpcConcurrency } = options; + const rpcOptions: ObligationEndorsementChainRpcOptions = { + maxBlockRange, + rpcConcurrency, + }; + + const escrowAddress = + titleEscrowAddress || + (await getTitleEscrowAddress(obligationRegistryAddress, tokenId, provider, { + titleEscrowVersion: 'v5', + })); + + const { transfers, statusEvents } = await fetchObligationEscrowTransfers( + provider, + obligationRegistryAddress, + tokenId, + escrowAddress, + rpcOptions, + ); + + const merged = mergeObligationTransfers([...transfers, ...statusEvents]); + const chain = await getObligationEndorsementChain(provider, merged, rpcOptions); + + if (!keyId) return chain; + + return chain.map((event) => { + if (!event.remark || event.remark === '0x' || event.remark === '') { + return { ...event, remark: '' }; + } + try { + const remarkHex = event.remark.startsWith('0x') ? event.remark.slice(2) : event.remark; + return { ...event, remark: decrypt(remarkHex, keyId) }; + } catch { + return event; + } + }); +}; diff --git a/src/deploy/obligation-registry.ts b/src/deploy/obligation-registry.ts new file mode 100644 index 0000000..6adad5d --- /dev/null +++ b/src/deploy/obligation-registry.ts @@ -0,0 +1,132 @@ +import { GasValue } from '../token-registry-functions/types'; +import { getChainIdSafe, getTxOptions } from '../token-registry-functions/utils'; +import { obligationRegistryContracts } from '../obligation-registry'; +import { CHAIN_ID } from '../utils'; +import { getEthersContractFactoryFromProvider, isV6EthersProvider } from '../utils/ethers'; +import { + Signer as SignerV6, + ContractTransactionReceipt as ContractReceiptV6, + ContractFactory as ContractFactoryV6, +} from 'ethersV6'; +import { + Signer as SignerV5, + ContractReceipt as ContractReceiptV5, + ContractFactory as ContractFactoryV5, +} from 'ethers'; + +export type ObligationRegistryTransactionReceipt = ContractReceiptV5 | ContractReceiptV6; +type TransactionReceipt = ObligationRegistryTransactionReceipt; + +export interface ObligationRegistryDeployOptions { + chainId?: CHAIN_ID; + maxFeePerGas?: GasValue; + maxPriorityFeePerGas?: GasValue; +} + +export interface DeployObligationRegistryOptions extends ObligationRegistryDeployOptions { + /** Existing ObligationEscrowFactory; if omitted a fresh one is deployed first. */ + escrowFactoryAddress?: string; +} + +export interface DeployObligationEscrowFactoryResult { + receipt: TransactionReceipt; + obligationEscrowFactoryAddress: string; +} + +export interface DeployObligationRegistryResult { + receipt: TransactionReceipt; + obligationRegistry: string; + obligationEscrowFactoryAddress: string; +} + +const deployAndWait = async ( + factory: ContractFactoryV5 | ContractFactoryV6, + signer: SignerV5 | SignerV6, + args: unknown[], +): Promise<{ receipt: TransactionReceipt; contractAddress: string }> => { + if (isV6EthersProvider(signer.provider)) { + const contract = await (factory as ContractFactoryV6).deploy(...args); + const receipt = await contract.deploymentTransaction()?.wait(); + if (!receipt) throw new Error('Deployment receipt missing'); + return { receipt, contractAddress: await contract.getAddress() }; + } + const contract = await (factory as ContractFactoryV5).deploy(...args); + const receipt = await contract.deployTransaction.wait(); + return { receipt, contractAddress: contract.address }; +}; + +/** + * Deploys a standalone `ObligationEscrowFactory` (classic TitleEscrowFactory deploy step). + * @param {SignerV5 | SignerV6} signer - Signer that authorizes deployment. + * @param {ObligationRegistryDeployOptions} [options] - Chain ID and gas options. + * @returns {Promise} Deployment receipt and factory address. + */ +export const deployObligationEscrowFactory = async ( + signer: SignerV5 | SignerV6, + options: ObligationRegistryDeployOptions = {}, +): Promise => { + if (!signer.provider) throw new Error('Provider is required'); + const { chainId, maxFeePerGas, maxPriorityFeePerGas } = options; + const resolvedChainId = (chainId ?? (await getChainIdSafe(signer))) as CHAIN_ID; + const txOptions = await getTxOptions(signer, resolvedChainId, maxFeePerGas, maxPriorityFeePerGas); + const ContractFactory = getEthersContractFactoryFromProvider(signer.provider); + const factoryDeployer = new ContractFactory( + obligationRegistryContracts.ObligationEscrowFactory__factory.abi, + obligationRegistryContracts.ObligationEscrowFactory__factory.bytecode, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer as any, + ); + const { receipt, contractAddress } = await deployAndWait(factoryDeployer, signer, [txOptions]); + return { receipt, obligationEscrowFactoryAddress: contractAddress }; +}; + +/** + * Deploys `TrustVCToken(name, symbol, escrowFactory)` like classic `TradeTrustToken`. + * Reuses an existing factory when `options.escrowFactoryAddress` is supplied. + * @param {string} registryName - Human-readable registry name. + * @param {string} registrySymbol - Token symbol for the registry. + * @param {SignerV5 | SignerV6} signer - Signer that authorizes deployment. + * @param {DeployObligationRegistryOptions} [options] - Factory reuse, chain ID, and gas options. + * @returns {Promise} Deployment receipt and contract addresses. + */ +export const deployObligationRegistry = async ( + registryName: string, + registrySymbol: string, + signer: SignerV5 | SignerV6, + options: DeployObligationRegistryOptions = {}, +): Promise => { + if (!signer.provider) throw new Error('Provider is required'); + const { chainId, maxFeePerGas, maxPriorityFeePerGas } = options; + let { escrowFactoryAddress } = options; + if (!escrowFactoryAddress) { + console.warn('No escrowFactoryAddress supplied — deploying a fresh ObligationEscrowFactory.'); + ({ obligationEscrowFactoryAddress: escrowFactoryAddress } = await deployObligationEscrowFactory( + signer, + { + chainId, + maxFeePerGas, + maxPriorityFeePerGas, + }, + )); + } + const resolvedChainId = (chainId ?? (await getChainIdSafe(signer))) as CHAIN_ID; + const txOptions = await getTxOptions(signer, resolvedChainId, maxFeePerGas, maxPriorityFeePerGas); + const ContractFactory = getEthersContractFactoryFromProvider(signer.provider); + const tokenDeployer = new ContractFactory( + obligationRegistryContracts.TrustVCToken__factory.abi, + obligationRegistryContracts.TrustVCToken__factory.bytecode, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer as any, + ); + const { receipt, contractAddress } = await deployAndWait(tokenDeployer, signer, [ + registryName, + registrySymbol, + escrowFactoryAddress, + txOptions, + ]); + return { + receipt, + obligationRegistry: contractAddress, + obligationEscrowFactoryAddress: escrowFactoryAddress, + }; +}; diff --git a/src/index.ts b/src/index.ts index 8a66ac0..4490b4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,8 @@ import { cancelTransaction } from './transaction'; export type { TypedContractMethod } from './token-registry-v5/typedContractMethod'; export type { CancelTransactionSigner } from './transaction'; export * from './token-registry-functions'; +export * from './obligation-registry'; +export * from './obligation-registry-functions'; export * from './core'; export * from './open-attestation'; export * from './verify'; diff --git a/src/obligation-registry-functions/index.ts b/src/obligation-registry-functions/index.ts new file mode 100644 index 0000000..b18c13d --- /dev/null +++ b/src/obligation-registry-functions/index.ts @@ -0,0 +1,9 @@ +export * from './mint'; +export * from './lifecycle'; +export * from './status'; +export * from './transfer'; +export * from './rejectTransfers'; +export * from './returnToken'; +export * from './verify'; +export * from './types'; +export * from '../deploy/obligation-registry'; diff --git a/src/obligation-registry-functions/lifecycle.ts b/src/obligation-registry-functions/lifecycle.ts new file mode 100644 index 0000000..f62e12a --- /dev/null +++ b/src/obligation-registry-functions/lifecycle.ts @@ -0,0 +1,50 @@ +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; +import { + ACCEPT, + AcceptObligationRegistryParams, + DISCHARGE, + DischargeObligationRegistryParams, + ObligationRegistryContractOptions, + ObligationRegistryTransactionOptions, + ObligationStatusActionName, + REJECT, + RejectObligationRegistryParams, +} from './types'; +import { + callStaticThenSend, + connectObligationEscrow, + encryptRemarks, + resolveObligationEscrowAddress, +} from './utils'; + +type EscrowRemarksParams = + | AcceptObligationRegistryParams + | RejectObligationRegistryParams + | DischargeObligationRegistryParams; + +const createEscrowRemarksAction = + (method: ObligationStatusActionName) => + async ( + contractOptions: ObligationRegistryContractOptions, + signer: Signer | SignerV6, + params: EscrowRemarksParams, + options: ObligationRegistryTransactionOptions = {}, + ): Promise => { + const { tokenId, remarks } = params; + const escrowAddress = await resolveObligationEscrowAddress( + { obligationRegistry: contractOptions.obligationRegistry, tokenId }, + signer, + ); + return callStaticThenSend( + connectObligationEscrow(escrowAddress, signer), + method, + [encryptRemarks(remarks, options.id)], + signer, + options, + ); + }; + +export const acceptObligationRegistry = createEscrowRemarksAction(ACCEPT); +export const rejectObligationRegistry = createEscrowRemarksAction(REJECT); +export const dischargeObligationRegistry = createEscrowRemarksAction(DISCHARGE); diff --git a/src/obligation-registry-functions/mint.ts b/src/obligation-registry-functions/mint.ts new file mode 100644 index 0000000..eab643e --- /dev/null +++ b/src/obligation-registry-functions/mint.ts @@ -0,0 +1,27 @@ +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; +import { + MintObligationRegistryParams, + ObligationRegistryContractOptions, + ObligationRegistryTransactionOptions, +} from './types'; +import { callStaticThenSend, connectTrustVCToken, encryptRemarks } from './utils'; + +export const mintObligationRegistry = async ( + contractOptions: ObligationRegistryContractOptions, + signer: Signer | SignerV6, + params: MintObligationRegistryParams, + options: ObligationRegistryTransactionOptions = {}, +): Promise => { + const { obligationRegistry } = contractOptions; + if (!obligationRegistry) throw new Error('Obligation registry address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { beneficiaryAddress, holderAddress, tokenId, remarks } = params; + return callStaticThenSend( + connectTrustVCToken(obligationRegistry, signer), + 'mint', + [beneficiaryAddress, holderAddress, tokenId, encryptRemarks(remarks, options.id)], + signer, + options, + ); +}; diff --git a/src/obligation-registry-functions/rejectTransfers.ts b/src/obligation-registry-functions/rejectTransfers.ts new file mode 100644 index 0000000..0f19b75 --- /dev/null +++ b/src/obligation-registry-functions/rejectTransfers.ts @@ -0,0 +1,38 @@ +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; +import { + ObligationEscrowContractOptions, + ObligationRegistryTransactionOptions, + ObligationRejectTransferParams, +} from './types'; +import { + callStaticThenSend, + connectObligationEscrow, + encryptRemarks, + resolveObligationEscrowAddress, +} from './utils'; + +const rejectTransfer = + (method: 'rejectTransferHolder' | 'rejectTransferBeneficiary' | 'rejectTransferOwners') => + async ( + contractOptions: ObligationEscrowContractOptions, + signer: Signer | SignerV6, + params: ObligationRejectTransferParams, + options: ObligationRegistryTransactionOptions = {}, + ): Promise => { + if (!signer.provider) throw new Error('Provider is required'); + const escrowAddress = await resolveObligationEscrowAddress(contractOptions, signer); + return callStaticThenSend( + connectObligationEscrow(escrowAddress, signer), + method, + [encryptRemarks(params.remarks, options.id)], + signer, + options, + ); + }; + +export const rejectTransferHolderObligationRegistry = rejectTransfer('rejectTransferHolder'); +export const rejectTransferBeneficiaryObligationRegistry = rejectTransfer( + 'rejectTransferBeneficiary', +); +export const rejectTransferOwnersObligationRegistry = rejectTransfer('rejectTransferOwners'); diff --git a/src/obligation-registry-functions/returnToken.ts b/src/obligation-registry-functions/returnToken.ts new file mode 100644 index 0000000..6fdd95e --- /dev/null +++ b/src/obligation-registry-functions/returnToken.ts @@ -0,0 +1,75 @@ +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; +import { + ObligationAcceptReturnedOptions, + ObligationAcceptReturnedParams, + ObligationEscrowContractOptions, + ObligationRegistryTransactionOptions, + ObligationRejectReturnedOptions, + ObligationRejectReturnedParams, + ObligationReturnToIssuerParams, +} from './types'; +import { + callStaticThenSend, + connectObligationEscrow, + connectTrustVCToken, + encryptRemarks, + resolveObligationEscrowAddress, +} from './utils'; + +export const returnToIssuerObligationRegistry = async ( + contractOptions: ObligationEscrowContractOptions, + signer: Signer | SignerV6, + params: ObligationReturnToIssuerParams, + options: ObligationRegistryTransactionOptions = {}, +): Promise => { + if (!signer.provider) throw new Error('Provider is required'); + const escrowAddress = await resolveObligationEscrowAddress(contractOptions, signer); + return callStaticThenSend( + connectObligationEscrow(escrowAddress, signer), + 'returnToIssuer', + [encryptRemarks(params.remarks, options.id)], + signer, + options, + ); +}; + +export const acceptReturnedObligationRegistry = async ( + contractOptions: ObligationAcceptReturnedOptions, + signer: Signer | SignerV6, + params: ObligationAcceptReturnedParams, + options: ObligationRegistryTransactionOptions = {}, +): Promise => { + const { obligationRegistry } = contractOptions; + if (!obligationRegistry) throw new Error('Obligation registry address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { tokenId, remarks } = params; + return callStaticThenSend( + connectTrustVCToken(obligationRegistry, signer), + 'burn', + [tokenId, encryptRemarks(remarks, options.id)], + signer, + options, + 'acceptReturned', + ); +}; + +export const rejectReturnedObligationRegistry = async ( + contractOptions: ObligationRejectReturnedOptions, + signer: Signer | SignerV6, + params: ObligationRejectReturnedParams, + options: ObligationRegistryTransactionOptions = {}, +): Promise => { + const { obligationRegistry } = contractOptions; + if (!obligationRegistry) throw new Error('Obligation registry address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { tokenId, remarks } = params; + return callStaticThenSend( + connectTrustVCToken(obligationRegistry, signer), + 'restore', + [tokenId, encryptRemarks(remarks, options.id)], + signer, + options, + 'rejectReturned', + ); +}; diff --git a/src/obligation-registry-functions/status.ts b/src/obligation-registry-functions/status.ts new file mode 100644 index 0000000..6d83438 --- /dev/null +++ b/src/obligation-registry-functions/status.ts @@ -0,0 +1,68 @@ +import { Signer as SignerV6 } from 'ethersV6'; +import { Signer } from 'ethers'; +import { + DocumentStatus, + ObligationEscrowTerminationReason, + ObligationRegistryContractOptions, + ObligationRegistryReadOptions, + ObligationRegistryReadParams, +} from './types'; +import { connectObligationEscrow, getObligationEscrowAddress } from './utils'; + +const getEscrowForRead = async ( + contractOptions: ObligationRegistryContractOptions, + signer: Signer | SignerV6, + params: ObligationRegistryReadParams, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise => { + const { obligationRegistry } = contractOptions; + const { tokenId } = params; + if (!obligationRegistry) throw new Error('Obligation registry address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const escrowAddress = await getObligationEscrowAddress( + obligationRegistry, + tokenId, + signer.provider, + ); + return connectObligationEscrow(escrowAddress, signer); +}; + +export const getObligationRegistryStatus = async ( + contractOptions: ObligationRegistryContractOptions, + signer: Signer | SignerV6, + params: ObligationRegistryReadParams, + options: ObligationRegistryReadOptions = {}, +): Promise => { + const obligationEscrowContract = await getEscrowForRead(contractOptions, signer, params); + const status = + options.blockTag !== undefined + ? await obligationEscrowContract.status({ blockTag: options.blockTag }) + : await obligationEscrowContract.status(); + return Number(status) as DocumentStatus; +}; + +export const isObligationRegistryRegistered = async ( + contractOptions: ObligationRegistryContractOptions, + signer: Signer | SignerV6, + params: ObligationRegistryReadParams, + options: ObligationRegistryReadOptions = {}, +): Promise => { + const obligationEscrowContract = await getEscrowForRead(contractOptions, signer, params); + return options.blockTag !== undefined + ? await obligationEscrowContract.isRegistered({ blockTag: options.blockTag }) + : await obligationEscrowContract.isRegistered(); +}; + +export const getObligationEscrowTerminationReason = async ( + contractOptions: ObligationRegistryContractOptions, + signer: Signer | SignerV6, + params: ObligationRegistryReadParams, + options: ObligationRegistryReadOptions = {}, +): Promise => { + const obligationEscrowContract = await getEscrowForRead(contractOptions, signer, params); + const reason = + options.blockTag !== undefined + ? await obligationEscrowContract.terminationReason({ blockTag: options.blockTag }) + : await obligationEscrowContract.terminationReason(); + return Number(reason) as ObligationEscrowTerminationReason; +}; diff --git a/src/obligation-registry-functions/transfer.ts b/src/obligation-registry-functions/transfer.ts new file mode 100644 index 0000000..d93f938 --- /dev/null +++ b/src/obligation-registry-functions/transfer.ts @@ -0,0 +1,89 @@ +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; +import { + ObligationEscrowContractOptions, + ObligationNominateParams, + ObligationRegistryTransactionOptions, + ObligationTransferBeneficiaryParams, + ObligationTransferHolderParams, + ObligationTransferOwnersParams, +} from './types'; +import { + callStaticThenSend, + connectObligationEscrow, + encryptRemarks, + resolveObligationEscrowAddress, +} from './utils'; + +const withEscrowContract = async ( + contractOptions: ObligationEscrowContractOptions, + signer: Signer | SignerV6, +) => { + if (!signer.provider) throw new Error('Provider is required'); + const escrowAddress = await resolveObligationEscrowAddress(contractOptions, signer); + return connectObligationEscrow(escrowAddress, signer); +}; + +export const nominateObligationRegistry = async ( + contractOptions: ObligationEscrowContractOptions, + signer: Signer | SignerV6, + params: ObligationNominateParams, + options: ObligationRegistryTransactionOptions = {}, +): Promise => { + const { newBeneficiaryAddress, remarks } = params; + return callStaticThenSend( + await withEscrowContract(contractOptions, signer), + 'nominate', + [newBeneficiaryAddress, encryptRemarks(remarks, options.id)], + signer, + options, + ); +}; + +export const transferBeneficiaryObligationRegistry = async ( + contractOptions: ObligationEscrowContractOptions, + signer: Signer | SignerV6, + params: ObligationTransferBeneficiaryParams, + options: ObligationRegistryTransactionOptions = {}, +): Promise => { + const { newBeneficiaryAddress, remarks } = params; + return callStaticThenSend( + await withEscrowContract(contractOptions, signer), + 'transferBeneficiary', + [newBeneficiaryAddress, encryptRemarks(remarks, options.id)], + signer, + options, + ); +}; + +export const transferHolderObligationRegistry = async ( + contractOptions: ObligationEscrowContractOptions, + signer: Signer | SignerV6, + params: ObligationTransferHolderParams, + options: ObligationRegistryTransactionOptions = {}, +): Promise => { + const { holderAddress, remarks } = params; + return callStaticThenSend( + await withEscrowContract(contractOptions, signer), + 'transferHolder', + [holderAddress, encryptRemarks(remarks, options.id)], + signer, + options, + ); +}; + +export const transferOwnersObligationRegistry = async ( + contractOptions: ObligationEscrowContractOptions, + signer: Signer | SignerV6, + params: ObligationTransferOwnersParams, + options: ObligationRegistryTransactionOptions = {}, +): Promise => { + const { newBeneficiaryAddress, newHolderAddress, remarks } = params; + return callStaticThenSend( + await withEscrowContract(contractOptions, signer), + 'transferOwners', + [newBeneficiaryAddress, newHolderAddress, encryptRemarks(remarks, options.id)], + signer, + options, + ); +}; diff --git a/src/obligation-registry-functions/types.ts b/src/obligation-registry-functions/types.ts new file mode 100644 index 0000000..135350a --- /dev/null +++ b/src/obligation-registry-functions/types.ts @@ -0,0 +1,143 @@ +import { CHAIN_ID } from '../utils/supportedChains'; +import { GasValue } from '../token-registry-functions/types'; + +/** + * Mirrors contracts/interfaces/IObligationEscrow.sol's `enum Status` 1:1. + */ +export enum DocumentStatus { + Issued = 0, + Accepted = 1, + Rejected = 2, + Discharged = 3, +} + +/** Escrow lifecycle write methods that advance document status. */ +export const ACCEPT = 'accept' as const; +export const REJECT = 'reject' as const; +export const DISCHARGE = 'discharge' as const; + +/** + * Status-advancing escrow methods for end users (maps to `DocumentStatus` Accepted / Rejected / Discharged). + */ +export const ObligationStatusAction = { + ACCEPT, + REJECT, + DISCHARGE, +} as const; + +export type ObligationStatusActionName = + (typeof ObligationStatusAction)[keyof typeof ObligationStatusAction]; + +/** + * Mirrors contracts/interfaces/IObligationEscrow.sol's `enum TerminationReason` 1:1. + */ +export enum ObligationEscrowTerminationReason { + None = 0, + ReturnToIssuer = 1, + Rejected = 2, + Discharged = 3, +} + +export interface ObligationRegistryContractOptions { + obligationRegistry: string; +} + +export interface ObligationRegistryTransactionOptions { + chainId?: CHAIN_ID; + maxFeePerGas?: GasValue; + maxPriorityFeePerGas?: GasValue; + id?: string; +} + +export interface ObligationRegistryReadParams { + tokenId: string | number; +} + +export interface ObligationRegistryReadOptions { + blockTag?: number; +} + +export interface MintObligationRegistryParams { + beneficiaryAddress: string; + holderAddress: string; + tokenId: string | number; + remarks?: string; +} + +export interface AcceptObligationRegistryParams { + tokenId: string | number; + remarks?: string; +} + +export interface RejectObligationRegistryParams { + tokenId: string | number; + remarks?: string; +} + +export interface DischargeObligationRegistryParams { + tokenId: string | number; + remarks?: string; +} + +/** + * Escrow-resolution options for the endorsement surface (nominate, transfer, rejectTransfer, returnToIssuer). + * Either pass the escrow address directly, or the registry address + tokenId so it can be resolved. + */ +export type ObligationEscrowContractOptions = + | { + titleEscrowAddress: string; + tokenId?: string | number; + obligationRegistry?: string; + } + | { + titleEscrowAddress?: undefined; + tokenId: string | number; + obligationRegistry: string; + }; + +export interface ObligationNominateParams { + newBeneficiaryAddress: string; + remarks?: string; +} + +export interface ObligationTransferBeneficiaryParams { + newBeneficiaryAddress: string; + remarks?: string; +} + +export interface ObligationTransferHolderParams { + holderAddress: string; + remarks?: string; +} + +export interface ObligationTransferOwnersParams { + newBeneficiaryAddress: string; + newHolderAddress: string; + remarks?: string; +} + +export interface ObligationRejectTransferParams { + remarks?: string; +} + +export interface ObligationReturnToIssuerParams { + remarks?: string; +} + +export interface ObligationAcceptReturnedOptions { + obligationRegistry: string; +} + +export interface ObligationRejectReturnedOptions { + obligationRegistry: string; +} + +export interface ObligationAcceptReturnedParams { + tokenId: string | number; + remarks?: string; +} + +export interface ObligationRejectReturnedParams { + tokenId: string | number; + remarks?: string; +} diff --git a/src/obligation-registry-functions/utils.ts b/src/obligation-registry-functions/utils.ts new file mode 100644 index 0000000..3853895 --- /dev/null +++ b/src/obligation-registry-functions/utils.ts @@ -0,0 +1,112 @@ +import { Provider } from '@ethersproject/abstract-provider'; +import { ContractTransaction, Signer } from 'ethers'; +import { Provider as ProviderV6, Signer as SignerV6 } from 'ethersV6'; +import { encrypt, getTitleEscrowAddress } from '../core'; +import { obligationRegistryContracts } from '../obligation-registry'; +import { + getChainIdSafe, + getSignerAddressSafe, + getTxOptions, +} from '../token-registry-functions/utils'; +import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; +import { ObligationEscrowContractOptions, ObligationRegistryTransactionOptions } from './types'; + +export { getChainIdSafe, getSignerAddressSafe, getTxOptions }; + +/** + * Resolves the ObligationEscrow address for a given (obligationRegistry, tokenId) pair. + * Delegates to the generic, contract-family-agnostic `getTitleEscrowAddress` core primitive with + * `titleEscrowVersion` fixed to 'v5', since ObligationRegistry is a v5-only feature. + * @param {string} obligationRegistry - Obligation registry contract address. + * @param {string | number} tokenId - Token ID whose escrow address to resolve. + * @param {Provider | ProviderV6} provider - Ethereum JSON-RPC provider. + * @returns {Promise} Obligation escrow contract address. + */ +export const getObligationEscrowAddress = async ( + obligationRegistry: string, + tokenId: string | number, + provider: Provider | ProviderV6, +): Promise => { + return getTitleEscrowAddress(obligationRegistry, String(tokenId), provider, { + titleEscrowVersion: 'v5', + }); +}; + +export const encryptRemarks = (remarks?: string, id?: string): string => + remarks ? `0x${encrypt(remarks, id ?? '')}` : '0x'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const connectObligationEscrow = (escrowAddress: string, signer: Signer | SignerV6): any => { + if (!signer.provider) throw new Error('Provider is required'); + const Contract = getEthersContractFromProvider(signer.provider); + return new Contract( + escrowAddress, + obligationRegistryContracts.ObligationEscrow__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer as any, + ); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const connectTrustVCToken = (registryAddress: string, signer: Signer | SignerV6): any => { + if (!signer.provider) throw new Error('Provider is required'); + const Contract = getEthersContractFromProvider(signer.provider); + return new Contract( + registryAddress, + obligationRegistryContracts.TrustVCToken__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer as any, + ); +}; + +export const resolveObligationEscrowAddress = async ( + contractOptions: ObligationEscrowContractOptions, + signer: Signer | SignerV6, +): Promise => { + if (contractOptions.titleEscrowAddress) return contractOptions.titleEscrowAddress; + const { obligationRegistry, tokenId } = contractOptions; + if (!obligationRegistry) throw new Error('Obligation registry address is required'); + if (tokenId === undefined || tokenId === null || tokenId === '') { + throw new Error('Token ID is required'); + } + if (!signer.provider) throw new Error('Provider is required'); + return getObligationEscrowAddress(obligationRegistry, tokenId, signer.provider); +}; + +/** + * Runs callStatic (v5/v6) then the live write with gas options. + * @param {any} contract - Connected ethers contract. + * @param {string} method - Contract method name. + * @param {unknown[]} args - Method args excluding overrides. + * @param {Signer | SignerV6} signer - Transaction signer. + * @param {ObligationRegistryTransactionOptions} options - Gas / encryption options. + * @param {string} [precheckName] - Name used in the pre-check error message. + * @returns {Promise} Submitted transaction. + */ +export const callStaticThenSend = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + contract: any, + method: string, + args: unknown[], + signer: Signer | SignerV6, + options: ObligationRegistryTransactionOptions = {}, + precheckName: string = method, +): Promise => { + if (!signer.provider) throw new Error('Provider is required'); + const { chainId, maxFeePerGas, maxPriorityFeePerGas } = options; + try { + const isV6 = isV6EthersProvider(signer.provider); + if (isV6) { + await contract[method].staticCall(...args); + } else { + await contract.callStatic[method](...args); + } + } catch (e: unknown) { + console.error('callStatic failed:', e); + const err = e as { reason?: string; message?: string }; + const reason = err?.reason || err?.message || String(e); + throw new Error(`Pre-check (callStatic) for ${precheckName} failed: ${reason}`); + } + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + return await contract[method](...args, txOptions); +}; diff --git a/src/obligation-registry-functions/verify.ts b/src/obligation-registry-functions/verify.ts new file mode 100644 index 0000000..38953b1 --- /dev/null +++ b/src/obligation-registry-functions/verify.ts @@ -0,0 +1,60 @@ +import { verifyDocument } from '../core/verify'; +import { isValid, VerificationFragment } from '../verify'; +import { OBLIGATION_RECORDS_NAME } from '../verify/fragments'; +import { ObligationRecordsResultFragment } from '../verify/fragments/document-status/obligationRecords/obligationRecordVerifier.types'; +import { DocumentStatus, ObligationEscrowTerminationReason } from './types'; +import { ethers } from 'ethers'; + +export interface VerifyObligationDocumentOptions { + rpcProviderUrl?: string; + provider?: ethers.providers.Provider; +} + +export interface VerifyObligationDocumentResult { + valid: boolean; + fragments: VerificationFragment[]; +} + +/** + * Verifies a signed BoE document via the unified `verifyDocument` pipeline. + * @param {unknown} document - Signed W3C VC with `credentialStatus.obligationRegistry`. + * @param {VerifyObligationDocumentOptions} [options] - RPC provider URL or ethers provider for on-chain checks. + * @returns {Promise} Overall validity and verification fragments. + */ +export const verifyObligationDocument = async ( + document: unknown, + options: VerifyObligationDocumentOptions = {}, +): Promise => { + const fragments = await verifyDocument(document as never, options); + return { valid: isValid(fragments), fragments }; +}; + +export interface ObligationDocumentStatus { + obligationRegistry: string; + status: DocumentStatus; + terminationReason: ObligationEscrowTerminationReason; +} + +/** + * Extracts the enriched ObligationRecords status from a `verifyDocument` result. + * @param {VerificationFragment[]} fragments - Fragments returned by `verifyDocument`. + * @returns {ObligationDocumentStatus | null} On-chain obligation status when ObligationRecords is VALID; otherwise null. + */ +export const getObligationDocumentStatus = ( + fragments: VerificationFragment[], +): ObligationDocumentStatus | null => { + const fragment = fragments.find((f) => f.name === OBLIGATION_RECORDS_NAME) as + | ObligationRecordsResultFragment + | undefined; + + if (!fragment || fragment.status !== 'VALID') return null; + + const { obligationRegistry, status, terminationReason } = fragment.data; + if (status === undefined || terminationReason === undefined) return null; + + return { + obligationRegistry, + status: status as DocumentStatus, + terminationReason: terminationReason as ObligationEscrowTerminationReason, + }; +}; diff --git a/src/obligation-registry/contracts.ts b/src/obligation-registry/contracts.ts new file mode 100644 index 0000000..bd077c1 --- /dev/null +++ b/src/obligation-registry/contracts.ts @@ -0,0 +1,10 @@ +export { + ObligationEscrowFactory__factory, + ObligationEscrow__factory, + TrustVCToken__factory, +} from '@tradetrust-tt/token-registry-v5/contracts'; +export type { + ObligationEscrow, + ObligationEscrowFactory, + TrustVCToken, +} from '@tradetrust-tt/token-registry-v5/contracts'; diff --git a/src/obligation-registry/index.ts b/src/obligation-registry/index.ts new file mode 100644 index 0000000..8888a59 --- /dev/null +++ b/src/obligation-registry/index.ts @@ -0,0 +1,5 @@ +export { roleHash as obligationRegistryRoleHash } from './roleHash'; +export { supportInterfaceIds as obligationRegistrySupportInterfaceIds } from './supportInterfaceIds'; +import * as contracts from './contracts'; +export { contracts as obligationRegistryContracts }; +export { getEventFromReceipt as obligationRegistryGetEventFromReceipt } from './utils'; diff --git a/src/obligation-registry/roleHash.ts b/src/obligation-registry/roleHash.ts new file mode 100644 index 0000000..06e00e5 --- /dev/null +++ b/src/obligation-registry/roleHash.ts @@ -0,0 +1,5 @@ +import { constants } from '@tradetrust-tt/token-registry-v5'; + +const roleHash = constants.roleHash; + +export { roleHash }; diff --git a/src/obligation-registry/supportInterfaceIds.ts b/src/obligation-registry/supportInterfaceIds.ts new file mode 100644 index 0000000..48902e6 --- /dev/null +++ b/src/obligation-registry/supportInterfaceIds.ts @@ -0,0 +1,5 @@ +import { constants } from '@tradetrust-tt/token-registry-v5'; + +const supportInterfaceIds = constants.contractInterfaceId; + +export { supportInterfaceIds }; diff --git a/src/obligation-registry/utils.ts b/src/obligation-registry/utils.ts new file mode 100644 index 0000000..63c03f5 --- /dev/null +++ b/src/obligation-registry/utils.ts @@ -0,0 +1,5 @@ +import { utils } from '@tradetrust-tt/token-registry-v5'; + +const { getEventFromReceipt } = utils; + +export { getEventFromReceipt }; diff --git a/src/utils/documents/index.ts b/src/utils/documents/index.ts index 3ffb72f..cc62bbb 100644 --- a/src/utils/documents/index.ts +++ b/src/utils/documents/index.ts @@ -31,7 +31,8 @@ export const isTransferableRecord = ( let isTransferableAssetVal: boolean = false; if (isSignedDocument(document)) { const credentialStatus = getTransferableRecordsCredentialStatus(document); - isTransferableAssetVal = credentialStatus?.type === TRANSFERABLE_RECORDS_TYPE; + isTransferableAssetVal = + credentialStatus?.type === TRANSFERABLE_RECORDS_TYPE && !credentialStatus?.obligationRegistry; } else { isTransferableAssetVal = isTransferableAsset(document); } @@ -39,6 +40,17 @@ export const isTransferableRecord = ( return isTransferableAssetVal; }; +export const isObligationRecord = ( + document: WrappedOrSignedOpenAttestationDocument | SignedVerifiableCredential, +): boolean => { + if (!isSignedDocument(document)) return false; + const credentialStatus = getTransferableRecordsCredentialStatus(document); + return ( + credentialStatus?.type === TRANSFERABLE_RECORDS_TYPE && + typeof credentialStatus?.obligationRegistry === 'string' + ); +}; + export const getTokenRegistryAddress = ( document: WrappedOrSignedOpenAttestationDocument | SignedVerifiableCredential, ): string | undefined => { @@ -52,6 +64,14 @@ export const getTokenRegistryAddress = ( return issuerAddress instanceof Array ? issuerAddress[0] : issuerAddress; }; +export const getObligationRegistryAddress = ( + document: WrappedOrSignedOpenAttestationDocument | SignedVerifiableCredential, +): string | undefined => { + if (!isSignedDocument(document)) return undefined; + const credentialStatus = getTransferableRecordsCredentialStatus(document); + return credentialStatus?.obligationRegistry; +}; + export const getTokenId = ( document: WrappedOrSignedOpenAttestationDocument | SignedVerifiableCredential, ): string => { diff --git a/src/verify-obligation/fragments/document-integrity/bbs2023W3CSignatureIntegrity.ts b/src/verify-obligation/fragments/document-integrity/bbs2023W3CSignatureIntegrity.ts new file mode 100644 index 0000000..e3fbcaa --- /dev/null +++ b/src/verify-obligation/fragments/document-integrity/bbs2023W3CSignatureIntegrity.ts @@ -0,0 +1,9 @@ +import { VerificationFragment, Verifier } from '@tradetrust-tt/tt-verify'; +import { createW3CSignatureIntegrityVerifier } from './w3cModernSignatureIntegrityFactory'; + +export const bbs2023W3CSignatureIntegrity: Verifier = + createW3CSignatureIntegrityVerifier({ + cryptosuite: 'bbs-2023', + name: 'Bbs2023W3CSignatureIntegrity', + derivationPaths: [], + }); diff --git a/src/verify-obligation/fragments/document-integrity/ecdsaW3CSignatureIntegrity.ts b/src/verify-obligation/fragments/document-integrity/ecdsaW3CSignatureIntegrity.ts new file mode 100644 index 0000000..afcd2f1 --- /dev/null +++ b/src/verify-obligation/fragments/document-integrity/ecdsaW3CSignatureIntegrity.ts @@ -0,0 +1,9 @@ +import { VerificationFragment, Verifier } from '@tradetrust-tt/tt-verify'; +import { createW3CSignatureIntegrityVerifier } from './w3cModernSignatureIntegrityFactory'; + +export const ecdsaW3CSignatureIntegrity: Verifier = + createW3CSignatureIntegrityVerifier({ + cryptosuite: 'ecdsa-sd-2023', + name: 'EcdsaW3CSignatureIntegrity', + derivationPaths: [], + }); diff --git a/src/verify-obligation/fragments/document-integrity/w3cModernSignatureIntegrityFactory.ts b/src/verify-obligation/fragments/document-integrity/w3cModernSignatureIntegrityFactory.ts new file mode 100644 index 0000000..249ddb1 --- /dev/null +++ b/src/verify-obligation/fragments/document-integrity/w3cModernSignatureIntegrityFactory.ts @@ -0,0 +1,108 @@ +import { VerificationFragment, Verifier, VerifierOptions } from '@tradetrust-tt/tt-verify'; +import { SignedVerifiableCredential, deriveCredential } from '@trustvc/w3c-vc'; +import { verifyW3CSignature } from '../../../w3c/verify'; + +const PROOF_TYPE = 'DataIntegrityProof' as const; +const DERIVE_CREDENTIAL_ERROR = 'Use deriveCredential() first' as const; + +export interface CryptosuiteConfig { + /** The cryptosuite identifier (e.g., 'bbs-2023', 'ecdsa-sd-2023') */ + cryptosuite: string; + /** Display name for the verifier (e.g., 'Bbs2023W3CSignatureIntegrity') */ + name: string; + /** Optional array of JSON paths to derive from the credential */ + derivationPaths?: string[]; +} + +function isSignedVerifiableCredential(document: unknown): document is SignedVerifiableCredential { + return typeof document === 'object' && document !== null && 'proof' in document; +} + +export function createW3CSignatureIntegrityVerifier( + config: CryptosuiteConfig, +): Verifier { + const { cryptosuite, name, derivationPaths = [] } = config; + + return { + skip: async () => { + return { + type: 'DOCUMENT_INTEGRITY', + name, + reason: { + code: 0, + codeString: 'SKIPPED', + message: `Document either has no proof or proof type is not '${PROOF_TYPE}' or proof cryptosuite is not '${cryptosuite}'.`, + }, + status: 'SKIPPED', + }; + }, + + test: (document: unknown) => { + const doc = document as SignedVerifiableCredential; + return doc.proof?.type === PROOF_TYPE && doc.proof?.cryptosuite === cryptosuite; + }, + + verify: async (document: unknown, verifierOptions: VerifierOptions) => { + if (!isSignedVerifiableCredential(document)) { + return { + type: 'DOCUMENT_INTEGRITY', + name, + data: false, + reason: { + message: 'Document is not a valid SignedVerifiableCredential', + }, + status: 'INVALID', + }; + } + + try { + let verificationResult = await verifyW3CSignature(document, verifierOptions); + let isDerived = true; + + // Handle derivation if needed + if ( + !verificationResult.verified && + verificationResult.error?.includes(DERIVE_CREDENTIAL_ERROR) + ) { + const derivedCredential = await deriveCredential(document, derivationPaths); + verificationResult = await verifyW3CSignature(derivedCredential.derived, verifierOptions); + isDerived = false; + } + + if (verificationResult.verified) { + return { + type: 'DOCUMENT_INTEGRITY', + name, + data: true, + reason: { + message: isDerived + ? 'Document verified successfully' + : 'Document verified after derivation', + }, + status: 'VALID', + }; + } else { + return { + type: 'DOCUMENT_INTEGRITY', + name, + data: false, + reason: { + message: verificationResult.error || 'Verification failed', + }, + status: 'INVALID', + }; + } + } catch (error) { + return { + type: 'DOCUMENT_INTEGRITY', + name, + data: false, + reason: { + message: error instanceof Error ? error.message : 'Unknown verification error', + }, + status: 'INVALID', + }; + } + }, + }; +} diff --git a/src/verify-obligation/fragments/document-integrity/w3cSignatureIntegrity.ts b/src/verify-obligation/fragments/document-integrity/w3cSignatureIntegrity.ts new file mode 100644 index 0000000..077dbcf --- /dev/null +++ b/src/verify-obligation/fragments/document-integrity/w3cSignatureIntegrity.ts @@ -0,0 +1,48 @@ +import { VerificationFragment, Verifier, VerifierOptions } from '@tradetrust-tt/tt-verify'; +import { SignedVerifiableCredential } from '@trustvc/w3c-vc'; +import { verifyW3CSignature } from '../../../w3c/verify'; + +export const w3cSignatureIntegrity: Verifier = { + skip: async () => { + return { + type: 'DOCUMENT_INTEGRITY', + name: 'W3CSignatureIntegrity', + reason: { + code: 0, + codeString: 'SKIPPED', + message: `Document either has no proof or proof.type is not 'BbsBlsSignature2020'.`, + }, + status: 'SKIPPED', + }; + }, + + test: (document: unknown) => { + const doc = document as SignedVerifiableCredential; + return ( + doc.proof?.type === 'BbsBlsSignature2020' || doc.proof?.type === 'BbsBlsSignatureProof2020' + ); + }, + + verify: async (document: unknown, verifierOptions: VerifierOptions) => { + const doc = document as SignedVerifiableCredential; + const verificationResult = await verifyW3CSignature(doc, verifierOptions); + if (verificationResult.verified) { + return { + type: 'DOCUMENT_INTEGRITY', + name: 'W3CSignatureIntegrity', + data: true, + status: 'VALID', + }; + } else { + return { + type: 'DOCUMENT_INTEGRITY', + name: 'W3CSignatureIntegrity', + data: false, + reason: { + message: verificationResult.error, + }, + status: 'INVALID', + }; + } + }, +}; diff --git a/src/verify-obligation/fragments/document-status/obligationRecords/obligationRecordVerifier.ts b/src/verify-obligation/fragments/document-status/obligationRecords/obligationRecordVerifier.ts new file mode 100644 index 0000000..8f4f04e --- /dev/null +++ b/src/verify-obligation/fragments/document-status/obligationRecords/obligationRecordVerifier.ts @@ -0,0 +1,218 @@ +import { DocumentsToVerify, VerifierOptions } from '@tradetrust-tt/tt-verify'; +import * as w3cVC from '@trustvc/w3c-vc'; +import { SignedVerifiableCredential } from '@trustvc/w3c-vc'; +import { + ObligationRecordsCodedError, + ObligationRecordsErrorFragment, + ObligationRecordsResultFragment, + ObligationRecordsStatusCode, + ObligationRecordsVerificationFragment, + ObligationRecordsVerifierType, + isValidObligationRegistryStatus, +} from './obligationRecordVerifier.types'; +import { getObligationEscrowEnrichment, isTokenMintedOnObligationRegistry } from './utils'; + +/** Shared credentialStatus type string for classic ETR and BoE obligation records. */ +export const TRANSFERABLE_RECORDS_TYPE = 'TransferableRecords'; +export const OBLIGATION_RECORDS_NAME = 'ObligationRecords'; +const type = 'DOCUMENT_STATUS'; +const name = OBLIGATION_RECORDS_NAME; + +type ObligationCredentialStatus = { + type?: string; + tokenId?: string; + obligationRegistry?: string; + tokenRegistry?: string; + tokenNetwork?: { chainId?: number; name?: string }; +}; + +const verify: ObligationRecordsVerifierType['verify'] = async ( + document: DocumentsToVerify | SignedVerifiableCredential, + options: VerifierOptions, +) => { + const signedDocument = document as SignedVerifiableCredential; + const credentialStatuses = ( + Array.isArray(signedDocument?.credentialStatus) + ? signedDocument?.credentialStatus + : [signedDocument?.credentialStatus] + ) as ObligationCredentialStatus[]; + if (credentialStatuses.length === 0) { + throw new ObligationRecordsCodedError( + "Document's credentialStatus is empty", + ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT], + ); + } + const { provider } = options; + + const verificationResult = await Promise.all( + credentialStatuses.map(async (credentialStatus) => { + if (!credentialStatus?.tokenId) { + throw new ObligationRecordsCodedError( + "Document's credentialStatus does not have tokenId", + ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT], + ); + } + const tokenId = '0x' + credentialStatus.tokenId; + const obligationRegistry = credentialStatus?.obligationRegistry; + if (!obligationRegistry) { + throw new ObligationRecordsCodedError( + "Document's credentialStatus does not have obligationRegistry", + ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT], + ); + } + if (credentialStatus?.tokenRegistry) { + throw new ObligationRecordsCodedError( + "Document's credentialStatus must not include both tokenRegistry and obligationRegistry", + ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT], + ); + } + if (!credentialStatus?.tokenNetwork || !credentialStatus?.tokenNetwork?.chainId) { + throw new ObligationRecordsCodedError( + "Document's credentialStatus does not have tokenNetwork.chainId", + ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT], + ); + } + const mintStatus = await isTokenMintedOnObligationRegistry({ + obligationRegistry, + tokenId, + provider, + chainId: credentialStatus.tokenNetwork.chainId, + }); + if (!isValidObligationRegistryStatus(mintStatus)) { + return { + ...mintStatus, + obligationRegistry, + }; + } + const enrichment = await getObligationEscrowEnrichment({ + obligationRegistry, + tokenId, + provider, + }); + return { + minted: true as const, + address: obligationRegistry, + obligationRegistry, + status: enrichment.status, + terminationReason: enrichment.terminationReason, + }; + }), + ); + + const first = verificationResult[0]; + const result: ObligationRecordsResultFragment = { + name, + type, + status: 'INVALID', + data: { + obligationRegistry: credentialStatuses?.[0]?.obligationRegistry as string, + }, + }; + + const isEnrichedValid = ( + entry: (typeof verificationResult)[number], + ): entry is { + minted: true; + address: string; + obligationRegistry: string; + status: number; + terminationReason: number; + } => + Boolean(entry) && + entry.minted === true && + typeof (entry as { status?: number }).status === 'number' && + typeof (entry as { terminationReason?: number }).terminationReason === 'number' && + typeof (entry as { obligationRegistry?: string }).obligationRegistry === 'string'; + + if (verificationResult.every(isEnrichedValid)) { + result.status = 'VALID'; + result.data = { + obligationRegistry: first.obligationRegistry as string, + status: (first as { status: number }).status, + terminationReason: (first as { terminationReason: number }).terminationReason, + }; + } else { + const invalidEntry = verificationResult.find((entry) => !isEnrichedValid(entry)); + result.reason = ( + invalidEntry as { reason?: ObligationRecordsResultFragment['reason'] } + )?.reason; + } + return result; +}; + +const skip: ObligationRecordsVerifierType['skip'] = async () => { + return { + status: 'SKIPPED', + type, + name, + reason: { + code: ObligationRecordsStatusCode.SKIPPED, + codeString: ObligationRecordsStatusCode[ObligationRecordsStatusCode.SKIPPED], + message: + 'Document is not an Obligation Records (BoE) document — classic ETR / other status skipped', + }, + }; +}; + +const test: ObligationRecordsVerifierType['test'] = ( + document: DocumentsToVerify | SignedVerifiableCredential, +): boolean => { + const doc = document as SignedVerifiableCredential; + const credentialStatuses = Array.isArray(doc?.credentialStatus) + ? doc?.credentialStatus + : [doc?.credentialStatus]; + if ( + w3cVC.isSignedDocument(document) && + credentialStatuses.length > 0 && + credentialStatuses.every((cs) => { + const status = cs as ObligationCredentialStatus; + // BoE / obligation only. Classic ETR (no obligationRegistry) → skip. + // Docs with both registries still enter verify() for mutual-exclusivity ERROR. + return ( + status?.type === TRANSFERABLE_RECORDS_TYPE && typeof status?.obligationRegistry === 'string' + ); + }) + ) { + return true; + } + return false; +}; + +export const credentialStatusObligationRecordVerifier: ObligationRecordsVerifierType = { + skip, + test, + verify: async (...args): Promise => { + try { + return await verify(...args); + } catch (e: unknown) { + if (e instanceof ObligationRecordsCodedError) { + const err: ObligationRecordsErrorFragment = { + name, + type, + status: 'ERROR', + reason: { + code: e.code, + codeString: e.codeString, + message: e.message, + }, + }; + return err; + } + return { + name, + type, + status: 'ERROR', + reason: { + code: ObligationRecordsStatusCode.UNEXPECTED_ERROR, + codeString: ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNEXPECTED_ERROR], + message: e instanceof Error ? e.message : 'An unexpected error occurred', + }, + }; + } + }, +}; diff --git a/src/verify-obligation/fragments/document-status/obligationRecords/obligationRecordVerifier.types.ts b/src/verify-obligation/fragments/document-status/obligationRecords/obligationRecordVerifier.types.ts new file mode 100644 index 0000000..9953ed0 --- /dev/null +++ b/src/verify-obligation/fragments/document-status/obligationRecords/obligationRecordVerifier.types.ts @@ -0,0 +1,71 @@ +import { + ErrorVerificationFragment, + VerificationFragment, + Verifier, +} from '@tradetrust-tt/tt-verify'; + +export enum ObligationRecordsStatusCode { + SKIPPED = 0, + DOCUMENT_NOT_MINTED = 1, + UNEXPECTED_ERROR = 4, + UNRECOGNIZED_DOCUMENT = 9, + SERVER_ERROR = 500, +} + +export type ObligationRecordsErrorReason = { + code: ObligationRecordsStatusCode; + codeString: string; + message: string; +}; + +export type ObligationRecordsResultFragment = VerificationFragment & { + status: 'VALID' | 'INVALID'; + data: { + obligationRegistry: string; + status?: number; + terminationReason?: number; + }; + reason?: ObligationRecordsErrorReason; +}; + +export type ObligationRecordsErrorFragment = Omit, 'data'> & { + data?: never; + reason: ObligationRecordsErrorReason; +}; + +export type ObligationRecordsVerificationFragment = + | ObligationRecordsResultFragment + | ObligationRecordsErrorFragment; + +export type ObligationRecordsVerifierType = Verifier; + +export type ValidObligationRegistryStatus = { + minted: true; + address: string; +}; + +export type InvalidObligationRegistryStatus = { + minted: false; + address: string; + reason: ObligationRecordsErrorReason; +}; + +export type ObligationRegistryMintStatus = + | ValidObligationRegistryStatus + | InvalidObligationRegistryStatus; + +export const isValidObligationRegistryStatus = ( + status: ObligationRegistryMintStatus, +): status is ValidObligationRegistryStatus => status.minted === true; + +export class ObligationRecordsCodedError extends Error { + code: ObligationRecordsStatusCode; + codeString: string; + + constructor(message: string, code: ObligationRecordsStatusCode, codeString: string) { + super(message); + this.name = 'ObligationRecordsCodedError'; + this.code = code; + this.codeString = codeString; + } +} diff --git a/src/verify-obligation/fragments/document-status/obligationRecords/utils.ts b/src/verify-obligation/fragments/document-status/obligationRecords/utils.ts new file mode 100644 index 0000000..69f90be --- /dev/null +++ b/src/verify-obligation/fragments/document-status/obligationRecords/utils.ts @@ -0,0 +1,155 @@ +import { TradeTrustToken__factory } from '@tradetrust-tt/token-registry-v4/contracts'; +import { errors, constants, Contract, providers } from 'ethers'; +import { Provider as ProviderV6 } from 'ethersV6'; +import { obligationRegistryContracts } from '../../../../obligation-registry'; +import { getTitleEscrowAddress } from '../../../../core/endorsement-chain'; +import { isV6EthersProvider } from '../../../../utils/ethers'; +import { + ObligationRecordsCodedError, + ObligationRecordsStatusCode, + ObligationRegistryMintStatus, +} from './obligationRecordVerifier.types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const isNonExistentToken = (error: any): boolean => { + const hasNonExistentSelector = Boolean(error.data && error.data.slice(0, 10) === '0x7e273289'); + const hasNonExistentMessage = + typeof error.message === 'string' && + error.message.includes('owner query for nonexistent token'); + return hasNonExistentSelector || hasNonExistentMessage; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const isMissingObligationRegistry = (error: any): boolean => { + return ( + !error.reason && + error.method?.toLowerCase() === 'ownerOf(uint256)'.toLowerCase() && + error.code === errors.CALL_EXCEPTION + ); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const decodeObligationRegistryError = (error: any): string => { + const reason = + error.reason && Array.isArray(error.reason) ? error.reason[0] : (error.reason ?? ''); + switch (true) { + case isNonExistentToken(error): + return `Document has not been issued under obligation registry`; + case isMissingObligationRegistry(error): + return `Obligation registry is not found`; + case reason.toLowerCase() === 'ENS name not configured'.toLowerCase() && + error.code === errors.UNSUPPORTED_OPERATION: + return 'ENS name is not configured'; + case reason.toLowerCase() === 'invalid address'.toLowerCase() && + error.code === errors.INVALID_ARGUMENT: + return `Invalid obligation registry address`; + case error.code === errors.INVALID_ARGUMENT: + return `Invalid contract arguments`; + case error.code === errors.SERVER_ERROR: + case error.code === errors.NETWORK_ERROR: + throw new ObligationRecordsCodedError( + 'Unable to connect to the network, please try again later', + ObligationRecordsStatusCode.SERVER_ERROR, + ObligationRecordsStatusCode[ObligationRecordsStatusCode.SERVER_ERROR], + ); + default: + throw error; + } +}; + +const normalizeChainId = (chainId: number | string): number => + typeof chainId === 'string' ? Number.parseInt(chainId, 10) : chainId; + +const getProviderChainId = async (provider: providers.Provider | ProviderV6): Promise => { + if (isV6EthersProvider(provider)) { + const network = await (provider as ProviderV6).getNetwork(); + return Number(network.chainId); + } + const network = await (provider as providers.Provider).getNetwork(); + return network.chainId; +}; + +export const isTokenMintedOnObligationRegistry = async ({ + obligationRegistry, + tokenId, + provider, + chainId, +}: { + obligationRegistry: string; + tokenId: string; + provider: providers.Provider | ProviderV6; + chainId: number | string; +}): Promise => { + try { + const providerChainId = await getProviderChainId(provider); + const declaredChainId = normalizeChainId(chainId); + if (providerChainId !== declaredChainId) { + return { + minted: false, + address: obligationRegistry, + reason: { + code: ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + codeString: + ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT], + message: `Provider network chain ID (${providerChainId}) does not match credential's declared chain ID (${declaredChainId})`, + }, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const registryContract = TradeTrustToken__factory.connect(obligationRegistry, provider as any); + const minted = await registryContract + .ownerOf(tokenId) + .then((owner) => owner !== constants.AddressZero); + return minted + ? { minted, address: obligationRegistry } + : { + minted, + address: obligationRegistry, + reason: { + code: ObligationRecordsStatusCode.DOCUMENT_NOT_MINTED, + codeString: + ObligationRecordsStatusCode[ObligationRecordsStatusCode.DOCUMENT_NOT_MINTED], + message: `Document ${tokenId} has not been issued under contract ${obligationRegistry}`, + }, + }; + } catch (error) { + return { + minted: false, + address: obligationRegistry, + reason: { + message: decodeObligationRegistryError(error), + code: ObligationRecordsStatusCode.DOCUMENT_NOT_MINTED, + codeString: ObligationRecordsStatusCode[ObligationRecordsStatusCode.DOCUMENT_NOT_MINTED], + }, + }; + } +}; + +export const getObligationEscrowEnrichment = async ({ + obligationRegistry, + tokenId, + provider, +}: { + obligationRegistry: string; + tokenId: string; + provider: providers.Provider | ProviderV6; +}): Promise<{ status: number; terminationReason: number }> => { + const escrowAddress = await getTitleEscrowAddress(obligationRegistry, tokenId, provider, { + titleEscrowVersion: 'v5', + }); + const escrow = new Contract( + escrowAddress, + obligationRegistryContracts.ObligationEscrow__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider as any, + ); + const [status, terminationReason] = await Promise.all([ + escrow.status(), + escrow.terminationReason(), + ]); + return { + status: Number(status), + terminationReason: Number(terminationReason), + }; +}; diff --git a/src/verify-obligation/fragments/document-status/w3cCredentialStatus.ts b/src/verify-obligation/fragments/document-status/w3cCredentialStatus.ts new file mode 100644 index 0000000..104f517 --- /dev/null +++ b/src/verify-obligation/fragments/document-status/w3cCredentialStatus.ts @@ -0,0 +1,118 @@ +import { VerificationFragment, Verifier, VerifierOptions } from '@tradetrust-tt/tt-verify'; +import { + BitstringStatusListCredentialStatus, + CredentialStatusType, +} from '@trustvc/w3c-credential-status'; +import { + CredentialStatus, + isSignedDocument, + verifyCredentialStatus, + SignedVerifiableCredential, +} from '@trustvc/w3c-vc'; +//w3cCredentialStatus enums +export enum W3CCredentialStatusCode { + SKIPPED = 0, + DOCUMENT_NOT_ISSUED = 1, + DOCUMENT_REVOKED = 11, + DOCUMENT_SUSPENDED = 12, + DOCUMENT_REVOKED_AND_SUSPENDED = 101, + STATUS_LIST_NOT_FOUND = 404, + INVALID_VALIDATION_METHOD = 8, + UNRECOGNIZED_DOCUMENT = 9, + SERVER_ERROR = 500, + UNEXPECTED_ERROR = 4, + INVALID_ARGUMENT = 6, + INVALID_ISSUERS = 7, +} +export const w3cCredentialStatus: Verifier = { + skip: async () => { + return { + type: 'DOCUMENT_STATUS', + name: 'W3CCredentialStatus', + reason: { + code: W3CCredentialStatusCode.SKIPPED, + codeString: 'SKIPPED', + message: `Document does not have a valid credentialStatus or type.`, + }, + status: 'SKIPPED', + }; + }, + + test: (document: unknown) => { + const doc = document as SignedVerifiableCredential; + const credentialStatuses = Array.isArray(doc?.credentialStatus) + ? doc?.credentialStatus + : [doc?.credentialStatus]; + + const test = (credentialStatus: CredentialStatus) => + ['BitstringStatusListEntry', 'StatusList2021Entry'].includes(credentialStatus?.type); + + if (isSignedDocument(document) && credentialStatuses.every(test)) { + return true; + } else { + return false; + } + }, + + verify: async (document: unknown, verifierOptions: VerifierOptions) => { + const doc = document as SignedVerifiableCredential; + const credentialStatuses = ( + Array.isArray(doc.credentialStatus) ? doc.credentialStatus : [doc.credentialStatus] + ) as BitstringStatusListCredentialStatus[]; + + const verificationResult = await Promise.all( + credentialStatuses.map((cs) => + verifyCredentialStatus(cs, cs?.type as CredentialStatusType, verifierOptions), + ), + ); + const purposes = verificationResult.map((item) => item.purpose); + const hasRevocation = purposes.includes('revocation'); + const hasSuspension = purposes.includes('suspension'); + const hasRevocationAndSuspension = hasRevocation && hasSuspension; + + if (verificationResult.some((r) => r.error)) { + return { + type: 'DOCUMENT_STATUS', + name: 'W3CCredentialStatus', + reason: { + code: W3CCredentialStatusCode.UNEXPECTED_ERROR, + codeString: 'ERROR', + message: verificationResult.map((r) => r.error).join(', '), + }, + data: verificationResult, + status: 'ERROR', + }; + } else if (verificationResult.every((r) => r.status === false)) { + return { + type: 'DOCUMENT_STATUS', + name: 'W3CCredentialStatus', + data: verificationResult, + status: 'VALID', + }; + } else { + return { + type: 'DOCUMENT_STATUS', + name: 'W3CCredentialStatus', + data: verificationResult, + status: 'INVALID', + reason: { + code: hasRevocationAndSuspension + ? W3CCredentialStatusCode.DOCUMENT_REVOKED_AND_SUSPENDED + : hasRevocation + ? W3CCredentialStatusCode.DOCUMENT_REVOKED + : W3CCredentialStatusCode.DOCUMENT_SUSPENDED, + codeString: hasRevocationAndSuspension + ? 'REVOKED_AND_SUSPENDED' + : hasRevocation + ? 'REVOKED' + : 'SUSPENDED', + message: hasRevocationAndSuspension + ? 'Document has been revoked and suspended.' + : hasRevocation + ? 'Document has been revoked.' + : 'Document has been suspended.', + }, + }; + } + }, +}; diff --git a/src/verify-obligation/fragments/document-status/w3cEmptyCredentialStatus/index.ts b/src/verify-obligation/fragments/document-status/w3cEmptyCredentialStatus/index.ts new file mode 100644 index 0000000..619869f --- /dev/null +++ b/src/verify-obligation/fragments/document-status/w3cEmptyCredentialStatus/index.ts @@ -0,0 +1,53 @@ +import { VerificationFragment, Verifier, VerifierOptions } from '@tradetrust-tt/tt-verify'; +import { isSignedDocument, SignedVerifiableCredential } from '@trustvc/w3c-vc'; + +const type = 'DOCUMENT_STATUS'; +const name = 'W3CEmptyCredentialStatus'; + +export const w3cEmptyCredentialStatus: Verifier = { + skip: async () => { + return { + type, + name, + reason: { + code: 0, + codeString: 'SKIPPED', + message: `Document contains a credentialStatus.`, + }, + status: 'SKIPPED', + }; + }, + + test: (document: unknown) => { + const doc = document as SignedVerifiableCredential; + return ( + !!doc.credentialStatus === false || + (Array.isArray(doc.credentialStatus) && doc.credentialStatus.length === 0) || + Object.keys(doc.credentialStatus)?.length === 0 + ); + }, + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + verify: async (document: unknown, verifierOptions: VerifierOptions) => { + const doc = document as SignedVerifiableCredential; + const verificationResult = isSignedDocument(doc); + if (verificationResult) { + return { + type, + name, + data: true, + status: 'VALID', + }; + } else { + return { + type, + name, + data: false, + reason: { + message: 'Document is not a valid SignedVerifiableCredential', + }, + status: 'INVALID', + }; + } + }, +}; diff --git a/src/verify-obligation/fragments/index.ts b/src/verify-obligation/fragments/index.ts new file mode 100644 index 0000000..3642650 --- /dev/null +++ b/src/verify-obligation/fragments/index.ts @@ -0,0 +1,23 @@ +import { w3cSignatureIntegrity } from './document-integrity/w3cSignatureIntegrity'; +import { ecdsaW3CSignatureIntegrity } from './document-integrity/ecdsaW3CSignatureIntegrity'; +import { bbs2023W3CSignatureIntegrity } from './document-integrity/bbs2023W3CSignatureIntegrity'; +import { + credentialStatusObligationRecordVerifier, + OBLIGATION_RECORDS_NAME, + TRANSFERABLE_RECORDS_TYPE, +} from './document-status/obligationRecords/obligationRecordVerifier'; +import { w3cCredentialStatus } from './document-status/w3cCredentialStatus'; +import { w3cEmptyCredentialStatus } from './document-status/w3cEmptyCredentialStatus'; +import { w3cIssuerIdentity } from './issuer-identity/w3cIssuerIdentity'; + +export { + TRANSFERABLE_RECORDS_TYPE, + OBLIGATION_RECORDS_NAME, + credentialStatusObligationRecordVerifier, + w3cEmptyCredentialStatus, + w3cCredentialStatus, + w3cIssuerIdentity, + w3cSignatureIntegrity, + ecdsaW3CSignatureIntegrity, + bbs2023W3CSignatureIntegrity, +}; diff --git a/src/verify-obligation/fragments/issuer-identity/w3cIssuerIdentity.ts b/src/verify-obligation/fragments/issuer-identity/w3cIssuerIdentity.ts new file mode 100644 index 0000000..ae8815e --- /dev/null +++ b/src/verify-obligation/fragments/issuer-identity/w3cIssuerIdentity.ts @@ -0,0 +1,106 @@ +import { VerificationFragment, Verifier, VerifierOptions } from '@tradetrust-tt/tt-verify'; +import { DocumentLoader } from '@trustvc/w3c-context'; +import { isDidKey, parseDidKey, queryDidDocument } from '@trustvc/w3c-issuer'; +import { SignedVerifiableCredential } from '@trustvc/w3c-vc'; + +const isInvalidDidKeyError = (error: unknown): boolean => { + if (!(error instanceof Error)) { + return false; + } + const { message } = error; + return ( + message.startsWith('Not a did:key:') || + message.startsWith('did:key must use base58btc') || + message.startsWith('Unsupported did:key multicodec:') || + message.startsWith('Invalid ') + ); +}; + +const checkDidResolve = async (did: string, documentLoader?: DocumentLoader): Promise => { + try { + if (isDidKey(did)) { + parseDidKey(did); + return true; + } + + if (documentLoader) { + const { document } = await documentLoader(did); + return Boolean(document); + } + + const { wellKnownDid } = await queryDidDocument({ did }); + return Boolean(wellKnownDid); + } catch (error) { + if (isInvalidDidKeyError(error)) { + return false; + } + throw error; + } +}; + +export const w3cIssuerIdentity: Verifier = { + skip: async () => { + return { + type: 'ISSUER_IDENTITY', + name: 'W3CIssuerIdentity', + reason: { + code: 0, + codeString: 'SKIPPED', + message: `Document has no issuer field.`, + }, + status: 'SKIPPED', + }; + }, + + test: (document: unknown) => { + const doc = document as SignedVerifiableCredential; + return Boolean(doc.issuer); + }, + + verify: async (document: unknown, verifierOptions: VerifierOptions) => { + const doc = document as SignedVerifiableCredential; + const issuerId = typeof doc.issuer === 'string' ? doc.issuer : doc.issuer?.id; + if (doc.proof?.verificationMethod?.split('#')[0] !== issuerId) { + return { + type: 'ISSUER_IDENTITY', + name: 'W3CIssuerIdentity', + data: false, + reason: { + message: `Issuer and verification method do not match.`, + }, + status: 'INVALID', + }; + } + try { + const resolutionResult = await checkDidResolve(issuerId, verifierOptions?.documentLoader); + + if (resolutionResult) { + return { + type: 'ISSUER_IDENTITY', + name: 'W3CIssuerIdentity', + data: true, + status: 'VALID', + }; + } + + return { + type: 'ISSUER_IDENTITY', + name: 'W3CIssuerIdentity', + data: false, + reason: { + message: `The DID cannot be resolved.`, + }, + status: 'INVALID', + }; + } catch (error) { + return { + type: 'ISSUER_IDENTITY', + name: 'W3CIssuerIdentity', + status: 'ERROR', + reason: { + message: error instanceof Error ? error.message : 'Failed to resolve issuer DID.', + }, + }; + } + }, +}; diff --git a/src/verify-obligation/index.ts b/src/verify-obligation/index.ts new file mode 100644 index 0000000..e3bf04b --- /dev/null +++ b/src/verify-obligation/index.ts @@ -0,0 +1,2 @@ +export * from './verify'; +export * as fragments from './fragments'; diff --git a/src/verify-obligation/verify.ts b/src/verify-obligation/verify.ts new file mode 100644 index 0000000..8c51fc0 --- /dev/null +++ b/src/verify-obligation/verify.ts @@ -0,0 +1,95 @@ +import { + isValid, + verificationBuilder, + verify, + getIdentifier, + createResolver, + utils, +} from '@tradetrust-tt/tt-verify'; +import type { + DocumentsToVerify, + ErrorVerificationFragment, + InvalidVerificationFragment, + ProviderDetails, + providerType as ProviderType, + SkippedVerificationFragment, + ValidVerificationFragment, + VerificationBuilderOptions, + VerificationFragment, + VerificationFragmentStatus, + VerificationFragmentType, + Verifier, + VerifierOptions, + VerificationFragmentWithData, +} from '@tradetrust-tt/tt-verify/dist/types/src/types/core'; +import { w3cSignatureIntegrity } from './fragments/document-integrity/w3cSignatureIntegrity'; +import { ecdsaW3CSignatureIntegrity } from './fragments/document-integrity/ecdsaW3CSignatureIntegrity'; +import { bbs2023W3CSignatureIntegrity } from './fragments/document-integrity/bbs2023W3CSignatureIntegrity'; +import { credentialStatusObligationRecordVerifier } from './fragments/document-status/obligationRecords/obligationRecordVerifier'; +import { w3cCredentialStatus } from './fragments/document-status/w3cCredentialStatus'; +import { w3cEmptyCredentialStatus } from './fragments/document-status/w3cEmptyCredentialStatus'; +import { w3cIssuerIdentity } from './fragments/issuer-identity/w3cIssuerIdentity'; + +/** + * Verifier catalog for the obligation / BoE verify pipeline. + * Classic ETR TransferableRecords fragment is intentionally omitted — BoE status + * fragment SKIPPED for ETR docs instead. + */ +const verifiers = { + documentIntegrity: { + w3cSignatureIntegrity, + ecdsaW3CSignatureIntegrity, + bbs2023W3CSignatureIntegrity, + }, + documentStatus: { + w3cCredentialStatus, + w3cEmptyCredentialStatus, + credentialStatusObligationRecordVerifier, + }, + issuerIdentity: { + w3cIssuerIdentity, + }, +}; + +/** + * W3C fragment list for obligation / BoE document verification. + * Valid BoE → ObligationRecords VALID; classic ETR → ObligationRecords SKIPPED; + * invalid obligation (e.g. not minted) → ObligationRecords INVALID. + */ +const obligationW3cVerifiers: Verifier[] = [ + w3cSignatureIntegrity, + ecdsaW3CSignatureIntegrity, + bbs2023W3CSignatureIntegrity, + w3cCredentialStatus, + credentialStatusObligationRecordVerifier, + w3cEmptyCredentialStatus, + w3cIssuerIdentity, +]; + +export { + isValid, + verifiers, + verificationBuilder, + verify, + obligationW3cVerifiers, + getIdentifier, + createResolver, + utils, +}; + +export type { + DocumentsToVerify, + ErrorVerificationFragment, + InvalidVerificationFragment, + ProviderDetails, + ProviderType, + SkippedVerificationFragment, + ValidVerificationFragment, + VerificationBuilderOptions, + VerificationFragment, + VerificationFragmentStatus, + VerificationFragmentWithData, + VerificationFragmentType, + VerifierOptions, + Verifier, +}; diff --git a/src/verify/fragments/document-status/obligationRecords/obligationRecordVerifier.ts b/src/verify/fragments/document-status/obligationRecords/obligationRecordVerifier.ts new file mode 100644 index 0000000..8ca166a --- /dev/null +++ b/src/verify/fragments/document-status/obligationRecords/obligationRecordVerifier.ts @@ -0,0 +1,213 @@ +import { DocumentsToVerify, VerifierOptions } from '@tradetrust-tt/tt-verify'; +import * as w3cVC from '@trustvc/w3c-vc'; +import { SignedVerifiableCredential } from '@trustvc/w3c-vc'; +import { TRANSFERABLE_RECORDS_TYPE } from '../transferableRecords/transferableRecordVerifier'; +import { + ObligationRecordsCodedError, + ObligationRecordsErrorFragment, + ObligationRecordsResultFragment, + ObligationRecordsStatusCode, + ObligationRecordsVerificationFragment, + ObligationRecordsVerifierType, + isValidObligationRegistryStatus, +} from './obligationRecordVerifier.types'; +import { getObligationEscrowEnrichment, isTokenMintedOnObligationRegistry } from './utils'; + +export const OBLIGATION_RECORDS_NAME = 'ObligationRecords'; +const type = 'DOCUMENT_STATUS'; +const name = OBLIGATION_RECORDS_NAME; + +type ObligationCredentialStatus = { + type?: string; + tokenId?: string; + obligationRegistry?: string; + tokenRegistry?: string; + tokenNetwork?: { chainId?: number; name?: string }; +}; + +const verify: ObligationRecordsVerifierType['verify'] = async ( + document: DocumentsToVerify | SignedVerifiableCredential, + options: VerifierOptions, +) => { + const signedDocument = document as SignedVerifiableCredential; + const credentialStatuses = ( + Array.isArray(signedDocument?.credentialStatus) + ? signedDocument?.credentialStatus + : [signedDocument?.credentialStatus] + ) as ObligationCredentialStatus[]; + if (credentialStatuses.length === 0) { + throw new ObligationRecordsCodedError( + "Document's credentialStatus is empty", + ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT], + ); + } + const { provider } = options; + + const verificationResult = await Promise.all( + credentialStatuses.map(async (credentialStatus) => { + if (!credentialStatus?.tokenId) { + throw new ObligationRecordsCodedError( + "Document's credentialStatus does not have tokenId", + ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT], + ); + } + const tokenId = '0x' + credentialStatus.tokenId; + const obligationRegistry = credentialStatus?.obligationRegistry; + if (!obligationRegistry) { + throw new ObligationRecordsCodedError( + "Document's credentialStatus does not have obligationRegistry", + ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT], + ); + } + if (credentialStatus?.tokenRegistry) { + throw new ObligationRecordsCodedError( + "Document's credentialStatus must not include both tokenRegistry and obligationRegistry", + ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT], + ); + } + if (!credentialStatus?.tokenNetwork || !credentialStatus?.tokenNetwork?.chainId) { + throw new ObligationRecordsCodedError( + "Document's credentialStatus does not have tokenNetwork.chainId", + ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT, + ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNRECOGNIZED_DOCUMENT], + ); + } + const mintStatus = await isTokenMintedOnObligationRegistry({ + obligationRegistry, + tokenId, + provider, + }); + if (!isValidObligationRegistryStatus(mintStatus)) { + return { + ...mintStatus, + obligationRegistry, + }; + } + const enrichment = await getObligationEscrowEnrichment({ + obligationRegistry, + tokenId, + provider, + }); + return { + minted: true as const, + address: obligationRegistry, + obligationRegistry, + status: enrichment.status, + terminationReason: enrichment.terminationReason, + }; + }), + ); + + const first = verificationResult[0]; + const result: ObligationRecordsResultFragment = { + name, + type, + status: 'INVALID', + data: { + obligationRegistry: credentialStatuses?.[0]?.obligationRegistry as string, + }, + }; + + const isEnrichedValid = ( + entry: (typeof verificationResult)[number], + ): entry is { + minted: true; + address: string; + obligationRegistry: string; + status: number; + terminationReason: number; + } => + Boolean(entry) && + entry.minted === true && + typeof (entry as { status?: number }).status === 'number' && + typeof (entry as { terminationReason?: number }).terminationReason === 'number' && + typeof (entry as { obligationRegistry?: string }).obligationRegistry === 'string'; + + if (verificationResult.every(isEnrichedValid)) { + result.status = 'VALID'; + result.data = { + obligationRegistry: first.obligationRegistry as string, + status: (first as { status: number }).status, + terminationReason: (first as { terminationReason: number }).terminationReason, + }; + } else { + const invalidEntry = verificationResult.find((entry) => !isEnrichedValid(entry)); + result.reason = ( + invalidEntry as { reason?: ObligationRecordsResultFragment['reason'] } + )?.reason; + } + return result; +}; + +const skip: ObligationRecordsVerifierType['skip'] = async () => { + return { + status: 'SKIPPED', + type, + name, + reason: { + code: ObligationRecordsStatusCode.SKIPPED, + codeString: ObligationRecordsStatusCode[ObligationRecordsStatusCode.SKIPPED], + message: `Document does not have ObligationRecords status`, + }, + }; +}; + +const test: ObligationRecordsVerifierType['test'] = ( + document: DocumentsToVerify | SignedVerifiableCredential, +): boolean => { + const doc = document as SignedVerifiableCredential; + const credentialStatuses = Array.isArray(doc?.credentialStatus) + ? doc?.credentialStatus + : [doc?.credentialStatus]; + if ( + w3cVC.isSignedDocument(document) && + credentialStatuses.length > 0 && + credentialStatuses.every((cs) => { + const status = cs as ObligationCredentialStatus; + // Match on obligationRegistry so documents with both registries still + // hit verify() and return the mutual-exclusivity ERROR. + return status?.type === TRANSFERABLE_RECORDS_TYPE && Boolean(status?.obligationRegistry); + }) + ) { + return true; + } + return false; +}; + +export const credentialStatusObligationRecordVerifier: ObligationRecordsVerifierType = { + skip, + test, + verify: async (...args): Promise => { + try { + return await verify(...args); + } catch (e: unknown) { + if (e instanceof ObligationRecordsCodedError) { + const err: ObligationRecordsErrorFragment = { + name, + type, + status: 'ERROR', + reason: { + code: e.code, + codeString: e.codeString, + message: e.message, + }, + }; + return err; + } + return { + name, + type, + status: 'ERROR', + reason: { + code: ObligationRecordsStatusCode.UNEXPECTED_ERROR, + codeString: ObligationRecordsStatusCode[ObligationRecordsStatusCode.UNEXPECTED_ERROR], + message: e instanceof Error ? e.message : 'An unexpected error occurred', + }, + }; + } + }, +}; diff --git a/src/verify/fragments/document-status/obligationRecords/obligationRecordVerifier.types.ts b/src/verify/fragments/document-status/obligationRecords/obligationRecordVerifier.types.ts new file mode 100644 index 0000000..9953ed0 --- /dev/null +++ b/src/verify/fragments/document-status/obligationRecords/obligationRecordVerifier.types.ts @@ -0,0 +1,71 @@ +import { + ErrorVerificationFragment, + VerificationFragment, + Verifier, +} from '@tradetrust-tt/tt-verify'; + +export enum ObligationRecordsStatusCode { + SKIPPED = 0, + DOCUMENT_NOT_MINTED = 1, + UNEXPECTED_ERROR = 4, + UNRECOGNIZED_DOCUMENT = 9, + SERVER_ERROR = 500, +} + +export type ObligationRecordsErrorReason = { + code: ObligationRecordsStatusCode; + codeString: string; + message: string; +}; + +export type ObligationRecordsResultFragment = VerificationFragment & { + status: 'VALID' | 'INVALID'; + data: { + obligationRegistry: string; + status?: number; + terminationReason?: number; + }; + reason?: ObligationRecordsErrorReason; +}; + +export type ObligationRecordsErrorFragment = Omit, 'data'> & { + data?: never; + reason: ObligationRecordsErrorReason; +}; + +export type ObligationRecordsVerificationFragment = + | ObligationRecordsResultFragment + | ObligationRecordsErrorFragment; + +export type ObligationRecordsVerifierType = Verifier; + +export type ValidObligationRegistryStatus = { + minted: true; + address: string; +}; + +export type InvalidObligationRegistryStatus = { + minted: false; + address: string; + reason: ObligationRecordsErrorReason; +}; + +export type ObligationRegistryMintStatus = + | ValidObligationRegistryStatus + | InvalidObligationRegistryStatus; + +export const isValidObligationRegistryStatus = ( + status: ObligationRegistryMintStatus, +): status is ValidObligationRegistryStatus => status.minted === true; + +export class ObligationRecordsCodedError extends Error { + code: ObligationRecordsStatusCode; + codeString: string; + + constructor(message: string, code: ObligationRecordsStatusCode, codeString: string) { + super(message); + this.name = 'ObligationRecordsCodedError'; + this.code = code; + this.codeString = codeString; + } +} diff --git a/src/verify/fragments/document-status/obligationRecords/utils.ts b/src/verify/fragments/document-status/obligationRecords/utils.ts new file mode 100644 index 0000000..63e616e --- /dev/null +++ b/src/verify/fragments/document-status/obligationRecords/utils.ts @@ -0,0 +1,125 @@ +import { TradeTrustToken__factory } from '@tradetrust-tt/token-registry-v4/contracts'; +import { errors, constants, Contract, providers } from 'ethers'; +import { Provider as ProviderV6 } from 'ethersV6'; +import { obligationRegistryContracts } from '../../../../obligation-registry'; +import { getTitleEscrowAddress } from '../../../../core/endorsement-chain'; +import { + ObligationRecordsCodedError, + ObligationRecordsStatusCode, + ObligationRegistryMintStatus, +} from './obligationRecordVerifier.types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const isNonExistentToken = (error: any): boolean => { + const hasNonExistentSelector = Boolean(error.data && error.data.slice(0, 10) === '0x7e273289'); + const hasNonExistentMessage = + typeof error.message === 'string' && + error.message.includes('owner query for nonexistent token'); + return hasNonExistentSelector || hasNonExistentMessage; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const isMissingObligationRegistry = (error: any): boolean => { + return ( + !error.reason && + error.method?.toLowerCase() === 'ownerOf(uint256)'.toLowerCase() && + error.code === errors.CALL_EXCEPTION + ); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const decodeObligationRegistryError = (error: any): string => { + const reason = + error.reason && Array.isArray(error.reason) ? error.reason[0] : (error.reason ?? ''); + switch (true) { + case isNonExistentToken(error): + return `Document has not been issued under obligation registry`; + case isMissingObligationRegistry(error): + return `Obligation registry is not found`; + case reason.toLowerCase() === 'ENS name not configured'.toLowerCase() && + error.code === errors.UNSUPPORTED_OPERATION: + return 'ENS name is not configured'; + case reason.toLowerCase() === 'invalid address'.toLowerCase() && + error.code === errors.INVALID_ARGUMENT: + return `Invalid obligation registry address`; + case error.code === errors.INVALID_ARGUMENT: + return `Invalid contract arguments`; + case error.code === errors.SERVER_ERROR: + case error.code === errors.NETWORK_ERROR: + throw new ObligationRecordsCodedError( + 'Unable to connect to the network, please try again later', + ObligationRecordsStatusCode.SERVER_ERROR, + ObligationRecordsStatusCode[ObligationRecordsStatusCode.SERVER_ERROR], + ); + default: + throw error; + } +}; + +export const isTokenMintedOnObligationRegistry = async ({ + obligationRegistry, + tokenId, + provider, +}: { + obligationRegistry: string; + tokenId: string; + provider: providers.Provider | ProviderV6; +}): Promise => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const registryContract = TradeTrustToken__factory.connect(obligationRegistry, provider as any); + const minted = await registryContract + .ownerOf(tokenId) + .then((owner) => owner !== constants.AddressZero); + return minted + ? { minted, address: obligationRegistry } + : { + minted, + address: obligationRegistry, + reason: { + code: ObligationRecordsStatusCode.DOCUMENT_NOT_MINTED, + codeString: + ObligationRecordsStatusCode[ObligationRecordsStatusCode.DOCUMENT_NOT_MINTED], + message: `Document ${tokenId} has not been issued under contract ${obligationRegistry}`, + }, + }; + } catch (error) { + return { + minted: false, + address: obligationRegistry, + reason: { + message: decodeObligationRegistryError(error), + code: ObligationRecordsStatusCode.DOCUMENT_NOT_MINTED, + codeString: ObligationRecordsStatusCode[ObligationRecordsStatusCode.DOCUMENT_NOT_MINTED], + }, + }; + } +}; + +export const getObligationEscrowEnrichment = async ({ + obligationRegistry, + tokenId, + provider, +}: { + obligationRegistry: string; + tokenId: string; + provider: providers.Provider | ProviderV6; +}): Promise<{ status: number; terminationReason: number }> => { + const escrowAddress = await getTitleEscrowAddress(obligationRegistry, tokenId, provider, { + titleEscrowVersion: 'v5', + }); + const escrow = new Contract( + escrowAddress, + obligationRegistryContracts.ObligationEscrow__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider as any, + ); + const [status, terminationReason] = await Promise.all([ + escrow.status(), + escrow.terminationReason(), + ]); + return { + status: Number(status), + terminationReason: Number(terminationReason), + }; +}; diff --git a/src/verify/fragments/document-status/transferableRecords/transferableRecordVerifier.ts b/src/verify/fragments/document-status/transferableRecords/transferableRecordVerifier.ts index 424a4f7..ce0946a 100644 --- a/src/verify/fragments/document-status/transferableRecords/transferableRecordVerifier.ts +++ b/src/verify/fragments/document-status/transferableRecords/transferableRecordVerifier.ts @@ -79,7 +79,8 @@ const verify: VerifierType['verify'] = async ( if (verificationResult.every(ValidTokenRegistryStatus.guard)) { result.status = 'VALID' as const; } else { - result.reason = (verificationResult as InvalidTokenRegistryStatus[])?.[0]?.reason; + const invalidEntry = verificationResult.find((entry) => !ValidTokenRegistryStatus.guard(entry)); + result.reason = (invalidEntry as InvalidTokenRegistryStatus)?.reason; } return result; }; @@ -110,7 +111,13 @@ const test: VerifierType['test'] = ( if ( w3cVC.isSignedDocument(document) && - credentialStatuses.every((cs: w3cVC.CredentialStatus) => cs?.type === TRANSFERABLE_RECORDS_TYPE) + credentialStatuses.every((cs: w3cVC.CredentialStatus) => { + const status = cs as w3cVC.CredentialStatus & { + tokenRegistry?: string; + obligationRegistry?: string; + }; + return status?.type === TRANSFERABLE_RECORDS_TYPE && !status?.obligationRegistry; + }) ) { return true; } diff --git a/src/verify/fragments/index.ts b/src/verify/fragments/index.ts index f82678d..20f9535 100644 --- a/src/verify/fragments/index.ts +++ b/src/verify/fragments/index.ts @@ -12,6 +12,10 @@ import { credentialStatusTransferableRecordVerifier, TRANSFERABLE_RECORDS_TYPE, } from './document-status/transferableRecords/transferableRecordVerifier'; +import { + credentialStatusObligationRecordVerifier, + OBLIGATION_RECORDS_NAME, +} from './document-status/obligationRecords/obligationRecordVerifier'; import { w3cCredentialStatus } from './document-status/w3cCredentialStatus'; import { w3cIssuerIdentity } from './issuer-identity/w3cIssuerIdentity'; import { w3cEmptyCredentialStatus } from './document-status/w3cEmptyCredentialStatus'; @@ -19,6 +23,8 @@ import { w3cEmptyCredentialStatus } from './document-status/w3cEmptyCredentialSt export { TRANSFERABLE_RECORDS_TYPE, credentialStatusTransferableRecordVerifier, + OBLIGATION_RECORDS_NAME, + credentialStatusObligationRecordVerifier, openAttestationDidSignedDocumentStatus, openAttestationDnsDidIdentityProof, openAttestationDnsTxtIdentityProof, diff --git a/src/verify/verify.ts b/src/verify/verify.ts index 1e32536..d5d37e2 100644 --- a/src/verify/verify.ts +++ b/src/verify/verify.ts @@ -32,6 +32,7 @@ import type { import { w3cSignatureIntegrity } from './fragments/document-integrity/w3cSignatureIntegrity'; import { ecdsaW3CSignatureIntegrity } from './fragments/document-integrity/ecdsaW3CSignatureIntegrity'; import { credentialStatusTransferableRecordVerifier } from './fragments/document-status/transferableRecords/transferableRecordVerifier'; +import { credentialStatusObligationRecordVerifier } from './fragments/document-status/obligationRecords/obligationRecordVerifier'; import { w3cCredentialStatus } from './fragments/document-status/w3cCredentialStatus'; import { w3cIssuerIdentity } from './fragments/issuer-identity/w3cIssuerIdentity'; import { w3cEmptyCredentialStatus } from './fragments'; @@ -66,6 +67,7 @@ const w3cVerifiers: Verifier[] = [ bbs2023W3CSignatureIntegrity, w3cCredentialStatus, credentialStatusTransferableRecordVerifier, + credentialStatusObligationRecordVerifier, w3cEmptyCredentialStatus, w3cIssuerIdentity, ];