diff --git a/README.md b/README.md index 7a9ce60..66ec320 100644 --- a/README.md +++ b/README.md @@ -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 @@ -304,98 +305,77 @@ See the [Gasless Transactions user guide](#gasless-transactions-user-guide) for ### Available Commands -| Category | Command | Description | -| -------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| **W3C Credentials** | [`key-pair-generation`](#key-pair-generation) | Generate cryptographic key pairs (ECDSA-SD-2023, BBS-2023) | -| | [`did-web`](#did-web) | Create did:web identifiers from key pairs | -| | [`w3c-sign`](#w3c-sign) | Sign W3C verifiable credentials | -| | [`verify`](#verify) | Verify W3C verifiable credentials and presentations | -| | [`vp-sign`](#vp-sign) | Create and sign a W3C verifiable presentation | -| | [`credential-status-create`](#credential-status-create) | Create credential status lists | -| | [`credential-status-update`](#credential-status-update) | Update credential status (revoke/suspend) | -| **OpenAttestation** | [`oa-sign`](#oa-sign) | Sign OpenAttestation v2/v3 documents | -| | [`verify`](#verify) | Verify OpenAttestation documents | -| | [`oa-wrap`](#oa-wrap) | Wrap OpenAttestation documents | -| | [`oa-unwrap`](#oa-unwrap) | Unwrap OpenAttestation documents | -| | [`oa-encrypt`](#oa-encrypt) | Encrypt an OA document for safe sharing and storage | -| | [`oa-decrypt`](#oa-decrypt) | Decrypt an OA document encrypted with oa-encrypt | -| **Token Registry** | [`mint`](#mint) | Mint tokens to blockchain registries | -| | [`token-registry deploy`](#token-registry-deploy) | Deploy token registry contracts | -| | `token-registry mint` | Alternative: `mint` | -| **Document Store** | [`document-store deploy`](#document-store-deploy) | Deploy document store contracts | -| | [`document-store issue`](#document-store-issue) | Issue document hashes | -| | [`document-store revoke`](#document-store-revoke) | Revoke document hashes | -| | [`document-store grant-role`](#document-store-grant-role) | Grant roles to accounts | -| | [`document-store revoke-role`](#document-store-revoke-role) | Revoke roles from accounts | -| | [`document-store transfer-ownership`](#document-store-transfer-ownership) | Transfer document store ownership | -| **Transaction** | [`transaction cancel`](#transaction-cancel) | Cancel a pending transaction | -| **Wallet** | [`wallet create`](#wallet-create) | Create a new encrypted wallet file | -| | [`wallet encrypt`](#wallet-encrypt) | Encrypt a wallet using a private key | -| | [`wallet decrypt`](#wallet-decrypt) | Decrypt an encrypted wallet file | -| **Title Escrow** | [`transfer-holder`](#title-escrow-transfer-holder) | Transfer document holder | -| | `title-escrow transfer-holder` | Alternative: `transfer-holder` | -| | [`nominate-transfer-owner`](#title-escrow-nominate-transfer-owner) | Nominate new beneficiary | -| | `title-escrow nominate-transfer-owner` | Alternative: `nominate-transfer-owner` | -| | [`endorse-transfer-owner`](#title-escrow-endorse-transfer-owner) | Endorse beneficiary change | -| | `title-escrow endorse-transfer-owner` | Alternative: `endorse-transfer-owner` | -| | [`transfer-owner-holder`](#title-escrow-transfer-owner-holder) | Endorse full ownership transfer | -| | `title-escrow transfer-owner-holder` | Alternative: `transfer-owner-holder` | -| | [`return-to-issuer`](#title-escrow-return-to-issuer) | Return document to issuer | -| | `title-escrow return-to-issuer` | Alternative: `return-to-issuer` | -| | [`accept-return-to-issuer`](#title-escrow-accept-return-to-issuer) | Accept returned document | -| | `title-escrow accept-return-to-issuer` | Alternative: `accept-return-to-issuer` | -| | [`reject-return-to-issuer`](#title-escrow-reject-return-to-issuer) | Reject returned document | -| | `title-escrow reject-return-to-issuer` | Alternative: `reject-return-to-issuer` | -| | [`reject-transfer-holder`](#title-escrow-reject-transfer-holder) | Reject holder transfer | -| | `title-escrow reject-transfer-holder` | Alternative: `reject-transfer-holder` | -| | [`reject-transfer-owner`](#title-escrow-reject-transfer-owner) | Reject owner transfer | -| | [`reject-transfer-owner-holder`](#title-escrow-reject-transfer-owner-holder) | Reject full transfer | -| | `title-escrow reject-transfer-owner-holder` | Alternative: `reject-transfer-owner-holder` | -| **Obligation / BoE** | [`obligation-registry deploy`](#obligation-registry-deploy) | Deploy Obligation Registry | -| | [`obligation-registry mint`](#obligation-registry-mint) | Mint BoE token to obligationRegistry | -| | [`obligation-escrow accept`](#obligation-escrow-accept) | Accept obligation | -| | [`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 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 | -| | [`obligation-escrow transfer-owner-holder`](#obligation-escrow-transfer-holder) | Endorse full BoE ownership transfer | -| | [`obligation-escrow return-to-issuer`](#obligation-escrow-return-to-issuer) | Return BoE to issuer | -| | [`obligation-escrow accept-return-to-issuer`](#obligation-escrow-accept-return-to-issuer) | Accept returned BoE | -| | [`obligation-escrow reject-return-to-issuer`](#obligation-escrow-reject-return-to-issuer) | Reject returned BoE | -| | [`obligation-escrow reject-transfer-*`](#obligation-escrow-transfer-holder) | Reject BoE transfer requests | -| **Gasless** (⚠️ beta, testnets only) | [`deploy-platform-paymaster`](#deploy-platform-paymaster) | Deploy a PlatformPaymaster contract | -| | `transfer-holder --gasless` | Transfer holder, sponsored — see [below](#running-any-command-gaslessly---gasless) | -| | `endorse-transfer-owner --gasless` | Transfer/endorse beneficiary, sponsored | -| | `transfer-owner-holder --gasless` | Transfer both beneficiary and holder, sponsored | -| | `nominate-transfer-owner --gasless` | Nominate a new beneficiary, sponsored | -| | `reject-transfer-holder --gasless` | Reject holder transfer, sponsored | -| | `reject-transfer-owner --gasless` | Reject beneficiary transfer, sponsored | -| | `reject-transfer-owner-holder --gasless` | Reject full transfer, sponsored | -| | `return-to-issuer --gasless` | Return document to issuer, sponsored | -| | `accept-return-to-issuer --gasless` | Accept returned document, sponsored | -| | `reject-return-to-issuer --gasless` | Reject returned document, sponsored | -| | `mint --gasless` | Mint gaslessly (registry must grant the paymaster MINTER_ROLE) | -| | `token-registry deploy --gasless` | Deploy a token registry gaslessly (caller needs deploy credits) | -| **Paymaster Admin** (⚠️ beta, testnets only) | [`paymaster-admin add-authorized-caller`](#paymaster-admin-method) | Authorize a caller to trigger sponsored calls | -| | [`paymaster-admin add-title-escrow`](#paymaster-admin-method) | Authorize a title escrow for sponsorship | -| | [`paymaster-admin add-registry`](#paymaster-admin-method) | Authorize a token registry for sponsorship | -| | [`paymaster-admin remove-authorized-caller`](#paymaster-admin-method) | Deauthorize a caller | -| | [`paymaster-admin remove-title-escrow`](#paymaster-admin-method) | Deauthorize a title escrow | -| | [`paymaster-admin remove-registry`](#paymaster-admin-method) | Deauthorize a token registry | -| | [`paymaster-admin set-daily-limit`](#paymaster-admin-method) | Set the per-user daily sponsored-gas limit | -| | [`paymaster-admin set-user-whitelist`](#paymaster-admin-method) | Grant a user token-registry deployment credits (0-3) | -| | [`paymaster-admin remove-user-from-whitelist`](#paymaster-admin-method) | Reset a user's deployment credits to 0 | -| | [`paymaster-admin fund-paymaster`](#paymaster-admin-method) | Deposit ETH into the paymaster's EntryPoint balance | -| | [`paymaster-admin stake-paymaster`](#paymaster-admin-method) | Stake ETH for the paymaster on the EntryPoint | -| | [`paymaster-admin delegate-user`](#paymaster-admin-method) | Delegate a user's EOA to the EIP7702Implementation contract | + +| Category | Command | Description | +| -------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| **W3C Credentials** | `[key-pair-generation](#key-pair-generation)` | Generate cryptographic key pairs (ECDSA-SD-2023, BBS-2023) | +| | `[did-web](#did-web)` | Create did:web identifiers from key pairs | +| | `[w3c-sign](#w3c-sign)` | Sign W3C verifiable credentials | +| | `[verify](#verify)` | Verify W3C verifiable credentials | +| | `[credential-status-create](#credential-status-create)` | Create credential status lists | +| | `[credential-status-update](#credential-status-update)` | Update credential status (revoke/suspend) | +| **OpenAttestation** | `[oa-sign](#oa-sign)` | Sign OpenAttestation v2/v3 documents | +| | `[verify](#verify)` | Verify OpenAttestation documents | +| | `[oa-wrap](#oa-wrap)` | Wrap OpenAttestation documents | +| | `[oa-unwrap](#oa-unwrap)` | Unwrap OpenAttestation documents | +| | `[oa-encrypt](#oa-encrypt)` | Encrypt an OA document for safe sharing and storage | +| | `[oa-decrypt](#oa-decrypt)` | Decrypt an OA document encrypted with oa-encrypt | +| **Token Registry** | `[mint](#mint)` | Mint tokens to blockchain registries | +| | `[token-registry deploy](#token-registry-deploy)` | Deploy token registry contracts | +| | `token-registry mint` | Alternative: `mint` | +| **Document Store** | `[document-store deploy](#document-store-deploy)` | Deploy document store contracts | +| | `[document-store issue](#document-store-issue)` | Issue document hashes | +| | `[document-store revoke](#document-store-revoke)` | Revoke document hashes | +| | `[document-store grant-role](#document-store-grant-role)` | Grant roles to accounts | +| | `[document-store revoke-role](#document-store-revoke-role)` | Revoke roles from accounts | +| | `[document-store transfer-ownership](#document-store-transfer-ownership)` | Transfer document store ownership | +| **Transaction** | `[transaction cancel](#transaction-cancel)` | Cancel a pending transaction | +| **Wallet** | `[wallet create](#wallet-create)` | Create a new encrypted wallet file | +| | `[wallet encrypt](#wallet-encrypt)` | Encrypt a wallet using a private key | +| | `[wallet decrypt](#wallet-decrypt)` | Decrypt an encrypted wallet file | +| **Title Escrow** | `[transfer-holder](#title-escrow-transfer-holder)` | Transfer document holder | +| | `title-escrow transfer-holder` | Alternative: `transfer-holder` | +| | `[nominate-transfer-owner](#title-escrow-nominate-transfer-owner)` | Nominate new beneficiary | +| | `title-escrow nominate-transfer-owner` | Alternative: `nominate-transfer-owner` | +| | `[endorse-transfer-owner](#title-escrow-endorse-transfer-owner)` | Endorse beneficiary change | +| | `title-escrow endorse-transfer-owner` | Alternative: `endorse-transfer-owner` | +| | `[transfer-owner-holder](#title-escrow-transfer-owner-holder)` | Endorse full ownership transfer | +| | `title-escrow transfer-owner-holder` | Alternative: `transfer-owner-holder` | +| | `[return-to-issuer](#title-escrow-return-to-issuer)` | Return document to issuer | +| | `title-escrow return-to-issuer` | Alternative: `return-to-issuer` | +| | `[accept-return-to-issuer](#title-escrow-accept-return-to-issuer)` | Accept returned document | +| | `title-escrow accept-return-to-issuer` | Alternative: `accept-return-to-issuer` | +| | `[reject-return-to-issuer](#title-escrow-reject-return-to-issuer)` | Reject returned document | +| | `title-escrow reject-return-to-issuer` | Alternative: `reject-return-to-issuer` | +| | `[reject-transfer-holder](#title-escrow-reject-transfer-holder)` | Reject holder transfer | +| | `title-escrow reject-transfer-holder` | Alternative: `reject-transfer-holder` | +| | `[reject-transfer-owner](#title-escrow-reject-transfer-owner)` | Reject owner transfer | +| | `[reject-transfer-owner-holder](#title-escrow-reject-transfer-owner-holder)` | Reject full transfer | +| | `title-escrow reject-transfer-owner-holder` | Alternative: `reject-transfer-owner-holder` | +| **Obligation / BoE** | `[obligation-registry deploy](#obligation-registry-deploy)` | Deploy Obligation Registry | +| | `[obligation-registry mint](#obligation-registry-mint)` | Mint BoE token to obligationRegistry | +| | `[obligation-escrow accept](#obligation-escrow-accept)` | Accept obligation | +| | `[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-nominate-transfer-owner)` | Nominate BoE beneficiary | +| | `[obligation-escrow endorse-transfer-owner](#obligation-escrow-endorse-transfer-owner)` | Endorse BoE beneficiary change | +| | `[obligation-escrow transfer-owner-holder](#obligation-escrow-transfer-owner-holder)` | Endorse full BoE ownership transfer | +| | `[obligation-escrow return-to-issuer](#obligation-escrow-return-to-issuer)` | Return BoE to issuer | +| | `[obligation-escrow accept-return-to-issuer](#obligation-escrow-accept-return-to-issuer)` | Accept returned BoE | +| | `[obligation-escrow reject-return-to-issuer](#obligation-escrow-reject-return-to-issuer)` | Reject returned BoE | +| | `[obligation-escrow reject-transfer-*](#obligation-escrow-transfer-holder)` | Reject BoE transfer requests | + + + --- ### 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 +507,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), not a registry address dump. **Usage:** @@ -541,7 +521,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:** @@ -1518,7 +1498,25 @@ 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). + +#### 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. + +**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, owner/holder, remark, tx hash). #### obligation-escrow transfer-holder @@ -1920,7 +1918,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. `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 +1941,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'); }); }); });