diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad76a6c..bcd3036 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,21 @@ name: CI +# Same checks for PRs targeting main or beta (and for pushes to those branches). on: push: + branches: + - main + - beta pull_request: + branches: + - main + - beta jobs: test: name: Test runs-on: ubuntu-latest - + strategy: matrix: node-version: [22.x, 24.x] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51da03e..918d69e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,13 @@ name: Release +# Publishes to npm on push to: +# - main → stable (@latest) +# - beta → prerelease (@beta), e.g. 1.2.0-beta.1 on: push: branches: - main + - beta jobs: test: @@ -39,6 +43,11 @@ jobs: name: Release needs: test runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + id-token: write steps: - name: Checkout code @@ -52,6 +61,7 @@ jobs: with: node-version: 22.x cache: 'npm' + registry-url: 'https://registry.npmjs.org/' - name: Install dependencies run: npm ci @@ -64,3 +74,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.releaserc.json b/.releaserc.json index d11fc16..88adc85 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -1,5 +1,12 @@ { - "branches": ["main"], + "branches": [ + "main", + { + "name": "beta", + "prerelease": "beta", + "channel": "beta" + } + ], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", @@ -9,7 +16,12 @@ "changelogFile": "CHANGELOG.md" } ], - "@semantic-release/npm", + [ + "@semantic-release/npm", + { + "npmPublish": true + } + ], [ "@semantic-release/git", { diff --git a/README.md b/README.md index c846ef5..afd3f57 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,21 @@ 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 - ✅ **Credential Status**: Create and update W3C credential status lists - ✅ **W3C Standards**: Compliant with latest W3C DID and Verifiable Credentials specifications - ✅ **Multi-Network Support**: Ethereum, Polygon, XDC, Stability, and Astron networks - ✅ **Interactive CLI**: User-friendly prompts for all operations + + ## Powered By This CLI leverages the TrustVC package: -- [`@trustvc/trustvc`](https://github.com/TrustVC/trustvc) - Core library for W3C credentials, OpenAttestation, token registries, and blockchain operations +- `[@trustvc/trustvc](https://github.com/TrustVC/trustvc)` — Core library for W3C credentials, OpenAttestation, token registries, Obligation Registry (BoE), and blockchain operations + +BoE on-chain helpers are imported from the **root** package (`mintObligationRegistry`, `acceptObligationRegistry`, …). Prefer those over any older `@trustvc/trustvc/obligation-registry` path. ## Table of Contents @@ -38,6 +43,9 @@ This CLI leverages the TrustVC package: - [Setup](#setup) - [Project Structure](#project-structure) - [License](#license) +- [Obligation Registry user guide](#obligation-registry-user-guide) + + ## Prerequisites @@ -48,6 +56,8 @@ nvm install 22 nvm use 22 ``` + + ## Installation Install the CLI globally: @@ -62,8 +72,12 @@ Or run a single command without installing: npx @trustvc/trustvc-cli ``` + + ## Quick Start + + ### W3C Verifiable Credentials ```sh @@ -86,6 +100,8 @@ trustvc credential-status-create trustvc credential-status-update ``` + + ### OpenAttestation Documents ```sh @@ -108,6 +124,8 @@ trustvc oa-encrypt trustvc oa-decrypt ``` + + ### Wallet Management ```sh @@ -121,6 +139,8 @@ trustvc wallet encrypt trustvc wallet decrypt ``` + + ### Document Store ```sh @@ -143,6 +163,8 @@ trustvc document-store revoke-role trustvc document-store transfer-ownership ``` + + ### Transaction ```sh @@ -154,6 +176,8 @@ trustvc transaction cancel [options] # e.g. trustvc transaction cancel --transaction-hash 0x... --network sepolia --encrypted-wallet-path ./wallet.json ``` + + ### Token Registry & Title Escrow ```sh @@ -188,99 +212,155 @@ trustvc title-escrow reject-transfer-owner trustvc title-escrow reject-transfer-owner-holder ``` + + +### Obligation Registry & Escrow (BoE) + +```sh +# Deploy Obligation Registry (TrustVCToken + factory) +trustvc obligation-registry deploy + +# Mint a BoE tokenId to obligationRegistry (sign first with w3c-sign) +trustvc w3c-sign +trustvc obligation-registry mint + +# Lifecycle +trustvc obligation-escrow accept +trustvc obligation-escrow reject +trustvc obligation-escrow discharge +trustvc obligation-escrow status + +# Transfers (mirror title-escrow) +trustvc obligation-escrow transfer-holder +trustvc obligation-escrow nominate-transfer-owner +trustvc obligation-escrow endorse-transfer-owner +trustvc obligation-escrow transfer-owner-holder +trustvc obligation-escrow reject-transfer-holder +trustvc obligation-escrow reject-transfer-owner +trustvc obligation-escrow reject-transfer-owner-holder +trustvc obligation-escrow return-to-issuer +trustvc obligation-escrow accept-return-to-issuer +trustvc obligation-escrow reject-return-to-issuer + +# Verify BoE or ETR documents (unified pipeline — auto-detects ObligationRecords) +trustvc verify +``` + + + ## How It Works + + ### W3C Credentials - **Key Pair Generation**: Uses `generateKeyPair` from `@trustvc/trustvc` to create cryptographic key pairs supporting ECDSA-SD-2023 and BBS-2023 cryptosuites in Multikey format. - - **DID Creation**: Uses `issueDID` to generate did:web identifiers, allowing self-hosted DIDs as unique identifiers in decentralized systems. - - **Credential Signing**: Uses `signW3C` to sign verifiable credentials with did:web identifiers and modern cryptosuites. - - **Credential Verification**: Uses `verifyDocument` to verify W3C verifiable credentials. - - **Credential Status**: Provides commands to create and update W3C credential status lists for managing credential revocation and suspension. + + ### OpenAttestation - **Document Signing**: Uses `signOA` to cryptographically sign OpenAttestation v2 and v3 documents with private keys. - - **Document Verification**: Uses `verifyDocument` to verify OpenAttestation documents. - - **Document Wrapping**: Uses `wrapOA` to wrap OpenAttestation documents. - - **Document Unwrapping**: Uses `unwrapOA` to unwrap OpenAttestation documents. - - **Document Encryption**: Uses `oa-encrypt` to encrypt OA documents for safe sharing; use `oa-decrypt` with the same key to recover the document. + + ### Blockchain Operations - **Token Registry**: Deploy token registry contracts and mint document hashes (tokenIds) to blockchain-based token registries across multiple networks (Ethereum, Polygon, XDC, Stability, Astron). - - **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). + + ## Commands + + ### 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 | -| | [`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 | -| | [`mint`](#mint) | Mint tokens to blockchain registries | -| | `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` | + +| 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 | +| | `[mint](#mint)` | Mint tokens to blockchain registries | +| | `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` | Nominate BoE beneficiary | +| | `obligation-escrow endorse-transfer-owner` | Endorse BoE beneficiary change | +| | `obligation-escrow transfer-owner-holder` | Endorse full BoE ownership transfer | +| | `obligation-escrow return-to-issuer` | Return BoE to issuer | +| | `obligation-escrow accept-return-to-issuer` | Accept returned BoE | +| | `obligation-escrow reject-return-to-issuer` | Reject returned BoE | +| | `obligation-escrow reject-transfer-*` | Reject BoE transfer requests | + + + --- + + ### Wallet/Private Key Options -All title-escrow, token registry, document-store, and transaction commands require a wallet or private key to sign transactions. You can provide your private key in one of the following ways: +All title-escrow, obligation-registry, obligation-escrow, token registry, document-store, and transaction commands require a wallet or private key to sign transactions. You can provide your private key in one of the following ways: **Select wallet/private key option:** @@ -291,10 +371,13 @@ All title-escrow, token registry, document-store, and transaction commands requi --- + + ### Detailed Command Reference -
-

transaction cancel

+ + +#### transaction cancel Cancels a pending transaction by replacing it with a 0-value transaction to yourself using the same nonce and a higher gas price (replace-by-fee). This action is irreversible. @@ -307,11 +390,9 @@ trustvc transaction cancel You will be prompted for: 1. **How to specify the pending transaction** - - **By transaction hash (recommended)** – Enter the pending transaction hash (0x...). Nonce and gas price are fetched from the network and the gas price is increased by 100% for the replacement. - - **By nonce and gas price** – Enter the pending transaction nonce and a higher gas price (wei) for the replacement. Use this when the pending transaction uses EIP-1559 (no legacy `gasPrice`) or when you prefer to set the replacement gas manually. - + - **By transaction hash (recommended)** – Enter the pending transaction hash (0x...). Nonce and gas price are fetched from the network and the gas price is increased by 100% for the replacement. + - **By nonce and gas price** – Enter the pending transaction nonce and a higher gas price (wei) for the replacement. Use this when the pending transaction uses EIP-1559 (no legacy `gasPrice`) or when you prefer to set the replacement gas manually. 2. **Network** – Select the network (e.g. Sepolia, Mainnet). - 3. **Wallet / private key** – Choose encrypted wallet file, environment variable (OA_PRIVATE_KEY), key file, or enter the private key. **With options (non-interactive):** @@ -343,10 +424,7 @@ trustvc transaction cancel \ **Note:** If the pending transaction uses EIP-1559 (maxFeePerGas / maxPriorityFeePerGas), it has no legacy `gasPrice`. In that case, specify the transaction by **nonce and gas price** and set a gas price (in wei) for the replacement. -
- -
-

key-pair-generation

+#### key-pair-generation Generates cryptographic key pairs for modern cryptosuites (ECDSA-SD-2023, BBS-2023). @@ -370,10 +448,9 @@ Creates `keypair.json` containing: - `secretKeyMultibase`: Secret key in multibase format - `seedBase58`: Seed (if provided for BBS-2023) -
-
-

did-web

+ +#### did-web Generates a did:web identifier from an existing key pair. @@ -395,10 +472,9 @@ trustvc did-web - `wellknown.json`: DID document for hosting at `/.well-known/did.json` - `didKeyPairs.json`: Key pair information with DID references -
-
-

w3c-sign

+ +#### w3c-sign Signs a W3C verifiable credential using a did:web identifier. @@ -418,12 +494,11 @@ trustvc w3c-sign **Output:** Creates `signed_vc.json` with cryptographic proof. -
+#### verify -
-

verify

+Verifies a W3C or OA document using the unified TrustVC verification pipeline. -Verifies a W3C or OA document using respective verification methods. +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. **Usage:** @@ -437,18 +512,17 @@ trustvc verify - [If network required but no network detected]: Select network **Output:** -Verifies the document integrity, status, and issuer identity. +Verifies document integrity, status, and issuer identity. For BoE documents, logs obligation registry status when the ObligationRecords fragment is VALID. **Supported Formats:** -- W3C Verifiable Credential +- W3C Verifiable Credential (ETR, BoE, revocable VDs) - OpenAttestation v2 - OpenAttestation v3 -
-
-

credential-status-create

+ +#### credential-status-create Creates a new W3C credential status list for managing revocation. @@ -469,10 +543,7 @@ trustvc credential-status-create **Output:** Signed credential status list file. -
- -
-

credential-status-update

+#### credential-status-update Updates an existing W3C credential status list to revoke or suspend credentials. @@ -492,10 +563,7 @@ trustvc credential-status-update **Output:** Updated credential status list file. -
- -
-

oa-sign

+#### oa-sign Signs OpenAttestation v2 or v3 documents with a private key. @@ -523,10 +591,9 @@ Signed OpenAttestation documents in the specified directory. - OpenAttestation v2 - OpenAttestation v3 -
-
-

oa-wrap

+ +#### oa-wrap Wraps OpenAttestation v2 or v3 documents @@ -550,10 +617,9 @@ Wrapped OpenAttestation document(s) in the specified directory. - OpenAttestation v2 - OpenAttestation v3 -
-
-

oa-unwrap

+ +#### oa-unwrap Unwraps OpenAttestation v2 or v3 documents @@ -576,10 +642,9 @@ Unwrapped OpenAttestation document(s) in the specified directory. - OpenAttestation v2 - OpenAttestation v3 -
-
-

oa-encrypt

+ +#### oa-encrypt Encrypts an Open Attestation document for safe sharing and storage. You will be prompted for an encryption key — remember it to decrypt later. @@ -604,10 +669,9 @@ Writes an encrypted document file containing `type: "encrypted-document"` and a - OpenAttestation v2 (raw or wrapped) - OpenAttestation v3 (raw or wrapped) -
-
-

oa-decrypt

+ +#### oa-decrypt Decrypts an Open Attestation document that was encrypted using `oa-encrypt`. You will be prompted for the decryption key. @@ -627,10 +691,7 @@ trustvc oa-decrypt Writes the decrypted Open Attestation document (raw OA v2/v3 or wrapped OA v2/v3) to the specified path. Fails if the key is wrong or the file is not a valid encrypted OA document. -
- -
-

mint

+#### mint Mints a document hash (tokenId) to a token registry smart contract. @@ -643,7 +704,7 @@ trustvc mint **Interactive Prompts:** - Path to TT/JSON document file (or manual input) - - _Network, token registry address, token ID, and document ID are extracted from the document_ + - *Network, token registry address, token ID, and document ID are extracted from the document* - Beneficiary address (initial recipient) - Holder address (initial holder) - Wallet/private key option @@ -660,10 +721,9 @@ Transaction receipt with hash, block number, gas used, and explorer link. - Stability (Mainnet, Testnet) - Astron (Mainnet, Testnet) -
-
-

token-registry deploy

+ +#### token-registry deploy Deploys a token registry contract on the blockchain. @@ -692,10 +752,9 @@ Transaction receipt with deployed contract address, hash, block number, gas used - Stability (Mainnet, Testnet) - Astron (Mainnet, Testnet) -
-
-

token-registry mint

+ +#### token-registry mint Alternative command for minting tokens. Functionally identical to `mint`. @@ -709,10 +768,9 @@ trustvc mint trustvc token-registry mint ``` -
-
-

document-store deploy

+ +#### document-store deploy Deploys a document store contract on the blockchain. @@ -740,10 +798,9 @@ Transaction receipt with contract address, hash, block number, gas used, and exp - Stability (Mainnet, Testnet) - Astron (Mainnet, Testnet) -
-
-

document-store issue

+ +#### document-store issue Issues a document hash to a deployed document store. @@ -761,10 +818,7 @@ trustvc document-store issue **Output:** Transaction receipt confirming the hash issuance. -
- -
-

document-store revoke

+#### document-store revoke Revokes a document hash from a deployed document store. @@ -782,10 +836,7 @@ trustvc document-store revoke **Output:** Transaction receipt confirming the hash revocation. -
- -
-

document-store grant-role

+#### document-store grant-role Grants a role (ISSUER_ROLE, REVOKER_ROLE, or DEFAULT_ADMIN_ROLE) to an account in a deployed document store. @@ -814,10 +865,9 @@ Transaction receipt with hash, block number, gas used, and explorer link. - Stability (Mainnet, Testnet) - Astron (Mainnet, Testnet) -
-
-

document-store revoke-role

+ +#### document-store revoke-role Revokes a role (ISSUER_ROLE, REVOKER_ROLE, or DEFAULT_ADMIN_ROLE) from an account in a deployed document store. @@ -846,10 +896,9 @@ Transaction receipt with hash, block number, gas used, and explorer link. - Stability (Mainnet, Testnet) - Astron (Mainnet, Testnet) -
-
-

document-store transfer-ownership

+ +#### document-store transfer-ownership Transfers ownership of a document store contract to a new owner. This grants DEFAULT_ADMIN_ROLE to the new owner and revokes it from the current owner. @@ -876,10 +925,9 @@ Transaction receipts for both grant and revoke operations with hashes, block num - Stability (Mainnet, Testnet) - Astron (Mainnet, Testnet) -
-
-

wallet create

+ +#### wallet create Creates a new encrypted wallet file with a randomly generated private key. @@ -924,10 +972,9 @@ Creates `wallet.json` containing the encrypted wallet in the specified directory ⚠ IMPORTANT: If you lose your password, you will not be able to recover your wallet! ``` -
-
-

wallet encrypt

+ +#### wallet encrypt Encrypts an existing private key into a secure wallet file. @@ -978,10 +1025,9 @@ Creates `wallet.json` containing the encrypted wallet in the specified directory ⚠ IMPORTANT: If you lose your password, you will not be able to recover your wallet! ``` -
-
-

wallet decrypt

+ +#### wallet decrypt Decrypts an encrypted wallet file and displays the private key and mnemonic phrase. @@ -1035,10 +1081,9 @@ Displays the decrypted wallet information: ⚠ IMPORTANT: Store this information securely and delete it from your terminal history! ``` -
-
-

title-escrow transfer-holder

+ +#### title-escrow transfer-holder Transfers the holder of a transferable record to a new address. @@ -1058,7 +1103,7 @@ trustvc title-escrow transfer-holder **Interactive Prompts:** - Path to TT/JSON document file (or manual input) - - _Network, token registry address, token ID, and document ID are extracted from the document_ + - *Network, token registry address, token ID, and document ID are extracted from the document* - New holder address - Wallet/private key option - Remark (optional, V5 registries only - will be encrypted with document ID as encryption key) @@ -1066,10 +1111,7 @@ trustvc title-escrow transfer-holder **Output:** Transaction receipt confirming holder transfer. -
- -
-

title-escrow nominate-transfer-owner

