diff --git a/README.md b/README.md index 7a9ce60..edb6796 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A comprehensive command-line interface for managing W3C Verifiable Credentials, - ✅ **Token Registry**: Mint tokens to blockchain-based token registries - ✅ **Document Store**: Deploy and manage document store contracts - ✅ **Title Escrow**: Complete transferable records management (holder/beneficiary transfers) -- ✅ **Obligation Registry (BoE)**: Deploy/mint obligation registries, manage ObligationEscrow lifecycle, and verify BoE documents via a dedicated pipeline +- ✅ **Obligation Registry (BoE)**: Deploy/mint obligation registries, manage ObligationEscrow lifecycle, fetch endorsement-chain history, and verify BoE documents via a dedicated pipeline - ✅ **Gasless Transactions**: Sponsor gas for title-escrow, minting, and token registry deployment via EIP-7702 + Pimlico-sponsored `PlatformPaymaster` beta contracts (Sepolia, Amoy) - ✅ **Credential Status**: Create and update W3C credential status lists - ✅ **W3C Standards**: Compliant with latest W3C DID and Verifiable Credentials specifications @@ -215,6 +215,7 @@ trustvc obligation-escrow accept trustvc obligation-escrow reject trustvc obligation-escrow discharge trustvc obligation-escrow status +trustvc obligation-escrow endorsement-chain # Transfers (mirror title-escrow) trustvc obligation-escrow transfer-holder @@ -297,7 +298,7 @@ See the [Gasless Transactions user guide](#gasless-transactions-user-guide) for - **Document Store**: Deploy document store contracts and use `documentStoreIssue` and `documentStoreRevoke` to issue and revoke document hashes in deployed contracts. - **Transaction Cancel**: Cancel a pending transaction by replacing it with a 0-value transaction to yourself (same nonce, higher gas price). Supports specifying by transaction hash or by nonce and gas price. - **Title Escrow**: Provides comprehensive transferable records management including holder transfers, beneficiary nominations, endorsements, returns, and rejections using smart contracts. -- **Obligation Registry (BoE)**: Separate command trees for electronic Bill of Exchange on-chain flows — `obligation-registry` (deploy/mint) and `obligation-escrow` (accept/reject/discharge, transfers, return). Use `trustvc verify` for both ETR and BoE documents (ObligationRecords vs TransferableRecords is auto-detected). Do not use classic `token-registry` / `title-escrow` for obligation documents. See [Obligation Registry user guide](#obligation-registry-user-guide). +- **Obligation Registry (BoE)**: Separate command trees for electronic Bill of Exchange on-chain flows — `obligation-registry` (deploy/mint) and `obligation-escrow` (accept/reject/discharge, transfers, return, status, endorsement-chain). Use `trustvc verify` for both ETR and BoE documents (ObligationRecords vs TransferableRecords is auto-detected). Do not use classic `token-registry` / `title-escrow` for obligation documents. See [Obligation Registry user guide](#obligation-registry-user-guide). - **Gasless Transactions** (⚠️ beta, testnets only): Pass `--gasless` to a supported title-escrow, `mint`, or `token-registry deploy` command to submit it as an EIP-7702 smart-account UserOperation sponsored by a `PlatformPaymaster`, instead of a regular transaction paid from your own wallet balance. Gas is drawn from the PlatformPaymaster's EntryPoint deposit; Pimlico bundles and (optionally) sponsors the UserOperation. Only available on Sepolia and Amoy — not for production use. See [Gasless Transactions user guide](#gasless-transactions-user-guide). ## Commands @@ -357,6 +358,7 @@ See the [Gasless Transactions user guide](#gasless-transactions-user-guide) for | | [`obligation-escrow reject`](#obligation-escrow-reject) | Reject obligation | | | [`obligation-escrow discharge`](#obligation-escrow-discharge) | Discharge obligation | | | [`obligation-escrow status`](#obligation-escrow-status) | Read obligation / escrow status | +| | [`obligation-escrow endorsement-chain`](#obligation-escrow-endorsement-chain) | Fetch endorsement chain (transfers + status events) | | | [`obligation-escrow transfer-holder`](#obligation-escrow-transfer-holder) | Transfer BoE holder | | | [`obligation-escrow nominate-transfer-owner`](#obligation-escrow-transfer-holder) | Nominate BoE beneficiary | | | [`obligation-escrow endorse-transfer-owner`](#obligation-escrow-transfer-holder) | Endorse BoE beneficiary change | @@ -395,7 +397,7 @@ See the [Gasless Transactions user guide](#gasless-transactions-user-guide) for ### Wallet/Private Key Options -Commands that submit transactions (title-escrow, obligation-registry, obligation-escrow write actions, token registry, document-store, and transaction) require a wallet or private key to sign. Read-only `obligation-escrow status` does not — it uses the network RPC/provider from the document (override with `{NETWORK}_RPC` if needed). You can provide your private key in one of the following ways: +Commands that submit transactions (title-escrow, obligation-registry, obligation-escrow write actions, token registry, document-store, and transaction) require a wallet or private key to sign. Read-only `obligation-escrow status` and `obligation-escrow endorsement-chain` do not — they use the network RPC/provider from the document (override with `{NETWORK}_RPC` if needed). You can provide your private key in one of the following ways: **Select wallet/private key option:** @@ -527,7 +529,7 @@ Creates `signed_vc.json` with cryptographic proof. Verifies a W3C or OA document using the unified TrustVC verification pipeline. -Works for **classic ETR** (`tokenRegistry` → TransferableRecords fragment) and **BoE** (`obligationRegistry` → ObligationRecords fragment). When the document is an obligation record, the CLI also prints enriched on-chain status when available. +Works for **classic ETR** (`tokenRegistry` → TransferableRecords fragment) and **BoE** (`obligationRegistry` → ObligationRecords fragment). Both report the same keys: `DOCUMENT_INTEGRITY`, `DOCUMENT_STATUS`, and `ISSUER_IDENTITY` with `VALID` / `INVALID` / `ERROR`. For BoE, `DOCUMENT_STATUS` is the ObligationRecords fragment (minted on the obligation registry). Use [`obligation-escrow status`](#obligation-escrow-status) or [`obligation-escrow endorsement-chain`](#obligation-escrow-endorsement-chain) for on-chain parties and history. **Usage:** @@ -541,7 +543,7 @@ trustvc verify - [If network required but no network detected]: Select network **Output:** -Verifies document integrity, status, and issuer identity. For BoE documents, logs obligation registry status when the ObligationRecords fragment is VALID. +Verifies document integrity, status, and issuer identity (`DOCUMENT_INTEGRITY`, `DOCUMENT_STATUS`, `ISSUER_IDENTITY`). **Supported Formats:** @@ -1424,7 +1426,7 @@ Mints a BoE tokenId to an Obligation Registry and creates the linked ObligationE **Do not use** classic `mint` / `token-registry mint` for BoE documents. -**Before minting:** set `credentialStatus.obligationRegistry` to your deployed registry address, then sign the document with `[w3c-sign](#w3c-sign)`. Mint only accepts a signed Verifiable Credential. +**Before minting:** set `credentialStatus.obligationRegistry` to your deployed registry address, then sign the document with [`w3c-sign`](#w3c-sign). Mint only accepts a signed Verifiable Credential. **Usage:** @@ -1446,7 +1448,10 @@ Transaction receipt confirming mint. #### obligation-escrow accept -Accepts an obligation on the ObligationEscrow (drawee acceptance). +Accepts an obligation on the ObligationEscrow (drawee acceptance). Issued → Accepted. + +**Who Can Execute:** +Current holder, while **beneficiary ≠ holder**. **Usage:** @@ -1518,7 +1523,27 @@ trustvc obligation-escrow status - _Network, obligationRegistry, and token ID are extracted from the document_ **Output:** -Obligation and escrow status fields from the chain. +Obligation and escrow status fields from the chain (status, registered, termination reason, escrow address, owner, holder, nominee when available). After shred, owner/holder fall back to `lastBeneficiary` / `lastHolder`. + +#### obligation-escrow endorsement-chain + +Fetches the BoE endorsement chain (transfers + obligation status events) via the network Infura/RPC. Remarks are decrypted with the credential `id`. Read-only: no wallet required. + +On a shred (`RETURN_TO_ISSUER_ACCEPTED` / `SURRENDER_ACCEPTED`) row, owner and holder are the last known parties before the title was burned. + +**Usage:** + +```sh +trustvc obligation-escrow endorsement-chain +``` + +**Interactive Prompts:** + +- Path to BoE / obligation document +- *Network, obligationRegistry, token ID, and document `id` (encryption key) are extracted from the document* + +**Output:** +Chronological list of endorsement-chain events (type, block, timestamp, owner/holder, remark, tx hash). Status events include `STATUS_INITIALIZED`, `STATUS_ACCEPTED`, `STATUS_REJECTED`, `STATUS_DISCHARGED`, `RETURN_TO_ISSUER_ACCEPTED`, and `SURRENDER_ACCEPTED`. #### obligation-escrow transfer-holder @@ -1807,6 +1832,7 @@ src/commands/ │ ├── reject.ts # Reject obligation │ ├── discharge.ts # Discharge obligation │ ├── status.ts # Read obligation / escrow status +│ ├── endorsement-chain.ts # Fetch endorsement chain (transfers + status events) │ ├── transfer-holder.ts # Transfer BoE holder │ ├── nominate-transfer-owner.ts # Nominate BoE beneficiary │ ├── endorse-transfer-owner.ts # Endorse BoE beneficiary change @@ -1874,6 +1900,7 @@ Operators and integrators who: - Deploy an Obligation Registry on a supported network - Mint a signed BoE credential on-chain - Accept, reject, discharge, transfer, or return the obligation +- Read current status or the full endorsement chain - Verify a BoE document You do **not** need to call the TypeScript SDK directly — the CLI wraps it with interactive prompts. @@ -1893,6 +1920,7 @@ You do **not** need to call the TypeScript SDK directly — the CLI wraps it wit | Deploy registry | `token-registry deploy` | `obligation-registry deploy` | | Mint | `mint` / `token-registry mint` | `obligation-registry mint` | | Escrow actions | `title-escrow …` | `obligation-escrow …` | +| History | — | `obligation-escrow endorsement-chain` / `status` | | Verify | `verify` (ETR and BoE) | `verify` (same command) | Using classic commands on a BoE document will fail or skip obligation checks. Using obligation commands on a classic eBL document will fail extraction (missing `obligationRegistry`). @@ -1920,7 +1948,7 @@ Signing/building the VC can also be done with TrustVC library tools or your app **4. Accept or reject (holder)** — `trustvc obligation-escrow accept` or `reject` while **beneficiary ≠ holder**. Accept moves Issued → Accepted. Reject moves Issued → Rejected and burns the title. -**5. Status** — `trustvc obligation-escrow status` reads `Issued` / `Accepted` / `Rejected` / `Discharged`, registration, and termination reason. +**5. Status / history** — `trustvc obligation-escrow status` reads `Issued` / `Accepted` / `Rejected` / `Discharged`, registration, termination reason, and escrow parties (owner, holder, nominee). `trustvc obligation-escrow endorsement-chain` prints the full on-chain history (transfers + status events). **6. Transfers (optional)** — `obligation-escrow transfer-holder`, nominate/endorse/reject-transfer variants mirror `title-escrow`. @@ -1943,6 +1971,7 @@ Signing/building the VC can also be done with TrustVC library tools or your app | `accept-return-to-issuer` | Connected wallet with registry **accepter** role (burn / shred) | | `reject-return-to-issuer` | Connected wallet with registry **restorer** role (restore) | | `obligation-escrow status` | Anyone with the document (read-only RPC; no signing key) | +| `obligation-escrow endorsement-chain` | Anyone with the document (read-only RPC; no signing key) | | `verify` | Anyone with the document (+ RPC when on-chain checks run) | ### What the CLI reads from your document diff --git a/package-lock.json b/package-lock.json index 1307b27..6654afc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@trustvc/trustvc-cli", - "version": "1.3.0-beta.2", + "version": "1.3.0-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trustvc/trustvc-cli", - "version": "1.3.0-beta.2", + "version": "1.3.0-beta.3", "license": "Apache-2.0", "dependencies": { "@inquirer/prompts": "^5.3.8", - "@trustvc/trustvc": "^2.16.0-beta.6", + "@trustvc/trustvc": "2.16.0-beta.6", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", "dotenv": "^16.0.0", @@ -17252,7 +17252,6 @@ "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, "license": "BSD-2-Clause", "optional": true, "bin": { diff --git a/package.json b/package.json index b45f828..608e463 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ }, "dependencies": { "@inquirer/prompts": "^5.3.8", - "@trustvc/trustvc": "^2.16.0-beta.6", + "@trustvc/trustvc": "2.16.0-beta.6", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", "dotenv": "^16.0.0", diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 432d769..44a118c 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -8,6 +8,7 @@ import { v5SupportInterfaceIds, DocumentStore__factory, encrypt, + getTitleEscrowAddress, } from '@trustvc/trustvc'; // Internal utilities @@ -462,7 +463,8 @@ interface ConnectToObligationEscrowArgs { } /** - * Resolves ObligationEscrow via ownerOf(tokenId) on the obligation registry and connects. + * Resolves ObligationEscrow via getTitleEscrowAddress (handles inactive/burned titles + * via factory CREATE2) and connects — same as websites / demo dry-run resolution. */ export const connectToObligationEscrow = async ({ tokenId, @@ -470,11 +472,15 @@ export const connectToObligationEscrow = async ({ wallet, }: ConnectToObligationEscrowArgs) => { try { - signale.info(`Connecting to obligation registry at: ${address}`); - const registry = new ethers.Contract(address, TrustVCToken__factory.abi, wallet as any); + const provider = wallet.provider; + if (!provider) { + throw new Error('Provider is required to resolve obligation escrow address'); + } - signale.info(`Fetching obligation escrow address for tokenId: ${tokenId}`); - const escrowAddress = await registry.ownerOf(tokenId); + signale.info(`Resolving obligation escrow for tokenId: ${tokenId} on ${address}`); + const escrowAddress = await getTitleEscrowAddress(address, tokenId, provider as any, { + titleEscrowVersion: 'v5', + }); signale.info(`Obligation escrow address: ${escrowAddress}`); if (!escrowAddress || escrowAddress === ZeroAddress) { diff --git a/src/commands/obligation-escrow/endorsement-chain.ts b/src/commands/obligation-escrow/endorsement-chain.ts new file mode 100644 index 0000000..b200704 --- /dev/null +++ b/src/commands/obligation-escrow/endorsement-chain.ts @@ -0,0 +1,80 @@ +import { info, success } from 'signale'; +import { fetchEndorsementChain } from '@trustvc/trustvc'; +import { BaseObligationEscrowCommand } from '../../types'; +import { + extractObligationDocumentInfo, + getSupportedNetwork, + promptAndReadDocument, + verifyDocumentSignature, +} from '../../utils'; +import { runObligationEscrowCommand } from './shared'; + +export const command = 'endorsement-chain'; +export const describe = + 'Fetch BoE obligation endorsement chain (transfers + status events) via network Infura/RPC'; + +/** Read-only — document only; decrypt remarks with credential `id`. */ +export type ObligationEscrowEndorsementChainCommand = Pick< + BaseObligationEscrowCommand, + 'network' | 'obligationRegistryAddress' | 'tokenId' | 'encryptionKey' +>; + +export const handler = async (): Promise => + runObligationEscrowCommand(promptForInputs, endorsementChainHandler); + +export const promptForInputs = async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + const { obligationRegistry, tokenId, network, documentId } = + await extractObligationDocumentInfo(document); + return { + network, + obligationRegistryAddress: obligationRegistry, + tokenId, + encryptionKey: documentId, + }; +}; + +export const endorsementChainHandler = async (args: ObligationEscrowEndorsementChainCommand) => { + const { obligationRegistryAddress, tokenId, network, encryptionKey } = args; + // Always use ChainInfo/Infura-style network RPC — never a wallet provider (MetaMask range caps). + const provider = getSupportedNetwork(network).provider(); + + info(`Fetching endorsement chain for ${tokenId} on ${obligationRegistryAddress} (${network})…`); + const chain = await fetchEndorsementChain( + obligationRegistryAddress, + tokenId, + provider as any, + encryptionKey, + ); + + success(`Endorsement chain (${chain.length} event${chain.length === 1 ? '' : 's'})`); + let lastOwner = ''; + let lastHolder = ''; + const isZero = (value?: string) => !value || /^0x0{40}$/i.test(value); + chain.forEach((event, index) => { + const isShred = + event.type === 'RETURN_TO_ISSUER_ACCEPTED' || event.type === 'SURRENDER_ACCEPTED'; + // eBoE shred keeps last owner/holder on the shred row. + const owner = isZero(event.owner) ? lastOwner : event.owner || lastOwner; + const holder = isZero(event.holder) ? lastHolder : event.holder || lastHolder; + if (!isZero(owner)) lastOwner = owner; + if (!isZero(holder)) lastHolder = holder; + if (isShred) { + lastOwner = ''; + lastHolder = ''; + } + + const when = event.timestamp ? new Date(event.timestamp).toISOString() : 'unknown-time'; + info(` ${index + 1}. [${event.type}] block=${event.blockNumber} @ ${when}`); + info(` Owner: ${owner || '-'}`); + info(` Holder: ${holder || '-'}`); + if (isShred && event.terminationReason && event.terminationReason !== 'None') { + const reasonLabel = + event.terminationReason === 'ReturnToIssuer' ? 'Return to issuer' : event.terminationReason; + info(` Reason: ${reasonLabel}`); + } + if (event.remark) info(` Remark: ${event.remark}`); + if (event.transactionHash) info(` Tx: ${event.transactionHash}`); + }); +}; diff --git a/src/commands/obligation-escrow/status.ts b/src/commands/obligation-escrow/status.ts index 19363de..6cd6e35 100644 --- a/src/commands/obligation-escrow/status.ts +++ b/src/commands/obligation-escrow/status.ts @@ -2,11 +2,13 @@ import { info, success } from 'signale'; import { ObligationDocumentStatus, ObligationEscrowTerminationReason, + getTitleEscrowAddress, getObligationEscrowTerminationReason, getObligationRegistryStatus, isObligationRegistryRegistered, + v5Contracts, } from '@trustvc/trustvc'; -import { VoidSigner, ZeroAddress } from 'ethers'; +import { Contract, VoidSigner, ZeroAddress } from 'ethers'; import { BaseObligationEscrowCommand } from '../../types'; import { extractObligationDocumentInfo, @@ -17,6 +19,8 @@ import { } from '../../utils'; import { runObligationEscrowCommand } from './shared'; +const { ObligationEscrow__factory } = v5Contracts; + export const command = 'status'; export const describe = 'Read BoE obligation escrow status / registration / termination reason'; @@ -60,16 +64,50 @@ export const statusHandler = async (args: ObligationEscrowStatusCommand) => { const readOnlySigner = new VoidSigner(ZeroAddress, provider); const opts = { obligationRegistryAddress, tokenId }; - const status = await getObligationRegistryStatus(opts, toSdkSigner(readOnlySigner), { tokenId }); - const registered = await isObligationRegistryRegistered(opts, toSdkSigner(readOnlySigner), { - tokenId, - }); - const reason = await getObligationEscrowTerminationReason(opts, toSdkSigner(readOnlySigner), { - tokenId, - }); + const [status, registered, reason] = await Promise.all([ + getObligationRegistryStatus(opts, toSdkSigner(readOnlySigner), { tokenId }), + isObligationRegistryRegistered(opts, toSdkSigner(readOnlySigner), { tokenId }), + getObligationEscrowTerminationReason(opts, toSdkSigner(readOnlySigner), { tokenId }), + ]); + + const isZero = (value?: string) => !value || value === ZeroAddress; + + let escrowAddress: string | undefined; + let beneficiary = ''; + let holder = ''; + let nominee = ''; + try { + escrowAddress = await getTitleEscrowAddress( + obligationRegistryAddress, + tokenId, + provider as any, + { + titleEscrowVersion: 'v5', + }, + ); + const escrow = new Contract(escrowAddress, ObligationEscrow__factory.abi, provider); + const [currentBeneficiary, currentHolder, currentNominee, lastBeneficiary, lastHolder] = + await Promise.all([ + escrow.beneficiary(), + escrow.holder(), + escrow.nominee(), + escrow.lastBeneficiary(), + escrow.lastHolder(), + ]); + beneficiary = isZero(currentBeneficiary) ? lastBeneficiary : currentBeneficiary; + holder = isZero(currentHolder) ? lastHolder : currentHolder; + nominee = currentNominee; + } catch { + // Escrow address may not resolve, or escrow may be inactive / unreadable after shred — + // still print registry-level status. + } success(`Obligation ${tokenId} on ${obligationRegistryAddress}`); + if (escrowAddress) info(` Escrow: ${escrowAddress}`); info(` Status: ${STATUS_LABEL[status] ?? status} (${status})`); info(` Registered: ${registered}`); info(` Termination reason: ${REASON_LABEL[reason] ?? reason} (${reason})`); + if (!isZero(beneficiary)) info(` Owner (beneficiary): ${beneficiary}`); + if (!isZero(holder)) info(` Holder: ${holder}`); + if (nominee && nominee !== ZeroAddress) info(` Nominee: ${nominee}`); }; diff --git a/src/commands/verify.ts b/src/commands/verify.ts index aca3fc5..a6410c1 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -126,18 +126,6 @@ export const verify = async (signedVC: SignedVerifiableCredential, options: Veri ]; fragments.forEach(logResultStatus); - const obligationStatus = getObligationDocumentStatus(result); - if (obligationStatus) { - const parts = [`registry=${obligationStatus.obligationRegistry}`]; - if (obligationStatus.status !== undefined) { - parts.push(`status=${obligationStatus.status}`); - } - if (obligationStatus.terminationReason !== undefined) { - parts.push(`terminationReason=${obligationStatus.terminationReason}`); - } - signale.info(`Obligation document status: ${parts.join(' ')}`); - } - if (isPresentation && fragments.every((fragment) => fragment.status === 'VALID')) { logPresentationCredentialCount(fragments[0]); } diff --git a/tests/commands/obligation-escrow/endorsement-chain.test.ts b/tests/commands/obligation-escrow/endorsement-chain.test.ts new file mode 100644 index 0000000..0aa814b --- /dev/null +++ b/tests/commands/obligation-escrow/endorsement-chain.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { endorsementChainHandler } from '../../../src/commands/obligation-escrow/endorsement-chain'; +import { NetworkCmdName } from '../../../src/utils'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + await: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + fetchEndorsementChain: vi.fn().mockResolvedValue([ + { + type: 'INITIAL', + blockNumber: 1, + timestamp: 1_700_000_000_000, + owner: '0xOwner', + holder: '0xHolder', + remark: 'minted', + transactionHash: '0xtx1', + }, + { + type: 'RETURN_TO_ISSUER_ACCEPTED', + blockNumber: 2, + timestamp: 1_700_000_100_000, + owner: '0xOwner', + holder: '0xHolder', + remark: 'burned', + transactionHash: '0xtx2', + terminationReason: 'Discharged', + }, + ]), + }; +}); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSupportedNetwork: vi.fn().mockReturnValue({ + provider: () => ({ mock: 'infura-provider' }), + networkId: 11155111, + }), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/endorsement-chain', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches chain via network Infura/RPC using document id as keyId', async () => { + const trustvc = await import('@trustvc/trustvc'); + const signale = await import('signale'); + await endorsementChainHandler({ + network: NetworkCmdName.Sepolia, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + encryptionKey: 'urn:uuid:test-doc-id', + }); + + expect(trustvc.fetchEndorsementChain as MockedFunction).toHaveBeenCalledWith( + '0xRegistry', + '0x1', + { mock: 'infura-provider' }, + 'urn:uuid:test-doc-id', + ); + + const infoMock = signale.info as MockedFunction; + const infoMessages = infoMock.mock.calls.map((args) => String(args[0])); + expect(infoMessages.some((msg) => msg.includes('Owner:') && msg.includes('0xOwner'))).toBe( + true, + ); + expect(infoMessages.some((msg) => msg.includes('Holder:') && msg.includes('0xHolder'))).toBe( + true, + ); + expect(infoMessages.some((msg) => msg.includes('Reason: Discharged'))).toBe(true); + expect(infoMessages.some((msg) => msg.includes('Remark: burned'))).toBe(true); + }); +}); diff --git a/tests/commands/obligation-escrow/status.test.ts b/tests/commands/obligation-escrow/status.test.ts index 0b948f9..0707232 100644 --- a/tests/commands/obligation-escrow/status.test.ts +++ b/tests/commands/obligation-escrow/status.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; import { statusHandler } from '../../../src/commands/obligation-escrow/status'; import { NetworkCmdName } from '../../../src/utils'; +import { Contract, ZeroAddress } from 'ethers'; +import { info, success } from 'signale'; vi.mock('signale', async (importOriginal) => { const originalSignale = await importOriginal(); @@ -36,6 +38,21 @@ vi.mock('@trustvc/trustvc', async () => { getObligationRegistryStatus: vi.fn().mockResolvedValue(0), isObligationRegistryRegistered: vi.fn().mockResolvedValue(true), getObligationEscrowTerminationReason: vi.fn().mockResolvedValue(0), + getTitleEscrowAddress: vi.fn().mockResolvedValue('0xEscrow'), + }; +}); + +vi.mock('ethers', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Contract: vi.fn().mockImplementation(() => ({ + beneficiary: vi.fn().mockResolvedValue('0xBeneficiary'), + holder: vi.fn().mockResolvedValue('0xHolder'), + nominee: vi.fn().mockResolvedValue(actual.ZeroAddress), + lastBeneficiary: vi.fn().mockResolvedValue(actual.ZeroAddress), + lastHolder: vi.fn().mockResolvedValue(actual.ZeroAddress), + })), }; }); @@ -52,8 +69,23 @@ vi.mock('../../../src/utils', async (importOriginal) => { }); describe('obligation-escrow/status', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + const trustvc = await import('@trustvc/trustvc'); + vi.mocked(trustvc.getObligationRegistryStatus).mockResolvedValue(0); + vi.mocked(trustvc.isObligationRegistryRegistered).mockResolvedValue(true); + vi.mocked(trustvc.getObligationEscrowTerminationReason).mockResolvedValue(0); + vi.mocked(trustvc.getTitleEscrowAddress).mockResolvedValue('0xEscrow'); + vi.mocked(Contract).mockImplementation( + () => + ({ + beneficiary: vi.fn().mockResolvedValue('0xBeneficiary'), + holder: vi.fn().mockResolvedValue('0xHolder'), + nominee: vi.fn().mockResolvedValue(ZeroAddress), + lastBeneficiary: vi.fn().mockResolvedValue(ZeroAddress), + lastHolder: vi.fn().mockResolvedValue(ZeroAddress), + }) as any, + ); }); it('reads status via network provider without a wallet', async () => { @@ -70,5 +102,53 @@ describe('obligation-escrow/status', () => { ); expect(trustvc.isObligationRegistryRegistered).toHaveBeenCalled(); expect(trustvc.getObligationEscrowTerminationReason).toHaveBeenCalled(); + expect(trustvc.getTitleEscrowAddress).toHaveBeenCalledWith( + '0xRegistry', + '0x1', + { mock: 'provider' }, + { titleEscrowVersion: 'v5' }, + ); + expect(info).toHaveBeenCalledWith(' Owner (beneficiary): 0xBeneficiary'); + expect(info).toHaveBeenCalledWith(' Holder: 0xHolder'); + }); + + it('prints lastBeneficiary and lastHolder when current parties are zero after shred', async () => { + vi.mocked(Contract).mockImplementation( + () => + ({ + beneficiary: vi.fn().mockResolvedValue(ZeroAddress), + holder: vi.fn().mockResolvedValue(ZeroAddress), + nominee: vi.fn().mockResolvedValue(ZeroAddress), + lastBeneficiary: vi.fn().mockResolvedValue('0xLastBeneficiary'), + lastHolder: vi.fn().mockResolvedValue('0xLastHolder'), + }) as any, + ); + + await statusHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + }); + + expect(info).toHaveBeenCalledWith(' Owner (beneficiary): 0xLastBeneficiary'); + expect(info).toHaveBeenCalledWith(' Holder: 0xLastHolder'); + }); + + it('still prints registry-level status when escrow address resolution fails', async () => { + const trustvc = await import('@trustvc/trustvc'); + vi.mocked(trustvc.getTitleEscrowAddress).mockRejectedValue(new Error('no escrow deployed')); + + await statusHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + }); + + expect(success).toHaveBeenCalledWith('Obligation 0x1 on 0xRegistry'); + expect(info).toHaveBeenCalledWith(' Status: Issued (0)'); + expect(info).toHaveBeenCalledWith(' Registered: true'); + expect(info).toHaveBeenCalledWith(' Termination reason: None (0)'); + expect(info).not.toHaveBeenCalledWith(expect.stringContaining('Escrow:')); + expect(Contract).not.toHaveBeenCalled(); }); }); diff --git a/tests/commands/verify.obligation.test.ts b/tests/commands/verify.obligation.test.ts index cadc915..16a38ca 100644 --- a/tests/commands/verify.obligation.test.ts +++ b/tests/commands/verify.obligation.test.ts @@ -116,16 +116,18 @@ describe('verify obligation / BoE (shredded titles)', () => { }); describe('verify', () => { - it('treats shredded BoE as DOCUMENT_STATUS VALID and logs obligation status', async () => { + it('treats shredded BoE as DOCUMENT_STATUS VALID without dumping the registry address', async () => { const signale = await import('signale'); await verify({ id: 'urn:uuid:shredded-boe' } as never); expect(verifyDocumentMock).toHaveBeenCalled(); expect(signale.default.success).toHaveBeenCalledWith('DOCUMENT_STATUS: VALID'); - expect(signale.default.info).toHaveBeenCalledWith( - 'Obligation document status: registry=0xRegistry status=2 terminationReason=2', - ); + const infoMessages = ( + signale.default.info as unknown as { mock: { calls: unknown[][] } } + ).mock.calls.flatMap((call) => call.map((arg) => String(arg))); + expect(infoMessages.join('\n')).not.toContain('Obligation document status'); + expect(infoMessages.join('\n')).not.toContain('0xRegistry'); }); }); });