+#### title-escrow nominate-transfer-owner Nominates a new beneficiary (owner) for the transferable record. @@ -1089,7 +1131,7 @@ trustvc title-escrow nominate-transfer-owner **Interactive Prompts:** - Path to TT/JSON document file (or manual input) - - _Network, token registry address, token ID, and document ID are extracted from the document_ + - *Network, token registry address, token ID, and document ID are extracted from the document* - New beneficiary address - Wallet/private key option - Remark (optional, V5 registries only - will be encrypted with document ID as encryption key) @@ -1097,10 +1139,7 @@ trustvc title-escrow nominate-transfer-owner **Output:** Transaction receipt confirming beneficiary nomination. -
- -
-

title-escrow endorse-transfer-owner

+#### title-escrow endorse-transfer-owner Endorses the change of beneficiary (owner) for the transferable record. @@ -1120,7 +1159,7 @@ trustvc title-escrow endorse-transfer-owner **Interactive Prompts:** - Path to TT/JSON document file (or manual input) - - _Network, token registry address, token ID, and document ID are extracted from the document_ + - *Network, token registry address, token ID, and document ID are extracted from the document* - New beneficiary address - Wallet/private key option - Remark (optional, V5 registries only - will be encrypted with document ID as encryption key) @@ -1128,10 +1167,7 @@ trustvc title-escrow endorse-transfer-owner **Output:** Transaction receipt confirming beneficiary endorsement. -
- -
-

title-escrow transfer-owner-holder

+#### title-escrow transfer-owner-holder Endorses the transfer of both beneficiary and holder to new addresses. @@ -1151,7 +1187,7 @@ trustvc title-escrow transfer-owner-holder **Interactive Prompts:** - Path to TT/JSON document file (or manual input) - - _Network, token registry address, token ID, and document ID are extracted from the document_ + - *Network, token registry address, token ID, and document ID are extracted from the document* - New beneficiary address - New holder address - Wallet/private key option @@ -1160,10 +1196,7 @@ trustvc title-escrow transfer-owner-holder **Output:** Transaction receipt confirming full ownership transfer. -
- -
-

title-escrow return-to-issuer

+#### title-escrow return-to-issuer Returns the transferable record to the issuer. @@ -1183,17 +1216,14 @@ trustvc title-escrow return-to-issuer **Interactive Prompts:** - Path to TT/JSON document file (or manual input) - - _Network, token registry address, token ID, and document ID are extracted from the document_ + - *Network, token registry address, token ID, and document ID are extracted from the document* - Wallet/private key option - Remark (optional, V5 registries only - will be encrypted with document ID as encryption key) **Output:** Transaction receipt confirming document return. -
- -
-

title-escrow accept-return-to-issuer

+#### title-escrow accept-return-to-issuer Accepts a returned transferable record (issuer action). @@ -1213,17 +1243,14 @@ trustvc title-escrow accept-return-to-issuer **Interactive Prompts:** - Path to TT/JSON document file (or manual input) - - _Network, token registry address, token ID, and document ID are extracted from the document_ + - *Network, token registry address, token ID, and document ID are extracted from the document* - Wallet/private key option - Remark (optional, V5 registries only - will be encrypted with document ID as encryption key) **Output:** Transaction receipt confirming acceptance. -
- -
-

title-escrow reject-return-to-issuer

+#### title-escrow reject-return-to-issuer Rejects a returned transferable record (issuer action). @@ -1243,17 +1270,14 @@ trustvc title-escrow reject-return-to-issuer **Interactive Prompts:** - Path to TT/JSON document file (or manual input) - - _Network, token registry address, token ID, and document ID are extracted from the document_ + - *Network, token registry address, token ID, and document ID are extracted from the document* - Wallet/private key option - Remark (optional, V5 registries only - will be encrypted with document ID as encryption key) **Output:** Transaction receipt confirming rejection. -
- -
-

title-escrow reject-transfer-holder

+#### title-escrow reject-transfer-holder Rejects a holder transfer request. @@ -1273,17 +1297,14 @@ trustvc title-escrow reject-transfer-holder **Interactive Prompts:** - Path to TT/JSON document file (or manual input) - - _Network, token registry address, token ID, and document ID are extracted from the document_ + - *Network, token registry address, token ID, and document ID are extracted from the document* - Wallet/private key option - Remark (optional, V5 registries only - will be encrypted with document ID as encryption key) **Output:** Transaction receipt confirming rejection. -
- -
-

title-escrow reject-transfer-owner

+#### title-escrow reject-transfer-owner Rejects a beneficiary transfer request. @@ -1303,17 +1324,14 @@ trustvc title-escrow reject-transfer-owner **Interactive Prompts:** - Path to TT/JSON document file (or manual input) - - _Network, token registry address, token ID, and document ID are extracted from the document_ + - *Network, token registry address, token ID, and document ID are extracted from the document* - Wallet/private key option - Remark (optional, V5 registries only - will be encrypted with document ID as encryption key) **Output:** Transaction receipt confirming rejection. -
- -
-

title-escrow reject-transfer-owner-holder

+#### title-escrow reject-transfer-owner-holder Rejects a full ownership transfer (both beneficiary and holder). @@ -1333,17 +1351,234 @@ trustvc title-escrow reject-transfer-owner-holder **Interactive Prompts:** - Path to TT/JSON document file (or manual input) - - _Network, token registry address, token ID, and document ID are extracted from the document_ + - *Network, token registry address, token ID, and document ID are extracted from the document* - Wallet/private key option - Remark (optional, V5 registries only - will be encrypted with document ID as encryption key) **Output:** Transaction receipt confirming rejection. -
+#### obligation-registry deploy + +Deploys an Obligation Registry (`TrustVCToken` + `ObligationEscrowFactory`) for electronic Bill of Exchange (BoE) flows. + +**Do not use** `token-registry deploy` for BoE documents. + +**Usage:** + +```sh +trustvc obligation-registry deploy +``` + +**Interactive Prompts:** + +- Network selection +- Registry name and symbol +- Optionally reuse an existing ObligationEscrowFactory address +- Wallet/private key option +- Dry-run confirmation before broadcasting + +**Output:** +Deployed `ObligationEscrowFactory` and Obligation Registry addresses plus transaction receipt. Copy the Obligation Registry address into `credentialStatus.obligationRegistry` in your BoE document before signing and minting. + +#### obligation-registry mint + +Mints a BoE tokenId to an Obligation Registry and creates the linked ObligationEscrow. + +**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. + +**Usage:** + +```sh +trustvc w3c-sign +trustvc obligation-registry mint +``` + +**Interactive Prompts:** + +- Path to signed BoE / obligation document (output of `w3c-sign`) + - *Network, obligationRegistry address, token ID, and document ID are extracted from the document* +- Holder and beneficiary (drawer/drawee) addresses as required +- Wallet/private key option +- Dry-run confirmation before broadcasting + +**Output:** +Transaction receipt confirming mint. + +#### obligation-escrow accept + +Accepts an obligation on the ObligationEscrow (drawee acceptance). + +**Usage:** + +```sh +trustvc obligation-escrow accept +``` + +**Interactive Prompts:** + +- Path to BoE / obligation document + - *Network, obligationRegistry, token ID, and document ID are extracted from the document* +- Wallet/private key option +- Remark (optional) + +**Output:** +Transaction receipt confirming acceptance. + +#### obligation-escrow reject + +Rejects an issued BoE obligation (Issued → Rejected). Burns the title (takes it out of circulation). + +**Usage:** + +```sh +trustvc obligation-escrow reject +``` + +**Interactive Prompts:** + +- Path to BoE / obligation document +- Wallet/private key option +- Remark (optional) + +**Output:** +Transaction receipt confirming rejection (and burn). + +#### obligation-escrow discharge + +Discharges an accepted BoE obligation (Accepted → Discharged). Burns the title. + +**Usage:** + +```sh +trustvc obligation-escrow discharge +``` + +**Interactive Prompts:** + +- Path to BoE / obligation document +- Wallet/private key option +- Remark (optional) + +**Output:** +Transaction receipt confirming discharge (and burn). + +#### obligation-escrow status + +Reads on-chain obligation / escrow status for a BoE document. The CLI still prompts for a wallet or private key (same base inputs as other `obligation-escrow` commands) so it can attach a network provider via `getWalletOrSigner`. + +**Usage:** + +```sh +trustvc obligation-escrow status +``` + +**Interactive Prompts:** + +- Path to BoE / obligation document +- Wallet/private key option +- Remark (optional) +- *Network, obligationRegistry, and token ID are extracted from the document* + +**Output:** +Obligation and escrow status fields from the chain. + +#### obligation-escrow transfer-holder + +Transfers the holder of a BoE obligation record. Nominate, endorse, transfer-owner-holder, and reject-transfer variants mirror `title-escrow` but operate on ObligationEscrow via `obligation-escrow `. + +**Usage:** + +```sh +trustvc obligation-escrow transfer-holder +# Also: +# nominate-transfer-owner, endorse-transfer-owner, transfer-owner-holder +# reject-transfer-holder, reject-transfer-owner, reject-transfer-owner-holder +``` + +**Interactive Prompts:** + +- Path to BoE / obligation document +- New address(es) where applicable +- Wallet/private key option +- Remark (optional) + +**Output:** +Transaction receipt confirming the escrow action. + +#### obligation-escrow return-to-issuer + +Returns the BoE obligation to the issuer. Same rules as classic ETR: the connected wallet must be both the current beneficiary (owner) and the current holder. + +**Who Can Execute:** +Both the **current holder** and **current beneficiary (owner)** must execute this together, or the entity that holds both roles. + +**Usage:** + +```sh +trustvc obligation-escrow return-to-issuer +``` + +**Interactive Prompts:** + +- Path to BoE / obligation document + - *Network, obligationRegistry, token ID, and document ID are extracted from the document* +- Wallet/private key option +- Remark (optional) + +**Output:** +Transaction receipt confirming return to issuer. + +#### obligation-escrow accept-return-to-issuer + +Accepts a returned BoE (issuer burn / shred). + +**Who Can Execute:** +Only the **issuer** (wallet with accepter role on the obligation registry). + +**Usage:** + +```sh +trustvc obligation-escrow accept-return-to-issuer +``` + +**Interactive Prompts:** + +- Path to BoE / obligation document +- Wallet/private key option +- Remark (optional) + +**Output:** +Transaction receipt confirming acceptance (burn). + +#### obligation-escrow reject-return-to-issuer + +Rejects a returned BoE and restores it to escrow (issuer restore). + +**Who Can Execute:** +Only the **issuer** (wallet with restorer role on the obligation registry). + +**Usage:** + +```sh +trustvc obligation-escrow reject-return-to-issuer +``` + +**Interactive Prompts:** + +- Path to BoE / obligation document +- Wallet/private key option +- Remark (optional) + +**Output:** +Transaction receipt confirming rejection (restore). ## Configuration + + ### Custom RPC Endpoints You can override the default RPC endpoints for any network by setting environment variables. The format is `{NETWORK_NAME}_RPC`. @@ -1381,6 +1616,8 @@ If no environment variable is set, the CLI will use the default RPC endpoint for ## Development + + ### Setup ```sh @@ -1397,6 +1634,8 @@ npm link npm test ``` + + ### Project Structure ``` @@ -1428,6 +1667,24 @@ src/commands/ │ ├── reject-transfer-holder.ts # Reject holder transfer │ ├── reject-transfer-owner.ts # Reject owner transfer │ └── reject-transfer-owner-holder.ts # Reject full transfer +├── obligation-registry/ +│ ├── deploy.ts # Deploy Obligation Registry +│ └── mint.ts # Mint BoE token to obligationRegistry +├── obligation-escrow/ +│ ├── accept.ts # Accept obligation +│ ├── reject.ts # Reject obligation +│ ├── discharge.ts # Discharge obligation +│ ├── status.ts # Read obligation / escrow status +│ ├── transfer-holder.ts # Transfer BoE holder +│ ├── nominate-transfer-owner.ts # Nominate BoE beneficiary +│ ├── endorse-transfer-owner.ts # Endorse BoE beneficiary change +│ ├── transfer-owner-holder.ts # Endorse full BoE transfer +│ ├── return-to-issuer.ts # Return BoE to issuer +│ ├── accept-return-to-issuer.ts # Accept returned BoE +│ ├── reject-return-to-issuer.ts # Reject returned BoE +│ ├── reject-transfer-holder.ts # Reject BoE holder transfer +│ ├── reject-transfer-owner.ts # Reject BoE owner transfer +│ └── reject-transfer-owner-holder.ts # Reject full BoE transfer ├── transaction/ │ └── cancel.ts # Cancel a pending transaction ├── wallet/ @@ -1444,6 +1701,125 @@ src/commands/ └── verify.ts # Verify W3C or OA document ``` + + +## Obligation Registry user guide + +User guide for electronic Bill of Exchange (BoE) / obligation flows with `trustvc-cli`. + +Classic transferable records (eBL / Token Registry + Title Escrow) use different commands. This section covers **only** Obligation Registry. + +For library / SDK details, see the TrustVC README [§7c Obligation Registry (BoE)](https://github.com/TrustVC/trustvc/blob/beta/README.md#c-obligation-registry-boe) (on the `beta` branch; not yet on `main`). + +SDK imports use the **root** package with the `*ObligationRegistry` suffix (no clash with classic ETR helpers): + +```ts +import { + mintObligationRegistry, + acceptObligationRegistry, + DocumentBuilder, + verifyDocument, +} from '@trustvc/trustvc'; +``` + +Build BoE credentials with `DocumentBuilder.obligationCredentialStatus({ obligationRegistry, chain, chainId, rpcProviderUrl })` (credential status uses `type: 'TransferableRecords'` plus an `obligationRegistry` field — not `tokenRegistry`). On-chain minting is separate via `obligation-registry mint` / `mintObligationRegistry`. + +### Who this is for + +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 +- Verify a BoE document + +You do **not** need to call the TypeScript SDK directly — the CLI wraps it with interactive prompts. + +### Before you start + +1. **Install the CLI** — `npm install -g @trustvc/trustvc-cli` or `npx @trustvc/trustvc-cli ` +2. **Node.js 22+** +3. **A wallet** — encrypted wallet file (recommended), private key, or key file +4. **A signed BoE document** — set `credentialStatus.obligationRegistry`, then sign with `trustvc w3c-sign` (not `tokenRegistry`) +5. **Network access** — select network in prompts, or set a custom RPC (e.g. `export SEPOLIA_RPC=…`) + + + +### Classic vs Obligation — pick the right commands + + +| Task | Classic ETR | Obligation / BoE | +| --------------- | ------------------------------ | ---------------------------- | +| Deploy registry | `token-registry deploy` | `obligation-registry deploy` | +| Mint | `mint` / `token-registry mint` | `obligation-registry mint` | +| Escrow actions | `title-escrow …` | `obligation-escrow …` | +| 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`). + +### Typical workflow + +```text +1. Deploy Obligation Registry +2. Put the registry address into your BoE credential status +3. Sign the credential with `trustvc w3c-sign` +4. Mint (issuer wallet) +5. Accept or reject (holder) — or transfer / discharge / return as needed +6. Verify with `trustvc verify` +``` + +Signing/building the VC can also be done with TrustVC library tools or your app (`DocumentBuilder` + `obligationRegistry`). With the CLI, use `w3c-sign` before minting. + +### Step-by-step + +**1. Deploy** — `trustvc obligation-registry deploy` (network, name/symbol, optional factory reuse, wallet, dry-run). + +**2. Sign** — put the deployed registry into `credentialStatus.obligationRegistry`, then run `trustvc w3c-sign` on the unsigned BoE JSON. + +**3. Mint** — `trustvc obligation-registry mint` with the signed BoE JSON from `w3c-sign` (registry, network, token ID, document ID are extracted). + +**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. + +**6. Transfers (optional)** — `obligation-escrow transfer-holder`, nominate/endorse/reject-transfer variants mirror `title-escrow`. + +**7. Discharge (optional)** — `trustvc obligation-escrow discharge` by **beneficiary** after Accepted (sets Discharged and burns). + +**8. Return to issuer** — same as classic ETR: **beneficiary == holder**, then issuer runs `reject-return-to-issuer` (restore) or `accept-return-to-issuer` (burn). No Rejected/Discharged status requirement. + +**9. Verify** — `trustvc verify` (auto-detects BoE vs ETR). + +### Who can run what + + +| Action | Typical role | +| ----------------------------------------------------- | ---------------------------------------------------------- | +| `obligation-registry deploy` | Deployer / issuer org | +| `obligation-registry mint` | Issuer (registry minter) | +| `obligation-escrow accept` / `reject` | Holder (roles split) | +| `obligation-escrow discharge` | Beneficiary | +| Transfer / nominate / endorse | Current holder or beneficiary per Title Escrow–style rules | +| `return-to-issuer` | Dual role (beneficiary == holder), same as ETR | +| `accept-return-to-issuer` / `reject-return-to-issuer` | Issuer | +| `obligation-escrow status` | Anyone with the document + wallet/private key (read-only) | +| `verify` | Anyone with the document (+ RPC when on-chain checks run) | + + + + +### What the CLI reads from your document + +For mint, escrow, and related flows, the CLI extracts: + +- Network +- `obligationRegistry` address +- Token ID +- Document ID (remark encryption key when remarks are set) + +If extraction fails with a message about classic transferable records / `tokenRegistry`, you are using the wrong document type or command family. + ## License -Apache-2.0 +Apache-2.0 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 737cc65..29c40c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@trustvc/trustvc-cli", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trustvc/trustvc-cli", - "version": "1.0.1", + "version": "1.1.0", "license": "Apache-2.0", "dependencies": { "@inquirer/prompts": "^5.3.8", - "@trustvc/trustvc": "^2.14.1", + "@trustvc/trustvc": "2.15.0-beta.3", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", "dotenv": "^16.0.0", @@ -134,6 +134,16 @@ "ethers": "^5.8.0" } }, + "node_modules/@account-abstraction/contracts": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@account-abstraction/contracts/-/contracts-0.8.0.tgz", + "integrity": "sha512-8krPx/gpnoT+5xAroagVCbeA7FbUigMZWXFKKPm+oghyr29Dksssdx5sI7xGv9212i4JPaDDUGFk58dpuwVgHA==", + "license": "MIT", + "dependencies": { + "@openzeppelin/contracts": "^5.1.0", + "@uniswap/v3-periphery": "^1.4.3" + } + }, "node_modules/@actions/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz", @@ -972,6 +982,81 @@ "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-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", @@ -2936,6 +3021,18 @@ "yarn": "1.x" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", @@ -3144,6 +3241,12 @@ "@octokit/openapi-types": "^27.0.0" } }, + "node_modules/@openzeppelin/contracts": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.6.1.tgz", + "integrity": "sha512-Ly6SlsVJ3mj+b18W3R8gNufB7dTICT105fJhodGAGgyC2oqnBAhqSiNDJ8V8DLY05cCz81GLI0CU5vNYA1EC/w==", + "license": "MIT" + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -3510,6 +3613,81 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/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/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -5004,9 +5182,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" @@ -5359,30 +5537,43 @@ "integrity": "sha512-YIECQwcoreIfyTbol1/5u9CGK6mbg0Q0bSN2/Ks388zLus1IXELWK5EHYuyrFPbb9d8ajsvz7m+ySsMIY9574w==", "license": "Apache-2.0" }, + "node_modules/@trustvc/eip7702": { + "version": "1.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@trustvc/eip7702/-/eip7702-1.0.0-beta.1.tgz", + "integrity": "sha512-cOGuZVLHd0+Uu7rZ/Dp2iF1VuNq+L+pWWGe+tPD/u74rSvCAzIc17dIk5HOwRBiC5awh1rXG46vLG8IRc5O01Q==", + "license": "MIT", + "dependencies": { + "@account-abstraction/contracts": "^0.8.0", + "@openzeppelin/contracts": "^5.6.1" + } + }, "node_modules/@trustvc/trustvc": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/@trustvc/trustvc/-/trustvc-2.14.1.tgz", - "integrity": "sha512-vGR6/s8nn69sl3Ntr/rE8+kg64RuqsRN2AeB0yeNOAUwTWEmQRk16cvB4g1xW7+CsLc5yI5sRuAqeAmbhHdV0g==", + "version": "2.15.0-beta.3", + "resolved": "https://registry.npmjs.org/@trustvc/trustvc/-/trustvc-2.15.0-beta.3.tgz", + "integrity": "sha512-EFiYmXZulyWnq+g3tb/yrpANkggE76QjDILuNE/68ZWdh9cw8yUxR+hcrqkk8tbhapmgulzQjtTuuASnuD1xog==", "license": "Apache-2.0", "dependencies": { "@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/eip7702": "^1.0.0-beta.1", + "@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", "node-fetch": "^2.7.0", "node-forge": "^1.3.3", - "ts-chacha20": "^1.2.0" + "permissionless": "^0.3.6", + "ts-chacha20": "^1.2.0", + "viem": "^2.53.1" }, "engines": { "node": ">=20.0.0" @@ -5469,33 +5660,33 @@ } }, "node_modules/@trustvc/trustvc/node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.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", @@ -5512,13 +5703,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" }, @@ -5527,9 +5718,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", @@ -5561,19 +5752,20 @@ } }, "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", @@ -5906,6 +6098,55 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true }, + "node_modules/@uniswap/lib": { + "version": "4.0.1-alpha", + "resolved": "https://registry.npmjs.org/@uniswap/lib/-/lib-4.0.1-alpha.tgz", + "integrity": "sha512-f6UIliwBbRsgVLxIaBANF6w09tYqc6Y/qXdsrbEmXHyFA7ILiKrIwRFXe1yOg8M3cksgVsO9N7yuL2DdCGQKBA==", + "license": "GPL-3.0-or-later", + "engines": { + "node": ">=10" + } + }, + "node_modules/@uniswap/v2-core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@uniswap/v2-core/-/v2-core-1.0.1.tgz", + "integrity": "sha512-MtybtkUPSyysqLY2U210NBDeCHX+ltHt3oADGdjqoThZaFRDKwM6k1Nb3F0A3hk5hwuQvytFWhrWHOEq6nVJ8Q==", + "license": "GPL-3.0-or-later", + "engines": { + "node": ">=10" + } + }, + "node_modules/@uniswap/v3-core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@uniswap/v3-core/-/v3-core-1.0.1.tgz", + "integrity": "sha512-7pVk4hEm00j9tc71Y9+ssYpO6ytkeI0y7WE9P6UcmNzhxPePwyAxImuhVsTqWK9YFvzgtvzJHi64pBl4jUzKMQ==", + "license": "BUSL-1.1", + "engines": { + "node": ">=10" + } + }, + "node_modules/@uniswap/v3-periphery": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@uniswap/v3-periphery/-/v3-periphery-1.4.4.tgz", + "integrity": "sha512-S4+m+wh8HbWSO3DKk4LwUCPZJTpCugIsHrWR86m/OrUyvSqGDTXKFfc2sMuGXCZrD1ZqO3rhQsKgdWg3Hbb2Kw==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@openzeppelin/contracts": "3.4.2-solc-0.7", + "@uniswap/lib": "^4.0.1-alpha", + "@uniswap/v2-core": "^1.0.1", + "@uniswap/v3-core": "^1.0.0", + "base64-sol": "1.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@uniswap/v3-periphery/node_modules/@openzeppelin/contracts": { + "version": "3.4.2-solc-0.7", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-3.4.2-solc-0.7.tgz", + "integrity": "sha512-W6QmqgkADuFcTLzHL8vVoNBtkwjvQRpYIAom7KiUNoLKghyx3FgH0GBjt8NRvigV1ZmMOBllvE1By1C+bi8WpA==", + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", @@ -6021,6 +6262,27 @@ "license": "ISC", "optional": true }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -6391,6 +6653,12 @@ "node": ">=14" } }, + "node_modules/base64-sol": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/base64-sol/-/base64-sol-1.0.1.tgz", + "integrity": "sha512-ld3cCNMeXt4uJXmLZBHFGMvVpK9KsLVEhPpFRXnvSVAqABKbuNZg/+dsq3NuM+wxFLb/UrVkz7m1ciWmkMfTbg==", + "license": "MIT" + }, "node_modules/base64url": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", @@ -6620,9 +6888,9 @@ } }, "node_modules/cborg": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-5.1.1.tgz", - "integrity": "sha512-BDbSRIp6XrQXkTc7g+DN0RB9RrDPTUfals2ecWUlt3juPLjbAvy/V72mJcXY0Ehu0Dq/3WpNCOCT68HUTbW+lw==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-5.1.11.tgz", + "integrity": "sha512-oc6Pzg/gkTobxHZNgMmny+G99dOeBMbAmnGHcZWMKtolxZBIVwfi0Pj0khxEtNU8HMFdbT5sK0HmtgecDUPP0A==", "license": "Apache-2.0", "bin": { "cborg": "lib/bin.js" @@ -8319,6 +8587,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -10441,6 +10715,21 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -13835,6 +14124,69 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ox": { + "version": "0.14.33", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.33.tgz", + "integrity": "sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/p-each-series": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", @@ -14076,6 +14428,21 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "license": "MIT" }, + "node_modules/permissionless": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/permissionless/-/permissionless-0.3.7.tgz", + "integrity": "sha512-r1UGWkVMqFzzJ0UcKEVHh6XvOm7+SfTvy7AoGA7ZlxpE96XR+zbtb+rRAA3DOQuq6/ifov5NvUyt1xf4zxbR0g==", + "license": "MIT", + "peerDependencies": { + "ox": "^0.11.3", + "viem": "^2.44.4" + }, + "peerDependenciesMeta": { + "ox": { + "optional": true + } + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -17012,6 +17379,84 @@ "extsprintf": "^1.2.0" } }, + "node_modules/viem": { + "version": "2.55.10", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.10.tgz", + "integrity": "sha512-Q9Ba+/ma81U2M5o5P2AQ7Ux8rTIwmCZvUcr8rKdQ22bV0IBFHllM2m5gWDP8hFaUN2nH2oW3QG44amRazflYNQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.33", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index 34257d5..93c3156 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ }, "dependencies": { "@inquirer/prompts": "^5.3.8", - "@trustvc/trustvc": "^2.14.1", + "@trustvc/trustvc": "2.15.0-beta.3", "@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 b11785e..24375a5 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -7,14 +7,19 @@ import { v4SupportInterfaceIds, v5SupportInterfaceIds, DocumentStore__factory, + encrypt, } from '@trustvc/trustvc'; -import { encrypt } from '@trustvc/trustvc'; // Internal utilities import { ConnectedSigner } from '../utils'; -// Contract factories from TrustVC v5 -const { TitleEscrow__factory, TradeTrustToken__factory, TDocDeployer__factory } = v5Contracts; +const { + TrustVCToken__factory, + ObligationEscrow__factory, + TitleEscrow__factory, + TradeTrustToken__factory, + TDocDeployer__factory, +} = v5Contracts; // Interface for connectToTokenRegistry function arguments interface ConnectToTokenRegistryArgs { @@ -27,6 +32,11 @@ interface ConnectToTDocDeployerContractArgs { wallet: Wallet | HDNodeWallet | ConnectedSigner | Signer; } +interface ConnectToObligationRegistryArgs { + address: string; + wallet: Wallet | HDNodeWallet | ConnectedSigner | Signer; +} + /** * Connects to a token registry contract instance. * @@ -419,3 +429,79 @@ export async function waitForTransaction(tx: any | string, provider: Provider, c throw new Error('Provider required for transaction hash'); } } + +/** + * Connects to a TrustVCToken (obligation registry) contract instance. + */ +export const connectToObligationRegistry = async ({ + address, + wallet, +}: ConnectToObligationRegistryArgs) => { + 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('Wallet provider is required to validate the obligation registry contract'); + } + const code = await provider.getCode(address); + if (!code || code === '0x') { + throw new Error(`Failed to connect to obligation registry at address: ${address}`); + } + signale.success(`Successfully connected to obligation registry`); + return registry; + } catch (error) { + signale.error( + `Error in connectToObligationRegistry: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } +}; + +interface ConnectToObligationEscrowArgs { + tokenId: string; + address: string; + wallet: Wallet | HDNodeWallet | ConnectedSigner | Signer; +} + +/** + * Resolves ObligationEscrow via ownerOf(tokenId) on the obligation registry and connects. + */ +export const connectToObligationEscrow = async ({ + tokenId, + address, + wallet, +}: ConnectToObligationEscrowArgs) => { + try { + signale.info(`Connecting to obligation registry at: ${address}`); + const registry = new ethers.Contract(address, TrustVCToken__factory.abi, wallet as any); + + signale.info(`Fetching obligation escrow address for tokenId: ${tokenId}`); + const escrowAddress = await registry.ownerOf(tokenId); + signale.info(`Obligation escrow address: ${escrowAddress}`); + + if (!escrowAddress || escrowAddress === ZeroAddress) { + throw new Error( + `Invalid obligation escrow address for tokenId: ${tokenId}. Address: ${escrowAddress}`, + ); + } + + signale.info(`Connecting to obligation escrow at: ${escrowAddress}`); + const escrow = new ethers.Contract(escrowAddress, ObligationEscrow__factory.abi, wallet as any); + const provider = wallet.provider; + if (!provider) { + throw new Error('Wallet provider is required to validate the obligation escrow contract'); + } + const code = await provider.getCode(escrowAddress); + if (!code || code === '0x') { + throw new Error(`Failed to connect to obligation escrow at address: ${escrowAddress}`); + } + signale.success(`Successfully connected to obligation escrow`); + return escrow; + } catch (error) { + signale.error( + `Error in connectToObligationEscrow: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } +}; diff --git a/src/commands/obligation-escrow/accept-return-to-issuer.ts b/src/commands/obligation-escrow/accept-return-to-issuer.ts new file mode 100644 index 0000000..eefdc55 --- /dev/null +++ b/src/commands/obligation-escrow/accept-return-to-issuer.ts @@ -0,0 +1,53 @@ +import { error, info, success } from 'signale'; +import { acceptReturnedObligationRegistry } from '@trustvc/trustvc'; +import { BaseObligationEscrowCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'accept-return-to-issuer'; +export const describe = 'Issuer accepts a returned BoE obligation (burn / shred)'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await acceptReturnedHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +export const promptForInputs = promptBaseObligationEscrowInputs; + +export const acceptReturnedHandler = async (args: BaseObligationEscrowCommand) => { + try { + info(`Accepting returned obligation ${args.tokenId}`); + const transaction = await runObligationEscrowTx({ + args, + // acceptReturned burns the title, which lives on the TrustVCToken registry, not the escrow. + populate: ({ registry }, encryptedRemark) => + registry.burn.populateTransaction(args.tokenId, encryptedRemark), + sdk: acceptReturnedObligationRegistry as any, + sdkParams: { tokenId: args.tokenId, remarks: args.remark }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Return accepted (burned)`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); + } catch (e) { + error(getErrorMessage(e)); + process.exitCode = 1; + } +}; diff --git a/src/commands/obligation-escrow/accept.ts b/src/commands/obligation-escrow/accept.ts new file mode 100644 index 0000000..49adc7d --- /dev/null +++ b/src/commands/obligation-escrow/accept.ts @@ -0,0 +1,51 @@ +import { error, info, success } from 'signale'; +import { acceptObligationRegistry } from '@trustvc/trustvc'; +import { BaseObligationEscrowCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'accept'; +export const describe = 'Holder accepts an issued BoE obligation (Issued → Accepted)'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await acceptHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +export const promptForInputs = promptBaseObligationEscrowInputs; + +export const acceptHandler = async (args: BaseObligationEscrowCommand) => { + try { + info(`Accepting obligation token ${args.tokenId} on ${args.obligationRegistryAddress}`); + const transaction = await runObligationEscrowTx({ + args, + populate: ({ escrow }, encryptedRemark) => escrow.accept.populateTransaction(encryptedRemark), + sdk: acceptObligationRegistry as any, + sdkParams: { remarks: args.remark }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Obligation ${args.tokenId} accepted`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); + } catch (e) { + error(getErrorMessage(e)); + process.exitCode = 1; + } +}; diff --git a/src/commands/obligation-escrow/discharge.ts b/src/commands/obligation-escrow/discharge.ts new file mode 100644 index 0000000..9539ddb --- /dev/null +++ b/src/commands/obligation-escrow/discharge.ts @@ -0,0 +1,53 @@ +import { error, info, success } from 'signale'; +import { dischargeObligationRegistry } from '@trustvc/trustvc'; +import { BaseObligationEscrowCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'discharge'; +export const describe = + 'Beneficiary discharges an accepted BoE obligation (Accepted → Discharged, burns)'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await dischargeHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +export const promptForInputs = promptBaseObligationEscrowInputs; + +export const dischargeHandler = async (args: BaseObligationEscrowCommand) => { + try { + info(`Discharging obligation token ${args.tokenId} on ${args.obligationRegistryAddress}`); + const transaction = await runObligationEscrowTx({ + args, + populate: ({ escrow }, encryptedRemark) => + escrow.discharge.populateTransaction(encryptedRemark), + sdk: dischargeObligationRegistry as any, + sdkParams: { remarks: args.remark }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Obligation ${args.tokenId} discharged`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); + } catch (e) { + error(getErrorMessage(e)); + process.exitCode = 1; + } +}; diff --git a/src/commands/obligation-escrow/endorse-transfer-owner.ts b/src/commands/obligation-escrow/endorse-transfer-owner.ts new file mode 100644 index 0000000..3166d5c --- /dev/null +++ b/src/commands/obligation-escrow/endorse-transfer-owner.ts @@ -0,0 +1,57 @@ +import { error, info, success } from 'signale'; +import { transferBeneficiaryObligationRegistry } from '@trustvc/trustvc'; +import { ObligationEscrowNominateBeneficiaryCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + promptAddress, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'endorse-transfer-owner'; +export const describe = 'Endorse / transfer beneficiary (owner) of a BoE obligation'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await endorseHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +export const promptForInputs = async (): Promise => { + const base = await promptBaseObligationEscrowInputs(); + const newBeneficiary = await promptAddress('new beneficiary', 'endorsed owner'); + return { ...base, newBeneficiary } as ObligationEscrowNominateBeneficiaryCommand; +}; + +export const endorseHandler = async (args: ObligationEscrowNominateBeneficiaryCommand) => { + try { + info(`Transferring beneficiary to ${args.newBeneficiary} for obligation ${args.tokenId}`); + const transaction = await runObligationEscrowTx({ + args, + populate: ({ escrow }, encryptedRemark) => + escrow.transferBeneficiary.populateTransaction(args.newBeneficiary, encryptedRemark), + sdk: transferBeneficiaryObligationRegistry as any, + sdkParams: { newBeneficiaryAddress: args.newBeneficiary, remarks: args.remark }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Beneficiary transferred to ${args.newBeneficiary}`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); + } catch (e) { + error(getErrorMessage(e)); + process.exitCode = 1; + } +}; diff --git a/src/commands/obligation-escrow/index.ts b/src/commands/obligation-escrow/index.ts new file mode 100644 index 0000000..28969ed --- /dev/null +++ b/src/commands/obligation-escrow/index.ts @@ -0,0 +1,11 @@ +import { Argv } from 'yargs'; + +export const command = 'obligation-escrow '; + +export const describe = + 'Invoke ObligationEscrow lifecycle / transfer functions for BoE obligation records'; + +export const builder = (yargs: Argv): Argv => + yargs.commandDir(__dirname, { extensions: ['ts', 'js'] }); + +export const handler = (): void => {}; diff --git a/src/commands/obligation-escrow/nominate-transfer-owner.ts b/src/commands/obligation-escrow/nominate-transfer-owner.ts new file mode 100644 index 0000000..7a4397d --- /dev/null +++ b/src/commands/obligation-escrow/nominate-transfer-owner.ts @@ -0,0 +1,44 @@ +import { info, success } from 'signale'; +import { nominateObligationRegistry } from '@trustvc/trustvc'; +import { ObligationEscrowNominateBeneficiaryCommand } from '../../types'; +import { + displayTransactionPrice, + getEtherscanAddress, + NetworkCmdName, + promptAddress, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs, runObligationEscrowCommand } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'nominate-transfer-owner'; +export const describe = 'Nominate a new beneficiary (owner) for a BoE obligation'; + +export const handler = async (): Promise => + runObligationEscrowCommand(promptForInputs, nominateHandler); + +export const promptForInputs = async (): Promise => { + const base = await promptBaseObligationEscrowInputs(); + const newBeneficiary = await promptAddress('new beneficiary', 'nominee'); + return { ...base, newBeneficiary } as ObligationEscrowNominateBeneficiaryCommand; +}; + +export const nominateHandler = async (args: ObligationEscrowNominateBeneficiaryCommand) => { + info(`Nominating beneficiary ${args.newBeneficiary} for obligation ${args.tokenId}`); + const transaction = await runObligationEscrowTx({ + args, + populate: ({ escrow }, encryptedRemark) => + escrow.nominate.populateTransaction(args.newBeneficiary, encryptedRemark), + sdk: nominateObligationRegistry as any, + sdkParams: { newBeneficiaryAddress: args.newBeneficiary, remarks: args.remark }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Nominated ${args.newBeneficiary}`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); +}; diff --git a/src/commands/obligation-escrow/reject-return-to-issuer.ts b/src/commands/obligation-escrow/reject-return-to-issuer.ts new file mode 100644 index 0000000..f810a53 --- /dev/null +++ b/src/commands/obligation-escrow/reject-return-to-issuer.ts @@ -0,0 +1,53 @@ +import { error, info, success } from 'signale'; +import { rejectReturnedObligationRegistry } from '@trustvc/trustvc'; +import { BaseObligationEscrowCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'reject-return-to-issuer'; +export const describe = 'Issuer rejects a returned BoE obligation (restore to escrow)'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await rejectReturnedHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +export const promptForInputs = promptBaseObligationEscrowInputs; + +export const rejectReturnedHandler = async (args: BaseObligationEscrowCommand) => { + try { + info(`Rejecting returned obligation ${args.tokenId} (restore)`); + const transaction = await runObligationEscrowTx({ + args, + // rejectReturned restores the title, which lives on the TrustVCToken registry, not the escrow. + populate: ({ registry }, encryptedRemark) => + registry.restore.populateTransaction(args.tokenId, encryptedRemark), + sdk: rejectReturnedObligationRegistry as any, + sdkParams: { tokenId: args.tokenId, remarks: args.remark }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Return rejected (restored to escrow)`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); + } catch (e) { + error(getErrorMessage(e)); + process.exitCode = 1; + } +}; diff --git a/src/commands/obligation-escrow/reject-transfer-holder.ts b/src/commands/obligation-escrow/reject-transfer-holder.ts new file mode 100644 index 0000000..4226765 --- /dev/null +++ b/src/commands/obligation-escrow/reject-transfer-holder.ts @@ -0,0 +1,39 @@ +import { info, success } from 'signale'; +import { rejectTransferHolderObligationRegistry } from '@trustvc/trustvc'; +import { BaseObligationEscrowCommand } from '../../types'; +import { + displayTransactionPrice, + getEtherscanAddress, + NetworkCmdName, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs, runObligationEscrowCommand } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'reject-transfer-holder'; +export const describe = 'Reject a pending holder transfer on a BoE obligation'; + +export const handler = async (): Promise => + runObligationEscrowCommand(promptForInputs, rejectTransferHolderHandler); + +export const promptForInputs = promptBaseObligationEscrowInputs; + +export const rejectTransferHolderHandler = async (args: BaseObligationEscrowCommand) => { + info(`Rejecting holder transfer for obligation ${args.tokenId}`); + const transaction = await runObligationEscrowTx({ + args, + populate: ({ escrow }, encryptedRemark) => + escrow.rejectTransferHolder.populateTransaction(encryptedRemark), + sdk: rejectTransferHolderObligationRegistry as any, + sdkParams: { remarks: args.remark }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Holder transfer rejected`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); +}; diff --git a/src/commands/obligation-escrow/reject-transfer-owner-holder.ts b/src/commands/obligation-escrow/reject-transfer-owner-holder.ts new file mode 100644 index 0000000..dc965c9 --- /dev/null +++ b/src/commands/obligation-escrow/reject-transfer-owner-holder.ts @@ -0,0 +1,39 @@ +import { info, success } from 'signale'; +import { rejectTransferOwnersObligationRegistry } from '@trustvc/trustvc'; +import { BaseObligationEscrowCommand } from '../../types'; +import { + displayTransactionPrice, + getEtherscanAddress, + NetworkCmdName, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs, runObligationEscrowCommand } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'reject-transfer-owner-holder'; +export const describe = 'Reject a pending joint owner+holder transfer on a BoE obligation'; + +export const handler = async (): Promise => + runObligationEscrowCommand(promptForInputs, rejectTransferOwnersHandler); + +export const promptForInputs = promptBaseObligationEscrowInputs; + +export const rejectTransferOwnersHandler = async (args: BaseObligationEscrowCommand) => { + info(`Rejecting owners transfer for obligation ${args.tokenId}`); + const transaction = await runObligationEscrowTx({ + args, + populate: ({ escrow }, encryptedRemark) => + escrow.rejectTransferOwners.populateTransaction(encryptedRemark), + sdk: rejectTransferOwnersObligationRegistry as any, + sdkParams: { remarks: args.remark }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Owners transfer rejected`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); +}; diff --git a/src/commands/obligation-escrow/reject-transfer-owner.ts b/src/commands/obligation-escrow/reject-transfer-owner.ts new file mode 100644 index 0000000..0ecdbf0 --- /dev/null +++ b/src/commands/obligation-escrow/reject-transfer-owner.ts @@ -0,0 +1,52 @@ +import { error, info, success } from 'signale'; +import { rejectTransferBeneficiaryObligationRegistry } from '@trustvc/trustvc'; +import { BaseObligationEscrowCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'reject-transfer-owner'; +export const describe = 'Reject a pending beneficiary transfer on a BoE obligation'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await rejectTransferOwnerHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +export const promptForInputs = promptBaseObligationEscrowInputs; + +export const rejectTransferOwnerHandler = async (args: BaseObligationEscrowCommand) => { + try { + info(`Rejecting beneficiary transfer for obligation ${args.tokenId}`); + const transaction = await runObligationEscrowTx({ + args, + populate: ({ escrow }, encryptedRemark) => + escrow.rejectTransferBeneficiary.populateTransaction(encryptedRemark), + sdk: rejectTransferBeneficiaryObligationRegistry as any, + sdkParams: { remarks: args.remark }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Beneficiary transfer rejected`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); + } catch (e) { + error(getErrorMessage(e)); + process.exitCode = 1; + } +}; diff --git a/src/commands/obligation-escrow/reject.ts b/src/commands/obligation-escrow/reject.ts new file mode 100644 index 0000000..1a956f5 --- /dev/null +++ b/src/commands/obligation-escrow/reject.ts @@ -0,0 +1,51 @@ +import { error, info, success } from 'signale'; +import { rejectObligationRegistry } from '@trustvc/trustvc'; +import { BaseObligationEscrowCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'reject'; +export const describe = 'Holder rejects an issued BoE obligation (Issued → Rejected, burns)'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await rejectHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +export const promptForInputs = promptBaseObligationEscrowInputs; + +export const rejectHandler = async (args: BaseObligationEscrowCommand) => { + try { + info(`Rejecting obligation token ${args.tokenId} on ${args.obligationRegistryAddress}`); + const transaction = await runObligationEscrowTx({ + args, + populate: ({ escrow }, encryptedRemark) => escrow.reject.populateTransaction(encryptedRemark), + sdk: rejectObligationRegistry as any, + sdkParams: { remarks: args.remark }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Obligation ${args.tokenId} rejected`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); + } catch (e) { + error(getErrorMessage(e)); + process.exitCode = 1; + } +}; diff --git a/src/commands/obligation-escrow/return-to-issuer.ts b/src/commands/obligation-escrow/return-to-issuer.ts new file mode 100644 index 0000000..5dcd6b8 --- /dev/null +++ b/src/commands/obligation-escrow/return-to-issuer.ts @@ -0,0 +1,52 @@ +import { error, info, success } from 'signale'; +import { returnToIssuerObligationRegistry } from '@trustvc/trustvc'; +import { BaseObligationEscrowCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'return-to-issuer'; +export const describe = 'Return a BoE obligation to the issuer (dual role, same as classic ETR)'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await returnToIssuerHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +export const promptForInputs = promptBaseObligationEscrowInputs; + +export const returnToIssuerHandler = async (args: BaseObligationEscrowCommand) => { + try { + info(`Returning obligation ${args.tokenId} to issuer`); + const transaction = await runObligationEscrowTx({ + args, + populate: ({ escrow }, encryptedRemark) => + escrow.returnToIssuer.populateTransaction(encryptedRemark), + sdk: returnToIssuerObligationRegistry as any, + sdkParams: { remarks: args.remark }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Returned to issuer`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); + } catch (e) { + error(getErrorMessage(e)); + process.exitCode = 1; + } +}; diff --git a/src/commands/obligation-escrow/runTx.ts b/src/commands/obligation-escrow/runTx.ts new file mode 100644 index 0000000..d8218c1 --- /dev/null +++ b/src/commands/obligation-escrow/runTx.ts @@ -0,0 +1,93 @@ +import signale from 'signale'; +import { BytesLike, TransactionReceipt } from 'ethers'; +import { CHAIN_ID } from '@trustvc/trustvc'; +import { + canEstimateGasPrice, + getGasFees, + getSupportedNetwork, + getWalletOrSigner, + performDryRunWithConfirmation, +} from '../../utils'; +import { + connectToObligationEscrow, + connectToObligationRegistry, + validateAndEncryptRemark, +} from '../helpers'; +import { BaseObligationEscrowCommand } from '../../types'; + +type PopulateFn = ( + contracts: { escrow: any; registry: any }, + encryptedRemark: BytesLike, +) => Promise; + +type SdkCall = ( + contractOptions: { obligationRegistryAddress: string; tokenId: string }, + wallet: any, + params: Record, + txOptions: Record, +) => Promise<{ hash: string; wait: () => Promise }>; + +/** + * Shared dry-run + gas + SDK send for obligation-escrow write methods that take remarks. + */ +export const runObligationEscrowTx = async (opts: { + args: BaseObligationEscrowCommand; + populate: PopulateFn; + sdk: SdkCall; + sdkParams: Record; +}): Promise => { + const { args, populate, sdk, sdkParams } = opts; + const { obligationRegistryAddress, tokenId, remark, encryptionKey, network, ...rest } = args; + const wallet = await getWalletOrSigner({ network, ...rest }); + const networkId = getSupportedNetwork(network).networkId; + + const shouldProceed = await performDryRunWithConfirmation({ + network, + getTransactionCallback: async () => { + const escrow = await connectToObligationEscrow({ + tokenId, + address: obligationRegistryAddress, + wallet, + }); + const registry = await connectToObligationRegistry({ + address: obligationRegistryAddress, + wallet, + }); + const encryptedRemark = validateAndEncryptRemark(remark, encryptionKey); + const tx = await populate({ escrow, registry }, encryptedRemark); + return { ...tx, from: await wallet.getAddress() }; + }, + }); + + if (!shouldProceed) { + return null; + } + + const contractOptions = { obligationRegistryAddress, tokenId }; + let transaction; + + if (canEstimateGasPrice(network)) { + if (!wallet.provider) { + throw new Error('Provider is required for gas estimation'); + } + const gasFees = await getGasFees({ provider: wallet.provider, ...rest }); + transaction = await sdk(contractOptions, wallet, sdkParams, { + chainId: networkId as unknown as CHAIN_ID, + maxFeePerGas: gasFees.maxFeePerGas?.toString(), + maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas?.toString(), + id: encryptionKey, + }); + } else { + transaction = await sdk(contractOptions, wallet, sdkParams, { + chainId: networkId as unknown as CHAIN_ID, + id: encryptionKey, + }); + } + + signale.await(`Waiting for transaction ${transaction.hash} to be mined`); + const receipt = (await transaction.wait()) as unknown as TransactionReceipt; + if (!receipt) { + throw new Error('Transaction receipt not found'); + } + return receipt; +}; diff --git a/src/commands/obligation-escrow/shared.ts b/src/commands/obligation-escrow/shared.ts new file mode 100644 index 0000000..149100d --- /dev/null +++ b/src/commands/obligation-escrow/shared.ts @@ -0,0 +1,60 @@ +import { error } from 'signale'; +import { + promptWalletSelection, + promptAndReadDocument, + promptRemark, + extractObligationDocumentInfo, + verifyDocumentSignature, + getErrorMessage, +} from '../../utils'; +import { BaseObligationEscrowCommand } from '../../types'; + +/** + * Shared prompt flow for obligation-escrow commands that only need document + wallet + remark. + */ +export const promptBaseObligationEscrowInputs = async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + const { obligationRegistry, tokenId, network, documentId } = + await extractObligationDocumentInfo(document); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark('v5'); + + const baseResult = { + network, + obligationRegistryAddress: obligationRegistry, + tokenId, + remark, + encryptionKey: documentId, + maxPriorityFeePerGasScale: 1, + }; + + if (encryptedWalletPath) { + return { ...baseResult, encryptedWalletPath } as BaseObligationEscrowCommand; + } + if (keyFile) { + return { ...baseResult, keyFile } as BaseObligationEscrowCommand; + } + if (key) { + return { ...baseResult, key } as BaseObligationEscrowCommand; + } + return baseResult as BaseObligationEscrowCommand; +}; + +/** + * Runs prompt → command with shared failure logging and non-zero exit on error. + * Prompt cancellation (falsy answers) returns without setting exitCode. + */ +export const runObligationEscrowCommand = async ( + promptForInputs: () => Promise, + commandHandler: (args: TArgs) => Promise, +): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await commandHandler(answers); + } catch (e) { + error(getErrorMessage(e)); + process.exitCode = 1; + } +}; diff --git a/src/commands/obligation-escrow/status.ts b/src/commands/obligation-escrow/status.ts new file mode 100644 index 0000000..ed1fff0 --- /dev/null +++ b/src/commands/obligation-escrow/status.ts @@ -0,0 +1,48 @@ +import { info, success } from 'signale'; +import { + ObligationDocumentStatus, + ObligationEscrowTerminationReason, + getObligationEscrowTerminationReason, + getObligationRegistryStatus, + isObligationRegistryRegistered, +} from '@trustvc/trustvc'; +import { BaseObligationEscrowCommand } from '../../types'; +import { getWalletOrSigner } from '../../utils'; +import { promptBaseObligationEscrowInputs, runObligationEscrowCommand } from './shared'; + +export const command = 'status'; +export const describe = 'Read BoE obligation escrow status / registration / termination reason'; + +const STATUS_LABEL: Record = { + [ObligationDocumentStatus.Issued]: 'Issued', + [ObligationDocumentStatus.Accepted]: 'Accepted', + [ObligationDocumentStatus.Rejected]: 'Rejected', + [ObligationDocumentStatus.Discharged]: 'Discharged', +}; +const REASON_LABEL: Record = { + [ObligationEscrowTerminationReason.None]: 'None', + [ObligationEscrowTerminationReason.ReturnToIssuer]: 'ReturnToIssuer', + [ObligationEscrowTerminationReason.Rejected]: 'Rejected', + [ObligationEscrowTerminationReason.Discharged]: 'Discharged', +}; + +export const handler = async (): Promise => + runObligationEscrowCommand(promptForInputs, statusHandler); + +export const promptForInputs = promptBaseObligationEscrowInputs; + +export const statusHandler = async (args: BaseObligationEscrowCommand) => { + const { obligationRegistryAddress, tokenId, network, ...rest } = args; + const wallet = await getWalletOrSigner({ network, ...rest }); + // Escrow resolution uses contractOptions.tokenId (params.tokenId is API parity only). + const opts = { obligationRegistryAddress, tokenId }; + + const status = await getObligationRegistryStatus(opts, wallet, { tokenId }); + const registered = await isObligationRegistryRegistered(opts, wallet, { tokenId }); + const reason = await getObligationEscrowTerminationReason(opts, wallet, { tokenId }); + + success(`Obligation ${tokenId} on ${obligationRegistryAddress}`); + info(` Status: ${STATUS_LABEL[status] ?? status} (${status})`); + info(` Registered: ${registered}`); + info(` Termination reason: ${REASON_LABEL[reason] ?? reason} (${reason})`); +}; diff --git a/src/commands/obligation-escrow/transfer-holder.ts b/src/commands/obligation-escrow/transfer-holder.ts new file mode 100644 index 0000000..c3a1aa1 --- /dev/null +++ b/src/commands/obligation-escrow/transfer-holder.ts @@ -0,0 +1,60 @@ +import { error, info, success, warn } from 'signale'; +import { transferHolderObligationRegistry } from '@trustvc/trustvc'; +import { ObligationEscrowTransferHolderCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + promptAddress, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'transfer-holder'; +export const describe = 'Transfer the holder of a BoE obligation record'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await changeHolderHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +export const promptForInputs = async (): Promise => { + const base = await promptBaseObligationEscrowInputs(); + const newHolder = await promptAddress('new holder', 'new holder'); + return { ...base, newHolder } as ObligationEscrowTransferHolderCommand; +}; + +export const changeHolderHandler = async (args: ObligationEscrowTransferHolderCommand) => { + try { + info( + `Changing holder of obligation ${args.tokenId} on ${args.obligationRegistryAddress} to ${args.newHolder}`, + ); + warn('Only the current holder can transfer the holder role.'); + const transaction = await runObligationEscrowTx({ + args, + populate: ({ escrow }, encryptedRemark) => + escrow.transferHolder.populateTransaction(args.newHolder, encryptedRemark), + sdk: transferHolderObligationRegistry as any, + sdkParams: { holderAddress: args.newHolder, remarks: args.remark }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Holder changed to ${args.newHolder}`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); + } catch (e) { + error(getErrorMessage(e)); + process.exitCode = 1; + } +}; diff --git a/src/commands/obligation-escrow/transfer-owner-holder.ts b/src/commands/obligation-escrow/transfer-owner-holder.ts new file mode 100644 index 0000000..a37da7c --- /dev/null +++ b/src/commands/obligation-escrow/transfer-owner-holder.ts @@ -0,0 +1,54 @@ +import { info, success } from 'signale'; +import { transferOwnersObligationRegistry } from '@trustvc/trustvc'; +import { ObligationEscrowEndorseTransferOfOwnersCommand } from '../../types'; +import { + displayTransactionPrice, + getEtherscanAddress, + NetworkCmdName, + promptAddress, + TransactionReceiptFees, +} from '../../utils'; +import { promptBaseObligationEscrowInputs, runObligationEscrowCommand } from './shared'; +import { runObligationEscrowTx } from './runTx'; + +export const command = 'transfer-owner-holder'; +export const describe = 'Transfer both beneficiary and holder of a BoE obligation'; + +export const handler = async (): Promise => + runObligationEscrowCommand(promptForInputs, transferOwnersHandler); + +export const promptForInputs = + async (): Promise => { + const base = await promptBaseObligationEscrowInputs(); + const newOwner = await promptAddress('new beneficiary', 'new owner'); + const newHolder = await promptAddress('new holder', 'new holder'); + return { ...base, newOwner, newHolder } as ObligationEscrowEndorseTransferOfOwnersCommand; + }; + +export const transferOwnersHandler = async ( + args: ObligationEscrowEndorseTransferOfOwnersCommand, +) => { + info( + `Transferring owners of obligation ${args.tokenId} to owner ${args.newOwner} / holder ${args.newHolder}`, + ); + const transaction = await runObligationEscrowTx({ + args, + populate: ({ escrow }, encryptedRemark) => + escrow.transferOwners.populateTransaction(args.newOwner, args.newHolder, encryptedRemark), + sdk: transferOwnersObligationRegistry as any, + sdkParams: { + newBeneficiaryAddress: args.newOwner, + newHolderAddress: args.newHolder, + remarks: args.remark, + }, + }); + if (!transaction) return; + displayTransactionPrice( + transaction as unknown as TransactionReceiptFees, + args.network as NetworkCmdName, + ); + success(`Owners transferred`); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); +}; diff --git a/src/commands/obligation-registry/deploy.ts b/src/commands/obligation-registry/deploy.ts new file mode 100644 index 0000000..3c6cc55 --- /dev/null +++ b/src/commands/obligation-registry/deploy.ts @@ -0,0 +1,149 @@ +import { error, info, success } from 'signale'; +import { CHAIN_ID, deployObligationRegistry } from '@trustvc/trustvc'; +import { input, confirm } from '@inquirer/prompts'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + promptWalletSelection, + promptNetworkSelection, + getWalletOrSigner, + getSupportedNetwork, + performDryRunWithConfirmation, + promptAddress, + supportedNetwork, +} from '../../utils'; +import { TransactionReceipt } from 'ethers'; + +export const command = 'deploy'; + +export const describe = 'Deploys an Obligation Registry (TrustVCToken + ObligationEscrowFactory)'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + await deployObligationRegistryContract(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +type DeployObligationRegistryCommand = { + registryName: string; + registrySymbol: string; + escrowFactoryAddress?: string; + network: string; + encryptedWalletPath?: string; + key?: string; + keyFile?: string; + maxPriorityFeePerGasScale: number; +}; + +export const promptForInputs = async (): Promise => { + const network = await promptNetworkSelection(); + + const registryName = await input({ + message: 'Enter the name of the obligation registry:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Registry name is required'; + } + return true; + }, + }); + + const registrySymbol = await input({ + message: 'Enter the symbol of the obligation registry:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Registry symbol is required'; + } + return true; + }, + }); + + const reuseFactory = await confirm({ + message: 'Reuse an existing ObligationEscrowFactory address?', + default: false, + }); + + const escrowFactoryAddress = reuseFactory + ? await promptAddress('ObligationEscrowFactory', 'optional', true) + : undefined; + + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + const baseResult = { + network, + registryName, + registrySymbol, + escrowFactoryAddress, + maxPriorityFeePerGasScale: 1, + }; + + if (encryptedWalletPath) { + return { ...baseResult, encryptedWalletPath } as DeployObligationRegistryCommand; + } + if (keyFile) { + return { ...baseResult, keyFile } as DeployObligationRegistryCommand; + } + if (key) { + return { ...baseResult, key } as DeployObligationRegistryCommand; + } + return baseResult as DeployObligationRegistryCommand; +}; + +export const deployObligationRegistryContract = async ({ + network, + registryName, + registrySymbol, + escrowFactoryAddress, + ...rest +}: DeployObligationRegistryCommand) => { + try { + const wallet = await getWalletOrSigner({ network, ...rest }); + const chainId = supportedNetwork[network as NetworkCmdName].networkId; + + const shouldProceed = await performDryRunWithConfirmation({ + network, + getTransactionCallback: async () => ({ + to: await wallet.getAddress(), + data: '0x', + from: await wallet.getAddress(), + value: 0n, + }), + }); + + if (!shouldProceed) { + process.exit(0); + } + + info(`Deploying obligation registry ${registryName}`); + + const result = await deployObligationRegistry(registryName, registrySymbol, wallet, { + chainId: getSupportedNetwork(network).networkId as unknown as CHAIN_ID, + escrowFactoryAddress, + }); + + displayTransactionPrice(result.receipt as any, network as NetworkCmdName); + + const { hash } = result.receipt as unknown as TransactionReceipt; + success(`Obligation registry ${registryName} deployed at ${result.obligationRegistry}`); + info(`ObligationEscrowFactory: ${result.obligationEscrowFactoryAddress}`); + info(`Obligation Registry: ${result.obligationRegistry}`); + info( + `Use this Obligation Registry address as credentialStatus.obligationRegistry in your document before signing and minting.`, + ); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${hash}`); + info(`Chain ID used: ${chainId}`); + + return result.obligationRegistry; + } catch (e) { + error(getErrorMessage(e)); + } +}; diff --git a/src/commands/obligation-registry/index.ts b/src/commands/obligation-registry/index.ts new file mode 100644 index 0000000..0ac0d6c --- /dev/null +++ b/src/commands/obligation-registry/index.ts @@ -0,0 +1,11 @@ +import { Argv } from 'yargs'; + +export const command = 'obligation-registry '; + +export const describe = + 'Invoke a function over an Obligation Registry (BoE / TrustVCToken) smart contract'; + +export const builder = (yargs: Argv): Argv => + yargs.commandDir(__dirname, { extensions: ['ts', 'js'] }); + +export const handler = (): void => {}; diff --git a/src/commands/obligation-registry/mint.ts b/src/commands/obligation-registry/mint.ts new file mode 100644 index 0000000..9997df1 --- /dev/null +++ b/src/commands/obligation-registry/mint.ts @@ -0,0 +1,161 @@ +import signale, { error, info, success } from 'signale'; +import { ObligationRegistryMintCommand } from '../../types'; +import { + addAddressPrefix, + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + promptWalletSelection, + getWalletOrSigner, + canEstimateGasPrice, + getGasFees, + extractObligationDocumentInfo, + promptAndReadDocument, + promptRemark, + promptAddress, + performDryRunWithConfirmation, + verifyDocumentSignature, +} from '../../utils'; +import { connectToObligationRegistry, validateAndEncryptRemark } from '../helpers'; +import { TransactionReceipt } from 'ethers'; +import { mintObligationRegistry } from '@trustvc/trustvc'; + +export const command = 'mint'; + +export const describe = 'Mint a tokenId to an Obligation Registry deployed on the blockchain'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + await mintObligationToken(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +export const promptForInputs = async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + + const { obligationRegistry, tokenId, network, documentId } = + await extractObligationDocumentInfo(document); + + const beneficiary = await promptAddress('beneficiary', 'initial recipient'); + const holder = await promptAddress('holder', 'initial holder'); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark('v5'); + const encryptionKey = documentId; + + const baseResult = { + network, + address: obligationRegistry, + tokenId, + beneficiary, + holder, + remark, + encryptionKey, + maxPriorityFeePerGasScale: 1, + }; + + if (encryptedWalletPath) { + return { ...baseResult, encryptedWalletPath } as ObligationRegistryMintCommand; + } + if (keyFile) { + return { ...baseResult, keyFile } as ObligationRegistryMintCommand; + } + if (key) { + return { ...baseResult, key } as ObligationRegistryMintCommand; + } + return baseResult as ObligationRegistryMintCommand; +}; + +export const mintObligationToken = async (args: ObligationRegistryMintCommand) => { + try { + info( + `Issuing ${args.tokenId} to recipient ${args.beneficiary} and holder ${args.holder} in obligation registry ${args.address}`, + ); + + const transaction = await mintToObligationRegistry({ + ...args, + tokenId: addAddressPrefix(args.tokenId), + }); + + displayTransactionPrice(transaction as any, args.network as NetworkCmdName); + success( + `Token ${args.tokenId} minted on obligation registry ${args.address} (beneficiary ${args.beneficiary}, holder ${args.holder})`, + ); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transaction.hash}`, + ); + return args.address; + } catch (e) { + error(getErrorMessage(e)); + } +}; + +const mintToObligationRegistry = async ({ + address, + beneficiary, + holder, + tokenId, + remark, + encryptionKey, + network, + ...rest +}: ObligationRegistryMintCommand): Promise => { + const wallet = await getWalletOrSigner({ network, ...rest }); + + const shouldProceed = await performDryRunWithConfirmation({ + network, + getTransactionCallback: async () => { + const registry = await connectToObligationRegistry({ address, wallet }); + const encryptedRemark = validateAndEncryptRemark(remark, encryptionKey); + const tx = await registry.mint.populateTransaction( + beneficiary, + holder, + tokenId, + encryptedRemark, + ); + return { ...tx, from: await wallet.getAddress() }; + }, + }); + + if (!shouldProceed) { + process.exit(0); + } + + let transaction; + if (canEstimateGasPrice(network)) { + if (!wallet.provider) { + throw new Error('Provider is required for gas estimation'); + } + const gasFees = await getGasFees({ provider: wallet.provider, ...rest }); + transaction = await mintObligationRegistry( + { obligationRegistryAddress: address }, + wallet, + { beneficiaryAddress: beneficiary, holderAddress: holder, tokenId, remarks: remark }, + { + id: encryptionKey, + maxFeePerGas: gasFees.maxFeePerGas?.toString(), + maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas?.toString(), + }, + ); + } else { + transaction = await mintObligationRegistry( + { obligationRegistryAddress: address }, + wallet, + { beneficiaryAddress: beneficiary, holderAddress: holder, tokenId, remarks: remark }, + { id: encryptionKey }, + ); + } + + signale.await(`Waiting for transaction ${transaction.hash} to be mined`); + const receipt = (await transaction.wait()) as unknown as TransactionReceipt; + if (!receipt) { + throw new Error('Transaction receipt not found'); + } + return receipt; +}; diff --git a/src/commands/verify.ts b/src/commands/verify.ts index 0220349..25d9084 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -11,6 +11,7 @@ import { getChainId, getDocumentData, isDocumentRevokable, + isObligationRecord, isTransferableRecord, isWrappedV2Document, isWrappedV3Document, @@ -24,8 +25,37 @@ import signale from 'signale'; import type { Provider as V5Provider } from '@ethersproject/providers'; import { FragmentType } from '../types'; +const OBLIGATION_RECORDS_FRAGMENT = 'ObligationRecords'; + +type ObligationDocumentStatusInfo = { + obligationRegistry: string; + status?: number; + terminationReason?: number; +}; + +/** Extract obligation registry info from a VALID ObligationRecords verify fragment. */ +export const getObligationDocumentStatus = ( + fragments: VerificationFragment[], +): ObligationDocumentStatusInfo | null => { + const fragment = fragments.find((f) => f.name === OBLIGATION_RECORDS_FRAGMENT); + if (!fragment || fragment.status !== 'VALID') return null; + + const data = ( + fragment as { + data?: { obligationRegistry?: string; status?: number; terminationReason?: number }; + } + ).data; + if (!data?.obligationRegistry) return null; + + return { + obligationRegistry: data.obligationRegistry, + status: data.status, + terminationReason: data.terminationReason, + }; +}; + export const command = 'verify'; -export const describe = 'Verify a document signed using w3c or OpenAttestation'; +export const describe = 'Verify a W3C or OpenAttestation document (ETR, BoE, or revocable VC)'; export const handler = async () => { try { @@ -69,6 +99,18 @@ export const verify = async (signedVC: SignedVerifiableCredential) => { logResultStatus(getResultFromFragment(FragmentType.DOCUMENT_INTEGRITY, result)); logResultStatus(getResultFromFragment(FragmentType.DOCUMENT_STATUS, result)); logResultStatus(getResultFromFragment(FragmentType.ISSUER_IDENTITY, result)); + + 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(' ')}`); + } }; // ==== Helper Functions ==== @@ -80,8 +122,13 @@ const verifyW3CDocument = async ( // To capture the console.warn from trustvc function const { result: isTransferable } = CaptureConsoleWarn(() => isTransferableRecord(signedVC)); + const isObligation = isObligationRecord(signedVC); const isRevokable = isDocumentRevokable(signedVC); - const requiresNetwork = isTransferable || isRevokable; + const requiresNetwork = isTransferable || isObligation || isRevokable; + + if (isObligation) { + signale.info('Verifying obligation / BoE document...'); + } // If the document is not transferable or revokable, verify directly // To capture the console.warn from trustvc function @@ -99,6 +146,12 @@ const verifyW3CDocument = async ( signale.warn(`${err instanceof Error ? err.message : String(err)}`); } + const networkName = await promptNetworkSelection(); + const provider = getSupportedNetwork(networkName).provider() as unknown as V5Provider; + if (provider) { + return await CaptureConsoleWarnAsync(() => verifyDocument(signedVC, { provider })); + } + // Fallback: Verify without provider return await CaptureConsoleWarnAsync(() => verifyDocument(signedVC)); }; diff --git a/src/types.ts b/src/types.ts index 9b58676..d15108d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,39 @@ export type TitleEscrowNominateBeneficiaryCommand = BaseTitleEscrowCommand & { newBeneficiary: string; }; +export type ObligationRegistryMintCommand = NetworkOption & + Partial & + WalletOrSignerOption & + GasPriceScale & { + address: string; + beneficiary: string; + holder: string; + tokenId: string; + remark?: string; + encryptionKey?: string; + }; + +export type BaseObligationEscrowCommand = NetworkAndWalletSignerOption & + GasPriceScale & { + obligationRegistryAddress: string; + tokenId: string; + remark?: string; + encryptionKey?: string; + }; + +export type ObligationEscrowTransferHolderCommand = BaseObligationEscrowCommand & { + newHolder: string; +}; + +export type ObligationEscrowEndorseTransferOfOwnersCommand = BaseObligationEscrowCommand & { + newHolder: string; + newOwner: string; +}; + +export type ObligationEscrowNominateBeneficiaryCommand = BaseObligationEscrowCommand & { + newBeneficiary: string; +}; + export type EncryptWalletCommand = PrivateKeyOption & { outputFile: string; }; diff --git a/src/utils/cli-errors.ts b/src/utils/cli-errors.ts index 9a8a400..28f29a5 100644 --- a/src/utils/cli-errors.ts +++ b/src/utils/cli-errors.ts @@ -1,4 +1,7 @@ -// CLI error helpers: turn caught errors into clear messages for the user. +/** + * Shared helpers for decoding ethers CALL_EXCEPTION / custom errors into + * clear CLI messages (gas estimation often leaves revert.name unset). + */ export type ErrnoException = NodeJS.ErrnoException; @@ -11,6 +14,250 @@ export type CliErrorOptions = { const DEFAULT_PATH_PLACEHOLDER = 'the specified path'; +/** Known on-chain custom errors → actionable CLI guidance. */ +const KNOWN_REVERT_MESSAGES: Record = { + OwnerHolderMustDiffer: + 'Beneficiary (owner) and holder must be different wallets before accept/reject can run. Remint with different beneficiary and holder addresses.', + CallerNotBeneficiary: 'Connected wallet is not the beneficiary for this obligation.', + CallerNotHolder: 'Connected wallet is not the holder for this obligation.', + NotRegistered: "This token ID hasn't been minted on this registry yet.", + RemarkLengthExceeded: 'The remark is too long. Shorten it and try again.', + RegistryContractPaused: 'This registry is paused — unpause it before continuing.', + InactiveTitleEscrow: 'This obligation escrow is inactive.', + TitleEscrowNotHoldingToken: 'This escrow is not holding the token.', + DualRoleRejectionRequired: + 'Both beneficiary and holder roles are required to reject this transfer.', + TokenNotReturnedToIssuer: 'Token must be returned to issuer before this action.', + RecipientAlreadyHolder: 'Recipient is already the holder.', + InvalidNominee: 'The nominee address is invalid.', + InvalidTransferToZeroAddress: 'Cannot transfer to the zero address.', + NomineeAlreadyNominated: 'This nominee is already nominated.', + TargetNomineeAlreadyBeneficiary: 'The nominee is already the beneficiary.', + AlreadyRegistered: 'This token ID is already minted on this registry.', + TokenExists: 'This token ID already exists on this registry.', + AccessControlUnauthorizedAccount: 'Connected wallet is not authorized for this action.', + CallerNotMinter: 'Connected wallet does not hold MINTER_ROLE on this registry.', + InvalidStatusTransition: 'This action is not allowed from the current obligation status.', + EmptyReceivingData: 'Receiving data was empty.', + InvalidTokenId: 'The token ID is invalid.', + InvalidRegistry: 'The registry address is invalid.', +}; + +/** + * keccak256 selectors for no-arg / known signatures. Gas estimation often returns + * only `data: 0x` with shortMessage "execution reverted (unknown custom error)". + */ +const REVERT_SELECTOR_TO_NAME: Record = { + '0x7e288225': 'OwnerHolderMustDiffer', + '0xf52018ac': 'CallerNotBeneficiary', + '0x667b3fbf': 'CallerNotHolder', + '0xaba47339': 'NotRegistered', + '0x14086584': 'RemarkLengthExceeded', + '0xad49811c': 'RegistryContractPaused', + '0x7c339b19': 'InactiveTitleEscrow', + '0x2fd39523': 'TitleEscrowNotHoldingToken', + '0x8c90bf45': 'DualRoleRejectionRequired', + '0xd5475a0b': 'TokenNotReturnedToIssuer', + '0x702e6e62': 'RecipientAlreadyHolder', + '0x1c98cac3': 'InvalidNominee', + '0x2c75c45a': 'InvalidTransferToZeroAddress', + '0x98f83ce8': 'NomineeAlreadyNominated', + '0xdd9f921d': 'TargetNomineeAlreadyBeneficiary', + '0x3a81d6fc': 'AlreadyRegistered', + '0x55c7e8ba': 'TokenExists', + '0x10daa94f': 'EmptyReceivingData', + '0xe3962ba3': 'InvalidStatusTransition', + '0xed15e6cf': 'InvalidTokenId', + '0x540b9601': 'InvalidRegistry', + '0xbd805e91': 'InvalidTokenTransferToZeroAddressOwners', + '0xe2517d3f': 'AccessControlUnauthorizedAccount', + '0x5eee367a': 'CallerNotMinter', +}; + +const UNKNOWN_CUSTOM_ERROR = /unknown custom error/i; + +/** Solidity custom errors are PascalCase identifiers (e.g. OwnerHolderMustDiffer). */ +const SOLIDITY_CUSTOM_ERROR_NAME = /^[A-Z][A-Za-z0-9_]*$/; + +const isKnownRevertName = (label: string): boolean => + label in KNOWN_REVERT_MESSAGES || Object.values(REVERT_SELECTOR_TO_NAME).includes(label); + +type EthersCallException = { + reason?: unknown; + shortMessage?: unknown; + message?: unknown; + revert?: { name?: unknown }; + errorName?: unknown; + code?: unknown; + data?: unknown; + info?: { error?: { data?: unknown; message?: unknown } }; + error?: { data?: unknown; message?: unknown }; +}; + +const asEthersError = (value: unknown): EthersCallException | null => + typeof value === 'object' && value !== null ? (value as EthersCallException) : null; + +const normalizeLabel = (raw: string): string | undefined => { + const cleaned = raw.trim().replace(/\(\)$/, ''); + if (!cleaned || UNKNOWN_CUSTOM_ERROR.test(cleaned) || cleaned === '(unknown custom error)') { + return undefined; + } + // Drop leading junk like ": OwnerHolderMustDiffer" or parentheses wrappers + const ident = cleaned.match(/([A-Za-z][A-Za-z0-9_]*)/); + return ident?.[1]; +}; + +/** Labels after generic "failed:" — only known reverts or Solidity-shaped names. */ +const acceptFailedSuffixLabel = (raw: string): string | undefined => { + const label = normalizeLabel(raw); + if (!label) return undefined; + if (isKnownRevertName(label) || SOLIDITY_CUSTOM_ERROR_NAME.test(label)) return label; + return undefined; +}; + +const labelFromWrappedMessage = (message: string): string | undefined => { + const fromContract = message.match(/Contract reverted with\s+([A-Za-z0-9_]+)/); + if (fromContract?.[1]) { + const label = normalizeLabel(fromContract[1]); + if (label) return label; + } + const fromFailed = message.match(/failed:\s*([A-Za-z0-9_]+)/); + if (fromFailed?.[1]) { + return acceptFailedSuffixLabel(fromFailed[1]); + } + return undefined; +}; + +const collectPossibleData = (err: EthersCallException): string[] => { + const values: unknown[] = [err.data, err.info?.error?.data, err.error?.data]; + const out: string[] = []; + for (const v of values) { + if (typeof v === 'string' && /^0x[0-9a-fA-F]+$/i.test(v)) { + out.push(v.toLowerCase()); + } else if (typeof v === 'object' && v !== null && 'data' in v) { + const nested = (v as { data?: unknown }).data; + if (typeof nested === 'string' && /^0x[0-9a-fA-F]+$/i.test(nested)) { + out.push(nested.toLowerCase()); + } + } + } + return out; +}; + +const labelFromRevertData = (err: EthersCallException): string | undefined => { + for (const data of collectPossibleData(err)) { + if (data === '0x') continue; + const selector = data.slice(0, 10); + if (REVERT_SELECTOR_TO_NAME[selector]) { + return REVERT_SELECTOR_TO_NAME[selector]; + } + } + return undefined; +}; + +export function extractContractRevertLabel(error: unknown): string | undefined { + const err = asEthersError(error); + if (!err) { + if (error instanceof Error) { + const fromExec = error.message.match(/execution reverted:\s*([A-Za-z0-9_]+)/); + if (fromExec?.[1]) { + const label = normalizeLabel(fromExec[1]); + if (label) return label; + } + return labelFromWrappedMessage(error.message); + } + return undefined; + } + + const revertName = err.revert?.name ?? err.errorName; + if (typeof revertName === 'string') { + const label = normalizeLabel(revertName); + if (label) return label; + } + + if (typeof err.reason === 'string') { + const label = normalizeLabel(err.reason); + if (label) return label; + } + + // Prefer selector decoding before shortMessage — gas estimate often only has + // "execution reverted (unknown custom error)" + data: 0x7e288225 + const fromData = labelFromRevertData(err); + if (fromData) return fromData; + + if (typeof err.shortMessage === 'string' && err.shortMessage.trim()) { + if (!UNKNOWN_CUSTOM_ERROR.test(err.shortMessage)) { + const match = err.shortMessage.match(/execution reverted(?::\s*)?(.+)?/i); + if (match?.[1]) { + const label = normalizeLabel(match[1]); + if (label) return label; + } + } + } + + if (typeof err.message === 'string') { + const custom = err.message.match(/execution reverted:\s*([A-Za-z0-9_]+)/); + if (custom?.[1]) { + const label = normalizeLabel(custom[1]); + if (label) return label; + } + const wrapped = labelFromWrappedMessage(err.message); + if (wrapped) return wrapped; + } + + return undefined; +} + +/** True when the failure is a definitive on-chain revert (not a transient gas/RPC issue). */ +export function isContractCallException(error: unknown): boolean { + const err = asEthersError(error); + if (!err) { + if (error instanceof Error && /Pre-check .* failed:/i.test(error.message)) { + return Boolean(extractContractRevertLabel(error)); + } + return false; + } + if (err.code === 'CALL_EXCEPTION') return true; + if (err.revert?.name || err.errorName) return true; + if (labelFromRevertData(err)) return true; + return Boolean(extractContractRevertLabel(error)); +} + +/** Maps ethers / contract errors to a short user-facing message. */ +export function describeContractError(error: unknown): string { + const label = extractContractRevertLabel(error); + if (label && KNOWN_REVERT_MESSAGES[label]) { + return `${label}: ${KNOWN_REVERT_MESSAGES[label]}`; + } + if (label) { + return `Contract reverted with ${label}`; + } + + const err = asEthersError(error); + if (err) { + // Never surface the useless ethers placeholder as the final message + if ( + typeof err.shortMessage === 'string' && + err.shortMessage.trim() && + !UNKNOWN_CUSTOM_ERROR.test(err.shortMessage) + ) { + return err.shortMessage; + } + if (typeof err.reason === 'string' && err.reason.trim()) { + return err.reason; + } + if (typeof err.message === 'string' && err.message.trim()) { + return err.message.split('\n')[0]!; + } + } + + if (error instanceof Error && error.message) { + return error.message.split('\n')[0]!; + } + + return String(error); +} + export function isErrnoException(err: unknown): err is ErrnoException { return ( typeof err === 'object' && diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts index 456bfba..652cd1e 100644 --- a/src/utils/cli-options.ts +++ b/src/utils/cli-options.ts @@ -12,7 +12,8 @@ import { readDocumentFile } from './file-io'; import { getTokenRegistryAddress, getTokenId, getChainId } from '@trustvc/trustvc'; import fs from 'fs'; import { getTokenRegistryVersion } from '../commands/helpers'; -import { getErrorMessage } from './index'; +import { getErrorMessage } from './formatting'; +import { isContractCallException } from './cli-errors'; import { dryRunMode } from './dryRun'; export interface NetworkOption { @@ -572,7 +573,16 @@ export const performDryRunWithConfirmation = async ({ info('\n✅ Proceeding with transaction...'); return true; } catch (estimateError) { - error(`Gas estimation failed: ${getErrorMessage(estimateError)}`); + const message = getErrorMessage(estimateError); + // Definitive on-chain reverts will never succeed if broadcast — abort instead of + // offering "proceed anyway" (which only dumps a larger callStatic stack next). + if (isContractCallException(estimateError)) { + error(`Transaction would revert: ${message}`); + info('Transaction cancelled.'); + return false; + } + + error(`Gas estimation failed: ${message}`); const proceedAnyway = await confirm({ message: 'Gas estimation failed. Do you want to proceed anyway?', default: false, diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index dd7faf9..cbdd83b 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import { describeContractError, isContractCallException } from './cli-errors'; export const addAddressPrefix = (address: string): string => address.startsWith('0x') ? address : `0x${address}`; @@ -33,11 +34,27 @@ const toErrorWithMessage = function (maybeError: unknown): ErrorWithMessage { export const extractErrorMessage = (error: unknown): string => toErrorWithMessage(error).message; export const getErrorMessage = function (error: unknown): string { + // Prefer structured ethers / custom-error decoding over raw `reason` (often null). + if (isContractCallException(error)) { + return describeContractError(error); + } + if (error instanceof Error) { - return 'reason' in error ? (error['reason'] as string) : error.message; - } else { - return extractErrorMessage(error); + const reason = + 'reason' in error && typeof (error as { reason?: unknown }).reason === 'string' + ? (error as { reason: string }).reason.trim() + : ''; + if (reason) return reason; + + // SDK may already wrap: "Pre-check for accept failed: OwnerHolderMustDiffer: ..." + if (/Pre-check .* failed:/i.test(error.message)) { + return describeContractError(error); + } + + return error.message.split('\n')[0]!; } + + return describeContractError(error) || extractErrorMessage(error); }; // Captures console.warn for a function to handle expected console.warn. diff --git a/src/utils/index.ts b/src/utils/index.ts index 081f499..3d83e77 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -30,3 +30,6 @@ export * from './dryRun'; // Document Verification export * from './document-verification'; + +// Obligation / BoE document helpers +export * from './obligation-document'; diff --git a/src/utils/obligation-document.ts b/src/utils/obligation-document.ts new file mode 100644 index 0000000..8298af5 --- /dev/null +++ b/src/utils/obligation-document.ts @@ -0,0 +1,73 @@ +import { info } from 'signale'; +import { + getObligationRegistryAddress, + getTokenId, + getChainId, + isObligationRecord, +} from '@trustvc/trustvc'; +import { SUPPORTED_CHAINS, CHAIN_ID } from './networks'; + +/** + * Extracts obligation / BoE document fields. Fails for classic ETR (tokenRegistry-only) docs. + */ +export const extractObligationDocumentInfo = async ( + document: any, +): Promise<{ + document: any; + obligationRegistry: string; + tokenId: string; + network: string; + documentId: string; +}> => { + if (!isObligationRecord(document)) { + throw new Error( + 'Document is not an obligation / BoE record. Use classic token-registry / title-escrow commands for TransferableRecords with tokenRegistry.', + ); + } + + let obligationRegistry: string | undefined; + let tokenId: string | undefined; + let chainId: CHAIN_ID | undefined; + + try { + obligationRegistry = getObligationRegistryAddress(document); + tokenId = getTokenId(document); + chainId = getChainId(document); + } catch (err) { + throw new Error( + `Failed to extract obligation document information: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (!obligationRegistry) { + throw new Error('Document does not contain a valid obligationRegistry address'); + } + if (!tokenId) { + throw new Error('Document does not contain a valid token ID'); + } + if (!chainId) { + throw new Error('Document does not contain a valid chain ID'); + } + if (!(chainId in SUPPORTED_CHAINS)) { + throw new Error( + `Unsupported chain ID in obligation document: ${chainId}. Use a BoE document on a supported network.`, + ); + } + + const network = SUPPORTED_CHAINS[chainId].name; + const documentId = document.id || 'N/A'; + + info(`Extracted from obligation document:`); + info(` Network: ${network} (Chain ID: ${chainId})`); + info(` Obligation Registry: ${obligationRegistry}`); + info(` Token ID: ${tokenId}`); + info(` Document ID: ${documentId}`); + + return { + document, + obligationRegistry, + tokenId, + network, + documentId, + }; +}; diff --git a/tests/commands/obligation-escrow/accept-return-to-issuer.test.ts b/tests/commands/obligation-escrow/accept-return-to-issuer.test.ts new file mode 100644 index 0000000..b175b05 --- /dev/null +++ b/tests/commands/obligation-escrow/accept-return-to-issuer.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + acceptReturnedHandler, + promptForInputs, +} from '../../../src/commands/obligation-escrow/accept-return-to-issuer'; +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, + acceptReturnedObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/shared', () => ({ + promptBaseObligationEscrowInputs: vi.fn().mockResolvedValue({ + network: 'amoy', + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + remark: 'ok', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }), +})); + +const { burnPopulateTransaction, escrowAcceptReturned } = vi.hoisted(() => ({ + burnPopulateTransaction: vi.fn().mockResolvedValue({ to: '0xRegistry', data: '0xburn' }), + escrowAcceptReturned: vi.fn(), +})); + +vi.mock('../../../src/commands/helpers', () => ({ + connectToObligationEscrow: vi.fn().mockResolvedValue({ + // The escrow contract intentionally has no acceptReturned method: burning + // a returned title happens on the registry, not the escrow. + acceptReturned: escrowAcceptReturned, + }), + connectToObligationRegistry: vi.fn().mockResolvedValue({ + burn: { populateTransaction: burnPopulateTransaction }, + }), + validateAndEncryptRemark: vi.fn().mockReturnValue('0xencrypted'), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getWalletOrSigner: vi.fn().mockResolvedValue({ + getAddress: vi.fn().mockResolvedValue('0xFrom'), + provider: {}, + }), + getSupportedNetwork: vi.fn().mockReturnValue({ networkId: 80002 }), + canEstimateGasPrice: vi.fn().mockReturnValue(false), + performDryRunWithConfirmation: vi.fn(async ({ getTransactionCallback }) => { + await getTransactionCallback(); + return true; + }), + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/accept-return-to-issuer', () => { + beforeEach(() => vi.clearAllMocks()); + + it('promptForInputs uses shared obligation prompts', async () => { + const result = await promptForInputs(); + expect(result.obligationRegistryAddress).toBe('0xRegistry'); + }); + + it('dry-run populates burn() on the registry contract, not the escrow', async () => { + const trustvc = await import('@trustvc/trustvc'); + (trustvc.acceptReturnedObligationRegistry as MockedFunction).mockResolvedValue({ + hash: '0xaccept-return', + wait: vi.fn().mockResolvedValue({ hash: '0xaccept-return' }), + }); + + await acceptReturnedHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + remark: 'ok', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + + expect(burnPopulateTransaction).toHaveBeenCalledWith('0x1', '0xencrypted'); + expect(escrowAcceptReturned).not.toHaveBeenCalled(); + expect(trustvc.acceptReturnedObligationRegistry).toHaveBeenCalled(); + }); +}); diff --git a/tests/commands/obligation-escrow/accept.test.ts b/tests/commands/obligation-escrow/accept.test.ts new file mode 100644 index 0000000..231bf3a --- /dev/null +++ b/tests/commands/obligation-escrow/accept.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { acceptHandler, promptForInputs } from '../../../src/commands/obligation-escrow/accept'; +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, + acceptObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/shared', () => ({ + promptBaseObligationEscrowInputs: vi.fn().mockResolvedValue({ + network: 'amoy', + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + remark: 'ok', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }), +})); + +vi.mock('../../../src/commands/obligation-escrow/runTx', () => ({ + runObligationEscrowTx: vi.fn().mockResolvedValue({ hash: '0xaccept' }), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/accept', () => { + beforeEach(() => vi.clearAllMocks()); + + it('promptForInputs uses shared obligation prompts', async () => { + const result = await promptForInputs(); + expect(result.obligationRegistryAddress).toBe('0xRegistry'); + }); + + it('acceptHandler runs SDK via runObligationEscrowTx', async () => { + const { runObligationEscrowTx } = await import('../../../src/commands/obligation-escrow/runTx'); + await acceptHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + expect(runObligationEscrowTx as MockedFunction).toHaveBeenCalled(); + }); +}); diff --git a/tests/commands/obligation-escrow/discharge.test.ts b/tests/commands/obligation-escrow/discharge.test.ts new file mode 100644 index 0000000..5a0c010 --- /dev/null +++ b/tests/commands/obligation-escrow/discharge.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + dischargeHandler, + promptForInputs, +} from '../../../src/commands/obligation-escrow/discharge'; +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, + dischargeObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/shared', () => ({ + promptBaseObligationEscrowInputs: vi.fn().mockResolvedValue({ + network: 'amoy', + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + remark: 'ok', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }), +})); + +vi.mock('../../../src/commands/obligation-escrow/runTx', () => ({ + runObligationEscrowTx: vi.fn().mockResolvedValue({ hash: '0xdischarge' }), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/discharge', () => { + beforeEach(() => vi.clearAllMocks()); + + it('promptForInputs uses shared obligation prompts', async () => { + const result = await promptForInputs(); + expect(result.obligationRegistryAddress).toBe('0xRegistry'); + }); + + it('dischargeHandler runs SDK via runObligationEscrowTx', async () => { + const { runObligationEscrowTx } = await import('../../../src/commands/obligation-escrow/runTx'); + await dischargeHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + expect(runObligationEscrowTx as MockedFunction).toHaveBeenCalledWith( + expect.objectContaining({ + sdkParams: expect.objectContaining({ remarks: undefined }), + }), + ); + }); +}); diff --git a/tests/commands/obligation-escrow/endorse-transfer-owner.test.ts b/tests/commands/obligation-escrow/endorse-transfer-owner.test.ts new file mode 100644 index 0000000..deb6ba8 --- /dev/null +++ b/tests/commands/obligation-escrow/endorse-transfer-owner.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + endorseHandler, + promptForInputs, +} from '../../../src/commands/obligation-escrow/endorse-transfer-owner'; +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, + transferBeneficiaryObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/shared', () => ({ + promptBaseObligationEscrowInputs: vi.fn().mockResolvedValue({ + network: 'amoy', + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }), +})); + +vi.mock('../../../src/commands/obligation-escrow/runTx', () => ({ + runObligationEscrowTx: vi.fn().mockResolvedValue({ hash: '0xendorse' }), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promptAddress: vi.fn().mockResolvedValue('0xEndorsee0000000000000000000000000000001'), + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/endorse-transfer-owner', () => { + beforeEach(() => vi.clearAllMocks()); + + it('promptForInputs includes newBeneficiary', async () => { + const result = await promptForInputs(); + expect(result.newBeneficiary).toBe('0xEndorsee0000000000000000000000000000001'); + }); + + it('endorseHandler calls runObligationEscrowTx with beneficiary params', async () => { + const { runObligationEscrowTx } = await import('../../../src/commands/obligation-escrow/runTx'); + await endorseHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + newBeneficiary: '0xEndorsee0000000000000000000000000000001', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + expect(runObligationEscrowTx as MockedFunction).toHaveBeenCalledWith( + expect.objectContaining({ + sdkParams: expect.objectContaining({ + newBeneficiaryAddress: '0xEndorsee0000000000000000000000000000001', + }), + }), + ); + }); +}); diff --git a/tests/commands/obligation-escrow/nominate-transfer-owner.test.ts b/tests/commands/obligation-escrow/nominate-transfer-owner.test.ts new file mode 100644 index 0000000..a3da87a --- /dev/null +++ b/tests/commands/obligation-escrow/nominate-transfer-owner.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + nominateHandler, + promptForInputs, +} from '../../../src/commands/obligation-escrow/nominate-transfer-owner'; +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, + nominateObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/shared', () => ({ + promptBaseObligationEscrowInputs: vi.fn().mockResolvedValue({ + network: 'amoy', + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }), +})); + +vi.mock('../../../src/commands/obligation-escrow/runTx', () => ({ + runObligationEscrowTx: vi.fn().mockResolvedValue({ hash: '0xnominate' }), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promptAddress: vi.fn().mockResolvedValue('0xNominee00000000000000000000000000000001'), + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/nominate-transfer-owner', () => { + beforeEach(() => vi.clearAllMocks()); + + it('promptForInputs includes newBeneficiary', async () => { + const result = await promptForInputs(); + expect(result.newBeneficiary).toBe('0xNominee00000000000000000000000000000001'); + }); + + it('nominateHandler calls runObligationEscrowTx with nominee params', async () => { + const { runObligationEscrowTx } = await import('../../../src/commands/obligation-escrow/runTx'); + await nominateHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + newBeneficiary: '0xNominee00000000000000000000000000000001', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + expect(runObligationEscrowTx as MockedFunction).toHaveBeenCalledWith( + expect.objectContaining({ + sdkParams: expect.objectContaining({ + newBeneficiaryAddress: '0xNominee00000000000000000000000000000001', + }), + }), + ); + }); +}); diff --git a/tests/commands/obligation-escrow/reject-return-to-issuer.test.ts b/tests/commands/obligation-escrow/reject-return-to-issuer.test.ts new file mode 100644 index 0000000..83c6c2c --- /dev/null +++ b/tests/commands/obligation-escrow/reject-return-to-issuer.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + rejectReturnedHandler, + promptForInputs, +} from '../../../src/commands/obligation-escrow/reject-return-to-issuer'; +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, + rejectReturnedObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/shared', () => ({ + promptBaseObligationEscrowInputs: vi.fn().mockResolvedValue({ + network: 'amoy', + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + remark: 'ok', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }), +})); + +const { restorePopulateTransaction, escrowRejectReturned } = vi.hoisted(() => ({ + restorePopulateTransaction: vi.fn().mockResolvedValue({ to: '0xRegistry', data: '0xrestore' }), + escrowRejectReturned: vi.fn(), +})); + +vi.mock('../../../src/commands/helpers', () => ({ + connectToObligationEscrow: vi.fn().mockResolvedValue({ + // The escrow contract intentionally has no rejectReturned method: restoring + // a returned title happens on the registry, not the escrow. + rejectReturned: escrowRejectReturned, + }), + connectToObligationRegistry: vi.fn().mockResolvedValue({ + restore: { populateTransaction: restorePopulateTransaction }, + }), + validateAndEncryptRemark: vi.fn().mockReturnValue('0xencrypted'), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getWalletOrSigner: vi.fn().mockResolvedValue({ + getAddress: vi.fn().mockResolvedValue('0xFrom'), + provider: {}, + }), + getSupportedNetwork: vi.fn().mockReturnValue({ networkId: 80002 }), + canEstimateGasPrice: vi.fn().mockReturnValue(false), + performDryRunWithConfirmation: vi.fn(async ({ getTransactionCallback }) => { + await getTransactionCallback(); + return true; + }), + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/reject-return-to-issuer', () => { + beforeEach(() => vi.clearAllMocks()); + + it('promptForInputs uses shared obligation prompts', async () => { + const result = await promptForInputs(); + expect(result.obligationRegistryAddress).toBe('0xRegistry'); + }); + + it('dry-run populates restore() on the registry contract, not the escrow', async () => { + const trustvc = await import('@trustvc/trustvc'); + (trustvc.rejectReturnedObligationRegistry as MockedFunction).mockResolvedValue({ + hash: '0xreject-return', + wait: vi.fn().mockResolvedValue({ hash: '0xreject-return' }), + }); + + await rejectReturnedHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + remark: 'ok', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + + expect(restorePopulateTransaction).toHaveBeenCalledWith('0x1', '0xencrypted'); + expect(escrowRejectReturned).not.toHaveBeenCalled(); + expect(trustvc.rejectReturnedObligationRegistry).toHaveBeenCalled(); + }); +}); diff --git a/tests/commands/obligation-escrow/reject-transfer-holder.test.ts b/tests/commands/obligation-escrow/reject-transfer-holder.test.ts new file mode 100644 index 0000000..e596fe2 --- /dev/null +++ b/tests/commands/obligation-escrow/reject-transfer-holder.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + rejectTransferHolderHandler, + promptForInputs, +} from '../../../src/commands/obligation-escrow/reject-transfer-holder'; +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, + rejectTransferHolderObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/shared', () => ({ + promptBaseObligationEscrowInputs: vi.fn().mockResolvedValue({ + network: 'amoy', + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + remark: 'ok', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }), +})); + +vi.mock('../../../src/commands/obligation-escrow/runTx', () => ({ + runObligationEscrowTx: vi.fn().mockResolvedValue({ hash: '0xreject-transfer-holder' }), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/reject-transfer-holder', () => { + beforeEach(() => vi.clearAllMocks()); + + it('promptForInputs uses shared obligation prompts', async () => { + const result = await promptForInputs(); + expect(result.obligationRegistryAddress).toBe('0xRegistry'); + }); + + it('rejectTransferHolderHandler runs SDK via runObligationEscrowTx', async () => { + const { runObligationEscrowTx } = await import('../../../src/commands/obligation-escrow/runTx'); + await rejectTransferHolderHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + expect(runObligationEscrowTx as MockedFunction).toHaveBeenCalledWith( + expect.objectContaining({ + sdkParams: expect.objectContaining({ remarks: undefined }), + }), + ); + }); +}); diff --git a/tests/commands/obligation-escrow/reject-transfer-owner-holder.test.ts b/tests/commands/obligation-escrow/reject-transfer-owner-holder.test.ts new file mode 100644 index 0000000..d01c1e2 --- /dev/null +++ b/tests/commands/obligation-escrow/reject-transfer-owner-holder.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + rejectTransferOwnersHandler, + promptForInputs, +} from '../../../src/commands/obligation-escrow/reject-transfer-owner-holder'; +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, + rejectTransferOwnersObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/shared', () => ({ + promptBaseObligationEscrowInputs: vi.fn().mockResolvedValue({ + network: 'amoy', + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + remark: 'ok', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }), +})); + +vi.mock('../../../src/commands/obligation-escrow/runTx', () => ({ + runObligationEscrowTx: vi.fn().mockResolvedValue({ hash: '0xreject-transfer-owner-holder' }), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/reject-transfer-owner-holder', () => { + beforeEach(() => vi.clearAllMocks()); + + it('promptForInputs uses shared obligation prompts', async () => { + const result = await promptForInputs(); + expect(result.obligationRegistryAddress).toBe('0xRegistry'); + }); + + it('rejectTransferOwnersHandler runs SDK via runObligationEscrowTx', async () => { + const { runObligationEscrowTx } = await import('../../../src/commands/obligation-escrow/runTx'); + await rejectTransferOwnersHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + expect(runObligationEscrowTx as MockedFunction).toHaveBeenCalledWith( + expect.objectContaining({ + sdkParams: expect.objectContaining({ remarks: undefined }), + }), + ); + }); +}); diff --git a/tests/commands/obligation-escrow/reject-transfer-owner.test.ts b/tests/commands/obligation-escrow/reject-transfer-owner.test.ts new file mode 100644 index 0000000..9ef97d3 --- /dev/null +++ b/tests/commands/obligation-escrow/reject-transfer-owner.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + rejectTransferOwnerHandler, + promptForInputs, +} from '../../../src/commands/obligation-escrow/reject-transfer-owner'; +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, + rejectTransferBeneficiaryObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/shared', () => ({ + promptBaseObligationEscrowInputs: vi.fn().mockResolvedValue({ + network: 'amoy', + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + remark: 'ok', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }), +})); + +vi.mock('../../../src/commands/obligation-escrow/runTx', () => ({ + runObligationEscrowTx: vi.fn().mockResolvedValue({ hash: '0xreject-transfer-owner' }), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/reject-transfer-owner', () => { + beforeEach(() => vi.clearAllMocks()); + + it('promptForInputs uses shared obligation prompts', async () => { + const result = await promptForInputs(); + expect(result.obligationRegistryAddress).toBe('0xRegistry'); + }); + + it('rejectTransferOwnerHandler runs SDK via runObligationEscrowTx', async () => { + const { runObligationEscrowTx } = await import('../../../src/commands/obligation-escrow/runTx'); + await rejectTransferOwnerHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + expect(runObligationEscrowTx as MockedFunction).toHaveBeenCalledWith( + expect.objectContaining({ + sdkParams: expect.objectContaining({ remarks: undefined }), + }), + ); + }); +}); diff --git a/tests/commands/obligation-escrow/reject.test.ts b/tests/commands/obligation-escrow/reject.test.ts new file mode 100644 index 0000000..90d0c07 --- /dev/null +++ b/tests/commands/obligation-escrow/reject.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { rejectHandler, promptForInputs } from '../../../src/commands/obligation-escrow/reject'; +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, + rejectObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/shared', () => ({ + promptBaseObligationEscrowInputs: vi.fn().mockResolvedValue({ + network: 'amoy', + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + remark: 'ok', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }), +})); + +vi.mock('../../../src/commands/obligation-escrow/runTx', () => ({ + runObligationEscrowTx: vi.fn().mockResolvedValue({ hash: '0xreject' }), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/reject', () => { + beforeEach(() => vi.clearAllMocks()); + + it('promptForInputs uses shared obligation prompts', async () => { + const result = await promptForInputs(); + expect(result.obligationRegistryAddress).toBe('0xRegistry'); + }); + + it('rejectHandler runs SDK via runObligationEscrowTx', async () => { + const { runObligationEscrowTx } = await import('../../../src/commands/obligation-escrow/runTx'); + await rejectHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + expect(runObligationEscrowTx as MockedFunction).toHaveBeenCalledWith( + expect.objectContaining({ + sdkParams: expect.objectContaining({ remarks: undefined }), + }), + ); + }); +}); diff --git a/tests/commands/obligation-escrow/return-to-issuer.test.ts b/tests/commands/obligation-escrow/return-to-issuer.test.ts new file mode 100644 index 0000000..bd13c7b --- /dev/null +++ b/tests/commands/obligation-escrow/return-to-issuer.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { returnToIssuerHandler } from '../../../src/commands/obligation-escrow/return-to-issuer'; +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, + returnToIssuerObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/runTx', () => ({ + runObligationEscrowTx: vi.fn().mockResolvedValue({ hash: '0xreturn' }), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/return-to-issuer', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returnToIssuerHandler invokes runObligationEscrowTx', async () => { + const { runObligationEscrowTx } = await import('../../../src/commands/obligation-escrow/runTx'); + await returnToIssuerHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + expect(runObligationEscrowTx as MockedFunction).toHaveBeenCalled(); + }); +}); diff --git a/tests/commands/obligation-escrow/status.test.ts b/tests/commands/obligation-escrow/status.test.ts new file mode 100644 index 0000000..e80f2cc --- /dev/null +++ b/tests/commands/obligation-escrow/status.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { statusHandler } from '../../../src/commands/obligation-escrow/status'; +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, + getObligationRegistryStatus: vi.fn().mockResolvedValue(0), + isObligationRegistryRegistered: vi.fn().mockResolvedValue(true), + getObligationEscrowTerminationReason: vi.fn().mockResolvedValue(0), + }; +}); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getWalletOrSigner: vi.fn().mockResolvedValue({ provider: {} }), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/status', () => { + beforeEach(() => vi.clearAllMocks()); + + it('reads status, registration, and termination reason', async () => { + const trustvc = await import('@trustvc/trustvc'); + await statusHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + expect(trustvc.getObligationRegistryStatus as MockedFunction).toHaveBeenCalledWith( + { obligationRegistryAddress: '0xRegistry', tokenId: '0x1' }, + expect.anything(), + { tokenId: '0x1' }, + ); + expect(trustvc.isObligationRegistryRegistered).toHaveBeenCalled(); + expect(trustvc.getObligationEscrowTerminationReason).toHaveBeenCalled(); + }); +}); diff --git a/tests/commands/obligation-escrow/transfer-holder.test.ts b/tests/commands/obligation-escrow/transfer-holder.test.ts new file mode 100644 index 0000000..4a5c7f6 --- /dev/null +++ b/tests/commands/obligation-escrow/transfer-holder.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + changeHolderHandler, + promptForInputs, +} from '../../../src/commands/obligation-escrow/transfer-holder'; +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, + transferHolderObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/shared', () => ({ + promptBaseObligationEscrowInputs: vi.fn().mockResolvedValue({ + network: 'amoy', + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }), +})); + +vi.mock('../../../src/commands/obligation-escrow/runTx', () => ({ + runObligationEscrowTx: vi.fn().mockResolvedValue({ hash: '0xtransfer' }), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promptAddress: vi.fn().mockResolvedValue('0xNewHolder000000000000000000000000000001'), + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/transfer-holder', () => { + beforeEach(() => vi.clearAllMocks()); + + it('promptForInputs includes newHolder', async () => { + const result = await promptForInputs(); + expect(result.newHolder).toBe('0xNewHolder000000000000000000000000000001'); + }); + + it('changeHolderHandler calls runObligationEscrowTx with holder params', async () => { + const { runObligationEscrowTx } = await import('../../../src/commands/obligation-escrow/runTx'); + await changeHolderHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + newHolder: '0xNewHolder000000000000000000000000000001', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + expect(runObligationEscrowTx as MockedFunction).toHaveBeenCalledWith( + expect.objectContaining({ + sdkParams: expect.objectContaining({ + holderAddress: '0xNewHolder000000000000000000000000000001', + }), + }), + ); + }); +}); diff --git a/tests/commands/obligation-escrow/transfer-owner-holder.test.ts b/tests/commands/obligation-escrow/transfer-owner-holder.test.ts new file mode 100644 index 0000000..05f89fb --- /dev/null +++ b/tests/commands/obligation-escrow/transfer-owner-holder.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + transferOwnersHandler, + promptForInputs, +} from '../../../src/commands/obligation-escrow/transfer-owner-holder'; +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, + transferOwnersObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/obligation-escrow/shared', () => ({ + promptBaseObligationEscrowInputs: vi.fn().mockResolvedValue({ + network: 'amoy', + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + encryptionKey: 'doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }), +})); + +vi.mock('../../../src/commands/obligation-escrow/runTx', () => ({ + runObligationEscrowTx: vi.fn().mockResolvedValue({ hash: '0xtransfer-owner-holder' }), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promptAddress: vi + .fn() + .mockResolvedValueOnce('0xNewOwner000000000000000000000000000001') + .mockResolvedValueOnce('0xNewHolder00000000000000000000000000001'), + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-escrow/transfer-owner-holder', () => { + beforeEach(() => vi.clearAllMocks()); + + it('promptForInputs includes newOwner and newHolder', async () => { + const result = await promptForInputs(); + expect(result.newOwner).toBe('0xNewOwner000000000000000000000000000001'); + expect(result.newHolder).toBe('0xNewHolder00000000000000000000000000001'); + }); + + it('transferOwnersHandler calls runObligationEscrowTx with owner+holder params', async () => { + const { runObligationEscrowTx } = await import('../../../src/commands/obligation-escrow/runTx'); + await transferOwnersHandler({ + network: NetworkCmdName.Amoy, + obligationRegistryAddress: '0xRegistry', + tokenId: '0x1', + newOwner: '0xNewOwner000000000000000000000000000001', + newHolder: '0xNewHolder00000000000000000000000000001', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + expect(runObligationEscrowTx as MockedFunction).toHaveBeenCalledWith( + expect.objectContaining({ + sdkParams: expect.objectContaining({ + newBeneficiaryAddress: '0xNewOwner000000000000000000000000000001', + newHolderAddress: '0xNewHolder00000000000000000000000000001', + }), + }), + ); + }); +}); diff --git a/tests/commands/obligation-registry/deploy.test.ts b/tests/commands/obligation-registry/deploy.test.ts new file mode 100644 index 0000000..11d1913 --- /dev/null +++ b/tests/commands/obligation-registry/deploy.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + handler, + deployObligationRegistryContract, + promptForInputs, +} from '../../../src/commands/obligation-registry/deploy'; +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('@inquirer/prompts', () => ({ + select: vi.fn(), + input: vi.fn(), + confirm: vi.fn(), + password: vi.fn(), +})); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + deployObligationRegistry: vi.fn(), + }; +}); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promptNetworkSelection: vi.fn().mockResolvedValue('amoy'), + promptWalletSelection: vi.fn().mockResolvedValue({ key: '0xabc' }), + promptAddress: vi.fn(), + getWalletOrSigner: vi.fn().mockResolvedValue({ + getAddress: vi.fn().mockResolvedValue('0xWallet'), + provider: {}, + }), + performDryRunWithConfirmation: vi.fn().mockResolvedValue(true), + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + getSupportedNetwork: vi.fn().mockReturnValue({ networkId: 80002 }), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + supportedNetwork: { + amoy: { networkId: 80002 }, + }, + }; +}); + +describe('obligation-registry/deploy', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('promptForInputs', () => { + it('collects registry name, symbol, and wallet', async () => { + const { input, confirm } = await import('@inquirer/prompts'); + (input as MockedFunction) + .mockResolvedValueOnce('My BoE Registry') + .mockResolvedValueOnce('BOE'); + (confirm as MockedFunction).mockResolvedValueOnce(false); + + const result = await promptForInputs(); + expect(result.registryName).toBe('My BoE Registry'); + expect(result.registrySymbol).toBe('BOE'); + expect(result.network).toBe(NetworkCmdName.Amoy); + expect(result.key).toBe('0xabc'); + }); + }); + + describe('deployObligationRegistryContract', () => { + it('deploys via SDK and returns registry address', async () => { + const trustvc = await import('@trustvc/trustvc'); + const deployMock = trustvc.deployObligationRegistry as MockedFunction; + deployMock.mockResolvedValue({ + receipt: { hash: '0xhash', logs: [] }, + obligationRegistry: '0xRegistry', + obligationEscrowFactoryAddress: '0xFactory', + }); + + const address = await deployObligationRegistryContract({ + network: NetworkCmdName.Amoy, + registryName: 'My BoE Registry', + registrySymbol: 'BOE', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + + expect(deployMock).toHaveBeenCalled(); + expect(address).toBe('0xRegistry'); + + const { success, info } = await import('signale'); + expect(success).toHaveBeenCalledWith( + 'Obligation registry My BoE Registry deployed at 0xRegistry', + ); + expect(info).toHaveBeenCalledWith('ObligationEscrowFactory: 0xFactory'); + expect(info).toHaveBeenCalledWith('Obligation Registry: 0xRegistry'); + expect(info).toHaveBeenCalledWith( + 'Use this Obligation Registry address as credentialStatus.obligationRegistry in your document before signing and minting.', + ); + }); + }); + + describe('handler', () => { + it('runs without throwing when prompts succeed', async () => { + const { input, confirm } = await import('@inquirer/prompts'); + (input as MockedFunction) + .mockResolvedValueOnce('My BoE Registry') + .mockResolvedValueOnce('BOE'); + (confirm as MockedFunction).mockResolvedValueOnce(false); + + const trustvc = await import('@trustvc/trustvc'); + (trustvc.deployObligationRegistry as MockedFunction).mockResolvedValue({ + receipt: { hash: '0xhash', logs: [] }, + obligationRegistry: '0xRegistry', + obligationEscrowFactoryAddress: '0xFactory', + }); + + await expect(handler()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/tests/commands/obligation-registry/mint.test.ts b/tests/commands/obligation-registry/mint.test.ts new file mode 100644 index 0000000..02c41d9 --- /dev/null +++ b/tests/commands/obligation-registry/mint.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + handler, + mintObligationToken, + promptForInputs, +} from '../../../src/commands/obligation-registry/mint'; +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('@inquirer/prompts', () => ({ + select: vi.fn(), + input: vi.fn(), + confirm: vi.fn(), + password: vi.fn(), +})); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + mintObligationRegistry: vi.fn(), + isObligationRecord: vi.fn(() => true), + getObligationRegistryAddress: vi.fn(() => '0x71D28767662cB233F887aD2Bb65d048d760bA694'), + getTokenId: vi.fn(() => '0x23f719b016c88ba1ef2e10c0718d7d0f0026b1dc6e219629f81e2f0f811c4e3e'), + getChainId: vi.fn(() => 80002), + }; +}); + +vi.mock('../../../src/commands/helpers', () => ({ + connectToObligationRegistry: vi.fn().mockResolvedValue({ + mint: { + populateTransaction: vi.fn().mockResolvedValue({ to: '0xRegistry', data: '0x' }), + }, + }), + validateAndEncryptRemark: vi.fn((r?: string) => r || '0x'), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const actual = await importOriginal(); + const { default: obligationFixture } = + await import('../../fixtures/obligation/w3c-obligation-record.json'); + return { + ...actual, + promptAndReadDocument: vi.fn().mockResolvedValue(obligationFixture), + verifyDocumentSignature: vi.fn().mockResolvedValue(undefined), + extractObligationDocumentInfo: vi.fn().mockResolvedValue({ + document: obligationFixture, + obligationRegistry: '0x71D28767662cB233F887aD2Bb65d048d760bA694', + tokenId: '0x23f719b016c88ba1ef2e10c0718d7d0f0026b1dc6e219629f81e2f0f811c4e3e', + network: 'amoy', + documentId: 'urn:uuid:obligation-test-doc-id', + }), + promptAddress: vi.fn(), + promptWalletSelection: vi.fn().mockResolvedValue({ key: '0xabc' }), + promptRemark: vi.fn().mockResolvedValue('issued'), + getWalletOrSigner: vi.fn().mockResolvedValue({ + getAddress: vi.fn().mockResolvedValue('0xWallet'), + provider: {}, + }), + performDryRunWithConfirmation: vi.fn().mockResolvedValue(true), + canEstimateGasPrice: vi.fn().mockReturnValue(false), + displayTransactionPrice: vi.fn(), + getEtherscanAddress: vi.fn().mockReturnValue('https://amoy.polygonscan.com'), + addAddressPrefix: (v: string) => (v.startsWith('0x') ? v : `0x${v}`), + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + }; +}); + +describe('obligation-registry/mint', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('promptForInputs builds mint command from obligation document', async () => { + const utils = await import('../../../src/utils'); + (utils.promptAddress as MockedFunction) + .mockResolvedValueOnce('0xBeneficiary000000000000000000000000000001') + .mockResolvedValueOnce('0xHolder0000000000000000000000000000000002'); + + const result = await promptForInputs(); + expect(result.address).toBe('0x71D28767662cB233F887aD2Bb65d048d760bA694'); + expect(result.beneficiary).toBe('0xBeneficiary000000000000000000000000000001'); + expect(result.holder).toBe('0xHolder0000000000000000000000000000000002'); + expect(result.remark).toBe('issued'); + }); + + it('mintObligationToken calls mintObligationRegistry', async () => { + const trustvc = await import('@trustvc/trustvc'); + const mintMock = trustvc.mintObligationRegistry as MockedFunction; + mintMock.mockResolvedValue({ + hash: '0xmint', + wait: vi.fn().mockResolvedValue({ hash: '0xmint' }), + }); + + await mintObligationToken({ + network: NetworkCmdName.Amoy, + address: '0x71D28767662cB233F887aD2Bb65d048d760bA694', + tokenId: '0x23f719b016c88ba1ef2e10c0718d7d0f0026b1dc6e219629f81e2f0f811c4e3e', + beneficiary: '0xBeneficiary000000000000000000000000000001', + holder: '0xHolder0000000000000000000000000000000002', + remark: 'issued', + encryptionKey: 'urn:uuid:obligation-test-doc-id', + key: '0xabc', + maxPriorityFeePerGasScale: 1, + }); + + expect(mintMock).toHaveBeenCalledWith( + { obligationRegistryAddress: '0x71D28767662cB233F887aD2Bb65d048d760bA694' }, + expect.anything(), + expect.objectContaining({ + beneficiaryAddress: '0xBeneficiary000000000000000000000000000001', + holderAddress: '0xHolder0000000000000000000000000000000002', + }), + expect.objectContaining({ id: 'urn:uuid:obligation-test-doc-id' }), + ); + }); + + it('handler completes mint flow', async () => { + const utils = await import('../../../src/utils'); + (utils.promptAddress as MockedFunction) + .mockResolvedValueOnce('0xBeneficiary000000000000000000000000000001') + .mockResolvedValueOnce('0xHolder0000000000000000000000000000000002'); + + const trustvc = await import('@trustvc/trustvc'); + (trustvc.mintObligationRegistry as MockedFunction).mockResolvedValue({ + hash: '0xmint', + wait: vi.fn().mockResolvedValue({ hash: '0xmint' }), + }); + + await expect(handler()).resolves.toBeUndefined(); + }); +}); diff --git a/tests/fixtures/obligation/w3c-obligation-record.json b/tests/fixtures/obligation/w3c-obligation-record.json new file mode 100644 index 0000000..3371dfb --- /dev/null +++ b/tests/fixtures/obligation/w3c-obligation-record.json @@ -0,0 +1,54 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/security/data-integrity/v2", + "https://trustvc.io/context/transferable-records-context.json", + "https://trustvc.io/context/bill-of-exchange.json" + ], + "type": ["VerifiableCredential"], + "issuer": "did:web:trustvc.github.io:did:1", + "id": "urn:uuid:obligation-test-doc-id", + "credentialStatus": { + "type": "TransferableRecords", + "tokenNetwork": { + "chain": "MATIC", + "chainId": "80002" + }, + "obligationRegistry": "0x71D28767662cB233F887aD2Bb65d048d760bA694", + "tokenId": "23f719b016c88ba1ef2e10c0718d7d0f0026b1dc6e219629f81e2f0f811c4e3e" + }, + "credentialSubject": { + "electronicDocumentIdentifier": "urn:uuid:obligation-test-doc-id", + "referenceNumber": "BOE-TEST-001", + "amountInFigures": "128500.00", + "amountInWords": "One hundred twenty-eight thousand five hundred United States Dollars only", + "currencyCode": "USD", + "blDate": "2026-06-28", + "placeOfIssue": "Singapore", + "dateOfIssue": "2026-07-06", + "tenor": "At 90 days sight", + "payee": "Meridian Commodities Pte Ltd", + "drawnUnder": "Documentary Credit No. LC-TEST-2026-88341 / Invoice No. INV-TEST-2026-0456 / B/L No. BL-TEST-2026-0781", + "drawnUnderDate": "2026-06-15", + "issuedBy": "DBS Bank Ltd", + "drawee": { + "name": "Fairview Industries Inc.", + "address": "1201 Market Street, Suite 900, Wilmington, DE 19801, USA", + "authorisedSignatoryName": "James R. Carter", + "signature": "" + }, + "drawer": { + "name": "Meridian Commodities Pte Ltd", + "address": "8 Marina Boulevard, #24-01, Singapore 018981", + "authorisedSignatoryName": "Wei Ling Tan", + "signature": "" + } + }, + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "ecdsa-sd-2023", + "proofPurpose": "assertionMethod", + "verificationMethod": "did:web:trustvc.github.io:did:1#multikey-1", + "proofValue": "uTEST" + } +} diff --git a/tests/utils/contract-errors.test.ts b/tests/utils/contract-errors.test.ts new file mode 100644 index 0000000..5712044 --- /dev/null +++ b/tests/utils/contract-errors.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + describeContractError, + extractContractRevertLabel, + getErrorMessage, + isContractCallException, +} from '../../src/utils'; + +describe('contract revert error formatting', () => { + const ownerHolderError = Object.assign(new Error('execution reverted: OwnerHolderMustDiffer()'), { + code: 'CALL_EXCEPTION', + reason: null, + revert: { name: 'OwnerHolderMustDiffer', signature: 'OwnerHolderMustDiffer()', args: [] }, + shortMessage: 'execution reverted (unknown custom error)', + }); + + it('extracts OwnerHolderMustDiffer from ethers call exceptions', () => { + expect(extractContractRevertLabel(ownerHolderError)).toBe('OwnerHolderMustDiffer'); + expect(isContractCallException(ownerHolderError)).toBe(true); + }); + + it('maps OwnerHolderMustDiffer to an actionable message', () => { + expect(describeContractError(ownerHolderError)).toMatch( + /OwnerHolderMustDiffer: Beneficiary \(owner\) and holder must be different/, + ); + expect(getErrorMessage(ownerHolderError)).toMatch(/OwnerHolderMustDiffer:/); + }); + + it('does not return null when reason is null', () => { + expect(getErrorMessage(ownerHolderError)).not.toBe('null'); + expect(getErrorMessage(ownerHolderError)).toBeTruthy(); + }); + + it('decodes OwnerHolderMustDiffer from revert data when name is missing', () => { + // Typical gas-estimation shape: undecoded shortMessage + selector only + const gasEstimateError = Object.assign(new Error('execution reverted'), { + code: 'CALL_EXCEPTION', + reason: null, + data: '0x7e288225', + shortMessage: 'execution reverted (unknown custom error)', + }); + + expect(extractContractRevertLabel(gasEstimateError)).toBe('OwnerHolderMustDiffer'); + expect(getErrorMessage(gasEstimateError)).toMatch( + /OwnerHolderMustDiffer: Beneficiary \(owner\) and holder must be different/, + ); + expect(getErrorMessage(gasEstimateError)).not.toMatch(/unknown custom error/i); + }); + + it('rewrites SDK pre-check wrappers', () => { + const wrapped = new Error( + 'Pre-check for accept failed: OwnerHolderMustDiffer: Beneficiary (owner) and holder must be different wallets before accept/reject can run. Remint with different beneficiary and holder addresses.', + ); + expect(getErrorMessage(wrapped)).toMatch(/OwnerHolderMustDiffer:/); + }); + + it('rejects generic failed: suffixes that are not contract reverts', () => { + const preprocessing = new Error('SDK preprocessing failed: badInput'); + expect(extractContractRevertLabel(preprocessing)).toBeUndefined(); + expect(isContractCallException(preprocessing)).toBe(false); + }); + + it('preserves Contract reverted with labels', () => { + const wrapped = new Error('Contract reverted with OwnerHolderMustDiffer'); + expect(extractContractRevertLabel(wrapped)).toBe('OwnerHolderMustDiffer'); + }); +});