From fb36368db9f8e498e048682af6c677db3e72ec2b Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Fri, 10 Jul 2026 14:26:54 +0530 Subject: [PATCH 01/10] feat: introduce Bill of Exchange functionality in the SDK functionality --- README.md | 180 +++++++ src/__tests__/boe/boe.test.ts | 284 ++++++++++ src/__tests__/boe/boeE2eDocument.test.ts | 487 ++++++++++++++++++ src/__tests__/boe/boeRules.test.ts | 102 ++++ src/__tests__/boe/fixtures.ts | 117 +++++ .../core/endorsement-chain-boe-events.test.ts | 109 ++++ src/__tests__/fixtures/boe-raw-file.json | 61 +++ src/__tests__/fixtures/endorsement-chain.ts | 36 ++ .../token-registry-functions/fixtures.ts | 30 ++ src/boe/BoeRules.ts | 77 +++ src/boe/accept.ts | 111 ++++ src/boe/discharge.ts | 116 +++++ src/boe/index.ts | 5 + src/boe/reject.ts | 112 ++++ src/boe/status.ts | 76 +++ src/boe/types.ts | 29 ++ src/core/documentBuilder.ts | 3 +- .../endorsement-chain/fetchEscrowTransfer.ts | 30 ++ src/core/endorsement-chain/types.ts | 5 +- src/index.ts | 1 + 20 files changed, 1969 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/boe/boe.test.ts create mode 100644 src/__tests__/boe/boeE2eDocument.test.ts create mode 100644 src/__tests__/boe/boeRules.test.ts create mode 100644 src/__tests__/boe/fixtures.ts create mode 100644 src/__tests__/core/endorsement-chain-boe-events.test.ts create mode 100644 src/__tests__/fixtures/boe-raw-file.json create mode 100644 src/boe/BoeRules.ts create mode 100644 src/boe/accept.ts create mode 100644 src/boe/discharge.ts create mode 100644 src/boe/index.ts create mode 100644 src/boe/reject.ts create mode 100644 src/boe/status.ts create mode 100644 src/boe/types.ts diff --git a/README.md b/README.md index fc2b368..c2349eb 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ TrustVC is a comprehensive wrapper library designed to simplify the signing and - [8. **Document Builder**](#8-document-builder) - [9. **Document Store**](#9-document-store) - [10. **Transaction Cancel**](#10-transaction-cancel) + - [11. **Bill of Exchange (Beta)**](#11-bill-of-exchange-beta) + - [Minting](#minting) + - [getBillOfExchangeStatus](#getbillofexchangestatus) + - [acceptBillOfExchange / rejectBillOfExchange / dischargeBillOfExchange](#acceptbillofexchange--rejectbillofexchange--dischargebillofexchange) + - [The existing transfer/reject functions are untouched](#the-existing-transferreject-functions-are-untouched) + - [Notes and limitations](#notes-and-limitations) ## Installation @@ -1203,3 +1209,177 @@ const replacementHash2 = await cancelTransaction(signer, { gasPrice: '25000000000', // 25 gwei in wei }); ``` + +--- + +## 11. Bill of Exchange (Beta) + +> A Bill of Exchange (eBOE) is not a separate contract. It is the same `TradeTrustToken`/`TitleEscrow` (**Token Registry V5 only**) you already use for ETR, carrying one extra field — `status` — that starts at `Issued` and can move to `Accepted`/`Rejected` (holder-only) or, from `Accepted`, to `Discharged` (owner-only). Minting is the exact same `mint()` call as ETR, and the contract treats every escrow identically. There is no `documentType` concept anywhere — on-chain or in the SDK. Whether a given `TitleEscrow` is "being used as" a Bill of Exchange is determined entirely by **whether anyone ever calls** `acceptBillOfExchange`/`rejectBillOfExchange`/`dischargeBillOfExchange` on it — three new, dedicated functions described below. Everything else — `transferHolder`, `nominate`, `transferBeneficiary`, `transferOwners`, the reject-transfer family, `returnToIssuer` — is the exact same ETR functionality, completely unmodified and unaware that a Bill of Exchange lifecycle exists. + +### Minting + +No new parameter, no new function — mint exactly as you would an ETR document: + +```ts +import { mint } from '@trustvc/trustvc'; + +const tx = await mint( + { tokenRegistryAddress }, + signer, + { beneficiaryAddress, holderAddress, tokenId, remarks: 'Drawer draws the bill' }, + { chainId: CHAIN_ID.sepolia, id: 'encryption-id' }, +); +``` + +### getBillOfExchangeStatus + +#### Description + +> Reads the `status` field off a `TitleEscrow` — `Issued` (0), `Accepted` (1), `Rejected` (2), or `Discharged` (3). Works on any V5 escrow, ETR or otherwise; a plain ETR document simply stays at `Issued` forever since nothing ever calls the status functions on it. + +#### Parameters + +- **contractOptions**: `titleEscrowAddress`, or both `tokenRegistryAddress` and `tokenId`. +- **signer**: Ethers v5 or v6 signer/provider. +- **options** (optional): `titleEscrowVersion` to skip auto-detection. + +#### Returns + +**Promise<BillOfExchangeStatus>** — one of `BillOfExchangeStatus.Issued/Accepted/Rejected/Discharged`. + +#### Throws + +- If the registry/token/title escrow address or provider is missing. +- If the escrow is not V5. +- If the escrow predates the Bill of Exchange upgrade (no `status()` getter at all). + +#### Example + +```ts +import { getBillOfExchangeStatus, BillOfExchangeStatus, BillOfExchangeStatusLabel } from '@trustvc/trustvc'; + +const status = await getBillOfExchangeStatus({ tokenRegistryAddress, tokenId }, signer); +console.log(BillOfExchangeStatusLabel[status]); // e.g. "Issued" +``` + +### acceptBillOfExchange / rejectBillOfExchange / dischargeBillOfExchange + +#### Description + +> Three dedicated functions, each mapping onto the AC's lifecycle rules: +> +> - **acceptBillOfExchange** — holder-only, requires `status === Issued`, moves to `Accepted`. +> - **rejectBillOfExchange** — holder-only, requires `status === Issued`, moves to `Rejected` (terminal — nothing can move it again). Status-only: it does not revert the holder role, use the existing `rejectTransferHolder` for that. +> - **dischargeBillOfExchange** — beneficiary(owner)-only, requires `status === Accepted`, moves to `Discharged` (terminal). Never callable from `Rejected`. +> +> All three additionally require `beneficiary != holder` at the moment they're called, and run client-side pre-flight checks — in this order — before sending the transaction, so failures surface as a specific message instead of a raw revert: +> +> 1. **Caller-role check** — signer must be the current holder (accept/reject) or beneficiary (discharge). +> 2. **Owner ≠ holder check**. +> 3. **Status-precondition check**, with a tailored message per terminal case (e.g. *"This Bill of Exchange was rejected and can never be discharged — surrender it (returnToIssuer) and reissue a new one instead."*). + +#### Parameters + +Same shape for all three: `contractOptions` (as above), `signer`, `params: { remarks?: string }`, `options` (as above). + +#### Returns + +**Promise<ContractTransaction>** + +#### Throws + +Any of the pre-flight checks above, or a failed `callStatic` dry-run. + +#### Example + +```ts +import { acceptBillOfExchange, rejectBillOfExchange, dischargeBillOfExchange } from '@trustvc/trustvc'; + +// Holder accepts +const acceptTx = await acceptBillOfExchange( + { tokenRegistryAddress, tokenId }, + holderSigner, + { remarks: 'Accepted, bound to pay at maturity' }, + { chainId: CHAIN_ID.sepolia, id: 'encryption-id' }, +); +await acceptTx.wait(); + +// ...or, instead, the holder declines: +// await rejectBillOfExchange({ tokenRegistryAddress, tokenId }, holderSigner, {}, options); + +// Owner discharges once payment is received off-chain +const dischargeTx = await dischargeBillOfExchange( + { tokenRegistryAddress, tokenId }, + ownerSigner, + { remarks: 'Paid at maturity' }, + { chainId: CHAIN_ID.sepolia, id: 'encryption-id' }, +); +await dischargeTx.wait(); +``` + +### The existing transfer/reject functions are untouched + +#### Description + +> `transferHolder`, `transferBeneficiary`, `transferOwners`, `nominate`, `rejectTransferHolder`, `rejectTransferBeneficiary`, `rejectTransferOwners`, and `returnToIssuer` are **exactly** the same functions ETR integrations already call — same signatures, same exports, same import paths, same behaviour. There is no `documentType` field, no status gating, no reconvergence guard, no circulation restriction woven into any of them. They have zero awareness that `status` or the Bill of Exchange lifecycle exists. +> +> This is a deliberate design choice: the contract is fully permissive by itself (transfers never mutate or check `status`), and this SDK doesn't add any client-side restriction on top either. If you want reconvergence guards, terminal-state circulation limits, or surrender hints specific to your own integration, build them in your own application layer — `BoeRules` (used internally by `acceptBillOfExchange`/`rejectBillOfExchange`/`dischargeBillOfExchange` only) is not applied to these functions and never will be. + +#### Example: full happy path + +```ts +import { + transferHolder, + nominate, + transferBeneficiary, + returnToIssuer, + mint, + acceptBillOfExchange, + dischargeBillOfExchange, +} from '@trustvc/trustvc'; + +const contractOptions = { tokenRegistryAddress, tokenId }; +const options = { chainId: CHAIN_ID.sepolia, id: 'encryption-id' }; + +// Presentment: diverge holder from the drawer — plain ETR call, no BOE awareness involved +await (await transferHolder(contractOptions, signer, { holderAddress: draweeAddress }, options)).wait(); + +// Drawee accepts — the dedicated function is what actually marks this escrow as a Bill of Exchange +await (await acceptBillOfExchange(contractOptions, signer, { remarks: 'Accepted' }, options)).wait(); + +// Optional: owner circulates the receivable to a financing bank — holder is untouched. +// Nothing stops the holder from also changing here; that's on your own integration to guard if desired. +await (await nominate(contractOptions, signer, { newBeneficiaryAddress: bankAddress }, options)).wait(); +await (await transferBeneficiary(contractOptions, signer, { newBeneficiaryAddress: bankAddress }, options)).wait(); + +// Owner discharges once paid +await (await dischargeBillOfExchange(contractOptions, signer, { remarks: 'Paid at maturity' }, options)).wait(); + +// Reconverge owner onto holder, then close it out — plain ETR calls throughout +await (await nominate(contractOptions, signer, { newBeneficiaryAddress: draweeAddress }, options)).wait(); +await (await transferBeneficiary(contractOptions, signer, { newBeneficiaryAddress: draweeAddress }, options)).wait(); +await (await returnToIssuer(contractOptions, signer, {}, options)).wait(); +``` + +#### Example: reject and reissue + +```ts +import { transferHolder, rejectTransferHolder, returnToIssuer, mint, rejectBillOfExchange } from '@trustvc/trustvc'; + +await (await transferHolder(contractOptions, signer, { holderAddress: draweeAddress }, options)).wait(); +await (await rejectBillOfExchange(contractOptions, signer, { remarks: 'Declined' }, options)).wait(); + +// rejectBillOfExchange is status-only — separately revert the holder role to reconverge +await (await rejectTransferHolder(contractOptions, signer, {}, options)).wait(); +await (await returnToIssuer(contractOptions, signer, {}, options)).wait(); + +// Mint a brand-new token to restart the flow — the rejected one is done for good +await mint({ tokenRegistryAddress }, signer, { beneficiaryAddress, holderAddress, tokenId: newTokenId }, options); +``` + +### Notes and limitations + +- **Beta** functionality — the API may still change. +- **Token Registry V5 only.** V4 has no `status()`/accept/reject/discharge at all. +- `BoeRules` validation applies **only** inside `acceptBillOfExchange`/`rejectBillOfExchange`/`dischargeBillOfExchange`. It is not applied to, and cannot be applied to, `transferHolder`/`transferBeneficiary`/`transferOwners`/`nominate`/the reject-transfer family/`returnToIssuer` — those remain exactly as permissive as plain ETR, by design. +- `getBillOfExchangeStatus`/`acceptBillOfExchange`/`rejectBillOfExchange`/`dischargeBillOfExchange` are exported flat from `@trustvc/trustvc`; `BoeRules` is internal and not exported — it only exists to keep the three dedicated functions' precondition checks in one place. diff --git a/src/__tests__/boe/boe.test.ts b/src/__tests__/boe/boe.test.ts new file mode 100644 index 0000000..ebb4b82 --- /dev/null +++ b/src/__tests__/boe/boe.test.ts @@ -0,0 +1,284 @@ +import '../token-registry-functions/fixtures.js'; +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import * as coreModule from '../../core'; +import { acceptBillOfExchange } from '../../boe/accept'; +import { rejectBillOfExchange } from '../../boe/reject'; +import { dischargeBillOfExchange } from '../../boe/discharge'; +import { getBillOfExchangeStatus } from '../../boe/status'; +import { BillOfExchangeStatus } from '../../boe/types'; +import { mockV5TitleEscrowContract } from '../token-registry-functions/fixtures'; +import { + configureSignerAsBeneficiary, + configureSignerAsHolder, + installBoeMockContract, + MOCK_CHAIN_ID, + MOCK_ENCRYPTION_ID, + MOCK_REMARKS, + MOCK_TITLE_ESCROW_ADDRESS, + MOCK_TOKEN_ID, + MOCK_TOKEN_REGISTRY_ADDRESS, + providers, + setupBoeTestContext, +} from './fixtures'; + +describe.each(providers)( + 'Bill of Exchange with ethers version $ethersVersion', + ({ Provider, ethersVersion }) => { + let wallet: ReturnType['wallet']; + + beforeAll(() => { + installBoeMockContract(); + }); + + beforeEach(() => { + ({ wallet } = setupBoeTestContext(Provider, ethersVersion)); + configureSignerAsHolder(wallet); + }); + + const options = { chainId: MOCK_CHAIN_ID, id: MOCK_ENCRYPTION_ID }; + const contractOptions = { + tokenRegistryAddress: MOCK_TOKEN_REGISTRY_ADDRESS, + tokenId: MOCK_TOKEN_ID, + }; + + describe('acceptBillOfExchange', () => { + it('should accept with signer and all required parameters', async () => { + const result = await acceptBillOfExchange( + contractOptions, + wallet, + { remarks: MOCK_REMARKS }, + options, + ); + expect(result).toEqual('v5_accept_bill_of_exchange_tx_hash'); + }); + + it('should accept when titleEscrowAddress is provided directly', async () => { + const result = await acceptBillOfExchange( + { titleEscrowAddress: MOCK_TITLE_ESCROW_ADDRESS }, + wallet, + { remarks: MOCK_REMARKS }, + options, + ); + expect(result).toEqual('v5_accept_bill_of_exchange_tx_hash'); + expect(coreModule.getTitleEscrowAddress).not.toHaveBeenCalled(); + }); + + it('should throw when tokenRegistryAddress is missing', async () => { + vi.mocked(coreModule.getTitleEscrowAddress).mockResolvedValue(undefined); + await expect( + acceptBillOfExchange( + { tokenId: MOCK_TOKEN_ID } as any, + wallet, + { remarks: MOCK_REMARKS }, + options, + ), + ).rejects.toThrow('Token registry address is required'); + }); + + it('should throw when provider is missing', async () => { + const { Wallet: WalletV5 } = await import('ethers'); + const signerWithoutProvider = new WalletV5('0x'.padEnd(66, '1')); + await expect( + acceptBillOfExchange( + contractOptions, + signerWithoutProvider, + { remarks: MOCK_REMARKS }, + options, + ), + ).rejects.toThrow('Provider is required'); + }); + + it('should throw when title escrow is not V5', async () => { + vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(false); + await expect( + acceptBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Only Token Registry V5 is supported'); + }); + + it('should throw a friendly error when the TitleEscrow predates the Bill of Exchange lifecycle', async () => { + mockV5TitleEscrowContract.status.mockRejectedValue(new Error('no such function')); + await expect( + acceptBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('does not support the Bill of Exchange lifecycle'); + }); + + it('should throw when the signer is not the current holder', async () => { + mockV5TitleEscrowContract.holder.mockResolvedValue('0xsomeone_else'); + await expect( + acceptBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Only the current holder can accept this Bill of Exchange'); + }); + + it('should throw when owner and holder are the same address', async () => { + mockV5TitleEscrowContract.beneficiary.mockResolvedValue(wallet.address); + await expect( + acceptBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Owner and holder must be different addresses'); + }); + + it.each([ + BillOfExchangeStatus.Accepted, + BillOfExchangeStatus.Rejected, + BillOfExchangeStatus.Discharged, + ])('should throw when status is already %i', async (status) => { + mockV5TitleEscrowContract.status.mockResolvedValue(status); + await expect( + acceptBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow(/already been|cannot be accepted or rejected again/); + }); + + it('should throw when callStatic fails', async () => { + mockV5TitleEscrowContract.callStatic.acceptBillOfExchange.mockRejectedValue( + new Error('Simulated failure'), + ); + mockV5TitleEscrowContract.acceptBillOfExchange.staticCall.mockRejectedValue( + new Error('Simulated failure'), + ); + await expect( + acceptBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Pre-check (callStatic) for acceptBillOfExchange failed'); + mockV5TitleEscrowContract.callStatic.acceptBillOfExchange = vi.fn(); + mockV5TitleEscrowContract.acceptBillOfExchange.staticCall = vi.fn(); + }); + }); + + describe('rejectBillOfExchange', () => { + it('should reject with signer and all required parameters', async () => { + const result = await rejectBillOfExchange( + contractOptions, + wallet, + { remarks: MOCK_REMARKS }, + options, + ); + expect(result).toEqual('v5_reject_bill_of_exchange_tx_hash'); + }); + + it('should throw when the signer is not the current holder', async () => { + mockV5TitleEscrowContract.holder.mockResolvedValue('0xsomeone_else'); + await expect( + rejectBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Only the current holder can reject this Bill of Exchange'); + }); + + it('should throw when owner and holder are the same address', async () => { + mockV5TitleEscrowContract.beneficiary.mockResolvedValue(wallet.address); + await expect( + rejectBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Owner and holder must be different addresses'); + }); + + it.each([BillOfExchangeStatus.Rejected, BillOfExchangeStatus.Discharged])( + 'should throw when status is already %i', + async (status) => { + mockV5TitleEscrowContract.status.mockResolvedValue(status); + await expect( + rejectBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('cannot be accepted or rejected again'); + }, + ); + + it('should throw when callStatic fails', async () => { + mockV5TitleEscrowContract.callStatic.rejectBillOfExchange.mockRejectedValue( + new Error('Simulated failure'), + ); + mockV5TitleEscrowContract.rejectBillOfExchange.staticCall.mockRejectedValue( + new Error('Simulated failure'), + ); + await expect( + rejectBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Pre-check (callStatic) for rejectBillOfExchange failed'); + mockV5TitleEscrowContract.callStatic.rejectBillOfExchange = vi.fn(); + mockV5TitleEscrowContract.rejectBillOfExchange.staticCall = vi.fn(); + }); + }); + + describe('dischargeBillOfExchange', () => { + beforeEach(() => { + configureSignerAsBeneficiary(wallet); + }); + + it('should discharge with signer and all required parameters', async () => { + const result = await dischargeBillOfExchange( + contractOptions, + wallet, + { remarks: MOCK_REMARKS }, + options, + ); + expect(result).toEqual('v5_discharge_bill_of_exchange_tx_hash'); + }); + + it('should throw when the signer is not the current beneficiary', async () => { + mockV5TitleEscrowContract.beneficiary.mockResolvedValue('0xsomeone_else'); + await expect( + dischargeBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow( + 'Only the current beneficiary (owner) can discharge this Bill of Exchange', + ); + }); + + it('should throw when owner and holder are the same address', async () => { + mockV5TitleEscrowContract.holder.mockResolvedValue(wallet.address); + await expect( + dischargeBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Owner and holder must be different addresses'); + }); + + it('should throw a specific message when status is Rejected', async () => { + mockV5TitleEscrowContract.status.mockResolvedValue(BillOfExchangeStatus.Rejected); + await expect( + dischargeBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('was rejected and can never be discharged'); + }); + + it('should throw a specific message when status is still Issued', async () => { + mockV5TitleEscrowContract.status.mockResolvedValue(BillOfExchangeStatus.Issued); + await expect( + dischargeBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('has not been accepted yet'); + }); + + it('should throw a specific message when already Discharged', async () => { + mockV5TitleEscrowContract.status.mockResolvedValue(BillOfExchangeStatus.Discharged); + await expect( + dischargeBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('This Bill of Exchange has already been discharged.'); + }); + + it('should throw when callStatic fails', async () => { + mockV5TitleEscrowContract.callStatic.dischargeBillOfExchange.mockRejectedValue( + new Error('Simulated failure'), + ); + mockV5TitleEscrowContract.dischargeBillOfExchange.staticCall.mockRejectedValue( + new Error('Simulated failure'), + ); + await expect( + dischargeBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Pre-check (callStatic) for dischargeBillOfExchange failed'); + mockV5TitleEscrowContract.callStatic.dischargeBillOfExchange = vi.fn(); + mockV5TitleEscrowContract.dischargeBillOfExchange.staticCall = vi.fn(); + }); + }); + + describe('getBillOfExchangeStatus', () => { + it('should return the current status', async () => { + mockV5TitleEscrowContract.status.mockResolvedValue(BillOfExchangeStatus.Accepted); + const result = await getBillOfExchangeStatus(contractOptions, wallet, {}); + expect(result).toEqual(BillOfExchangeStatus.Accepted); + }); + + it('should throw when title escrow is not V5', async () => { + vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(false); + await expect(getBillOfExchangeStatus(contractOptions, wallet, {})).rejects.toThrow( + 'Only Token Registry V5 is supported', + ); + }); + + it('should throw a friendly error when status() reverts', async () => { + mockV5TitleEscrowContract.status.mockRejectedValue(new Error('no such function')); + await expect(getBillOfExchangeStatus(contractOptions, wallet, {})).rejects.toThrow( + 'does not support the Bill of Exchange lifecycle', + ); + }); + }); + }, +); diff --git a/src/__tests__/boe/boeE2eDocument.test.ts b/src/__tests__/boe/boeE2eDocument.test.ts new file mode 100644 index 0000000..0cf60cc --- /dev/null +++ b/src/__tests__/boe/boeE2eDocument.test.ts @@ -0,0 +1,487 @@ +import '../token-registry-functions/fixtures.js'; +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import { ethers as ethersV5, Wallet as WalletV5 } from 'ethers'; +import { ethers as ethersV6, Wallet as WalletV6, Network } from 'ethersV6'; +import * as coreModule from '../../core'; +import { signW3C, verifyW3CSignature, deriveW3C } from '../../w3c'; +import { VerificationType, PrivateKeyPair } from '@trustvc/w3c-issuer'; +import type { SignedVerifiableCredential } from '@trustvc/w3c-vc'; +import { + mint, + transferHolder, + nominate, + transferBeneficiary, + rejectTransferHolder, + returnToIssuer, + acceptReturned, +} from '../../token-registry-functions'; +import { + acceptBillOfExchange, + rejectBillOfExchange, + dischargeBillOfExchange, + getBillOfExchangeStatus, +} from '../../boe'; +import { BillOfExchangeStatus } from '../../boe/types'; +import { getEthersContractFromProvider } from '../../utils/ethers'; +import { CHAIN_ID } from '../../utils/supportedChains'; +import { + mockV5TitleEscrowContract, + mockV5TradeTrustTokenContract, + providerV5, + providerV6, +} from '../token-registry-functions/fixtures.js'; +import { ProviderInfo } from '../../token-registry-functions/types'; +import { MOCK_TITLE_ESCROW_ADDRESS } from './fixtures'; +import boeRawDocument from '../fixtures/boe-raw-file.json'; + +// Real signed VC roundtrip needs a key we actually hold — the fixture's own `issuer` +// (did:web:didhost.vercel.app) isn't ours to sign as, so it's overridden to this repo's +// existing test DID (same key used throughout documentBuilder.test.ts). +const ISSUER_PRIVATE_KEY: PrivateKeyPair = { + '@context': 'https://w3id.org/security/multikey/v1', + id: 'did:web:trustvc.github.io:did:1#multikey-1', + type: VerificationType.Multikey, + controller: 'did:web:trustvc.github.io:did:1', + publicKeyMultibase: 'zDnaemDNwi4G5eTzGfRooFFu5Kns3be6yfyVNtiaMhWkZbwtc', + secretKeyMultibase: 'z42tmUXTVn3n9BihE6NhdMpvVBTnFTgmb6fw18o5Ud6puhRW', +}; + +const OWNER_PRIVATE_KEY = '0x59c6995e998f97a5a004497e5f1ebce0c16828d44b3f8d0bfa3a89d271d5b6b9'; +const HOLDER_PRIVATE_KEY = '0x0000000000000000000000000000000000000000000000000000000000000002'; +const BANK_PRIVATE_KEY = '0x0000000000000000000000000000000000000000000000000000000000000003'; + +const REGISTRY_ADDRESS = boeRawDocument.credentialStatus.tokenRegistry; +const TOKEN_ID = `0x${Buffer.from(boeRawDocument.credentialSubject.boeNumber).toString('hex')}`; +const REISSUE_TOKEN_ID = `${TOKEN_ID}01`; + +const providers: ProviderInfo[] = [ + { Provider: providerV5, ethersVersion: 'v5', titleEscrowVersion: 'v5' }, + { Provider: providerV6, ethersVersion: 'v6', titleEscrowVersion: 'v5' }, +]; + +const options = { + chainId: CHAIN_ID.sepolia, + id: 'encryption-id', + titleEscrowVersion: 'v5' as const, +}; + +function createWalletFromKey( + Provider: ProviderInfo['Provider'], + ethersVersion: 'v5' | 'v6', + privateKey: string, +) { + if (ethersVersion === 'v5') { + const wallet = new WalletV5(privateKey, Provider as any) as ethersV5.Wallet; + vi.spyOn(wallet, 'getChainId').mockResolvedValue(Number(CHAIN_ID.sepolia)); + return wallet; + } + const wallet = new WalletV6(privateKey, Provider as any); + vi.spyOn(Provider, 'getNetwork').mockResolvedValue({ + chainId: BigInt(CHAIN_ID.sepolia), + } as unknown as Network); + return wallet; +} + +function installDocumentMockContracts(): void { + const ContractConstructor = vi.fn((address: string) => + address === REGISTRY_ADDRESS ? mockV5TradeTrustTokenContract : mockV5TitleEscrowContract, + ); + vi.mocked(getEthersContractFromProvider).mockReturnValue( + ContractConstructor as unknown as ReturnType, + ); +} + +function resetCoreMocks(): void { + vi.spyOn(coreModule, 'getTitleEscrowAddress').mockResolvedValue(MOCK_TITLE_ESCROW_ADDRESS); + vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(true); + vi.spyOn(coreModule, 'encrypt').mockReturnValue('encryptedRemarks'); + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(true); +} + +async function setRoles(params: { + beneficiary: string; + holder: string; + status?: BillOfExchangeStatus; + prevHolder?: string; +}) { + mockV5TitleEscrowContract.beneficiary.mockResolvedValue(params.beneficiary); + mockV5TitleEscrowContract.holder.mockResolvedValue(params.holder); + mockV5TitleEscrowContract.status.mockResolvedValue(params.status ?? BillOfExchangeStatus.Issued); + mockV5TitleEscrowContract.prevHolder.mockResolvedValue( + params.prevHolder ?? '0x0000000000000000000000000000000000dEaD', + ); +} + +describe.each(providers)( + 'Bill of Exchange end-to-end with a real signed document ($ethersVersion)', + ({ Provider, ethersVersion }) => { + let ownerWallet: ethersV5.Wallet | ethersV6.Wallet; + let holderWallet: ethersV5.Wallet | ethersV6.Wallet; + let bankWallet: ethersV5.Wallet | ethersV6.Wallet; + let ownerAddress: string; + let holderAddress: string; + let bankAddress: string; + let signedDocument: SignedVerifiableCredential; + + const contractOptions = { tokenRegistryAddress: REGISTRY_ADDRESS, tokenId: TOKEN_ID }; + + beforeAll(() => { + installDocumentMockContracts(); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + resetCoreMocks(); + ownerWallet = createWalletFromKey(Provider, ethersVersion, OWNER_PRIVATE_KEY); + holderWallet = createWalletFromKey(Provider, ethersVersion, HOLDER_PRIVATE_KEY); + bankWallet = createWalletFromKey(Provider, ethersVersion, BANK_PRIVATE_KEY); + ownerAddress = await ownerWallet.getAddress(); + holderAddress = await holderWallet.getAddress(); + bankAddress = await bankWallet.getAddress(); + }); + + describe('sign', () => { + it('signs the real document and produces a verifiable ECDSA-SD-2023 credential', async () => { + const document = { + ...boeRawDocument, + issuer: ISSUER_PRIVATE_KEY.id.split('#')[0], + }; + + const result = await signW3C(document, ISSUER_PRIVATE_KEY, 'ecdsa-sd-2023'); + expect(result.error).toBeUndefined(); + expect(result.signed).toBeDefined(); + signedDocument = result.signed as SignedVerifiableCredential; + + const derived = await deriveW3C(signedDocument, []); + expect(derived.error).toBeUndefined(); + + const verification = await verifyW3CSignature(derived.derived!); + expect(verification.verified).toBe(true); + + expect(signedDocument.credentialSubject).toMatchObject(boeRawDocument.credentialSubject); + expect(signedDocument.credentialStatus).toMatchObject({ + tokenRegistry: REGISTRY_ADDRESS, + }); + + const mintedDocument = { + ...signedDocument, + credentialStatus: { ...signedDocument.credentialStatus, tokenId: TOKEN_ID }, + }; + expect(mintedDocument.credentialStatus).toMatchObject({ tokenId: TOKEN_ID }); + }); + }); + + describe('happy path: issue → present → accept → discharge → surrender → burn', () => { + it('runs the full eBOE lifecycle', async () => { + // Step 0 — Issue with owner == holder (drawer holds both roles) + await setRoles({ beneficiary: ownerAddress, holder: ownerAddress }); + const mintTx = await mint( + { tokenRegistryAddress: REGISTRY_ADDRESS }, + ownerWallet, + { + beneficiaryAddress: ownerAddress, + holderAddress: ownerAddress, + tokenId: TOKEN_ID, + remarks: `Issuing ${boeRawDocument.credentialSubject.boeNumber}`, + }, + options, + ); + expect(mintTx).toEqual('v5_mint_tx_hash'); + expect(await getBillOfExchangeStatus(contractOptions, ownerWallet)).toEqual( + BillOfExchangeStatus.Issued, + ); + + // Step 1 — Presentment: diverge holder to drawee + const presentTx = await transferHolder( + contractOptions, + ownerWallet, + { holderAddress, remarks: 'Presentment' }, + options, + ); + expect(presentTx).toBeDefined(); + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + prevHolder: ownerAddress, + }); + + // Step 2 — Holder accepts + const acceptTx = await acceptBillOfExchange( + contractOptions, + holderWallet, + { remarks: 'Accepted, bound to pay at maturity' }, + options, + ); + expect(acceptTx).toEqual('v5_accept_bill_of_exchange_tx_hash'); + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: BillOfExchangeStatus.Accepted, + }); + expect(await getBillOfExchangeStatus(contractOptions, ownerWallet)).toEqual( + BillOfExchangeStatus.Accepted, + ); + + // Step 3 — Owner discharges after payment + const dischargeTx = await dischargeBillOfExchange( + contractOptions, + ownerWallet, + { remarks: `Paid at maturity for ${boeRawDocument.credentialSubject.boeNumber}` }, + options, + ); + expect(dischargeTx).toEqual('v5_discharge_bill_of_exchange_tx_hash'); + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: BillOfExchangeStatus.Discharged, + }); + expect(await getBillOfExchangeStatus(contractOptions, ownerWallet)).toEqual( + BillOfExchangeStatus.Discharged, + ); + + // Step 4 — Reconverge owner onto holder, then surrender + await nominate( + contractOptions, + ownerWallet, + { newBeneficiaryAddress: holderAddress, remarks: 'Reconverge owner to holder' }, + options, + ); + await transferBeneficiary( + contractOptions, + holderWallet, + { newBeneficiaryAddress: holderAddress, remarks: 'Confirm nomination' }, + options, + ); + await setRoles({ + beneficiary: holderAddress, + holder: holderAddress, + status: BillOfExchangeStatus.Discharged, + }); + + const surrenderTx = await returnToIssuer( + contractOptions, + holderWallet, + { remarks: 'Surrender' }, + options, + ); + expect(surrenderTx).toBeDefined(); + + // Step 5 — Burn (accepter role) + const burnTx = await acceptReturned( + { tokenRegistryAddress: REGISTRY_ADDRESS }, + ownerWallet, + { tokenId: TOKEN_ID, remarks: 'Burn discharged BOE' }, + options, + ); + expect(burnTx).toEqual('v5_burn_tx_hash'); + expect(mockV5TradeTrustTokenContract.burn).toHaveBeenCalled(); + }); + }); + + describe('edge case: reject path (Issued → Rejected → reconverge → surrender → reissue)', () => { + it('rejects, reconverges via rejectTransferHolder, surrenders, and remints', async () => { + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + prevHolder: ownerAddress, + }); + + const rejectTx = await rejectBillOfExchange( + contractOptions, + holderWallet, + { remarks: 'Declined — goods not received' }, + options, + ); + expect(rejectTx).toEqual('v5_reject_bill_of_exchange_tx_hash'); + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: BillOfExchangeStatus.Rejected, + prevHolder: ownerAddress, + }); + expect(await getBillOfExchangeStatus(contractOptions, ownerWallet)).toEqual( + BillOfExchangeStatus.Rejected, + ); + + // Terminal: cannot discharge or accept a rejected bill + await expect( + dischargeBillOfExchange(contractOptions, ownerWallet, {}, options), + ).rejects.toThrow('was rejected and can never be discharged'); + await expect( + acceptBillOfExchange(contractOptions, holderWallet, {}, options), + ).rejects.toThrow(/already been|cannot be accepted or rejected again/); + + // Status-only reject — separately revert holder role + const revertHolderTx = await rejectTransferHolder( + contractOptions, + holderWallet, + { remarks: 'Revert holdership' }, + options, + ); + expect(revertHolderTx).toBeDefined(); + await setRoles({ + beneficiary: ownerAddress, + holder: ownerAddress, + status: BillOfExchangeStatus.Rejected, + }); + + const surrenderTx = await returnToIssuer( + contractOptions, + ownerWallet, + { remarks: 'Surrender rejected BOE' }, + options, + ); + expect(surrenderTx).toBeDefined(); + + // Reissue a new token + const remintTx = await mint( + { tokenRegistryAddress: REGISTRY_ADDRESS }, + ownerWallet, + { + beneficiaryAddress: ownerAddress, + holderAddress: ownerAddress, + tokenId: REISSUE_TOKEN_ID, + remarks: 'Reissue after reject', + }, + options, + ); + expect(remintTx).toEqual('v5_mint_tx_hash'); + expect(mockV5TradeTrustTokenContract.mint).toHaveBeenCalledWith( + ownerAddress, + ownerAddress, + REISSUE_TOKEN_ID, + expect.any(String), + expect.any(Object), + ); + }); + }); + + describe('edge case: financing path (Accepted → endorse to bank → bank discharges)', () => { + it('endorses ownership to a bank after accept; bank discharges', async () => { + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: BillOfExchangeStatus.Accepted, + }); + + await nominate( + contractOptions, + ownerWallet, + { newBeneficiaryAddress: bankAddress, remarks: 'Nominate bank' }, + options, + ); + await transferBeneficiary( + contractOptions, + holderWallet, + { newBeneficiaryAddress: bankAddress, remarks: 'Endorse to bank' }, + options, + ); + await setRoles({ + beneficiary: bankAddress, + holder: holderAddress, + status: BillOfExchangeStatus.Accepted, + }); + + // Original owner can no longer discharge + await expect( + dischargeBillOfExchange(contractOptions, ownerWallet, {}, options), + ).rejects.toThrow( + 'Only the current beneficiary (owner) can discharge this Bill of Exchange', + ); + + const dischargeTx = await dischargeBillOfExchange( + contractOptions, + bankWallet, + { remarks: 'Paid; bank discharges' }, + options, + ); + expect(dischargeTx).toEqual('v5_discharge_bill_of_exchange_tx_hash'); + await setRoles({ + beneficiary: bankAddress, + holder: holderAddress, + status: BillOfExchangeStatus.Discharged, + }); + expect(await getBillOfExchangeStatus(contractOptions, bankWallet)).toEqual( + BillOfExchangeStatus.Discharged, + ); + }); + }); + + describe('edge case: invalid lifecycle transitions', () => { + it('blocks accept/reject/discharge when owner == holder (no presentment)', async () => { + await setRoles({ beneficiary: ownerAddress, holder: ownerAddress }); + + await expect( + acceptBillOfExchange(contractOptions, ownerWallet, {}, options), + ).rejects.toThrow('Owner and holder must be different addresses'); + await expect( + rejectBillOfExchange(contractOptions, ownerWallet, {}, options), + ).rejects.toThrow('Owner and holder must be different addresses'); + }); + + it('blocks discharge before acceptance and after discharge', async () => { + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: BillOfExchangeStatus.Issued, + }); + await expect( + dischargeBillOfExchange(contractOptions, ownerWallet, {}, options), + ).rejects.toThrow('has not been accepted yet'); + + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: BillOfExchangeStatus.Discharged, + }); + await expect( + dischargeBillOfExchange(contractOptions, ownerWallet, {}, options), + ).rejects.toThrow('already been discharged'); + await expect( + acceptBillOfExchange(contractOptions, holderWallet, {}, options), + ).rejects.toThrow(/already been|cannot be accepted or rejected again/); + }); + + it('blocks wrong-role callers at each lifecycle step', async () => { + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: BillOfExchangeStatus.Issued, + }); + await expect( + acceptBillOfExchange(contractOptions, ownerWallet, {}, options), + ).rejects.toThrow('Only the current holder can accept'); + await expect( + rejectBillOfExchange(contractOptions, ownerWallet, {}, options), + ).rejects.toThrow('Only the current holder can reject'); + + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: BillOfExchangeStatus.Accepted, + }); + await expect( + dischargeBillOfExchange(contractOptions, holderWallet, {}, options), + ).rejects.toThrow('Only the current beneficiary (owner) can discharge'); + }); + + it('allows ETR circulation after Accepted without reading or mutating status', async () => { + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: BillOfExchangeStatus.Accepted, + }); + mockV5TitleEscrowContract.status.mockClear(); + + await expect( + transferHolder(contractOptions, holderWallet, { holderAddress: bankAddress }, options), + ).resolves.toBeDefined(); + await expect( + nominate(contractOptions, ownerWallet, { newBeneficiaryAddress: bankAddress }, options), + ).resolves.toBeDefined(); + + expect(mockV5TitleEscrowContract.status).not.toHaveBeenCalled(); + }); + }); + }, +); diff --git a/src/__tests__/boe/boeRules.test.ts b/src/__tests__/boe/boeRules.test.ts new file mode 100644 index 0000000..75159cc --- /dev/null +++ b/src/__tests__/boe/boeRules.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { BoeRules } from '../../boe/BoeRules'; +import { BillOfExchangeStatus } from '../../boe/types'; +import { HOLDER, OWNER } from './fixtures'; + +const baseTransition = { + currentBeneficiary: OWNER, + currentHolder: HOLDER, + currentStatus: BillOfExchangeStatus.Issued, + signerAddress: HOLDER, +}; + +describe('BoeRules — BOE status transitions (accept / reject / discharge)', () => { + it('accept succeeds for holder at Issued with diverged roles', () => { + expect(() => BoeRules.assertAccept(baseTransition)).not.toThrow(); + }); + + it('accept fails for non-holder', () => { + expect(() => BoeRules.assertAccept({ ...baseTransition, signerAddress: OWNER })).toThrow( + 'Only the current holder can accept', + ); + }); + + it('accept fails when owner equals holder', () => { + expect(() => + BoeRules.assertAccept({ + ...baseTransition, + currentBeneficiary: OWNER, + currentHolder: OWNER, + signerAddress: OWNER, + }), + ).toThrow('Owner and holder must be different addresses'); + }); + + it('accept fails from Accepted, Rejected, and Discharged', () => { + for (const status of [ + BillOfExchangeStatus.Accepted, + BillOfExchangeStatus.Rejected, + BillOfExchangeStatus.Discharged, + ]) { + expect(() => BoeRules.assertAccept({ ...baseTransition, currentStatus: status })).toThrow( + 'cannot be accepted or rejected again', + ); + } + }); + + it('reject succeeds for holder at Issued', () => { + expect(() => BoeRules.assertReject(baseTransition)).not.toThrow(); + }); + + it('reject fails from Discharged', () => { + expect(() => + BoeRules.assertReject({ ...baseTransition, currentStatus: BillOfExchangeStatus.Discharged }), + ).toThrow('cannot be accepted or rejected again'); + }); + + it('discharge succeeds for beneficiary at Accepted', () => { + expect(() => + BoeRules.assertDischarge({ + ...baseTransition, + currentStatus: BillOfExchangeStatus.Accepted, + signerAddress: OWNER, + }), + ).not.toThrow(); + }); + + it('discharge fails for non-beneficiary', () => { + expect(() => + BoeRules.assertDischarge({ + ...baseTransition, + currentStatus: BillOfExchangeStatus.Accepted, + signerAddress: HOLDER, + }), + ).toThrow('Only the current beneficiary (owner) can discharge'); + }); + + it('discharge fails from Issued', () => { + expect(() => BoeRules.assertDischarge({ ...baseTransition, signerAddress: OWNER })).toThrow( + 'has not been accepted yet', + ); + }); + + it('discharge fails from Rejected with reissue hint', () => { + expect(() => + BoeRules.assertDischarge({ + ...baseTransition, + currentStatus: BillOfExchangeStatus.Rejected, + signerAddress: OWNER, + }), + ).toThrow('was rejected and can never be discharged'); + }); + + it('discharge fails when already Discharged', () => { + expect(() => + BoeRules.assertDischarge({ + ...baseTransition, + currentStatus: BillOfExchangeStatus.Discharged, + signerAddress: OWNER, + }), + ).toThrow('already been discharged'); + }); +}); diff --git a/src/__tests__/boe/fixtures.ts b/src/__tests__/boe/fixtures.ts new file mode 100644 index 0000000..fc45b60 --- /dev/null +++ b/src/__tests__/boe/fixtures.ts @@ -0,0 +1,117 @@ +import { vi } from 'vitest'; +import { ethers as ethersV5, Wallet as WalletV5 } from 'ethers'; +import { ethers as ethersV6, Network, Wallet as WalletV6 } from 'ethersV6'; +import * as coreModule from '../../core'; +import { CHAIN_ID } from '../../utils/supportedChains'; +import { BillOfExchangeStatus } from '../../boe/types'; +import { + mockV5TitleEscrowContract, + PRIVATE_KEY, + providerV5, + providerV6, +} from '../token-registry-functions/fixtures'; +import { ProviderInfo } from '../../token-registry-functions/types'; +import { getEthersContractFromProvider } from '../../utils/ethers'; + +export const OWNER = '0x1000000000000000000000000000000000000A'; +export const HOLDER = '0x2000000000000000000000000000000000000B'; +export const DEAD = '0x0000000000000000000000000000000000dEaD'; + +export const MOCK_TOKEN_REGISTRY_ADDRESS = '0xTokenRegistry'; +export const MOCK_TOKEN_ID = '0xTokenId'; +export const MOCK_TITLE_ESCROW_ADDRESS = '0xTitleEscrow'; +export const MOCK_CHAIN_ID = CHAIN_ID.local; +export const MOCK_ENCRYPTION_ID = 'encryption-id'; +export const MOCK_REMARKS = 'BOE remarks'; + +export const providers: ProviderInfo[] = [ + { Provider: providerV5, ethersVersion: 'v5', titleEscrowVersion: 'v5' }, + { Provider: providerV6, ethersVersion: 'v6', titleEscrowVersion: 'v5' }, +]; + +export interface EscrowState { + beneficiary?: string; + holder?: string; + prevBeneficiary?: string; + prevHolder?: string; + status?: BillOfExchangeStatus; +} + +export interface BoeTestContext { + wallet: ethersV5.Wallet | ethersV6.Wallet; + ethersVersion: 'v5' | 'v6'; +} + +export function installBoeMockContract(): void { + const mockContractConstructor = (mockContract: typeof mockV5TitleEscrowContract) => + vi.fn(() => mockContract); + vi.mocked(getEthersContractFromProvider).mockReturnValue( + mockContractConstructor(mockV5TitleEscrowContract) as unknown as ReturnType< + typeof getEthersContractFromProvider + >, + ); +} + +export function configureSignerAsHolder( + wallet: { address: string }, + beneficiary = '0xsome_other_owner', +): void { + mockV5TitleEscrowContract.holder.mockResolvedValue(wallet.address); + mockV5TitleEscrowContract.beneficiary.mockResolvedValue(beneficiary); + mockV5TitleEscrowContract.status.mockResolvedValue(BillOfExchangeStatus.Issued); +} + +export function configureSignerAsBeneficiary( + wallet: { address: string }, + holder = '0xsome_other_holder', +): void { + mockV5TitleEscrowContract.beneficiary.mockResolvedValue(wallet.address); + mockV5TitleEscrowContract.holder.mockResolvedValue(holder); + mockV5TitleEscrowContract.status.mockResolvedValue(BillOfExchangeStatus.Accepted); +} + +export function createWallet(Provider: ProviderInfo['Provider'], ethersVersion: 'v5' | 'v6') { + if (ethersVersion === 'v5') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wallet = new WalletV5(PRIVATE_KEY, Provider as any) as ethersV5.Wallet; + vi.spyOn(wallet, 'getChainId').mockResolvedValue(CHAIN_ID.mainnet as unknown as number); + return wallet; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wallet = new WalletV6(PRIVATE_KEY, Provider as any); + vi.spyOn(Provider, 'getNetwork').mockResolvedValue({ + chainId: CHAIN_ID.mainnet, + } as unknown as Network); + return wallet; +} + +export function resetBoeCoreMocks(): void { + vi.spyOn(coreModule, 'getTitleEscrowAddress').mockResolvedValue(MOCK_TITLE_ESCROW_ADDRESS); + vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(true); + vi.spyOn(coreModule, 'encrypt').mockReturnValue('encryptedRemarks'); +} + +export function configureEscrowState({ + beneficiary = OWNER, + holder = HOLDER, + prevBeneficiary = DEAD, + prevHolder = DEAD, + status = BillOfExchangeStatus.Issued, +}: EscrowState = {}): void { + mockV5TitleEscrowContract.beneficiary.mockResolvedValue(beneficiary); + mockV5TitleEscrowContract.holder.mockResolvedValue(holder); + mockV5TitleEscrowContract.prevBeneficiary.mockResolvedValue(prevBeneficiary); + mockV5TitleEscrowContract.prevHolder.mockResolvedValue(prevHolder); + mockV5TitleEscrowContract.status.mockResolvedValue(status); +} + +export function setupBoeTestContext( + Provider: ProviderInfo['Provider'], + ethersVersion: 'v5' | 'v6', +): BoeTestContext { + vi.clearAllMocks(); + resetBoeCoreMocks(); + configureEscrowState(); + return { wallet: createWallet(Provider, ethersVersion), ethersVersion }; +} diff --git a/src/__tests__/core/endorsement-chain-boe-events.test.ts b/src/__tests__/core/endorsement-chain-boe-events.test.ts new file mode 100644 index 0000000..6fa1116 --- /dev/null +++ b/src/__tests__/core/endorsement-chain-boe-events.test.ts @@ -0,0 +1,109 @@ +import { ethers as ethersV6 } from 'ethersV6'; +import { describe, expect, it } from 'vitest'; +import { TitleEscrow__factory } from '../../token-registry-v5/contracts'; +import { fetchEscrowTransfersV5 } from '../../core/endorsement-chain/fetchEscrowTransfer'; + +const ESCROW_ADDRESS = '0x24c9C688cf919D133abB512A41163972dA150f1b'; +const REGISTRY_ADDRESS = '0x3781bd0bbd15Bf5e45c7296115821933d47362be'; +const HOLDER = '0x433097a1C1b8a3e9188d8C54eCC057B1D69f1638'; +const BENEFICIARY = '0xCA93690Bb57EEaB273c796a9309246BC0FB93649'; +const TOKEN_ID = 1n; + +const remark = (text: string): string => ethersV6.hexlify(ethersV6.toUtf8Bytes(text)); + +describe('fetchEscrowTransfersV5 - Bill of Exchange events', () => { + const iface = new ethersV6.Interface(TitleEscrow__factory.abi); + + const encodeLog = (eventName: string, args: unknown[]) => { + const fragment = iface.getEvent(eventName)!; + const { data, topics } = iface.encodeEventLog(fragment, args); + return { + address: ESCROW_ADDRESS, + topics, + data, + blockNumber: 100, + blockHash: `0x${'11'.repeat(32)}`, + transactionHash: `0x${'22'.repeat(32)}`, + transactionIndex: 0, + index: 0, + removed: false, + }; + }; + + const logsByTopic = new Map([ + [ + iface.getEvent('BillOfExchangeAccepted')!.topicHash, + [ + encodeLog('BillOfExchangeAccepted', [ + HOLDER, + REGISTRY_ADDRESS, + TOKEN_ID, + remark('accepted'), + ]), + ], + ], + [ + iface.getEvent('BillOfExchangeRejected')!.topicHash, + [ + encodeLog('BillOfExchangeRejected', [ + HOLDER, + REGISTRY_ADDRESS, + TOKEN_ID, + remark('rejected'), + ]), + ], + ], + [ + iface.getEvent('BillOfExchangeDischarged')!.topicHash, + [ + encodeLog('BillOfExchangeDischarged', [ + BENEFICIARY, + REGISTRY_ADDRESS, + TOKEN_ID, + remark('discharged'), + ]), + ], + ], + ]); + + const provider = new ethersV6.JsonRpcProvider('http://localhost:1', 11155111, { + staticNetwork: true, + }); + + (provider as any)._send = async (payload: any) => { + const requests = Array.isArray(payload) ? payload : [payload]; + return requests.map((request: any) => { + if (request.method === 'eth_getLogs') { + const topic = request.params[0]?.topics?.[0]; + return { id: request.id, result: logsByTopic.get(topic) ?? [] }; + } + return { id: request.id, result: null }; + }); + }; + + it('maps BillOfExchangeAccepted/Rejected/Discharged into the endorsement chain event types', async () => { + const events = await fetchEscrowTransfersV5(provider, ESCROW_ADDRESS, REGISTRY_ADDRESS); + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'BILL_OF_EXCHANGE_ACCEPTED', + holder: HOLDER, + remark: remark('accepted'), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'BILL_OF_EXCHANGE_REJECTED', + holder: HOLDER, + remark: remark('rejected'), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'BILL_OF_EXCHANGE_DISCHARGED', + owner: BENEFICIARY, + remark: remark('discharged'), + }), + ); + }); +}); diff --git a/src/__tests__/fixtures/boe-raw-file.json b/src/__tests__/fixtures/boe-raw-file.json new file mode 100644 index 0000000..2f84ff5 --- /dev/null +++ b/src/__tests__/fixtures/boe-raw-file.json @@ -0,0 +1,61 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/security/data-integrity/v2", + "https://trustvc.io/context/render-method-context-v2.json", + "https://didhost.vercel.app/bill-of-exchange.json", + "https://trustvc.io/context/transferable-records-context.json", + "https://trustvc.io/context/qrcode-context.json" + ], + "renderMethod": [ + { + "type": "EMBEDDED_RENDERER", + "templateName": "BILL_OF_EXCHANGE", + "id": "https://generic-templates.tradetrust.io" + } + ], + "credentialSubject": { + "type": "BillOfExchange", + "boeNumber": "BOE-2026-00147", + "placeOfIssue": "Singapore", + "dateOfIssue": "2026-07-01", + "tenor": "90 days after sight", + "drawerCompanyName": "Meridian Commodities Pte Ltd", + "drawerCompanyNo": "UEN-202312345A", + "drawerJurisdiction": "Singapore", + "drawerAddress": "8 Marina Boulevard, #24-01, Singapore 018981", + "drawerEmail": "trade.finance@meridiancommodities.com", + "drawerWalletAddress": "0x71bE63f3384f5fb98995898A86B02Fb2426c5788", + "draweeCompanyName": "Fairview Industries Inc.", + "draweeCompanyNo": "EIN-84-1923456", + "draweeJurisdiction": "Delaware, United States", + "draweeAddress": "1201 Market Street, Suite 900, Wilmington, DE 19801, USA", + "draweeEmail": "accounts.payable@fairviewindustries.com", + "draweeWalletAddress": "0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199", + "payeeCompanyName": "Meridian Commodities Pte Ltd", + "payeeWalletAddress": "0x71bE63f3384f5fb98995898A86B02Fb2426c5788", + "dueDate": "2026-09-29", + "currency": "USD", + "amount": "128,500.00", + "amountInWords": "One hundred twenty-eight thousand five hundred United States Dollars only", + "clause": "Pay against this Bill of Exchange, on the tenor stated above, to the order of the Payee the sum stated, value received, without set-off, deduction or counterclaim, for goods shipped under Invoice No. INV-2026-0456 and Bill of Lading No. BL-SIN-2026-0781.", + "signerName": "Wei Ling Tan", + "signerPosition": "Director of Trade Finance", + "signerEmail": "wl.tan@meridiancommodities.com", + "signerTimeStamp": "2026-07-01" + }, + "type": [ + "VerifiableCredential" + ], + "credentialStatus": { + "type": "TransferableRecords", + "tokenNetwork": { + "chain": "Sepolia", + "chainId": 11155111 + }, + "tokenRegistry": "0x0Fb7F338FcB88290224A144A2b7E716D480a69E7" + }, + "issuer": "did:web:didhost.vercel.app", + "validFrom": "2026-07-01T09:00:00Z" + } + \ No newline at end of file diff --git a/src/__tests__/fixtures/endorsement-chain.ts b/src/__tests__/fixtures/endorsement-chain.ts index 4e9b0a9..5ca503d 100644 --- a/src/__tests__/fixtures/endorsement-chain.ts +++ b/src/__tests__/fixtures/endorsement-chain.ts @@ -3361,6 +3361,42 @@ export const testCases = [ baseFeePerGas: 15n, }, }, + { + function: 'getLogs', + params: [ + { + address: '0x24c9C688cf919D133abB512A41163972dA150f1b', + topics: ['0x99b8651fe6a15f20e29f103ad21eef0362603a62c0d4b96e7a0f493d101dc0a0'], + fromBlock: 0, + toBlock: 'latest', + }, + ], + result: [], + }, + { + function: 'getLogs', + params: [ + { + address: '0x24c9C688cf919D133abB512A41163972dA150f1b', + topics: ['0xcb2ddde652b648da9d5396742a3bf19fafe4566e74c425d5480193b3dc1ee570'], + fromBlock: 0, + toBlock: 'latest', + }, + ], + result: [], + }, + { + function: 'getLogs', + params: [ + { + address: '0x24c9C688cf919D133abB512A41163972dA150f1b', + topics: ['0x3c6f2526f3431eedd04ae7bfc6d1dc1fdaea2bc9eef72e6a647912ef8f2621ec'], + fromBlock: 0, + toBlock: 'latest', + }, + ], + result: [], + }, ], timeout: 180_000, }, diff --git a/src/__tests__/token-registry-functions/fixtures.ts b/src/__tests__/token-registry-functions/fixtures.ts index 3a1d35f..3d75d44 100644 --- a/src/__tests__/token-registry-functions/fixtures.ts +++ b/src/__tests__/token-registry-functions/fixtures.ts @@ -198,6 +198,9 @@ export const mockV5TitleEscrowContract = { rejectTransferBeneficiary: vi.fn(), rejectTransferOwners: vi.fn(), returnToIssuer: vi.fn(), + acceptBillOfExchange: vi.fn(), + rejectBillOfExchange: vi.fn(), + dischargeBillOfExchange: vi.fn(), }, transferHolder: Object.assign( // Direct call returns hash string @@ -233,6 +236,33 @@ export const mockV5TitleEscrowContract = { ), holder: vi.fn(() => Promise.resolve('0xcurrent_holder')), beneficiary: vi.fn(() => Promise.resolve('0xcurrent_beneficiary')), + prevHolder: vi.fn(() => Promise.resolve('0x0000000000000000000000000000000000dEaD')), + prevBeneficiary: vi.fn(() => Promise.resolve('0x0000000000000000000000000000000000dEaD')), + status: vi.fn(() => Promise.resolve(0)), + acceptBillOfExchange: Object.assign( + // Direct call returns hash string + vi.fn(() => Promise.resolve('v5_accept_bill_of_exchange_tx_hash')), + { + // Static call returns boolean + staticCall: vi.fn(() => Promise.resolve(true)), + }, + ), + rejectBillOfExchange: Object.assign( + // Direct call returns hash string + vi.fn(() => Promise.resolve('v5_reject_bill_of_exchange_tx_hash')), + { + // Static call returns boolean + staticCall: vi.fn(() => Promise.resolve(true)), + }, + ), + dischargeBillOfExchange: Object.assign( + // Direct call returns hash string + vi.fn(() => Promise.resolve('v5_discharge_bill_of_exchange_tx_hash')), + { + // Static call returns boolean + staticCall: vi.fn(() => Promise.resolve(true)), + }, + ), rejectTransferHolder: Object.assign( // Direct call returns hash string vi.fn(() => Promise.resolve('v5_reject_transfer_holder_tx_hash')), diff --git a/src/boe/BoeRules.ts b/src/boe/BoeRules.ts new file mode 100644 index 0000000..c41d5b2 --- /dev/null +++ b/src/boe/BoeRules.ts @@ -0,0 +1,77 @@ +import { BillOfExchangeStatus, BillOfExchangeStatusLabel } from './types'; + +interface TransitionParams { + currentBeneficiary: string; + currentHolder: string; + currentStatus: BillOfExchangeStatus; + signerAddress: string; +} + +const sameAddress = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase(); + +// Business rules for the three dedicated Bill of Exchange functions (acceptBillOfExchange, +// rejectBillOfExchange, dischargeBillOfExchange). Every check here is purely advisory, client-side +// validation — the contract enforces the on-chain-relevant subset of these independently. Nothing +// in this class talks to a contract; it only reasons about state its callers already fetched. +// +// The existing ETR functions (transferHolder, nominate, transferBeneficiary, transferOwners, the +// reject-transfer family, returnToIssuer) are untouched by this class and by documentType — they +// behave exactly as they did before the Bill of Exchange lifecycle existed. +export class BoeRules { + private static assertTransition( + action: 'accept' | 'reject' | 'discharge', + requiredCallerRole: 'holder' | 'beneficiary', + requiredStatus: BillOfExchangeStatus, + { currentBeneficiary, currentHolder, currentStatus, signerAddress }: TransitionParams, + ): void { + const expectedCaller = requiredCallerRole === 'holder' ? currentHolder : currentBeneficiary; + if (!sameAddress(signerAddress, expectedCaller)) { + throw new Error( + requiredCallerRole === 'holder' + ? `Only the current holder can ${action} this Bill of Exchange` + : `Only the current beneficiary (owner) can ${action} this Bill of Exchange`, + ); + } + + if (sameAddress(currentBeneficiary, currentHolder)) { + throw new Error( + 'Owner and holder must be different addresses before this Bill of Exchange can be accepted, rejected, or discharged', + ); + } + + if (currentStatus === requiredStatus) return; + + if (action === 'discharge') { + if (currentStatus === BillOfExchangeStatus.Rejected) { + throw new Error( + 'This Bill of Exchange was rejected and can never be discharged — surrender it (returnToIssuer) and reissue a new one instead.', + ); + } + if (currentStatus === BillOfExchangeStatus.Issued) { + throw new Error( + 'This Bill of Exchange has not been accepted yet — only an Accepted bill can be discharged.', + ); + } + throw new Error('This Bill of Exchange has already been discharged.'); + } + + throw new Error( + `This Bill of Exchange has already been ${BillOfExchangeStatusLabel[currentStatus]} and cannot be accepted or rejected again.`, + ); + } + + // acceptBillOfExchange: holder-only, requires status Issued. + static assertAccept(params: TransitionParams): void { + BoeRules.assertTransition('accept', 'holder', BillOfExchangeStatus.Issued, params); + } + + // rejectBillOfExchange: holder-only, requires status Issued. + static assertReject(params: TransitionParams): void { + BoeRules.assertTransition('reject', 'holder', BillOfExchangeStatus.Issued, params); + } + + // dischargeBillOfExchange: beneficiary-only, requires status Accepted. + static assertDischarge(params: TransitionParams): void { + BoeRules.assertTransition('discharge', 'beneficiary', BillOfExchangeStatus.Accepted, params); + } +} diff --git a/src/boe/accept.ts b/src/boe/accept.ts new file mode 100644 index 0000000..9af0b80 --- /dev/null +++ b/src/boe/accept.ts @@ -0,0 +1,111 @@ +import { + encrypt, + getTitleEscrowAddress, + isTitleEscrowVersion, + TitleEscrowInterface, +} from '../core'; +import { v5Contracts } from '../token-registry-v5'; +import { Signer as SignerV6, Contract as ContractV6 } from 'ethersV6'; +import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; +import { getSignerAddressSafe, getTxOptions } from '../token-registry-functions/utils'; +import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; +import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; +import { BillOfExchangeActionParams, BillOfExchangeStatus } from './types'; +import { BoeRules } from './BoeRules'; + +/** + * Beta. Holder accepts a Bill of Exchange, moving its status from Issued to Accepted. Callable on any + * TitleEscrow — not gated on document type — but only makes sense once beneficiary != holder. + * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. + * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. Must be the current holder. + * @param {BillOfExchangeActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. + * @param {TransactionOptions} options - Includes optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. + * @throws if the title escrow address or signer provider is missing. + * @throws if the version is not V5 compatible, or the TitleEscrow predates the Bill of Exchange lifecycle. + * @throws if the signer is not the current holder, if owner and holder are the same address, or if the current status isn't Issued. + * @throws if the dry-run (`callStatic`) fails. + * @returns {Promise} The transaction response of the acceptBillOfExchange call. + */ +const acceptBillOfExchange = async ( + contractOptions: ContractOptions, + signer: Signer | SignerV6, + params: BillOfExchangeActionParams, + options: TransactionOptions, +): Promise => { + const { tokenRegistryAddress, tokenId } = contractOptions; + let { titleEscrowAddress } = contractOptions; + const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; + + if (!titleEscrowAddress) { + if (!tokenRegistryAddress) throw new Error('Token registry address is required'); + if (!tokenId) throw new Error('Token ID is required'); + titleEscrowAddress = await getTitleEscrowAddress( + tokenRegistryAddress, + tokenId as string, + signer.provider, + {}, + ); + } + + if (!titleEscrowAddress) throw new Error('Title escrow address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { remarks } = params; + + const Contract = getEthersContractFromProvider(signer.provider); + const titleEscrowContract: ContractV5 | ContractV6 = new Contract( + titleEscrowAddress, + v5Contracts.TitleEscrow__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer as any, + ); + const encryptedRemarks = remarks ? `0x${encrypt(remarks, options.id!)}` : '0x'; + + let isV5TT = titleEscrowVersion === 'v5'; + if (titleEscrowVersion === undefined) { + isV5TT = await isTitleEscrowVersion({ + titleEscrowAddress, + versionInterface: TitleEscrowInterface.V5, + provider: signer.provider, + }); + } + + if (!isV5TT) { + throw new Error('Only Token Registry V5 is supported'); + } + + const [currentBeneficiary, currentHolder] = await Promise.all([ + titleEscrowContract.beneficiary(), + titleEscrowContract.holder(), + ]); + + let currentStatus: BillOfExchangeStatus; + try { + currentStatus = Number(await titleEscrowContract.status()) as BillOfExchangeStatus; + } catch (e) { + console.error('status() failed:', e); + throw new Error( + 'This TitleEscrow does not support the Bill of Exchange lifecycle (status()) — it likely predates the eBOE contract upgrade.', + ); + } + + const signerAddress = await getSignerAddressSafe(signer); + BoeRules.assertAccept({ currentBeneficiary, currentHolder, currentStatus, signerAddress }); + + // Check callStatic (dry run) + try { + if (isV6EthersProvider(signer.provider)) { + await (titleEscrowContract as ContractV6).acceptBillOfExchange.staticCall(encryptedRemarks); + } else { + await (titleEscrowContract as ContractV5).callStatic.acceptBillOfExchange(encryptedRemarks); + } + } catch (e) { + console.error('callStatic failed:', e); + throw new Error('Pre-check (callStatic) for acceptBillOfExchange failed'); + } + + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + return await titleEscrowContract.acceptBillOfExchange(encryptedRemarks, txOptions); +}; + +export { acceptBillOfExchange }; diff --git a/src/boe/discharge.ts b/src/boe/discharge.ts new file mode 100644 index 0000000..eb7b861 --- /dev/null +++ b/src/boe/discharge.ts @@ -0,0 +1,116 @@ +import { + encrypt, + getTitleEscrowAddress, + isTitleEscrowVersion, + TitleEscrowInterface, +} from '../core'; +import { v5Contracts } from '../token-registry-v5'; +import { Signer as SignerV6, Contract as ContractV6 } from 'ethersV6'; +import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; +import { getSignerAddressSafe, getTxOptions } from '../token-registry-functions/utils'; +import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; +import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; +import { BillOfExchangeActionParams, BillOfExchangeStatus } from './types'; +import { BoeRules } from './BoeRules'; + +/** + * Beta. Beneficiary (owner) discharges a Bill of Exchange, moving its status from Accepted to + * Discharged — terminal, confirming money received off-chain. Never callable from Rejected. Not + * gated on document type. + * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. + * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. Must be the current beneficiary. + * @param {BillOfExchangeActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. + * @param {TransactionOptions} options - Includes optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. + * @throws if the title escrow address or signer provider is missing. + * @throws if the version is not V5 compatible, or the TitleEscrow predates the Bill of Exchange lifecycle. + * @throws if the signer is not the current beneficiary, if owner and holder are the same address, or if the current status isn't Accepted. + * @throws if the dry-run (`callStatic`) fails. + * @returns {Promise} The transaction response of the dischargeBillOfExchange call. + */ +const dischargeBillOfExchange = async ( + contractOptions: ContractOptions, + signer: Signer | SignerV6, + params: BillOfExchangeActionParams, + options: TransactionOptions, +): Promise => { + const { tokenRegistryAddress, tokenId } = contractOptions; + let { titleEscrowAddress } = contractOptions; + const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; + + if (!titleEscrowAddress) { + if (!tokenRegistryAddress) throw new Error('Token registry address is required'); + if (!tokenId) throw new Error('Token ID is required'); + titleEscrowAddress = await getTitleEscrowAddress( + tokenRegistryAddress, + tokenId as string, + signer.provider, + {}, + ); + } + + if (!titleEscrowAddress) throw new Error('Title escrow address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { remarks } = params; + + const Contract = getEthersContractFromProvider(signer.provider); + const titleEscrowContract: ContractV5 | ContractV6 = new Contract( + titleEscrowAddress, + v5Contracts.TitleEscrow__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer as any, + ); + const encryptedRemarks = remarks ? `0x${encrypt(remarks, options.id!)}` : '0x'; + + let isV5TT = titleEscrowVersion === 'v5'; + if (titleEscrowVersion === undefined) { + isV5TT = await isTitleEscrowVersion({ + titleEscrowAddress, + versionInterface: TitleEscrowInterface.V5, + provider: signer.provider, + }); + } + + if (!isV5TT) { + throw new Error('Only Token Registry V5 is supported'); + } + + const [currentBeneficiary, currentHolder] = await Promise.all([ + titleEscrowContract.beneficiary(), + titleEscrowContract.holder(), + ]); + + let currentStatus: BillOfExchangeStatus; + try { + currentStatus = Number(await titleEscrowContract.status()) as BillOfExchangeStatus; + } catch (e) { + console.error('status() failed:', e); + throw new Error( + 'This TitleEscrow does not support the Bill of Exchange lifecycle (status()) — it likely predates the eBOE contract upgrade.', + ); + } + + const signerAddress = await getSignerAddressSafe(signer); + BoeRules.assertDischarge({ currentBeneficiary, currentHolder, currentStatus, signerAddress }); + + // Check callStatic (dry run) + try { + if (isV6EthersProvider(signer.provider)) { + await (titleEscrowContract as ContractV6).dischargeBillOfExchange.staticCall( + encryptedRemarks, + ); + } else { + await (titleEscrowContract as ContractV5).callStatic.dischargeBillOfExchange( + encryptedRemarks, + ); + } + } catch (e) { + console.error('callStatic failed:', e); + throw new Error('Pre-check (callStatic) for dischargeBillOfExchange failed'); + } + + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + return await titleEscrowContract.dischargeBillOfExchange(encryptedRemarks, txOptions); +}; + +export { dischargeBillOfExchange }; diff --git a/src/boe/index.ts b/src/boe/index.ts new file mode 100644 index 0000000..f31364c --- /dev/null +++ b/src/boe/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export * from './status'; +export * from './accept'; +export * from './reject'; +export * from './discharge'; diff --git a/src/boe/reject.ts b/src/boe/reject.ts new file mode 100644 index 0000000..ddc5a1f --- /dev/null +++ b/src/boe/reject.ts @@ -0,0 +1,112 @@ +import { + encrypt, + getTitleEscrowAddress, + isTitleEscrowVersion, + TitleEscrowInterface, +} from '../core'; +import { v5Contracts } from '../token-registry-v5'; +import { Signer as SignerV6, Contract as ContractV6 } from 'ethersV6'; +import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; +import { getSignerAddressSafe, getTxOptions } from '../token-registry-functions/utils'; +import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; +import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; +import { BillOfExchangeActionParams, BillOfExchangeStatus } from './types'; +import { BoeRules } from './BoeRules'; + +/** + * Beta. Holder rejects (dishonours) a Bill of Exchange, moving its status from Issued to Rejected — + * terminal. Status-only: it does not revert the holder role, use the existing + * `rejectTransferHolder` for that. Not gated on document type. + * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. + * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. Must be the current holder. + * @param {BillOfExchangeActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. + * @param {TransactionOptions} options - Includes optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. + * @throws if the title escrow address or signer provider is missing. + * @throws if the version is not V5 compatible, or the TitleEscrow predates the Bill of Exchange lifecycle. + * @throws if the signer is not the current holder, if owner and holder are the same address, or if the current status isn't Issued. + * @throws if the dry-run (`callStatic`) fails. + * @returns {Promise} The transaction response of the rejectBillOfExchange call. + */ +const rejectBillOfExchange = async ( + contractOptions: ContractOptions, + signer: Signer | SignerV6, + params: BillOfExchangeActionParams, + options: TransactionOptions, +): Promise => { + const { tokenRegistryAddress, tokenId } = contractOptions; + let { titleEscrowAddress } = contractOptions; + const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; + + if (!titleEscrowAddress) { + if (!tokenRegistryAddress) throw new Error('Token registry address is required'); + if (!tokenId) throw new Error('Token ID is required'); + titleEscrowAddress = await getTitleEscrowAddress( + tokenRegistryAddress, + tokenId as string, + signer.provider, + {}, + ); + } + + if (!titleEscrowAddress) throw new Error('Title escrow address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { remarks } = params; + + const Contract = getEthersContractFromProvider(signer.provider); + const titleEscrowContract: ContractV5 | ContractV6 = new Contract( + titleEscrowAddress, + v5Contracts.TitleEscrow__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer as any, + ); + const encryptedRemarks = remarks ? `0x${encrypt(remarks, options.id!)}` : '0x'; + + let isV5TT = titleEscrowVersion === 'v5'; + if (titleEscrowVersion === undefined) { + isV5TT = await isTitleEscrowVersion({ + titleEscrowAddress, + versionInterface: TitleEscrowInterface.V5, + provider: signer.provider, + }); + } + + if (!isV5TT) { + throw new Error('Only Token Registry V5 is supported'); + } + + const [currentBeneficiary, currentHolder] = await Promise.all([ + titleEscrowContract.beneficiary(), + titleEscrowContract.holder(), + ]); + + let currentStatus: BillOfExchangeStatus; + try { + currentStatus = Number(await titleEscrowContract.status()) as BillOfExchangeStatus; + } catch (e) { + console.error('status() failed:', e); + throw new Error( + 'This TitleEscrow does not support the Bill of Exchange lifecycle (status()) — it likely predates the eBOE contract upgrade.', + ); + } + + const signerAddress = await getSignerAddressSafe(signer); + BoeRules.assertReject({ currentBeneficiary, currentHolder, currentStatus, signerAddress }); + + // Check callStatic (dry run) + try { + if (isV6EthersProvider(signer.provider)) { + await (titleEscrowContract as ContractV6).rejectBillOfExchange.staticCall(encryptedRemarks); + } else { + await (titleEscrowContract as ContractV5).callStatic.rejectBillOfExchange(encryptedRemarks); + } + } catch (e) { + console.error('callStatic failed:', e); + throw new Error('Pre-check (callStatic) for rejectBillOfExchange failed'); + } + + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + return await titleEscrowContract.rejectBillOfExchange(encryptedRemarks, txOptions); +}; + +export { rejectBillOfExchange }; diff --git a/src/boe/status.ts b/src/boe/status.ts new file mode 100644 index 0000000..8500e26 --- /dev/null +++ b/src/boe/status.ts @@ -0,0 +1,76 @@ +import { getTitleEscrowAddress, isTitleEscrowVersion, TitleEscrowInterface } from '../core'; +import { v5Contracts } from '../token-registry-v5'; +import { Signer as SignerV6 } from 'ethersV6'; +import { Signer as SignerV5 } from 'ethers'; +import { getEthersContractFromProvider } from '../utils/ethers'; +import { BillOfExchangeStatus, BillOfExchangeStatusOptions } from './types'; +import { TransactionOptions } from '../token-registry-functions/types'; + +/** + * Beta. Reads the `status` field off a TitleEscrow. Every TitleEscrow carries this field, ETR or + * otherwise — it defaults to `Issued` and only ever advances via `acceptBillOfExchange`, + * `rejectBillOfExchange`, or `dischargeBillOfExchange`. + * @param {BillOfExchangeStatusOptions} contractOptions - Either `titleEscrowAddress`, or both `tokenRegistryAddress` and `tokenId`. + * @param {SignerV5 | SignerV6} signer - Ethers signer (V5 or V6) used to read the contract. + * @param {TransactionOptions} options - Only `titleEscrowVersion` is relevant here; skips version detection when provided. + * @throws if the title escrow address or signer provider is missing. + * @throws if the version is not V5 compatible. + * @throws if the TitleEscrow predates the Bill of Exchange lifecycle (no `status()` getter). + * @returns {Promise} The current status. + */ +const getBillOfExchangeStatus = async ( + contractOptions: BillOfExchangeStatusOptions, + signer: SignerV5 | SignerV6, + options: TransactionOptions = {}, +): Promise => { + const { tokenRegistryAddress, tokenId } = contractOptions; + let { titleEscrowAddress } = contractOptions; + const { titleEscrowVersion } = options; + + if (!titleEscrowAddress) { + if (!tokenRegistryAddress) throw new Error('Token registry address is required'); + if (!tokenId) throw new Error('Token ID is required'); + titleEscrowAddress = await getTitleEscrowAddress( + tokenRegistryAddress, + tokenId as string, + signer.provider, + {}, + ); + } + + if (!titleEscrowAddress) throw new Error('Title escrow address is required'); + if (!signer.provider) throw new Error('Provider is required'); + + let isV5TT = titleEscrowVersion === 'v5'; + if (titleEscrowVersion === undefined) { + isV5TT = await isTitleEscrowVersion({ + titleEscrowAddress, + versionInterface: TitleEscrowInterface.V5, + provider: signer.provider, + }); + } + + if (!isV5TT) { + throw new Error('Only Token Registry V5 is supported'); + } + + const Contract = getEthersContractFromProvider(signer.provider); + const titleEscrowContract = new Contract( + titleEscrowAddress, + v5Contracts.TitleEscrow__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer as any, + ); + + try { + const status = await titleEscrowContract.status(); + return Number(status) as BillOfExchangeStatus; + } catch (e) { + console.error('status() failed:', e); + throw new Error( + 'This TitleEscrow does not support the Bill of Exchange lifecycle (status()) — it likely predates the eBOE contract upgrade.', + ); + } +}; + +export { getBillOfExchangeStatus }; diff --git a/src/boe/types.ts b/src/boe/types.ts new file mode 100644 index 0000000..b587fb7 --- /dev/null +++ b/src/boe/types.ts @@ -0,0 +1,29 @@ +import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; + +export type { ContractOptions, TransactionOptions }; + +export interface BillOfExchangeActionParams { + remarks?: string; +} + +export type BillOfExchangeStatusOptions = ContractOptions; + +/** + * Lifecycle status of a TitleEscrow's Bill of Exchange status field. Mirrors the `Status` enum on + * `TitleEscrow.sol` — every TitleEscrow carries this field, not just ones used as a Bill of Exchange. + */ +export const BillOfExchangeStatus = { + Issued: 0, + Accepted: 1, + Rejected: 2, + Discharged: 3, +} as const; + +export type BillOfExchangeStatus = (typeof BillOfExchangeStatus)[keyof typeof BillOfExchangeStatus]; + +export const BillOfExchangeStatusLabel: Record = { + [BillOfExchangeStatus.Issued]: 'Issued', + [BillOfExchangeStatus.Accepted]: 'Accepted', + [BillOfExchangeStatus.Rejected]: 'Rejected', + [BillOfExchangeStatus.Discharged]: 'Discharged', +}; diff --git a/src/core/documentBuilder.ts b/src/core/documentBuilder.ts index db7b5ff..3c9b247 100644 --- a/src/core/documentBuilder.ts +++ b/src/core/documentBuilder.ts @@ -348,7 +348,8 @@ export class DocumentBuilder { interfaceId: string, provider: ethers.providers.JsonRpcProvider, ) { - const contract = contractFactory.connect(this.statusConfig.tokenRegistry, provider); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contract = contractFactory.connect(this.statusConfig.tokenRegistry, provider as any); return contract.supportsInterface(interfaceId); } } diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index 6b822b8..e825742 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -133,6 +133,9 @@ const fetchAllTransfers = async ( titleEscrowContract.filters.RejectTransferBeneficiary, titleEscrowContract.filters.RejectTransferHolder, titleEscrowContract.filters.Shred, + titleEscrowContract.filters.BillOfExchangeAccepted, + titleEscrowContract.filters.BillOfExchangeRejected, + titleEscrowContract.filters.BillOfExchangeDischarged, ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const allLogs: any = await Promise.all( @@ -237,6 +240,33 @@ const fetchAllTransfers = async ( transactionIndex: event.transactionIndex, remark: event.args?.remark, } as TokenTransferEvent; + } else if (event?.name === 'BillOfExchangeAccepted') { + return { + type: 'BILL_OF_EXCHANGE_ACCEPTED', + holder: event.args.holder, + blockNumber: event.blockNumber, + transactionHash: event.transactionHash, + transactionIndex: event.transactionIndex, + remark: event.args?.remark, + } as TitleEscrowTransferEvent; + } else if (event?.name === 'BillOfExchangeRejected') { + return { + type: 'BILL_OF_EXCHANGE_REJECTED', + holder: event.args.holder, + blockNumber: event.blockNumber, + transactionHash: event.transactionHash, + transactionIndex: event.transactionIndex, + remark: event.args?.remark, + } as TitleEscrowTransferEvent; + } else if (event?.name === 'BillOfExchangeDischarged') { + return { + type: 'BILL_OF_EXCHANGE_DISCHARGED', + owner: event.args.beneficiary, + blockNumber: event.blockNumber, + transactionHash: event.transactionHash, + transactionIndex: event.transactionIndex, + remark: event.args?.remark, + } as TitleEscrowTransferEvent; } return undefined; diff --git a/src/core/endorsement-chain/types.ts b/src/core/endorsement-chain/types.ts index e13d430..6ea84e8 100644 --- a/src/core/endorsement-chain/types.ts +++ b/src/core/endorsement-chain/types.ts @@ -46,7 +46,10 @@ export type TitleEscrowTransferEventType = | 'TRANSFER_OWNERS' | 'REJECT_TRANSFER_BENEFICIARY' // V5 | 'REJECT_TRANSFER_HOLDER' // V5 - | 'REJECT_TRANSFER_OWNERS'; // V5 + | 'REJECT_TRANSFER_OWNERS' // V5 + | 'BILL_OF_EXCHANGE_ACCEPTED' // V5, eBOE + | 'BILL_OF_EXCHANGE_REJECTED' // V5, eBOE + | 'BILL_OF_EXCHANGE_DISCHARGED'; // V5, eBOE export interface TokenTransferEvent extends TransferBaseEvent { type: TokenTransferEventType; diff --git a/src/index.ts b/src/index.ts index 8a66ac0..e84f620 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import { cancelTransaction } from './transaction'; export type { TypedContractMethod } from './token-registry-v5/typedContractMethod'; export type { CancelTransactionSigner } from './transaction'; export * from './token-registry-functions'; +export * from './boe'; export * from './core'; export * from './open-attestation'; export * from './verify'; From c4c309d63f2f3bba3e3485d8498cc5beb4a87a65 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Fri, 10 Jul 2026 19:10:39 +0530 Subject: [PATCH 02/10] feat: introduce Bill of Exchange functionality in the SDK functionality --- README.md | 69 ++--- src/__tests__/boe/boe.test.ts | 284 ------------------ ...> endorsement-chain-status-events.test.ts} | 36 +-- src/__tests__/{boe => status}/fixtures.ts | 22 +- src/__tests__/status/status.test.ts | 256 ++++++++++++++++ .../statusE2eDocument.test.ts} | 143 ++++----- .../statusRules.test.ts} | 44 ++- .../token-registry-functions/fixtures.ts | 18 +- src/boe/BoeRules.ts | 77 ----- src/boe/types.ts | 29 -- .../endorsement-chain/fetchEscrowTransfer.ts | 18 +- src/core/endorsement-chain/helpers.ts | 3 +- src/core/endorsement-chain/types.ts | 6 +- src/index.ts | 2 +- src/status/StatusRules.ts | 77 +++++ src/{boe => status}/accept.ts | 35 +-- src/{boe => status}/discharge.ts | 39 ++- src/{boe => status}/index.ts | 0 src/{boe => status}/reject.ts | 35 +-- src/{boe => status}/status.ts | 23 +- src/status/types.ts | 29 ++ 21 files changed, 591 insertions(+), 654 deletions(-) delete mode 100644 src/__tests__/boe/boe.test.ts rename src/__tests__/core/{endorsement-chain-boe-events.test.ts => endorsement-chain-status-events.test.ts} (73%) rename src/__tests__/{boe => status}/fixtures.ts (88%) create mode 100644 src/__tests__/status/status.test.ts rename src/__tests__/{boe/boeE2eDocument.test.ts => status/statusE2eDocument.test.ts} (78%) rename src/__tests__/{boe/boeRules.test.ts => status/statusRules.test.ts} (60%) delete mode 100644 src/boe/BoeRules.ts delete mode 100644 src/boe/types.ts create mode 100644 src/status/StatusRules.ts rename src/{boe => status}/accept.ts (76%) rename src/{boe => status}/discharge.ts (76%) rename src/{boe => status}/index.ts (100%) rename src/{boe => status}/reject.ts (77%) rename src/{boe => status}/status.ts (74%) create mode 100644 src/status/types.ts diff --git a/README.md b/README.md index c2349eb..6d45da2 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,10 @@ TrustVC is a comprehensive wrapper library designed to simplify the signing and - [8. **Document Builder**](#8-document-builder) - [9. **Document Store**](#9-document-store) - [10. **Transaction Cancel**](#10-transaction-cancel) - - [11. **Bill of Exchange (Beta)**](#11-bill-of-exchange-beta) + - [11. **Bill of Exchange / Status (Beta)**](#11-bill-of-exchange--status-beta) - [Minting](#minting) - - [getBillOfExchangeStatus](#getbillofexchangestatus) - - [acceptBillOfExchange / rejectBillOfExchange / dischargeBillOfExchange](#acceptbillofexchange--rejectbillofexchange--dischargebillofexchange) + - [getStatus](#getstatus) + - [accept / reject / discharge](#accept--reject--discharge) - [The existing transfer/reject functions are untouched](#the-existing-transferreject-functions-are-untouched) - [Notes and limitations](#notes-and-limitations) @@ -1212,9 +1212,9 @@ const replacementHash2 = await cancelTransaction(signer, { --- -## 11. Bill of Exchange (Beta) +## 11. Bill of Exchange / Status (Beta) -> A Bill of Exchange (eBOE) is not a separate contract. It is the same `TradeTrustToken`/`TitleEscrow` (**Token Registry V5 only**) you already use for ETR, carrying one extra field — `status` — that starts at `Issued` and can move to `Accepted`/`Rejected` (holder-only) or, from `Accepted`, to `Discharged` (owner-only). Minting is the exact same `mint()` call as ETR, and the contract treats every escrow identically. There is no `documentType` concept anywhere — on-chain or in the SDK. Whether a given `TitleEscrow` is "being used as" a Bill of Exchange is determined entirely by **whether anyone ever calls** `acceptBillOfExchange`/`rejectBillOfExchange`/`dischargeBillOfExchange` on it — three new, dedicated functions described below. Everything else — `transferHolder`, `nominate`, `transferBeneficiary`, `transferOwners`, the reject-transfer family, `returnToIssuer` — is the exact same ETR functionality, completely unmodified and unaware that a Bill of Exchange lifecycle exists. +> A Bill of Exchange (eBOE) is not a separate contract. It is the same `TradeTrustToken`/`TitleEscrow` (**Token Registry V5 only**) you already use for ETR, carrying one extra field — `status` — that starts at `Issued` and can move to `Accepted`/`Rejected` (holder-only) or, from `Accepted`, to `Discharged` (owner-only). Minting is the exact same `mint()` call as ETR, and the contract treats every escrow identically. There is no `documentType` concept anywhere — on-chain or in the SDK. Whether a given `TitleEscrow` is "being used as" a Bill of Exchange is determined entirely by **whether anyone ever calls** `accept`/`reject`/`discharge` on it — three dedicated SDK helpers that wrap the on-chain methods of the same name and emit `StatusAccepted`/`StatusRejected`/`StatusDischarged`. Everything else — `transferHolder`, `nominate`, `transferBeneficiary`, `transferOwners`, the reject-transfer family, `returnToIssuer` — is the exact same ETR functionality, completely unmodified and unaware that a status lifecycle exists. ### Minting @@ -1231,7 +1231,7 @@ const tx = await mint( ); ``` -### getBillOfExchangeStatus +### getStatus #### Description @@ -1245,38 +1245,38 @@ const tx = await mint( #### Returns -**Promise<BillOfExchangeStatus>** — one of `BillOfExchangeStatus.Issued/Accepted/Rejected/Discharged`. +**Promise<Status>** — one of `Status.Issued/Accepted/Rejected/Discharged`. #### Throws - If the registry/token/title escrow address or provider is missing. - If the escrow is not V5. -- If the escrow predates the Bill of Exchange upgrade (no `status()` getter at all). +- If the escrow predates the status upgrade (no `status()` getter at all). #### Example ```ts -import { getBillOfExchangeStatus, BillOfExchangeStatus, BillOfExchangeStatusLabel } from '@trustvc/trustvc'; +import { getStatus, Status, StatusLabel } from '@trustvc/trustvc'; -const status = await getBillOfExchangeStatus({ tokenRegistryAddress, tokenId }, signer); -console.log(BillOfExchangeStatusLabel[status]); // e.g. "Issued" +const status = await getStatus({ tokenRegistryAddress, tokenId }, signer); +console.log(StatusLabel[status]); // e.g. "Issued" ``` -### acceptBillOfExchange / rejectBillOfExchange / dischargeBillOfExchange +### accept / reject / discharge #### Description -> Three dedicated functions, each mapping onto the AC's lifecycle rules: +> Three dedicated functions, each mapping onto the on-chain `accept`/`reject`/`discharge` methods and the AC's lifecycle rules: > -> - **acceptBillOfExchange** — holder-only, requires `status === Issued`, moves to `Accepted`. -> - **rejectBillOfExchange** — holder-only, requires `status === Issued`, moves to `Rejected` (terminal — nothing can move it again). Status-only: it does not revert the holder role, use the existing `rejectTransferHolder` for that. -> - **dischargeBillOfExchange** — beneficiary(owner)-only, requires `status === Accepted`, moves to `Discharged` (terminal). Never callable from `Rejected`. +> - **accept** — holder-only, requires `status === Issued`, moves to `Accepted`. Emits `StatusAccepted`. +> - **reject** — holder-only, requires `status === Issued`, moves to `Rejected` (terminal — nothing can move it again). Status-only: it does not revert the holder role, use the existing `rejectTransferHolder` for that. Emits `StatusRejected`. +> - **discharge** — beneficiary(owner)-only, requires `status === Accepted`, moves to `Discharged` (terminal). Never callable from `Rejected`. Emits `StatusDischarged`. > > All three additionally require `beneficiary != holder` at the moment they're called, and run client-side pre-flight checks — in this order — before sending the transaction, so failures surface as a specific message instead of a raw revert: > > 1. **Caller-role check** — signer must be the current holder (accept/reject) or beneficiary (discharge). > 2. **Owner ≠ holder check**. -> 3. **Status-precondition check**, with a tailored message per terminal case (e.g. *"This Bill of Exchange was rejected and can never be discharged — surrender it (returnToIssuer) and reissue a new one instead."*). +> 3. **Status-precondition check**, with a tailored message per terminal case (e.g. *"This TitleEscrow was rejected and can never be discharged — surrender it (returnToIssuer) and reissue a new one instead."*). #### Parameters @@ -1293,10 +1293,10 @@ Any of the pre-flight checks above, or a failed `callStatic` dry-run. #### Example ```ts -import { acceptBillOfExchange, rejectBillOfExchange, dischargeBillOfExchange } from '@trustvc/trustvc'; +import { accept, reject, discharge } from '@trustvc/trustvc'; // Holder accepts -const acceptTx = await acceptBillOfExchange( +const acceptTx = await accept( { tokenRegistryAddress, tokenId }, holderSigner, { remarks: 'Accepted, bound to pay at maturity' }, @@ -1305,10 +1305,10 @@ const acceptTx = await acceptBillOfExchange( await acceptTx.wait(); // ...or, instead, the holder declines: -// await rejectBillOfExchange({ tokenRegistryAddress, tokenId }, holderSigner, {}, options); +// await reject({ tokenRegistryAddress, tokenId }, holderSigner, {}, options); // Owner discharges once payment is received off-chain -const dischargeTx = await dischargeBillOfExchange( +const dischargeTx = await discharge( { tokenRegistryAddress, tokenId }, ownerSigner, { remarks: 'Paid at maturity' }, @@ -1323,7 +1323,7 @@ await dischargeTx.wait(); > `transferHolder`, `transferBeneficiary`, `transferOwners`, `nominate`, `rejectTransferHolder`, `rejectTransferBeneficiary`, `rejectTransferOwners`, and `returnToIssuer` are **exactly** the same functions ETR integrations already call — same signatures, same exports, same import paths, same behaviour. There is no `documentType` field, no status gating, no reconvergence guard, no circulation restriction woven into any of them. They have zero awareness that `status` or the Bill of Exchange lifecycle exists. > -> This is a deliberate design choice: the contract is fully permissive by itself (transfers never mutate or check `status`), and this SDK doesn't add any client-side restriction on top either. If you want reconvergence guards, terminal-state circulation limits, or surrender hints specific to your own integration, build them in your own application layer — `BoeRules` (used internally by `acceptBillOfExchange`/`rejectBillOfExchange`/`dischargeBillOfExchange` only) is not applied to these functions and never will be. +> This is a deliberate design choice: the contract is fully permissive by itself (transfers never mutate or check `status`), and this SDK doesn't add any client-side restriction on top either. If you want reconvergence guards, terminal-state circulation limits, or surrender hints specific to your own integration, build them in your own application layer — `StatusRules` (used internally by `accept`/`reject`/`discharge` only) is not applied to these functions and never will be. #### Example: full happy path @@ -1334,18 +1334,18 @@ import { transferBeneficiary, returnToIssuer, mint, - acceptBillOfExchange, - dischargeBillOfExchange, + accept, + discharge, } from '@trustvc/trustvc'; const contractOptions = { tokenRegistryAddress, tokenId }; const options = { chainId: CHAIN_ID.sepolia, id: 'encryption-id' }; -// Presentment: diverge holder from the drawer — plain ETR call, no BOE awareness involved +// Presentment: diverge holder from the drawer — plain ETR call, no status awareness involved await (await transferHolder(contractOptions, signer, { holderAddress: draweeAddress }, options)).wait(); -// Drawee accepts — the dedicated function is what actually marks this escrow as a Bill of Exchange -await (await acceptBillOfExchange(contractOptions, signer, { remarks: 'Accepted' }, options)).wait(); +// Drawee accepts — the dedicated function is what actually advances status +await (await accept(contractOptions, signer, { remarks: 'Accepted' }, options)).wait(); // Optional: owner circulates the receivable to a financing bank — holder is untouched. // Nothing stops the holder from also changing here; that's on your own integration to guard if desired. @@ -1353,7 +1353,7 @@ await (await nominate(contractOptions, signer, { newBeneficiaryAddress: bankAddr await (await transferBeneficiary(contractOptions, signer, { newBeneficiaryAddress: bankAddress }, options)).wait(); // Owner discharges once paid -await (await dischargeBillOfExchange(contractOptions, signer, { remarks: 'Paid at maturity' }, options)).wait(); +await (await discharge(contractOptions, signer, { remarks: 'Paid at maturity' }, options)).wait(); // Reconverge owner onto holder, then close it out — plain ETR calls throughout await (await nominate(contractOptions, signer, { newBeneficiaryAddress: draweeAddress }, options)).wait(); @@ -1364,12 +1364,12 @@ await (await returnToIssuer(contractOptions, signer, {}, options)).wait(); #### Example: reject and reissue ```ts -import { transferHolder, rejectTransferHolder, returnToIssuer, mint, rejectBillOfExchange } from '@trustvc/trustvc'; +import { transferHolder, rejectTransferHolder, returnToIssuer, mint, reject } from '@trustvc/trustvc'; await (await transferHolder(contractOptions, signer, { holderAddress: draweeAddress }, options)).wait(); -await (await rejectBillOfExchange(contractOptions, signer, { remarks: 'Declined' }, options)).wait(); +await (await reject(contractOptions, signer, { remarks: 'Declined' }, options)).wait(); -// rejectBillOfExchange is status-only — separately revert the holder role to reconverge +// reject is status-only — separately revert the holder role to reconverge await (await rejectTransferHolder(contractOptions, signer, {}, options)).wait(); await (await returnToIssuer(contractOptions, signer, {}, options)).wait(); @@ -1380,6 +1380,7 @@ await mint({ tokenRegistryAddress }, signer, { beneficiaryAddress, holderAddress ### Notes and limitations - **Beta** functionality — the API may still change. -- **Token Registry V5 only.** V4 has no `status()`/accept/reject/discharge at all. -- `BoeRules` validation applies **only** inside `acceptBillOfExchange`/`rejectBillOfExchange`/`dischargeBillOfExchange`. It is not applied to, and cannot be applied to, `transferHolder`/`transferBeneficiary`/`transferOwners`/`nominate`/the reject-transfer family/`returnToIssuer` — those remain exactly as permissive as plain ETR, by design. -- `getBillOfExchangeStatus`/`acceptBillOfExchange`/`rejectBillOfExchange`/`dischargeBillOfExchange` are exported flat from `@trustvc/trustvc`; `BoeRules` is internal and not exported — it only exists to keep the three dedicated functions' precondition checks in one place. +- **Token Registry V5 only.** V4 has no `status()`/`accept`/`reject`/`discharge` at all. +- On-chain method names are `accept`/`reject`/`discharge`; events are `StatusAccepted`/`StatusRejected`/`StatusDischarged`. The endorsement chain surfaces these as `STATUS_ACCEPTED`/`STATUS_REJECTED`/`STATUS_DISCHARGED`. +- `StatusRules` validation applies **only** inside `accept`/`reject`/`discharge`. It is not applied to, and cannot be applied to, `transferHolder`/`transferBeneficiary`/`transferOwners`/`nominate`/the reject-transfer family/`returnToIssuer` — those remain exactly as permissive as plain ETR, by design. +- `getStatus`/`accept`/`reject`/`discharge` (plus `Status`/`StatusLabel`) are exported flat from `@trustvc/trustvc`; `StatusRules` is internal and not exported — it only exists to keep the three dedicated functions' precondition checks in one place. diff --git a/src/__tests__/boe/boe.test.ts b/src/__tests__/boe/boe.test.ts deleted file mode 100644 index ebb4b82..0000000 --- a/src/__tests__/boe/boe.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import '../token-registry-functions/fixtures.js'; -import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; -import * as coreModule from '../../core'; -import { acceptBillOfExchange } from '../../boe/accept'; -import { rejectBillOfExchange } from '../../boe/reject'; -import { dischargeBillOfExchange } from '../../boe/discharge'; -import { getBillOfExchangeStatus } from '../../boe/status'; -import { BillOfExchangeStatus } from '../../boe/types'; -import { mockV5TitleEscrowContract } from '../token-registry-functions/fixtures'; -import { - configureSignerAsBeneficiary, - configureSignerAsHolder, - installBoeMockContract, - MOCK_CHAIN_ID, - MOCK_ENCRYPTION_ID, - MOCK_REMARKS, - MOCK_TITLE_ESCROW_ADDRESS, - MOCK_TOKEN_ID, - MOCK_TOKEN_REGISTRY_ADDRESS, - providers, - setupBoeTestContext, -} from './fixtures'; - -describe.each(providers)( - 'Bill of Exchange with ethers version $ethersVersion', - ({ Provider, ethersVersion }) => { - let wallet: ReturnType['wallet']; - - beforeAll(() => { - installBoeMockContract(); - }); - - beforeEach(() => { - ({ wallet } = setupBoeTestContext(Provider, ethersVersion)); - configureSignerAsHolder(wallet); - }); - - const options = { chainId: MOCK_CHAIN_ID, id: MOCK_ENCRYPTION_ID }; - const contractOptions = { - tokenRegistryAddress: MOCK_TOKEN_REGISTRY_ADDRESS, - tokenId: MOCK_TOKEN_ID, - }; - - describe('acceptBillOfExchange', () => { - it('should accept with signer and all required parameters', async () => { - const result = await acceptBillOfExchange( - contractOptions, - wallet, - { remarks: MOCK_REMARKS }, - options, - ); - expect(result).toEqual('v5_accept_bill_of_exchange_tx_hash'); - }); - - it('should accept when titleEscrowAddress is provided directly', async () => { - const result = await acceptBillOfExchange( - { titleEscrowAddress: MOCK_TITLE_ESCROW_ADDRESS }, - wallet, - { remarks: MOCK_REMARKS }, - options, - ); - expect(result).toEqual('v5_accept_bill_of_exchange_tx_hash'); - expect(coreModule.getTitleEscrowAddress).not.toHaveBeenCalled(); - }); - - it('should throw when tokenRegistryAddress is missing', async () => { - vi.mocked(coreModule.getTitleEscrowAddress).mockResolvedValue(undefined); - await expect( - acceptBillOfExchange( - { tokenId: MOCK_TOKEN_ID } as any, - wallet, - { remarks: MOCK_REMARKS }, - options, - ), - ).rejects.toThrow('Token registry address is required'); - }); - - it('should throw when provider is missing', async () => { - const { Wallet: WalletV5 } = await import('ethers'); - const signerWithoutProvider = new WalletV5('0x'.padEnd(66, '1')); - await expect( - acceptBillOfExchange( - contractOptions, - signerWithoutProvider, - { remarks: MOCK_REMARKS }, - options, - ), - ).rejects.toThrow('Provider is required'); - }); - - it('should throw when title escrow is not V5', async () => { - vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(false); - await expect( - acceptBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Only Token Registry V5 is supported'); - }); - - it('should throw a friendly error when the TitleEscrow predates the Bill of Exchange lifecycle', async () => { - mockV5TitleEscrowContract.status.mockRejectedValue(new Error('no such function')); - await expect( - acceptBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('does not support the Bill of Exchange lifecycle'); - }); - - it('should throw when the signer is not the current holder', async () => { - mockV5TitleEscrowContract.holder.mockResolvedValue('0xsomeone_else'); - await expect( - acceptBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Only the current holder can accept this Bill of Exchange'); - }); - - it('should throw when owner and holder are the same address', async () => { - mockV5TitleEscrowContract.beneficiary.mockResolvedValue(wallet.address); - await expect( - acceptBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Owner and holder must be different addresses'); - }); - - it.each([ - BillOfExchangeStatus.Accepted, - BillOfExchangeStatus.Rejected, - BillOfExchangeStatus.Discharged, - ])('should throw when status is already %i', async (status) => { - mockV5TitleEscrowContract.status.mockResolvedValue(status); - await expect( - acceptBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow(/already been|cannot be accepted or rejected again/); - }); - - it('should throw when callStatic fails', async () => { - mockV5TitleEscrowContract.callStatic.acceptBillOfExchange.mockRejectedValue( - new Error('Simulated failure'), - ); - mockV5TitleEscrowContract.acceptBillOfExchange.staticCall.mockRejectedValue( - new Error('Simulated failure'), - ); - await expect( - acceptBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Pre-check (callStatic) for acceptBillOfExchange failed'); - mockV5TitleEscrowContract.callStatic.acceptBillOfExchange = vi.fn(); - mockV5TitleEscrowContract.acceptBillOfExchange.staticCall = vi.fn(); - }); - }); - - describe('rejectBillOfExchange', () => { - it('should reject with signer and all required parameters', async () => { - const result = await rejectBillOfExchange( - contractOptions, - wallet, - { remarks: MOCK_REMARKS }, - options, - ); - expect(result).toEqual('v5_reject_bill_of_exchange_tx_hash'); - }); - - it('should throw when the signer is not the current holder', async () => { - mockV5TitleEscrowContract.holder.mockResolvedValue('0xsomeone_else'); - await expect( - rejectBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Only the current holder can reject this Bill of Exchange'); - }); - - it('should throw when owner and holder are the same address', async () => { - mockV5TitleEscrowContract.beneficiary.mockResolvedValue(wallet.address); - await expect( - rejectBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Owner and holder must be different addresses'); - }); - - it.each([BillOfExchangeStatus.Rejected, BillOfExchangeStatus.Discharged])( - 'should throw when status is already %i', - async (status) => { - mockV5TitleEscrowContract.status.mockResolvedValue(status); - await expect( - rejectBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('cannot be accepted or rejected again'); - }, - ); - - it('should throw when callStatic fails', async () => { - mockV5TitleEscrowContract.callStatic.rejectBillOfExchange.mockRejectedValue( - new Error('Simulated failure'), - ); - mockV5TitleEscrowContract.rejectBillOfExchange.staticCall.mockRejectedValue( - new Error('Simulated failure'), - ); - await expect( - rejectBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Pre-check (callStatic) for rejectBillOfExchange failed'); - mockV5TitleEscrowContract.callStatic.rejectBillOfExchange = vi.fn(); - mockV5TitleEscrowContract.rejectBillOfExchange.staticCall = vi.fn(); - }); - }); - - describe('dischargeBillOfExchange', () => { - beforeEach(() => { - configureSignerAsBeneficiary(wallet); - }); - - it('should discharge with signer and all required parameters', async () => { - const result = await dischargeBillOfExchange( - contractOptions, - wallet, - { remarks: MOCK_REMARKS }, - options, - ); - expect(result).toEqual('v5_discharge_bill_of_exchange_tx_hash'); - }); - - it('should throw when the signer is not the current beneficiary', async () => { - mockV5TitleEscrowContract.beneficiary.mockResolvedValue('0xsomeone_else'); - await expect( - dischargeBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow( - 'Only the current beneficiary (owner) can discharge this Bill of Exchange', - ); - }); - - it('should throw when owner and holder are the same address', async () => { - mockV5TitleEscrowContract.holder.mockResolvedValue(wallet.address); - await expect( - dischargeBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Owner and holder must be different addresses'); - }); - - it('should throw a specific message when status is Rejected', async () => { - mockV5TitleEscrowContract.status.mockResolvedValue(BillOfExchangeStatus.Rejected); - await expect( - dischargeBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('was rejected and can never be discharged'); - }); - - it('should throw a specific message when status is still Issued', async () => { - mockV5TitleEscrowContract.status.mockResolvedValue(BillOfExchangeStatus.Issued); - await expect( - dischargeBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('has not been accepted yet'); - }); - - it('should throw a specific message when already Discharged', async () => { - mockV5TitleEscrowContract.status.mockResolvedValue(BillOfExchangeStatus.Discharged); - await expect( - dischargeBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('This Bill of Exchange has already been discharged.'); - }); - - it('should throw when callStatic fails', async () => { - mockV5TitleEscrowContract.callStatic.dischargeBillOfExchange.mockRejectedValue( - new Error('Simulated failure'), - ); - mockV5TitleEscrowContract.dischargeBillOfExchange.staticCall.mockRejectedValue( - new Error('Simulated failure'), - ); - await expect( - dischargeBillOfExchange(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Pre-check (callStatic) for dischargeBillOfExchange failed'); - mockV5TitleEscrowContract.callStatic.dischargeBillOfExchange = vi.fn(); - mockV5TitleEscrowContract.dischargeBillOfExchange.staticCall = vi.fn(); - }); - }); - - describe('getBillOfExchangeStatus', () => { - it('should return the current status', async () => { - mockV5TitleEscrowContract.status.mockResolvedValue(BillOfExchangeStatus.Accepted); - const result = await getBillOfExchangeStatus(contractOptions, wallet, {}); - expect(result).toEqual(BillOfExchangeStatus.Accepted); - }); - - it('should throw when title escrow is not V5', async () => { - vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(false); - await expect(getBillOfExchangeStatus(contractOptions, wallet, {})).rejects.toThrow( - 'Only Token Registry V5 is supported', - ); - }); - - it('should throw a friendly error when status() reverts', async () => { - mockV5TitleEscrowContract.status.mockRejectedValue(new Error('no such function')); - await expect(getBillOfExchangeStatus(contractOptions, wallet, {})).rejects.toThrow( - 'does not support the Bill of Exchange lifecycle', - ); - }); - }); - }, -); diff --git a/src/__tests__/core/endorsement-chain-boe-events.test.ts b/src/__tests__/core/endorsement-chain-status-events.test.ts similarity index 73% rename from src/__tests__/core/endorsement-chain-boe-events.test.ts rename to src/__tests__/core/endorsement-chain-status-events.test.ts index 6fa1116..470ea54 100644 --- a/src/__tests__/core/endorsement-chain-boe-events.test.ts +++ b/src/__tests__/core/endorsement-chain-status-events.test.ts @@ -11,7 +11,7 @@ const TOKEN_ID = 1n; const remark = (text: string): string => ethersV6.hexlify(ethersV6.toUtf8Bytes(text)); -describe('fetchEscrowTransfersV5 - Bill of Exchange events', () => { +describe('fetchEscrowTransfersV5 - Status events', () => { const iface = new ethersV6.Interface(TitleEscrow__factory.abi); const encodeLog = (eventName: string, args: unknown[]) => { @@ -32,31 +32,17 @@ describe('fetchEscrowTransfersV5 - Bill of Exchange events', () => { const logsByTopic = new Map([ [ - iface.getEvent('BillOfExchangeAccepted')!.topicHash, - [ - encodeLog('BillOfExchangeAccepted', [ - HOLDER, - REGISTRY_ADDRESS, - TOKEN_ID, - remark('accepted'), - ]), - ], + iface.getEvent('StatusAccepted')!.topicHash, + [encodeLog('StatusAccepted', [HOLDER, REGISTRY_ADDRESS, TOKEN_ID, remark('accepted')])], ], [ - iface.getEvent('BillOfExchangeRejected')!.topicHash, - [ - encodeLog('BillOfExchangeRejected', [ - HOLDER, - REGISTRY_ADDRESS, - TOKEN_ID, - remark('rejected'), - ]), - ], + iface.getEvent('StatusRejected')!.topicHash, + [encodeLog('StatusRejected', [HOLDER, REGISTRY_ADDRESS, TOKEN_ID, remark('rejected')])], ], [ - iface.getEvent('BillOfExchangeDischarged')!.topicHash, + iface.getEvent('StatusDischarged')!.topicHash, [ - encodeLog('BillOfExchangeDischarged', [ + encodeLog('StatusDischarged', [ BENEFICIARY, REGISTRY_ADDRESS, TOKEN_ID, @@ -81,26 +67,26 @@ describe('fetchEscrowTransfersV5 - Bill of Exchange events', () => { }); }; - it('maps BillOfExchangeAccepted/Rejected/Discharged into the endorsement chain event types', async () => { + it('maps StatusAccepted/Rejected/Discharged into the endorsement chain event types', async () => { const events = await fetchEscrowTransfersV5(provider, ESCROW_ADDRESS, REGISTRY_ADDRESS); expect(events).toContainEqual( expect.objectContaining({ - type: 'BILL_OF_EXCHANGE_ACCEPTED', + type: 'STATUS_ACCEPTED', holder: HOLDER, remark: remark('accepted'), }), ); expect(events).toContainEqual( expect.objectContaining({ - type: 'BILL_OF_EXCHANGE_REJECTED', + type: 'STATUS_REJECTED', holder: HOLDER, remark: remark('rejected'), }), ); expect(events).toContainEqual( expect.objectContaining({ - type: 'BILL_OF_EXCHANGE_DISCHARGED', + type: 'STATUS_DISCHARGED', owner: BENEFICIARY, remark: remark('discharged'), }), diff --git a/src/__tests__/boe/fixtures.ts b/src/__tests__/status/fixtures.ts similarity index 88% rename from src/__tests__/boe/fixtures.ts rename to src/__tests__/status/fixtures.ts index fc45b60..e0d4ceb 100644 --- a/src/__tests__/boe/fixtures.ts +++ b/src/__tests__/status/fixtures.ts @@ -3,7 +3,7 @@ import { ethers as ethersV5, Wallet as WalletV5 } from 'ethers'; import { ethers as ethersV6, Network, Wallet as WalletV6 } from 'ethersV6'; import * as coreModule from '../../core'; import { CHAIN_ID } from '../../utils/supportedChains'; -import { BillOfExchangeStatus } from '../../boe/types'; +import { Status } from '../../status/types'; import { mockV5TitleEscrowContract, PRIVATE_KEY, @@ -34,15 +34,15 @@ export interface EscrowState { holder?: string; prevBeneficiary?: string; prevHolder?: string; - status?: BillOfExchangeStatus; + status?: Status; } -export interface BoeTestContext { +export interface StatusTestContext { wallet: ethersV5.Wallet | ethersV6.Wallet; ethersVersion: 'v5' | 'v6'; } -export function installBoeMockContract(): void { +export function installStatusMockContract(): void { const mockContractConstructor = (mockContract: typeof mockV5TitleEscrowContract) => vi.fn(() => mockContract); vi.mocked(getEthersContractFromProvider).mockReturnValue( @@ -58,7 +58,7 @@ export function configureSignerAsHolder( ): void { mockV5TitleEscrowContract.holder.mockResolvedValue(wallet.address); mockV5TitleEscrowContract.beneficiary.mockResolvedValue(beneficiary); - mockV5TitleEscrowContract.status.mockResolvedValue(BillOfExchangeStatus.Issued); + mockV5TitleEscrowContract.status.mockResolvedValue(Status.Issued); } export function configureSignerAsBeneficiary( @@ -67,7 +67,7 @@ export function configureSignerAsBeneficiary( ): void { mockV5TitleEscrowContract.beneficiary.mockResolvedValue(wallet.address); mockV5TitleEscrowContract.holder.mockResolvedValue(holder); - mockV5TitleEscrowContract.status.mockResolvedValue(BillOfExchangeStatus.Accepted); + mockV5TitleEscrowContract.status.mockResolvedValue(Status.Accepted); } export function createWallet(Provider: ProviderInfo['Provider'], ethersVersion: 'v5' | 'v6') { @@ -86,7 +86,7 @@ export function createWallet(Provider: ProviderInfo['Provider'], ethersVersion: return wallet; } -export function resetBoeCoreMocks(): void { +export function resetStatusCoreMocks(): void { vi.spyOn(coreModule, 'getTitleEscrowAddress').mockResolvedValue(MOCK_TITLE_ESCROW_ADDRESS); vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(true); vi.spyOn(coreModule, 'encrypt').mockReturnValue('encryptedRemarks'); @@ -97,7 +97,7 @@ export function configureEscrowState({ holder = HOLDER, prevBeneficiary = DEAD, prevHolder = DEAD, - status = BillOfExchangeStatus.Issued, + status = Status.Issued, }: EscrowState = {}): void { mockV5TitleEscrowContract.beneficiary.mockResolvedValue(beneficiary); mockV5TitleEscrowContract.holder.mockResolvedValue(holder); @@ -106,12 +106,12 @@ export function configureEscrowState({ mockV5TitleEscrowContract.status.mockResolvedValue(status); } -export function setupBoeTestContext( +export function setupStatusTestContext( Provider: ProviderInfo['Provider'], ethersVersion: 'v5' | 'v6', -): BoeTestContext { +): StatusTestContext { vi.clearAllMocks(); - resetBoeCoreMocks(); + resetStatusCoreMocks(); configureEscrowState(); return { wallet: createWallet(Provider, ethersVersion), ethersVersion }; } diff --git a/src/__tests__/status/status.test.ts b/src/__tests__/status/status.test.ts new file mode 100644 index 0000000..c25d01b --- /dev/null +++ b/src/__tests__/status/status.test.ts @@ -0,0 +1,256 @@ +import '../token-registry-functions/fixtures.js'; +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import * as coreModule from '../../core'; +import { accept } from '../../status/accept'; +import { reject } from '../../status/reject'; +import { discharge } from '../../status/discharge'; +import { getStatus } from '../../status/status'; +import { Status } from '../../status/types'; +import { mockV5TitleEscrowContract } from '../token-registry-functions/fixtures'; +import { + configureSignerAsBeneficiary, + configureSignerAsHolder, + installStatusMockContract, + MOCK_CHAIN_ID, + MOCK_ENCRYPTION_ID, + MOCK_REMARKS, + MOCK_TITLE_ESCROW_ADDRESS, + MOCK_TOKEN_ID, + MOCK_TOKEN_REGISTRY_ADDRESS, + providers, + setupStatusTestContext, +} from './fixtures'; + +describe.each(providers)( + 'TitleEscrow status with ethers version $ethersVersion', + ({ Provider, ethersVersion }) => { + let wallet: ReturnType['wallet']; + + beforeAll(() => { + installStatusMockContract(); + }); + + beforeEach(() => { + ({ wallet } = setupStatusTestContext(Provider, ethersVersion)); + configureSignerAsHolder(wallet); + }); + + const options = { chainId: MOCK_CHAIN_ID, id: MOCK_ENCRYPTION_ID }; + const contractOptions = { + tokenRegistryAddress: MOCK_TOKEN_REGISTRY_ADDRESS, + tokenId: MOCK_TOKEN_ID, + }; + + describe('accept', () => { + it('should accept with signer and all required parameters', async () => { + const result = await accept(contractOptions, wallet, { remarks: MOCK_REMARKS }, options); + expect(result).toEqual('v5_accept_tx_hash'); + }); + + it('should accept when titleEscrowAddress is provided directly', async () => { + const result = await accept( + { titleEscrowAddress: MOCK_TITLE_ESCROW_ADDRESS }, + wallet, + { remarks: MOCK_REMARKS }, + options, + ); + expect(result).toEqual('v5_accept_tx_hash'); + expect(coreModule.getTitleEscrowAddress).not.toHaveBeenCalled(); + }); + + it('should throw when tokenRegistryAddress is missing', async () => { + vi.mocked(coreModule.getTitleEscrowAddress).mockResolvedValue(undefined); + await expect( + accept({ tokenId: MOCK_TOKEN_ID } as any, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Token registry address is required'); + }); + + it('should throw when provider is missing', async () => { + const { Wallet: WalletV5 } = await import('ethers'); + const signerWithoutProvider = new WalletV5('0x'.padEnd(66, '1')); + await expect( + accept(contractOptions, signerWithoutProvider, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Provider is required'); + }); + + it('should throw when title escrow is not V5', async () => { + vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(false); + await expect( + accept(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Only Token Registry V5 is supported'); + }); + + it('should throw a friendly error when the TitleEscrow predates the status lifecycle', async () => { + mockV5TitleEscrowContract.status.mockRejectedValue(new Error('no such function')); + await expect( + accept(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('does not support the status lifecycle'); + }); + + it('should throw when the signer is not the current holder', async () => { + mockV5TitleEscrowContract.holder.mockResolvedValue('0xsomeone_else'); + await expect( + accept(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Only the current holder can accept this TitleEscrow'); + }); + + it('should throw when owner and holder are the same address', async () => { + mockV5TitleEscrowContract.beneficiary.mockResolvedValue(wallet.address); + await expect( + accept(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Owner and holder must be different addresses'); + }); + + it.each([Status.Accepted, Status.Rejected, Status.Discharged])( + 'should throw when status is already %i', + async (status) => { + mockV5TitleEscrowContract.status.mockResolvedValue(status); + await expect( + accept(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow(/already been|cannot be accepted or rejected again/); + }, + ); + + it('should throw when callStatic fails', async () => { + mockV5TitleEscrowContract.callStatic.accept.mockRejectedValue( + new Error('Simulated failure'), + ); + mockV5TitleEscrowContract.accept.staticCall.mockRejectedValue( + new Error('Simulated failure'), + ); + await expect( + accept(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Pre-check (callStatic) for accept failed'); + mockV5TitleEscrowContract.callStatic.accept = vi.fn(); + mockV5TitleEscrowContract.accept.staticCall = vi.fn(); + }); + }); + + describe('reject', () => { + it('should reject with signer and all required parameters', async () => { + const result = await reject(contractOptions, wallet, { remarks: MOCK_REMARKS }, options); + expect(result).toEqual('v5_reject_tx_hash'); + }); + + it('should throw when the signer is not the current holder', async () => { + mockV5TitleEscrowContract.holder.mockResolvedValue('0xsomeone_else'); + await expect( + reject(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Only the current holder can reject this TitleEscrow'); + }); + + it('should throw when owner and holder are the same address', async () => { + mockV5TitleEscrowContract.beneficiary.mockResolvedValue(wallet.address); + await expect( + reject(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Owner and holder must be different addresses'); + }); + + it.each([Status.Rejected, Status.Discharged])( + 'should throw when status is already %i', + async (status) => { + mockV5TitleEscrowContract.status.mockResolvedValue(status); + await expect( + reject(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('cannot be accepted or rejected again'); + }, + ); + + it('should throw when callStatic fails', async () => { + mockV5TitleEscrowContract.callStatic.reject.mockRejectedValue( + new Error('Simulated failure'), + ); + mockV5TitleEscrowContract.reject.staticCall.mockRejectedValue( + new Error('Simulated failure'), + ); + await expect( + reject(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Pre-check (callStatic) for reject failed'); + mockV5TitleEscrowContract.callStatic.reject = vi.fn(); + mockV5TitleEscrowContract.reject.staticCall = vi.fn(); + }); + }); + + describe('discharge', () => { + beforeEach(() => { + configureSignerAsBeneficiary(wallet); + }); + + it('should discharge with signer and all required parameters', async () => { + const result = await discharge(contractOptions, wallet, { remarks: MOCK_REMARKS }, options); + expect(result).toEqual('v5_discharge_tx_hash'); + }); + + it('should throw when the signer is not the current beneficiary', async () => { + mockV5TitleEscrowContract.beneficiary.mockResolvedValue('0xsomeone_else'); + await expect( + discharge(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Only the current beneficiary (owner) can discharge this TitleEscrow'); + }); + + it('should throw when owner and holder are the same address', async () => { + mockV5TitleEscrowContract.holder.mockResolvedValue(wallet.address); + await expect( + discharge(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Owner and holder must be different addresses'); + }); + + it('should throw a specific message when status is Rejected', async () => { + mockV5TitleEscrowContract.status.mockResolvedValue(Status.Rejected); + await expect( + discharge(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('was rejected and can never be discharged'); + }); + + it('should throw a specific message when status is still Issued', async () => { + mockV5TitleEscrowContract.status.mockResolvedValue(Status.Issued); + await expect( + discharge(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('has not been accepted yet'); + }); + + it('should throw a specific message when already Discharged', async () => { + mockV5TitleEscrowContract.status.mockResolvedValue(Status.Discharged); + await expect( + discharge(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('This TitleEscrow has already been discharged.'); + }); + + it('should throw when callStatic fails', async () => { + mockV5TitleEscrowContract.callStatic.discharge.mockRejectedValue( + new Error('Simulated failure'), + ); + mockV5TitleEscrowContract.discharge.staticCall.mockRejectedValue( + new Error('Simulated failure'), + ); + await expect( + discharge(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), + ).rejects.toThrow('Pre-check (callStatic) for discharge failed'); + mockV5TitleEscrowContract.callStatic.discharge = vi.fn(); + mockV5TitleEscrowContract.discharge.staticCall = vi.fn(); + }); + }); + + describe('getStatus', () => { + it('should return the current status', async () => { + mockV5TitleEscrowContract.status.mockResolvedValue(Status.Accepted); + const result = await getStatus(contractOptions, wallet, {}); + expect(result).toEqual(Status.Accepted); + }); + + it('should throw when title escrow is not V5', async () => { + vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(false); + await expect(getStatus(contractOptions, wallet, {})).rejects.toThrow( + 'Only Token Registry V5 is supported', + ); + }); + + it('should throw a friendly error when status() reverts', async () => { + mockV5TitleEscrowContract.status.mockRejectedValue(new Error('no such function')); + await expect(getStatus(contractOptions, wallet, {})).rejects.toThrow( + 'does not support the status lifecycle', + ); + }); + }); + }, +); diff --git a/src/__tests__/boe/boeE2eDocument.test.ts b/src/__tests__/status/statusE2eDocument.test.ts similarity index 78% rename from src/__tests__/boe/boeE2eDocument.test.ts rename to src/__tests__/status/statusE2eDocument.test.ts index 0cf60cc..836bda8 100644 --- a/src/__tests__/boe/boeE2eDocument.test.ts +++ b/src/__tests__/status/statusE2eDocument.test.ts @@ -15,13 +15,8 @@ import { returnToIssuer, acceptReturned, } from '../../token-registry-functions'; -import { - acceptBillOfExchange, - rejectBillOfExchange, - dischargeBillOfExchange, - getBillOfExchangeStatus, -} from '../../boe'; -import { BillOfExchangeStatus } from '../../boe/types'; +import { accept, reject, discharge, getStatus } from '../../status'; +import { Status } from '../../status/types'; import { getEthersContractFromProvider } from '../../utils/ethers'; import { CHAIN_ID } from '../../utils/supportedChains'; import { @@ -101,19 +96,19 @@ function resetCoreMocks(): void { async function setRoles(params: { beneficiary: string; holder: string; - status?: BillOfExchangeStatus; + status?: Status; prevHolder?: string; }) { mockV5TitleEscrowContract.beneficiary.mockResolvedValue(params.beneficiary); mockV5TitleEscrowContract.holder.mockResolvedValue(params.holder); - mockV5TitleEscrowContract.status.mockResolvedValue(params.status ?? BillOfExchangeStatus.Issued); + mockV5TitleEscrowContract.status.mockResolvedValue(params.status ?? Status.Issued); mockV5TitleEscrowContract.prevHolder.mockResolvedValue( params.prevHolder ?? '0x0000000000000000000000000000000000dEaD', ); } describe.each(providers)( - 'Bill of Exchange end-to-end with a real signed document ($ethersVersion)', + 'TitleEscrow status end-to-end with a real signed document ($ethersVersion)', ({ Provider, ethersVersion }) => { let ownerWallet: ethersV5.Wallet | ethersV6.Wallet; let holderWallet: ethersV5.Wallet | ethersV6.Wallet; @@ -187,9 +182,7 @@ describe.each(providers)( options, ); expect(mintTx).toEqual('v5_mint_tx_hash'); - expect(await getBillOfExchangeStatus(contractOptions, ownerWallet)).toEqual( - BillOfExchangeStatus.Issued, - ); + expect(await getStatus(contractOptions, ownerWallet)).toEqual(Status.Issued); // Step 1 — Presentment: diverge holder to drawee const presentTx = await transferHolder( @@ -206,38 +199,34 @@ describe.each(providers)( }); // Step 2 — Holder accepts - const acceptTx = await acceptBillOfExchange( + const acceptTx = await accept( contractOptions, holderWallet, { remarks: 'Accepted, bound to pay at maturity' }, options, ); - expect(acceptTx).toEqual('v5_accept_bill_of_exchange_tx_hash'); + expect(acceptTx).toEqual('v5_accept_tx_hash'); await setRoles({ beneficiary: ownerAddress, holder: holderAddress, - status: BillOfExchangeStatus.Accepted, + status: Status.Accepted, }); - expect(await getBillOfExchangeStatus(contractOptions, ownerWallet)).toEqual( - BillOfExchangeStatus.Accepted, - ); + expect(await getStatus(contractOptions, ownerWallet)).toEqual(Status.Accepted); // Step 3 — Owner discharges after payment - const dischargeTx = await dischargeBillOfExchange( + const dischargeTx = await discharge( contractOptions, ownerWallet, { remarks: `Paid at maturity for ${boeRawDocument.credentialSubject.boeNumber}` }, options, ); - expect(dischargeTx).toEqual('v5_discharge_bill_of_exchange_tx_hash'); + expect(dischargeTx).toEqual('v5_discharge_tx_hash'); await setRoles({ beneficiary: ownerAddress, holder: holderAddress, - status: BillOfExchangeStatus.Discharged, + status: Status.Discharged, }); - expect(await getBillOfExchangeStatus(contractOptions, ownerWallet)).toEqual( - BillOfExchangeStatus.Discharged, - ); + expect(await getStatus(contractOptions, ownerWallet)).toEqual(Status.Discharged); // Step 4 — Reconverge owner onto holder, then surrender await nominate( @@ -255,7 +244,7 @@ describe.each(providers)( await setRoles({ beneficiary: holderAddress, holder: holderAddress, - status: BillOfExchangeStatus.Discharged, + status: Status.Discharged, }); const surrenderTx = await returnToIssuer( @@ -286,30 +275,28 @@ describe.each(providers)( prevHolder: ownerAddress, }); - const rejectTx = await rejectBillOfExchange( + const rejectTx = await reject( contractOptions, holderWallet, { remarks: 'Declined — goods not received' }, options, ); - expect(rejectTx).toEqual('v5_reject_bill_of_exchange_tx_hash'); + expect(rejectTx).toEqual('v5_reject_tx_hash'); await setRoles({ beneficiary: ownerAddress, holder: holderAddress, - status: BillOfExchangeStatus.Rejected, + status: Status.Rejected, prevHolder: ownerAddress, }); - expect(await getBillOfExchangeStatus(contractOptions, ownerWallet)).toEqual( - BillOfExchangeStatus.Rejected, - ); + expect(await getStatus(contractOptions, ownerWallet)).toEqual(Status.Rejected); // Terminal: cannot discharge or accept a rejected bill - await expect( - dischargeBillOfExchange(contractOptions, ownerWallet, {}, options), - ).rejects.toThrow('was rejected and can never be discharged'); - await expect( - acceptBillOfExchange(contractOptions, holderWallet, {}, options), - ).rejects.toThrow(/already been|cannot be accepted or rejected again/); + await expect(discharge(contractOptions, ownerWallet, {}, options)).rejects.toThrow( + 'was rejected and can never be discharged', + ); + await expect(accept(contractOptions, holderWallet, {}, options)).rejects.toThrow( + /already been|cannot be accepted or rejected again/, + ); // Status-only reject — separately revert holder role const revertHolderTx = await rejectTransferHolder( @@ -322,7 +309,7 @@ describe.each(providers)( await setRoles({ beneficiary: ownerAddress, holder: ownerAddress, - status: BillOfExchangeStatus.Rejected, + status: Status.Rejected, }); const surrenderTx = await returnToIssuer( @@ -361,7 +348,7 @@ describe.each(providers)( await setRoles({ beneficiary: ownerAddress, holder: holderAddress, - status: BillOfExchangeStatus.Accepted, + status: Status.Accepted, }); await nominate( @@ -379,31 +366,27 @@ describe.each(providers)( await setRoles({ beneficiary: bankAddress, holder: holderAddress, - status: BillOfExchangeStatus.Accepted, + status: Status.Accepted, }); // Original owner can no longer discharge - await expect( - dischargeBillOfExchange(contractOptions, ownerWallet, {}, options), - ).rejects.toThrow( - 'Only the current beneficiary (owner) can discharge this Bill of Exchange', + await expect(discharge(contractOptions, ownerWallet, {}, options)).rejects.toThrow( + 'Only the current beneficiary (owner) can discharge this TitleEscrow', ); - const dischargeTx = await dischargeBillOfExchange( + const dischargeTx = await discharge( contractOptions, bankWallet, { remarks: 'Paid; bank discharges' }, options, ); - expect(dischargeTx).toEqual('v5_discharge_bill_of_exchange_tx_hash'); + expect(dischargeTx).toEqual('v5_discharge_tx_hash'); await setRoles({ beneficiary: bankAddress, holder: holderAddress, - status: BillOfExchangeStatus.Discharged, + status: Status.Discharged, }); - expect(await getBillOfExchangeStatus(contractOptions, bankWallet)).toEqual( - BillOfExchangeStatus.Discharged, - ); + expect(await getStatus(contractOptions, bankWallet)).toEqual(Status.Discharged); }); }); @@ -411,65 +394,65 @@ describe.each(providers)( it('blocks accept/reject/discharge when owner == holder (no presentment)', async () => { await setRoles({ beneficiary: ownerAddress, holder: ownerAddress }); - await expect( - acceptBillOfExchange(contractOptions, ownerWallet, {}, options), - ).rejects.toThrow('Owner and holder must be different addresses'); - await expect( - rejectBillOfExchange(contractOptions, ownerWallet, {}, options), - ).rejects.toThrow('Owner and holder must be different addresses'); + await expect(accept(contractOptions, ownerWallet, {}, options)).rejects.toThrow( + 'Owner and holder must be different addresses', + ); + await expect(reject(contractOptions, ownerWallet, {}, options)).rejects.toThrow( + 'Owner and holder must be different addresses', + ); }); it('blocks discharge before acceptance and after discharge', async () => { await setRoles({ beneficiary: ownerAddress, holder: holderAddress, - status: BillOfExchangeStatus.Issued, + status: Status.Issued, }); - await expect( - dischargeBillOfExchange(contractOptions, ownerWallet, {}, options), - ).rejects.toThrow('has not been accepted yet'); + await expect(discharge(contractOptions, ownerWallet, {}, options)).rejects.toThrow( + 'has not been accepted yet', + ); await setRoles({ beneficiary: ownerAddress, holder: holderAddress, - status: BillOfExchangeStatus.Discharged, + status: Status.Discharged, }); - await expect( - dischargeBillOfExchange(contractOptions, ownerWallet, {}, options), - ).rejects.toThrow('already been discharged'); - await expect( - acceptBillOfExchange(contractOptions, holderWallet, {}, options), - ).rejects.toThrow(/already been|cannot be accepted or rejected again/); + await expect(discharge(contractOptions, ownerWallet, {}, options)).rejects.toThrow( + 'already been discharged', + ); + await expect(accept(contractOptions, holderWallet, {}, options)).rejects.toThrow( + /already been|cannot be accepted or rejected again/, + ); }); it('blocks wrong-role callers at each lifecycle step', async () => { await setRoles({ beneficiary: ownerAddress, holder: holderAddress, - status: BillOfExchangeStatus.Issued, + status: Status.Issued, }); - await expect( - acceptBillOfExchange(contractOptions, ownerWallet, {}, options), - ).rejects.toThrow('Only the current holder can accept'); - await expect( - rejectBillOfExchange(contractOptions, ownerWallet, {}, options), - ).rejects.toThrow('Only the current holder can reject'); + await expect(accept(contractOptions, ownerWallet, {}, options)).rejects.toThrow( + 'Only the current holder can accept', + ); + await expect(reject(contractOptions, ownerWallet, {}, options)).rejects.toThrow( + 'Only the current holder can reject', + ); await setRoles({ beneficiary: ownerAddress, holder: holderAddress, - status: BillOfExchangeStatus.Accepted, + status: Status.Accepted, }); - await expect( - dischargeBillOfExchange(contractOptions, holderWallet, {}, options), - ).rejects.toThrow('Only the current beneficiary (owner) can discharge'); + await expect(discharge(contractOptions, holderWallet, {}, options)).rejects.toThrow( + 'Only the current beneficiary (owner) can discharge', + ); }); it('allows ETR circulation after Accepted without reading or mutating status', async () => { await setRoles({ beneficiary: ownerAddress, holder: holderAddress, - status: BillOfExchangeStatus.Accepted, + status: Status.Accepted, }); mockV5TitleEscrowContract.status.mockClear(); diff --git a/src/__tests__/boe/boeRules.test.ts b/src/__tests__/status/statusRules.test.ts similarity index 60% rename from src/__tests__/boe/boeRules.test.ts rename to src/__tests__/status/statusRules.test.ts index 75159cc..2c9cfdd 100644 --- a/src/__tests__/boe/boeRules.test.ts +++ b/src/__tests__/status/statusRules.test.ts @@ -1,29 +1,29 @@ import { describe, it, expect } from 'vitest'; -import { BoeRules } from '../../boe/BoeRules'; -import { BillOfExchangeStatus } from '../../boe/types'; +import { StatusRules } from '../../status/StatusRules'; +import { Status } from '../../status/types'; import { HOLDER, OWNER } from './fixtures'; const baseTransition = { currentBeneficiary: OWNER, currentHolder: HOLDER, - currentStatus: BillOfExchangeStatus.Issued, + currentStatus: Status.Issued, signerAddress: HOLDER, }; -describe('BoeRules — BOE status transitions (accept / reject / discharge)', () => { +describe('StatusRules — status transitions (accept / reject / discharge)', () => { it('accept succeeds for holder at Issued with diverged roles', () => { - expect(() => BoeRules.assertAccept(baseTransition)).not.toThrow(); + expect(() => StatusRules.assertAccept(baseTransition)).not.toThrow(); }); it('accept fails for non-holder', () => { - expect(() => BoeRules.assertAccept({ ...baseTransition, signerAddress: OWNER })).toThrow( + expect(() => StatusRules.assertAccept({ ...baseTransition, signerAddress: OWNER })).toThrow( 'Only the current holder can accept', ); }); it('accept fails when owner equals holder', () => { expect(() => - BoeRules.assertAccept({ + StatusRules.assertAccept({ ...baseTransition, currentBeneficiary: OWNER, currentHolder: OWNER, @@ -33,32 +33,28 @@ describe('BoeRules — BOE status transitions (accept / reject / discharge)', () }); it('accept fails from Accepted, Rejected, and Discharged', () => { - for (const status of [ - BillOfExchangeStatus.Accepted, - BillOfExchangeStatus.Rejected, - BillOfExchangeStatus.Discharged, - ]) { - expect(() => BoeRules.assertAccept({ ...baseTransition, currentStatus: status })).toThrow( + for (const status of [Status.Accepted, Status.Rejected, Status.Discharged]) { + expect(() => StatusRules.assertAccept({ ...baseTransition, currentStatus: status })).toThrow( 'cannot be accepted or rejected again', ); } }); it('reject succeeds for holder at Issued', () => { - expect(() => BoeRules.assertReject(baseTransition)).not.toThrow(); + expect(() => StatusRules.assertReject(baseTransition)).not.toThrow(); }); it('reject fails from Discharged', () => { expect(() => - BoeRules.assertReject({ ...baseTransition, currentStatus: BillOfExchangeStatus.Discharged }), + StatusRules.assertReject({ ...baseTransition, currentStatus: Status.Discharged }), ).toThrow('cannot be accepted or rejected again'); }); it('discharge succeeds for beneficiary at Accepted', () => { expect(() => - BoeRules.assertDischarge({ + StatusRules.assertDischarge({ ...baseTransition, - currentStatus: BillOfExchangeStatus.Accepted, + currentStatus: Status.Accepted, signerAddress: OWNER, }), ).not.toThrow(); @@ -66,25 +62,25 @@ describe('BoeRules — BOE status transitions (accept / reject / discharge)', () it('discharge fails for non-beneficiary', () => { expect(() => - BoeRules.assertDischarge({ + StatusRules.assertDischarge({ ...baseTransition, - currentStatus: BillOfExchangeStatus.Accepted, + currentStatus: Status.Accepted, signerAddress: HOLDER, }), ).toThrow('Only the current beneficiary (owner) can discharge'); }); it('discharge fails from Issued', () => { - expect(() => BoeRules.assertDischarge({ ...baseTransition, signerAddress: OWNER })).toThrow( + expect(() => StatusRules.assertDischarge({ ...baseTransition, signerAddress: OWNER })).toThrow( 'has not been accepted yet', ); }); it('discharge fails from Rejected with reissue hint', () => { expect(() => - BoeRules.assertDischarge({ + StatusRules.assertDischarge({ ...baseTransition, - currentStatus: BillOfExchangeStatus.Rejected, + currentStatus: Status.Rejected, signerAddress: OWNER, }), ).toThrow('was rejected and can never be discharged'); @@ -92,9 +88,9 @@ describe('BoeRules — BOE status transitions (accept / reject / discharge)', () it('discharge fails when already Discharged', () => { expect(() => - BoeRules.assertDischarge({ + StatusRules.assertDischarge({ ...baseTransition, - currentStatus: BillOfExchangeStatus.Discharged, + currentStatus: Status.Discharged, signerAddress: OWNER, }), ).toThrow('already been discharged'); diff --git a/src/__tests__/token-registry-functions/fixtures.ts b/src/__tests__/token-registry-functions/fixtures.ts index 3d75d44..74c37b6 100644 --- a/src/__tests__/token-registry-functions/fixtures.ts +++ b/src/__tests__/token-registry-functions/fixtures.ts @@ -198,9 +198,9 @@ export const mockV5TitleEscrowContract = { rejectTransferBeneficiary: vi.fn(), rejectTransferOwners: vi.fn(), returnToIssuer: vi.fn(), - acceptBillOfExchange: vi.fn(), - rejectBillOfExchange: vi.fn(), - dischargeBillOfExchange: vi.fn(), + accept: vi.fn(), + reject: vi.fn(), + discharge: vi.fn(), }, transferHolder: Object.assign( // Direct call returns hash string @@ -239,25 +239,25 @@ export const mockV5TitleEscrowContract = { prevHolder: vi.fn(() => Promise.resolve('0x0000000000000000000000000000000000dEaD')), prevBeneficiary: vi.fn(() => Promise.resolve('0x0000000000000000000000000000000000dEaD')), status: vi.fn(() => Promise.resolve(0)), - acceptBillOfExchange: Object.assign( + accept: Object.assign( // Direct call returns hash string - vi.fn(() => Promise.resolve('v5_accept_bill_of_exchange_tx_hash')), + vi.fn(() => Promise.resolve('v5_accept_tx_hash')), { // Static call returns boolean staticCall: vi.fn(() => Promise.resolve(true)), }, ), - rejectBillOfExchange: Object.assign( + reject: Object.assign( // Direct call returns hash string - vi.fn(() => Promise.resolve('v5_reject_bill_of_exchange_tx_hash')), + vi.fn(() => Promise.resolve('v5_reject_tx_hash')), { // Static call returns boolean staticCall: vi.fn(() => Promise.resolve(true)), }, ), - dischargeBillOfExchange: Object.assign( + discharge: Object.assign( // Direct call returns hash string - vi.fn(() => Promise.resolve('v5_discharge_bill_of_exchange_tx_hash')), + vi.fn(() => Promise.resolve('v5_discharge_tx_hash')), { // Static call returns boolean staticCall: vi.fn(() => Promise.resolve(true)), diff --git a/src/boe/BoeRules.ts b/src/boe/BoeRules.ts deleted file mode 100644 index c41d5b2..0000000 --- a/src/boe/BoeRules.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { BillOfExchangeStatus, BillOfExchangeStatusLabel } from './types'; - -interface TransitionParams { - currentBeneficiary: string; - currentHolder: string; - currentStatus: BillOfExchangeStatus; - signerAddress: string; -} - -const sameAddress = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase(); - -// Business rules for the three dedicated Bill of Exchange functions (acceptBillOfExchange, -// rejectBillOfExchange, dischargeBillOfExchange). Every check here is purely advisory, client-side -// validation — the contract enforces the on-chain-relevant subset of these independently. Nothing -// in this class talks to a contract; it only reasons about state its callers already fetched. -// -// The existing ETR functions (transferHolder, nominate, transferBeneficiary, transferOwners, the -// reject-transfer family, returnToIssuer) are untouched by this class and by documentType — they -// behave exactly as they did before the Bill of Exchange lifecycle existed. -export class BoeRules { - private static assertTransition( - action: 'accept' | 'reject' | 'discharge', - requiredCallerRole: 'holder' | 'beneficiary', - requiredStatus: BillOfExchangeStatus, - { currentBeneficiary, currentHolder, currentStatus, signerAddress }: TransitionParams, - ): void { - const expectedCaller = requiredCallerRole === 'holder' ? currentHolder : currentBeneficiary; - if (!sameAddress(signerAddress, expectedCaller)) { - throw new Error( - requiredCallerRole === 'holder' - ? `Only the current holder can ${action} this Bill of Exchange` - : `Only the current beneficiary (owner) can ${action} this Bill of Exchange`, - ); - } - - if (sameAddress(currentBeneficiary, currentHolder)) { - throw new Error( - 'Owner and holder must be different addresses before this Bill of Exchange can be accepted, rejected, or discharged', - ); - } - - if (currentStatus === requiredStatus) return; - - if (action === 'discharge') { - if (currentStatus === BillOfExchangeStatus.Rejected) { - throw new Error( - 'This Bill of Exchange was rejected and can never be discharged — surrender it (returnToIssuer) and reissue a new one instead.', - ); - } - if (currentStatus === BillOfExchangeStatus.Issued) { - throw new Error( - 'This Bill of Exchange has not been accepted yet — only an Accepted bill can be discharged.', - ); - } - throw new Error('This Bill of Exchange has already been discharged.'); - } - - throw new Error( - `This Bill of Exchange has already been ${BillOfExchangeStatusLabel[currentStatus]} and cannot be accepted or rejected again.`, - ); - } - - // acceptBillOfExchange: holder-only, requires status Issued. - static assertAccept(params: TransitionParams): void { - BoeRules.assertTransition('accept', 'holder', BillOfExchangeStatus.Issued, params); - } - - // rejectBillOfExchange: holder-only, requires status Issued. - static assertReject(params: TransitionParams): void { - BoeRules.assertTransition('reject', 'holder', BillOfExchangeStatus.Issued, params); - } - - // dischargeBillOfExchange: beneficiary-only, requires status Accepted. - static assertDischarge(params: TransitionParams): void { - BoeRules.assertTransition('discharge', 'beneficiary', BillOfExchangeStatus.Accepted, params); - } -} diff --git a/src/boe/types.ts b/src/boe/types.ts deleted file mode 100644 index b587fb7..0000000 --- a/src/boe/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; - -export type { ContractOptions, TransactionOptions }; - -export interface BillOfExchangeActionParams { - remarks?: string; -} - -export type BillOfExchangeStatusOptions = ContractOptions; - -/** - * Lifecycle status of a TitleEscrow's Bill of Exchange status field. Mirrors the `Status` enum on - * `TitleEscrow.sol` — every TitleEscrow carries this field, not just ones used as a Bill of Exchange. - */ -export const BillOfExchangeStatus = { - Issued: 0, - Accepted: 1, - Rejected: 2, - Discharged: 3, -} as const; - -export type BillOfExchangeStatus = (typeof BillOfExchangeStatus)[keyof typeof BillOfExchangeStatus]; - -export const BillOfExchangeStatusLabel: Record = { - [BillOfExchangeStatus.Issued]: 'Issued', - [BillOfExchangeStatus.Accepted]: 'Accepted', - [BillOfExchangeStatus.Rejected]: 'Rejected', - [BillOfExchangeStatus.Discharged]: 'Discharged', -}; diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index e825742..9b8b476 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -133,9 +133,9 @@ const fetchAllTransfers = async ( titleEscrowContract.filters.RejectTransferBeneficiary, titleEscrowContract.filters.RejectTransferHolder, titleEscrowContract.filters.Shred, - titleEscrowContract.filters.BillOfExchangeAccepted, - titleEscrowContract.filters.BillOfExchangeRejected, - titleEscrowContract.filters.BillOfExchangeDischarged, + titleEscrowContract.filters.StatusAccepted, + titleEscrowContract.filters.StatusRejected, + titleEscrowContract.filters.StatusDischarged, ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const allLogs: any = await Promise.all( @@ -240,27 +240,27 @@ const fetchAllTransfers = async ( transactionIndex: event.transactionIndex, remark: event.args?.remark, } as TokenTransferEvent; - } else if (event?.name === 'BillOfExchangeAccepted') { + } else if (event?.name === 'StatusAccepted') { return { - type: 'BILL_OF_EXCHANGE_ACCEPTED', + type: 'STATUS_ACCEPTED', holder: event.args.holder, blockNumber: event.blockNumber, transactionHash: event.transactionHash, transactionIndex: event.transactionIndex, remark: event.args?.remark, } as TitleEscrowTransferEvent; - } else if (event?.name === 'BillOfExchangeRejected') { + } else if (event?.name === 'StatusRejected') { return { - type: 'BILL_OF_EXCHANGE_REJECTED', + type: 'STATUS_REJECTED', holder: event.args.holder, blockNumber: event.blockNumber, transactionHash: event.transactionHash, transactionIndex: event.transactionIndex, remark: event.args?.remark, } as TitleEscrowTransferEvent; - } else if (event?.name === 'BillOfExchangeDischarged') { + } else if (event?.name === 'StatusDischarged') { return { - type: 'BILL_OF_EXCHANGE_DISCHARGED', + type: 'STATUS_DISCHARGED', owner: event.args.beneficiary, blockNumber: event.blockNumber, transactionHash: event.transactionHash, diff --git a/src/core/endorsement-chain/helpers.ts b/src/core/endorsement-chain/helpers.ts index e59e406..1aee806 100644 --- a/src/core/endorsement-chain/helpers.ts +++ b/src/core/endorsement-chain/helpers.ts @@ -92,7 +92,8 @@ const identifyEventTypeFromLogs = (groupedEvents: TransferBaseEvent[]): Transfer 'RETURN_TO_ISSUER_ACCEPTED', 'RETURN_TO_ISSUER_REJECTED', ].includes(event.type) || - event.type.startsWith('REJECT_') + event.type.startsWith('REJECT_') || + event.type.startsWith('STATUS_') ) { return event.type; } diff --git a/src/core/endorsement-chain/types.ts b/src/core/endorsement-chain/types.ts index 6ea84e8..3fc0b4f 100644 --- a/src/core/endorsement-chain/types.ts +++ b/src/core/endorsement-chain/types.ts @@ -47,9 +47,9 @@ export type TitleEscrowTransferEventType = | 'REJECT_TRANSFER_BENEFICIARY' // V5 | 'REJECT_TRANSFER_HOLDER' // V5 | 'REJECT_TRANSFER_OWNERS' // V5 - | 'BILL_OF_EXCHANGE_ACCEPTED' // V5, eBOE - | 'BILL_OF_EXCHANGE_REJECTED' // V5, eBOE - | 'BILL_OF_EXCHANGE_DISCHARGED'; // V5, eBOE + | 'STATUS_ACCEPTED' // V5, status lifecycle + | 'STATUS_REJECTED' // V5, status lifecycle + | 'STATUS_DISCHARGED'; // V5, status lifecycle export interface TokenTransferEvent extends TransferBaseEvent { type: TokenTransferEventType; diff --git a/src/index.ts b/src/index.ts index e84f620..808817d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,7 @@ import { cancelTransaction } from './transaction'; export type { TypedContractMethod } from './token-registry-v5/typedContractMethod'; export type { CancelTransactionSigner } from './transaction'; export * from './token-registry-functions'; -export * from './boe'; +export * from './status'; export * from './core'; export * from './open-attestation'; export * from './verify'; diff --git a/src/status/StatusRules.ts b/src/status/StatusRules.ts new file mode 100644 index 0000000..f75b0e5 --- /dev/null +++ b/src/status/StatusRules.ts @@ -0,0 +1,77 @@ +import { Status, StatusLabel } from './types'; + +interface TransitionParams { + currentBeneficiary: string; + currentHolder: string; + currentStatus: Status; + signerAddress: string; +} + +const sameAddress = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase(); + +// Business rules for the three dedicated status functions (accept, reject, discharge). +// Every check here is purely advisory, client-side validation — the contract enforces the +// on-chain-relevant subset of these independently. Nothing in this class talks to a contract; +// it only reasons about state its callers already fetched. +// +// The existing ETR functions (transferHolder, nominate, transferBeneficiary, transferOwners, the +// reject-transfer family, returnToIssuer) are untouched by this class — they behave exactly as +// they did before the status lifecycle existed. +export class StatusRules { + private static assertTransition( + action: 'accept' | 'reject' | 'discharge', + requiredCallerRole: 'holder' | 'beneficiary', + requiredStatus: Status, + { currentBeneficiary, currentHolder, currentStatus, signerAddress }: TransitionParams, + ): void { + const expectedCaller = requiredCallerRole === 'holder' ? currentHolder : currentBeneficiary; + if (!sameAddress(signerAddress, expectedCaller)) { + throw new Error( + requiredCallerRole === 'holder' + ? `Only the current holder can ${action} this TitleEscrow` + : `Only the current beneficiary (owner) can ${action} this TitleEscrow`, + ); + } + + if (sameAddress(currentBeneficiary, currentHolder)) { + throw new Error( + 'Owner and holder must be different addresses before this TitleEscrow can be accepted, rejected, or discharged', + ); + } + + if (currentStatus === requiredStatus) return; + + if (action === 'discharge') { + if (currentStatus === Status.Rejected) { + throw new Error( + 'This TitleEscrow was rejected and can never be discharged — surrender it (returnToIssuer) and reissue a new one instead.', + ); + } + if (currentStatus === Status.Issued) { + throw new Error( + 'This TitleEscrow has not been accepted yet — only an Accepted escrow can be discharged.', + ); + } + throw new Error('This TitleEscrow has already been discharged.'); + } + + throw new Error( + `This TitleEscrow has already been ${StatusLabel[currentStatus]} and cannot be accepted or rejected again.`, + ); + } + + // accept: holder-only, requires status Issued. + static assertAccept(params: TransitionParams): void { + StatusRules.assertTransition('accept', 'holder', Status.Issued, params); + } + + // reject: holder-only, requires status Issued. + static assertReject(params: TransitionParams): void { + StatusRules.assertTransition('reject', 'holder', Status.Issued, params); + } + + // discharge: beneficiary-only, requires status Accepted. + static assertDischarge(params: TransitionParams): void { + StatusRules.assertTransition('discharge', 'beneficiary', Status.Accepted, params); + } +} diff --git a/src/boe/accept.ts b/src/status/accept.ts similarity index 76% rename from src/boe/accept.ts rename to src/status/accept.ts index 9af0b80..77e21e8 100644 --- a/src/boe/accept.ts +++ b/src/status/accept.ts @@ -10,26 +10,27 @@ import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; import { getSignerAddressSafe, getTxOptions } from '../token-registry-functions/utils'; import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; -import { BillOfExchangeActionParams, BillOfExchangeStatus } from './types'; -import { BoeRules } from './BoeRules'; +import { StatusActionParams, Status } from './types'; +import { StatusRules } from './StatusRules'; /** - * Beta. Holder accepts a Bill of Exchange, moving its status from Issued to Accepted. Callable on any + * Beta. Holder accepts a TitleEscrow, moving its status from Issued to Accepted. Callable on any * TitleEscrow — not gated on document type — but only makes sense once beneficiary != holder. + * Calls the on-chain `accept(bytes)` method. * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. Must be the current holder. - * @param {BillOfExchangeActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. + * @param {StatusActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. * @param {TransactionOptions} options - Includes optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. * @throws if the title escrow address or signer provider is missing. - * @throws if the version is not V5 compatible, or the TitleEscrow predates the Bill of Exchange lifecycle. + * @throws if the version is not V5 compatible, or the TitleEscrow predates the status lifecycle. * @throws if the signer is not the current holder, if owner and holder are the same address, or if the current status isn't Issued. * @throws if the dry-run (`callStatic`) fails. - * @returns {Promise} The transaction response of the acceptBillOfExchange call. + * @returns {Promise} The transaction response of the accept call. */ -const acceptBillOfExchange = async ( +const accept = async ( contractOptions: ContractOptions, signer: Signer | SignerV6, - params: BillOfExchangeActionParams, + params: StatusActionParams, options: TransactionOptions, ): Promise => { const { tokenRegistryAddress, tokenId } = contractOptions; @@ -78,34 +79,34 @@ const acceptBillOfExchange = async ( titleEscrowContract.holder(), ]); - let currentStatus: BillOfExchangeStatus; + let currentStatus: Status; try { - currentStatus = Number(await titleEscrowContract.status()) as BillOfExchangeStatus; + currentStatus = Number(await titleEscrowContract.status()) as Status; } catch (e) { console.error('status() failed:', e); throw new Error( - 'This TitleEscrow does not support the Bill of Exchange lifecycle (status()) — it likely predates the eBOE contract upgrade.', + 'This TitleEscrow does not support the status lifecycle (status()) — it likely predates the eBOE contract upgrade.', ); } const signerAddress = await getSignerAddressSafe(signer); - BoeRules.assertAccept({ currentBeneficiary, currentHolder, currentStatus, signerAddress }); + StatusRules.assertAccept({ currentBeneficiary, currentHolder, currentStatus, signerAddress }); // Check callStatic (dry run) try { if (isV6EthersProvider(signer.provider)) { - await (titleEscrowContract as ContractV6).acceptBillOfExchange.staticCall(encryptedRemarks); + await (titleEscrowContract as ContractV6).accept.staticCall(encryptedRemarks); } else { - await (titleEscrowContract as ContractV5).callStatic.acceptBillOfExchange(encryptedRemarks); + await (titleEscrowContract as ContractV5).callStatic.accept(encryptedRemarks); } } catch (e) { console.error('callStatic failed:', e); - throw new Error('Pre-check (callStatic) for acceptBillOfExchange failed'); + throw new Error('Pre-check (callStatic) for accept failed'); } const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); - return await titleEscrowContract.acceptBillOfExchange(encryptedRemarks, txOptions); + return await titleEscrowContract.accept(encryptedRemarks, txOptions); }; -export { acceptBillOfExchange }; +export { accept }; diff --git a/src/boe/discharge.ts b/src/status/discharge.ts similarity index 76% rename from src/boe/discharge.ts rename to src/status/discharge.ts index eb7b861..e018e9a 100644 --- a/src/boe/discharge.ts +++ b/src/status/discharge.ts @@ -10,27 +10,28 @@ import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; import { getSignerAddressSafe, getTxOptions } from '../token-registry-functions/utils'; import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; -import { BillOfExchangeActionParams, BillOfExchangeStatus } from './types'; -import { BoeRules } from './BoeRules'; +import { StatusActionParams, Status } from './types'; +import { StatusRules } from './StatusRules'; /** - * Beta. Beneficiary (owner) discharges a Bill of Exchange, moving its status from Accepted to + * Beta. Beneficiary (owner) discharges a TitleEscrow, moving its status from Accepted to * Discharged — terminal, confirming money received off-chain. Never callable from Rejected. Not * gated on document type. + * Calls the on-chain `discharge(bytes)` method. * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. Must be the current beneficiary. - * @param {BillOfExchangeActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. + * @param {StatusActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. * @param {TransactionOptions} options - Includes optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. * @throws if the title escrow address or signer provider is missing. - * @throws if the version is not V5 compatible, or the TitleEscrow predates the Bill of Exchange lifecycle. + * @throws if the version is not V5 compatible, or the TitleEscrow predates the status lifecycle. * @throws if the signer is not the current beneficiary, if owner and holder are the same address, or if the current status isn't Accepted. * @throws if the dry-run (`callStatic`) fails. - * @returns {Promise} The transaction response of the dischargeBillOfExchange call. + * @returns {Promise} The transaction response of the discharge call. */ -const dischargeBillOfExchange = async ( +const discharge = async ( contractOptions: ContractOptions, signer: Signer | SignerV6, - params: BillOfExchangeActionParams, + params: StatusActionParams, options: TransactionOptions, ): Promise => { const { tokenRegistryAddress, tokenId } = contractOptions; @@ -79,38 +80,34 @@ const dischargeBillOfExchange = async ( titleEscrowContract.holder(), ]); - let currentStatus: BillOfExchangeStatus; + let currentStatus: Status; try { - currentStatus = Number(await titleEscrowContract.status()) as BillOfExchangeStatus; + currentStatus = Number(await titleEscrowContract.status()) as Status; } catch (e) { console.error('status() failed:', e); throw new Error( - 'This TitleEscrow does not support the Bill of Exchange lifecycle (status()) — it likely predates the eBOE contract upgrade.', + 'This TitleEscrow does not support the status lifecycle (status()) — it likely predates the eBOE contract upgrade.', ); } const signerAddress = await getSignerAddressSafe(signer); - BoeRules.assertDischarge({ currentBeneficiary, currentHolder, currentStatus, signerAddress }); + StatusRules.assertDischarge({ currentBeneficiary, currentHolder, currentStatus, signerAddress }); // Check callStatic (dry run) try { if (isV6EthersProvider(signer.provider)) { - await (titleEscrowContract as ContractV6).dischargeBillOfExchange.staticCall( - encryptedRemarks, - ); + await (titleEscrowContract as ContractV6).discharge.staticCall(encryptedRemarks); } else { - await (titleEscrowContract as ContractV5).callStatic.dischargeBillOfExchange( - encryptedRemarks, - ); + await (titleEscrowContract as ContractV5).callStatic.discharge(encryptedRemarks); } } catch (e) { console.error('callStatic failed:', e); - throw new Error('Pre-check (callStatic) for dischargeBillOfExchange failed'); + throw new Error('Pre-check (callStatic) for discharge failed'); } const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); - return await titleEscrowContract.dischargeBillOfExchange(encryptedRemarks, txOptions); + return await titleEscrowContract.discharge(encryptedRemarks, txOptions); }; -export { dischargeBillOfExchange }; +export { discharge }; diff --git a/src/boe/index.ts b/src/status/index.ts similarity index 100% rename from src/boe/index.ts rename to src/status/index.ts diff --git a/src/boe/reject.ts b/src/status/reject.ts similarity index 77% rename from src/boe/reject.ts rename to src/status/reject.ts index ddc5a1f..d67074c 100644 --- a/src/boe/reject.ts +++ b/src/status/reject.ts @@ -10,27 +10,28 @@ import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; import { getSignerAddressSafe, getTxOptions } from '../token-registry-functions/utils'; import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; -import { BillOfExchangeActionParams, BillOfExchangeStatus } from './types'; -import { BoeRules } from './BoeRules'; +import { StatusActionParams, Status } from './types'; +import { StatusRules } from './StatusRules'; /** - * Beta. Holder rejects (dishonours) a Bill of Exchange, moving its status from Issued to Rejected — + * Beta. Holder rejects (dishonours) a TitleEscrow, moving its status from Issued to Rejected — * terminal. Status-only: it does not revert the holder role, use the existing * `rejectTransferHolder` for that. Not gated on document type. + * Calls the on-chain `reject(bytes)` method. * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. Must be the current holder. - * @param {BillOfExchangeActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. + * @param {StatusActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. * @param {TransactionOptions} options - Includes optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. * @throws if the title escrow address or signer provider is missing. - * @throws if the version is not V5 compatible, or the TitleEscrow predates the Bill of Exchange lifecycle. + * @throws if the version is not V5 compatible, or the TitleEscrow predates the status lifecycle. * @throws if the signer is not the current holder, if owner and holder are the same address, or if the current status isn't Issued. * @throws if the dry-run (`callStatic`) fails. - * @returns {Promise} The transaction response of the rejectBillOfExchange call. + * @returns {Promise} The transaction response of the reject call. */ -const rejectBillOfExchange = async ( +const reject = async ( contractOptions: ContractOptions, signer: Signer | SignerV6, - params: BillOfExchangeActionParams, + params: StatusActionParams, options: TransactionOptions, ): Promise => { const { tokenRegistryAddress, tokenId } = contractOptions; @@ -79,34 +80,34 @@ const rejectBillOfExchange = async ( titleEscrowContract.holder(), ]); - let currentStatus: BillOfExchangeStatus; + let currentStatus: Status; try { - currentStatus = Number(await titleEscrowContract.status()) as BillOfExchangeStatus; + currentStatus = Number(await titleEscrowContract.status()) as Status; } catch (e) { console.error('status() failed:', e); throw new Error( - 'This TitleEscrow does not support the Bill of Exchange lifecycle (status()) — it likely predates the eBOE contract upgrade.', + 'This TitleEscrow does not support the status lifecycle (status()) — it likely predates the eBOE contract upgrade.', ); } const signerAddress = await getSignerAddressSafe(signer); - BoeRules.assertReject({ currentBeneficiary, currentHolder, currentStatus, signerAddress }); + StatusRules.assertReject({ currentBeneficiary, currentHolder, currentStatus, signerAddress }); // Check callStatic (dry run) try { if (isV6EthersProvider(signer.provider)) { - await (titleEscrowContract as ContractV6).rejectBillOfExchange.staticCall(encryptedRemarks); + await (titleEscrowContract as ContractV6).reject.staticCall(encryptedRemarks); } else { - await (titleEscrowContract as ContractV5).callStatic.rejectBillOfExchange(encryptedRemarks); + await (titleEscrowContract as ContractV5).callStatic.reject(encryptedRemarks); } } catch (e) { console.error('callStatic failed:', e); - throw new Error('Pre-check (callStatic) for rejectBillOfExchange failed'); + throw new Error('Pre-check (callStatic) for reject failed'); } const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); - return await titleEscrowContract.rejectBillOfExchange(encryptedRemarks, txOptions); + return await titleEscrowContract.reject(encryptedRemarks, txOptions); }; -export { rejectBillOfExchange }; +export { reject }; diff --git a/src/boe/status.ts b/src/status/status.ts similarity index 74% rename from src/boe/status.ts rename to src/status/status.ts index 8500e26..797dd0a 100644 --- a/src/boe/status.ts +++ b/src/status/status.ts @@ -3,26 +3,25 @@ import { v5Contracts } from '../token-registry-v5'; import { Signer as SignerV6 } from 'ethersV6'; import { Signer as SignerV5 } from 'ethers'; import { getEthersContractFromProvider } from '../utils/ethers'; -import { BillOfExchangeStatus, BillOfExchangeStatusOptions } from './types'; +import { Status, StatusOptions } from './types'; import { TransactionOptions } from '../token-registry-functions/types'; /** * Beta. Reads the `status` field off a TitleEscrow. Every TitleEscrow carries this field, ETR or - * otherwise — it defaults to `Issued` and only ever advances via `acceptBillOfExchange`, - * `rejectBillOfExchange`, or `dischargeBillOfExchange`. - * @param {BillOfExchangeStatusOptions} contractOptions - Either `titleEscrowAddress`, or both `tokenRegistryAddress` and `tokenId`. + * otherwise — it defaults to `Issued` and only ever advances via `accept`, `reject`, or `discharge`. + * @param {StatusOptions} contractOptions - Either `titleEscrowAddress`, or both `tokenRegistryAddress` and `tokenId`. * @param {SignerV5 | SignerV6} signer - Ethers signer (V5 or V6) used to read the contract. * @param {TransactionOptions} options - Only `titleEscrowVersion` is relevant here; skips version detection when provided. * @throws if the title escrow address or signer provider is missing. * @throws if the version is not V5 compatible. - * @throws if the TitleEscrow predates the Bill of Exchange lifecycle (no `status()` getter). - * @returns {Promise} The current status. + * @throws if the TitleEscrow predates the status lifecycle (no `status()` getter). + * @returns {Promise} The current status. */ -const getBillOfExchangeStatus = async ( - contractOptions: BillOfExchangeStatusOptions, +const getStatus = async ( + contractOptions: StatusOptions, signer: SignerV5 | SignerV6, options: TransactionOptions = {}, -): Promise => { +): Promise => { const { tokenRegistryAddress, tokenId } = contractOptions; let { titleEscrowAddress } = contractOptions; const { titleEscrowVersion } = options; @@ -64,13 +63,13 @@ const getBillOfExchangeStatus = async ( try { const status = await titleEscrowContract.status(); - return Number(status) as BillOfExchangeStatus; + return Number(status) as Status; } catch (e) { console.error('status() failed:', e); throw new Error( - 'This TitleEscrow does not support the Bill of Exchange lifecycle (status()) — it likely predates the eBOE contract upgrade.', + 'This TitleEscrow does not support the status lifecycle (status()) — it likely predates the eBOE contract upgrade.', ); } }; -export { getBillOfExchangeStatus }; +export { getStatus }; diff --git a/src/status/types.ts b/src/status/types.ts new file mode 100644 index 0000000..3fed67e --- /dev/null +++ b/src/status/types.ts @@ -0,0 +1,29 @@ +import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; + +export type { ContractOptions, TransactionOptions }; + +export interface StatusActionParams { + remarks?: string; +} + +export type StatusOptions = ContractOptions; + +/** + * Lifecycle status of a TitleEscrow. Mirrors the `Status` enum on `TitleEscrow.sol` — + * every TitleEscrow carries this field. + */ +export const Status = { + Issued: 0, + Accepted: 1, + Rejected: 2, + Discharged: 3, +} as const; + +export type Status = (typeof Status)[keyof typeof Status]; + +export const StatusLabel: Record = { + [Status.Issued]: 'Issued', + [Status.Accepted]: 'Accepted', + [Status.Rejected]: 'Rejected', + [Status.Discharged]: 'Discharged', +}; From c43a6b92bca212362eefeeb77bf8adeca5e0ba20 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Fri, 10 Jul 2026 19:27:25 +0530 Subject: [PATCH 03/10] test: add cases for handling empty remarks in accept, reject, and discharge functions --- src/__tests__/status/status.test.ts | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/__tests__/status/status.test.ts b/src/__tests__/status/status.test.ts index c25d01b..a6c0c78 100644 --- a/src/__tests__/status/status.test.ts +++ b/src/__tests__/status/status.test.ts @@ -47,6 +47,21 @@ describe.each(providers)( expect(result).toEqual('v5_accept_tx_hash'); }); + it.each([undefined, ''] as const)( + 'should accept empty remarks (%j) by sending 0x to the contract', + async (remarks) => { + const result = await accept(contractOptions, wallet, { remarks }, options); + expect(result).toEqual('v5_accept_tx_hash'); + expect(coreModule.encrypt).not.toHaveBeenCalled(); + if (ethersVersion === 'v6') { + expect(mockV5TitleEscrowContract.accept.staticCall).toHaveBeenCalledWith('0x'); + } else { + expect(mockV5TitleEscrowContract.callStatic.accept).toHaveBeenCalledWith('0x'); + } + expect(mockV5TitleEscrowContract.accept).toHaveBeenCalledWith('0x', expect.anything()); + }, + ); + it('should accept when titleEscrowAddress is provided directly', async () => { const result = await accept( { titleEscrowAddress: MOCK_TITLE_ESCROW_ADDRESS }, @@ -132,6 +147,21 @@ describe.each(providers)( expect(result).toEqual('v5_reject_tx_hash'); }); + it.each([undefined, ''] as const)( + 'should reject empty remarks (%j) by sending 0x to the contract', + async (remarks) => { + const result = await reject(contractOptions, wallet, { remarks }, options); + expect(result).toEqual('v5_reject_tx_hash'); + expect(coreModule.encrypt).not.toHaveBeenCalled(); + if (ethersVersion === 'v6') { + expect(mockV5TitleEscrowContract.reject.staticCall).toHaveBeenCalledWith('0x'); + } else { + expect(mockV5TitleEscrowContract.callStatic.reject).toHaveBeenCalledWith('0x'); + } + expect(mockV5TitleEscrowContract.reject).toHaveBeenCalledWith('0x', expect.anything()); + }, + ); + it('should throw when the signer is not the current holder', async () => { mockV5TitleEscrowContract.holder.mockResolvedValue('0xsomeone_else'); await expect( @@ -181,6 +211,21 @@ describe.each(providers)( expect(result).toEqual('v5_discharge_tx_hash'); }); + it.each([undefined, ''] as const)( + 'should discharge empty remarks (%j) by sending 0x to the contract', + async (remarks) => { + const result = await discharge(contractOptions, wallet, { remarks }, options); + expect(result).toEqual('v5_discharge_tx_hash'); + expect(coreModule.encrypt).not.toHaveBeenCalled(); + if (ethersVersion === 'v6') { + expect(mockV5TitleEscrowContract.discharge.staticCall).toHaveBeenCalledWith('0x'); + } else { + expect(mockV5TitleEscrowContract.callStatic.discharge).toHaveBeenCalledWith('0x'); + } + expect(mockV5TitleEscrowContract.discharge).toHaveBeenCalledWith('0x', expect.anything()); + }, + ); + it('should throw when the signer is not the current beneficiary', async () => { mockV5TitleEscrowContract.beneficiary.mockResolvedValue('0xsomeone_else'); await expect( From 6a11889ad31d0696cdfdf2a8b601c4dfeeb01bd8 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Sun, 12 Jul 2026 22:34:34 +0530 Subject: [PATCH 04/10] refactor: remove StatusRules validation from accept reject and discharge --- README.md | 5 +- src/__tests__/status/status.test.ts | 90 ----------------- .../status/statusE2eDocument.test.ts | 78 ++------------- src/__tests__/status/statusRules.test.ts | 98 ------------------- src/status/StatusRules.ts | 77 --------------- src/status/accept.ts | 30 +----- src/status/discharge.ts | 30 +----- src/status/reject.ts | 30 +----- 8 files changed, 25 insertions(+), 413 deletions(-) delete mode 100644 src/__tests__/status/statusRules.test.ts delete mode 100644 src/status/StatusRules.ts diff --git a/README.md b/README.md index 6d45da2..ec72cb2 100644 --- a/README.md +++ b/README.md @@ -1323,7 +1323,6 @@ await dischargeTx.wait(); > `transferHolder`, `transferBeneficiary`, `transferOwners`, `nominate`, `rejectTransferHolder`, `rejectTransferBeneficiary`, `rejectTransferOwners`, and `returnToIssuer` are **exactly** the same functions ETR integrations already call — same signatures, same exports, same import paths, same behaviour. There is no `documentType` field, no status gating, no reconvergence guard, no circulation restriction woven into any of them. They have zero awareness that `status` or the Bill of Exchange lifecycle exists. > -> This is a deliberate design choice: the contract is fully permissive by itself (transfers never mutate or check `status`), and this SDK doesn't add any client-side restriction on top either. If you want reconvergence guards, terminal-state circulation limits, or surrender hints specific to your own integration, build them in your own application layer — `StatusRules` (used internally by `accept`/`reject`/`discharge` only) is not applied to these functions and never will be. #### Example: full happy path @@ -1382,5 +1381,5 @@ await mint({ tokenRegistryAddress }, signer, { beneficiaryAddress, holderAddress - **Beta** functionality — the API may still change. - **Token Registry V5 only.** V4 has no `status()`/`accept`/`reject`/`discharge` at all. - On-chain method names are `accept`/`reject`/`discharge`; events are `StatusAccepted`/`StatusRejected`/`StatusDischarged`. The endorsement chain surfaces these as `STATUS_ACCEPTED`/`STATUS_REJECTED`/`STATUS_DISCHARGED`. -- `StatusRules` validation applies **only** inside `accept`/`reject`/`discharge`. It is not applied to, and cannot be applied to, `transferHolder`/`transferBeneficiary`/`transferOwners`/`nominate`/the reject-transfer family/`returnToIssuer` — those remain exactly as permissive as plain ETR, by design. -- `getStatus`/`accept`/`reject`/`discharge` (plus `Status`/`StatusLabel`) are exported flat from `@trustvc/trustvc`; `StatusRules` is internal and not exported — it only exists to keep the three dedicated functions' precondition checks in one place. +- Role and status preconditions for `accept`/`reject`/`discharge` are enforced on-chain only; the SDK surfaces them via `callStatic` like other TitleEscrow helpers. Transfers/`nominate`/reject-transfer/`returnToIssuer` remain exactly as permissive as plain ETR. +- `getStatus`/`accept`/`reject`/`discharge` (plus `Status`/`StatusLabel`) are exported flat from `@trustvc/trustvc`. diff --git a/src/__tests__/status/status.test.ts b/src/__tests__/status/status.test.ts index a6c0c78..d7c903c 100644 --- a/src/__tests__/status/status.test.ts +++ b/src/__tests__/status/status.test.ts @@ -95,37 +95,6 @@ describe.each(providers)( ).rejects.toThrow('Only Token Registry V5 is supported'); }); - it('should throw a friendly error when the TitleEscrow predates the status lifecycle', async () => { - mockV5TitleEscrowContract.status.mockRejectedValue(new Error('no such function')); - await expect( - accept(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('does not support the status lifecycle'); - }); - - it('should throw when the signer is not the current holder', async () => { - mockV5TitleEscrowContract.holder.mockResolvedValue('0xsomeone_else'); - await expect( - accept(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Only the current holder can accept this TitleEscrow'); - }); - - it('should throw when owner and holder are the same address', async () => { - mockV5TitleEscrowContract.beneficiary.mockResolvedValue(wallet.address); - await expect( - accept(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Owner and holder must be different addresses'); - }); - - it.each([Status.Accepted, Status.Rejected, Status.Discharged])( - 'should throw when status is already %i', - async (status) => { - mockV5TitleEscrowContract.status.mockResolvedValue(status); - await expect( - accept(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow(/already been|cannot be accepted or rejected again/); - }, - ); - it('should throw when callStatic fails', async () => { mockV5TitleEscrowContract.callStatic.accept.mockRejectedValue( new Error('Simulated failure'), @@ -162,30 +131,6 @@ describe.each(providers)( }, ); - it('should throw when the signer is not the current holder', async () => { - mockV5TitleEscrowContract.holder.mockResolvedValue('0xsomeone_else'); - await expect( - reject(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Only the current holder can reject this TitleEscrow'); - }); - - it('should throw when owner and holder are the same address', async () => { - mockV5TitleEscrowContract.beneficiary.mockResolvedValue(wallet.address); - await expect( - reject(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Owner and holder must be different addresses'); - }); - - it.each([Status.Rejected, Status.Discharged])( - 'should throw when status is already %i', - async (status) => { - mockV5TitleEscrowContract.status.mockResolvedValue(status); - await expect( - reject(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('cannot be accepted or rejected again'); - }, - ); - it('should throw when callStatic fails', async () => { mockV5TitleEscrowContract.callStatic.reject.mockRejectedValue( new Error('Simulated failure'), @@ -226,41 +171,6 @@ describe.each(providers)( }, ); - it('should throw when the signer is not the current beneficiary', async () => { - mockV5TitleEscrowContract.beneficiary.mockResolvedValue('0xsomeone_else'); - await expect( - discharge(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Only the current beneficiary (owner) can discharge this TitleEscrow'); - }); - - it('should throw when owner and holder are the same address', async () => { - mockV5TitleEscrowContract.holder.mockResolvedValue(wallet.address); - await expect( - discharge(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('Owner and holder must be different addresses'); - }); - - it('should throw a specific message when status is Rejected', async () => { - mockV5TitleEscrowContract.status.mockResolvedValue(Status.Rejected); - await expect( - discharge(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('was rejected and can never be discharged'); - }); - - it('should throw a specific message when status is still Issued', async () => { - mockV5TitleEscrowContract.status.mockResolvedValue(Status.Issued); - await expect( - discharge(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('has not been accepted yet'); - }); - - it('should throw a specific message when already Discharged', async () => { - mockV5TitleEscrowContract.status.mockResolvedValue(Status.Discharged); - await expect( - discharge(contractOptions, wallet, { remarks: MOCK_REMARKS }, options), - ).rejects.toThrow('This TitleEscrow has already been discharged.'); - }); - it('should throw when callStatic fails', async () => { mockV5TitleEscrowContract.callStatic.discharge.mockRejectedValue( new Error('Simulated failure'), diff --git a/src/__tests__/status/statusE2eDocument.test.ts b/src/__tests__/status/statusE2eDocument.test.ts index 836bda8..283be69 100644 --- a/src/__tests__/status/statusE2eDocument.test.ts +++ b/src/__tests__/status/statusE2eDocument.test.ts @@ -127,6 +127,14 @@ describe.each(providers)( beforeEach(async () => { vi.clearAllMocks(); resetCoreMocks(); + // clearAllMocks does not drain mockRejectedValueOnce queues; reset callStatic paths so + // v5-suite once-mocks cannot leak into the v6 suite (and vice versa). + mockV5TitleEscrowContract.callStatic.accept.mockResolvedValue(true); + mockV5TitleEscrowContract.callStatic.reject.mockResolvedValue(true); + mockV5TitleEscrowContract.callStatic.discharge.mockResolvedValue(true); + mockV5TitleEscrowContract.accept.staticCall.mockResolvedValue(true); + mockV5TitleEscrowContract.reject.staticCall.mockResolvedValue(true); + mockV5TitleEscrowContract.discharge.staticCall.mockResolvedValue(true); ownerWallet = createWalletFromKey(Provider, ethersVersion, OWNER_PRIVATE_KEY); holderWallet = createWalletFromKey(Provider, ethersVersion, HOLDER_PRIVATE_KEY); bankWallet = createWalletFromKey(Provider, ethersVersion, BANK_PRIVATE_KEY); @@ -290,14 +298,6 @@ describe.each(providers)( }); expect(await getStatus(contractOptions, ownerWallet)).toEqual(Status.Rejected); - // Terminal: cannot discharge or accept a rejected bill - await expect(discharge(contractOptions, ownerWallet, {}, options)).rejects.toThrow( - 'was rejected and can never be discharged', - ); - await expect(accept(contractOptions, holderWallet, {}, options)).rejects.toThrow( - /already been|cannot be accepted or rejected again/, - ); - // Status-only reject — separately revert holder role const revertHolderTx = await rejectTransferHolder( contractOptions, @@ -369,11 +369,6 @@ describe.each(providers)( status: Status.Accepted, }); - // Original owner can no longer discharge - await expect(discharge(contractOptions, ownerWallet, {}, options)).rejects.toThrow( - 'Only the current beneficiary (owner) can discharge this TitleEscrow', - ); - const dischargeTx = await discharge( contractOptions, bankWallet, @@ -391,63 +386,6 @@ describe.each(providers)( }); describe('edge case: invalid lifecycle transitions', () => { - it('blocks accept/reject/discharge when owner == holder (no presentment)', async () => { - await setRoles({ beneficiary: ownerAddress, holder: ownerAddress }); - - await expect(accept(contractOptions, ownerWallet, {}, options)).rejects.toThrow( - 'Owner and holder must be different addresses', - ); - await expect(reject(contractOptions, ownerWallet, {}, options)).rejects.toThrow( - 'Owner and holder must be different addresses', - ); - }); - - it('blocks discharge before acceptance and after discharge', async () => { - await setRoles({ - beneficiary: ownerAddress, - holder: holderAddress, - status: Status.Issued, - }); - await expect(discharge(contractOptions, ownerWallet, {}, options)).rejects.toThrow( - 'has not been accepted yet', - ); - - await setRoles({ - beneficiary: ownerAddress, - holder: holderAddress, - status: Status.Discharged, - }); - await expect(discharge(contractOptions, ownerWallet, {}, options)).rejects.toThrow( - 'already been discharged', - ); - await expect(accept(contractOptions, holderWallet, {}, options)).rejects.toThrow( - /already been|cannot be accepted or rejected again/, - ); - }); - - it('blocks wrong-role callers at each lifecycle step', async () => { - await setRoles({ - beneficiary: ownerAddress, - holder: holderAddress, - status: Status.Issued, - }); - await expect(accept(contractOptions, ownerWallet, {}, options)).rejects.toThrow( - 'Only the current holder can accept', - ); - await expect(reject(contractOptions, ownerWallet, {}, options)).rejects.toThrow( - 'Only the current holder can reject', - ); - - await setRoles({ - beneficiary: ownerAddress, - holder: holderAddress, - status: Status.Accepted, - }); - await expect(discharge(contractOptions, holderWallet, {}, options)).rejects.toThrow( - 'Only the current beneficiary (owner) can discharge', - ); - }); - it('allows ETR circulation after Accepted without reading or mutating status', async () => { await setRoles({ beneficiary: ownerAddress, diff --git a/src/__tests__/status/statusRules.test.ts b/src/__tests__/status/statusRules.test.ts deleted file mode 100644 index 2c9cfdd..0000000 --- a/src/__tests__/status/statusRules.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { StatusRules } from '../../status/StatusRules'; -import { Status } from '../../status/types'; -import { HOLDER, OWNER } from './fixtures'; - -const baseTransition = { - currentBeneficiary: OWNER, - currentHolder: HOLDER, - currentStatus: Status.Issued, - signerAddress: HOLDER, -}; - -describe('StatusRules — status transitions (accept / reject / discharge)', () => { - it('accept succeeds for holder at Issued with diverged roles', () => { - expect(() => StatusRules.assertAccept(baseTransition)).not.toThrow(); - }); - - it('accept fails for non-holder', () => { - expect(() => StatusRules.assertAccept({ ...baseTransition, signerAddress: OWNER })).toThrow( - 'Only the current holder can accept', - ); - }); - - it('accept fails when owner equals holder', () => { - expect(() => - StatusRules.assertAccept({ - ...baseTransition, - currentBeneficiary: OWNER, - currentHolder: OWNER, - signerAddress: OWNER, - }), - ).toThrow('Owner and holder must be different addresses'); - }); - - it('accept fails from Accepted, Rejected, and Discharged', () => { - for (const status of [Status.Accepted, Status.Rejected, Status.Discharged]) { - expect(() => StatusRules.assertAccept({ ...baseTransition, currentStatus: status })).toThrow( - 'cannot be accepted or rejected again', - ); - } - }); - - it('reject succeeds for holder at Issued', () => { - expect(() => StatusRules.assertReject(baseTransition)).not.toThrow(); - }); - - it('reject fails from Discharged', () => { - expect(() => - StatusRules.assertReject({ ...baseTransition, currentStatus: Status.Discharged }), - ).toThrow('cannot be accepted or rejected again'); - }); - - it('discharge succeeds for beneficiary at Accepted', () => { - expect(() => - StatusRules.assertDischarge({ - ...baseTransition, - currentStatus: Status.Accepted, - signerAddress: OWNER, - }), - ).not.toThrow(); - }); - - it('discharge fails for non-beneficiary', () => { - expect(() => - StatusRules.assertDischarge({ - ...baseTransition, - currentStatus: Status.Accepted, - signerAddress: HOLDER, - }), - ).toThrow('Only the current beneficiary (owner) can discharge'); - }); - - it('discharge fails from Issued', () => { - expect(() => StatusRules.assertDischarge({ ...baseTransition, signerAddress: OWNER })).toThrow( - 'has not been accepted yet', - ); - }); - - it('discharge fails from Rejected with reissue hint', () => { - expect(() => - StatusRules.assertDischarge({ - ...baseTransition, - currentStatus: Status.Rejected, - signerAddress: OWNER, - }), - ).toThrow('was rejected and can never be discharged'); - }); - - it('discharge fails when already Discharged', () => { - expect(() => - StatusRules.assertDischarge({ - ...baseTransition, - currentStatus: Status.Discharged, - signerAddress: OWNER, - }), - ).toThrow('already been discharged'); - }); -}); diff --git a/src/status/StatusRules.ts b/src/status/StatusRules.ts deleted file mode 100644 index f75b0e5..0000000 --- a/src/status/StatusRules.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Status, StatusLabel } from './types'; - -interface TransitionParams { - currentBeneficiary: string; - currentHolder: string; - currentStatus: Status; - signerAddress: string; -} - -const sameAddress = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase(); - -// Business rules for the three dedicated status functions (accept, reject, discharge). -// Every check here is purely advisory, client-side validation — the contract enforces the -// on-chain-relevant subset of these independently. Nothing in this class talks to a contract; -// it only reasons about state its callers already fetched. -// -// The existing ETR functions (transferHolder, nominate, transferBeneficiary, transferOwners, the -// reject-transfer family, returnToIssuer) are untouched by this class — they behave exactly as -// they did before the status lifecycle existed. -export class StatusRules { - private static assertTransition( - action: 'accept' | 'reject' | 'discharge', - requiredCallerRole: 'holder' | 'beneficiary', - requiredStatus: Status, - { currentBeneficiary, currentHolder, currentStatus, signerAddress }: TransitionParams, - ): void { - const expectedCaller = requiredCallerRole === 'holder' ? currentHolder : currentBeneficiary; - if (!sameAddress(signerAddress, expectedCaller)) { - throw new Error( - requiredCallerRole === 'holder' - ? `Only the current holder can ${action} this TitleEscrow` - : `Only the current beneficiary (owner) can ${action} this TitleEscrow`, - ); - } - - if (sameAddress(currentBeneficiary, currentHolder)) { - throw new Error( - 'Owner and holder must be different addresses before this TitleEscrow can be accepted, rejected, or discharged', - ); - } - - if (currentStatus === requiredStatus) return; - - if (action === 'discharge') { - if (currentStatus === Status.Rejected) { - throw new Error( - 'This TitleEscrow was rejected and can never be discharged — surrender it (returnToIssuer) and reissue a new one instead.', - ); - } - if (currentStatus === Status.Issued) { - throw new Error( - 'This TitleEscrow has not been accepted yet — only an Accepted escrow can be discharged.', - ); - } - throw new Error('This TitleEscrow has already been discharged.'); - } - - throw new Error( - `This TitleEscrow has already been ${StatusLabel[currentStatus]} and cannot be accepted or rejected again.`, - ); - } - - // accept: holder-only, requires status Issued. - static assertAccept(params: TransitionParams): void { - StatusRules.assertTransition('accept', 'holder', Status.Issued, params); - } - - // reject: holder-only, requires status Issued. - static assertReject(params: TransitionParams): void { - StatusRules.assertTransition('reject', 'holder', Status.Issued, params); - } - - // discharge: beneficiary-only, requires status Accepted. - static assertDischarge(params: TransitionParams): void { - StatusRules.assertTransition('discharge', 'beneficiary', Status.Accepted, params); - } -} diff --git a/src/status/accept.ts b/src/status/accept.ts index 77e21e8..0f8029c 100644 --- a/src/status/accept.ts +++ b/src/status/accept.ts @@ -7,23 +7,22 @@ import { import { v5Contracts } from '../token-registry-v5'; import { Signer as SignerV6, Contract as ContractV6 } from 'ethersV6'; import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; -import { getSignerAddressSafe, getTxOptions } from '../token-registry-functions/utils'; +import { getTxOptions } from '../token-registry-functions/utils'; import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; -import { StatusActionParams, Status } from './types'; -import { StatusRules } from './StatusRules'; +import { StatusActionParams } from './types'; /** * Beta. Holder accepts a TitleEscrow, moving its status from Issued to Accepted. Callable on any * TitleEscrow — not gated on document type — but only makes sense once beneficiary != holder. - * Calls the on-chain `accept(bytes)` method. + * Calls the on-chain `accept(bytes)` method. Role and status preconditions are enforced by the + * contract (surfaced via callStatic). * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. Must be the current holder. * @param {StatusActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. * @param {TransactionOptions} options - Includes optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. * @throws if the title escrow address or signer provider is missing. - * @throws if the version is not V5 compatible, or the TitleEscrow predates the status lifecycle. - * @throws if the signer is not the current holder, if owner and holder are the same address, or if the current status isn't Issued. + * @throws if the version is not V5 compatible. * @throws if the dry-run (`callStatic`) fails. * @returns {Promise} The transaction response of the accept call. */ @@ -74,25 +73,6 @@ const accept = async ( throw new Error('Only Token Registry V5 is supported'); } - const [currentBeneficiary, currentHolder] = await Promise.all([ - titleEscrowContract.beneficiary(), - titleEscrowContract.holder(), - ]); - - let currentStatus: Status; - try { - currentStatus = Number(await titleEscrowContract.status()) as Status; - } catch (e) { - console.error('status() failed:', e); - throw new Error( - 'This TitleEscrow does not support the status lifecycle (status()) — it likely predates the eBOE contract upgrade.', - ); - } - - const signerAddress = await getSignerAddressSafe(signer); - StatusRules.assertAccept({ currentBeneficiary, currentHolder, currentStatus, signerAddress }); - - // Check callStatic (dry run) try { if (isV6EthersProvider(signer.provider)) { await (titleEscrowContract as ContractV6).accept.staticCall(encryptedRemarks); diff --git a/src/status/discharge.ts b/src/status/discharge.ts index e018e9a..ce01ef7 100644 --- a/src/status/discharge.ts +++ b/src/status/discharge.ts @@ -7,24 +7,23 @@ import { import { v5Contracts } from '../token-registry-v5'; import { Signer as SignerV6, Contract as ContractV6 } from 'ethersV6'; import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; -import { getSignerAddressSafe, getTxOptions } from '../token-registry-functions/utils'; +import { getTxOptions } from '../token-registry-functions/utils'; import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; -import { StatusActionParams, Status } from './types'; -import { StatusRules } from './StatusRules'; +import { StatusActionParams } from './types'; /** * Beta. Beneficiary (owner) discharges a TitleEscrow, moving its status from Accepted to * Discharged — terminal, confirming money received off-chain. Never callable from Rejected. Not * gated on document type. - * Calls the on-chain `discharge(bytes)` method. + * Calls the on-chain `discharge(bytes)` method. Role and status preconditions are enforced by the + * contract (surfaced via callStatic). * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. Must be the current beneficiary. * @param {StatusActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. * @param {TransactionOptions} options - Includes optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. * @throws if the title escrow address or signer provider is missing. - * @throws if the version is not V5 compatible, or the TitleEscrow predates the status lifecycle. - * @throws if the signer is not the current beneficiary, if owner and holder are the same address, or if the current status isn't Accepted. + * @throws if the version is not V5 compatible. * @throws if the dry-run (`callStatic`) fails. * @returns {Promise} The transaction response of the discharge call. */ @@ -75,25 +74,6 @@ const discharge = async ( throw new Error('Only Token Registry V5 is supported'); } - const [currentBeneficiary, currentHolder] = await Promise.all([ - titleEscrowContract.beneficiary(), - titleEscrowContract.holder(), - ]); - - let currentStatus: Status; - try { - currentStatus = Number(await titleEscrowContract.status()) as Status; - } catch (e) { - console.error('status() failed:', e); - throw new Error( - 'This TitleEscrow does not support the status lifecycle (status()) — it likely predates the eBOE contract upgrade.', - ); - } - - const signerAddress = await getSignerAddressSafe(signer); - StatusRules.assertDischarge({ currentBeneficiary, currentHolder, currentStatus, signerAddress }); - - // Check callStatic (dry run) try { if (isV6EthersProvider(signer.provider)) { await (titleEscrowContract as ContractV6).discharge.staticCall(encryptedRemarks); diff --git a/src/status/reject.ts b/src/status/reject.ts index d67074c..fd4224b 100644 --- a/src/status/reject.ts +++ b/src/status/reject.ts @@ -7,24 +7,23 @@ import { import { v5Contracts } from '../token-registry-v5'; import { Signer as SignerV6, Contract as ContractV6 } from 'ethersV6'; import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; -import { getSignerAddressSafe, getTxOptions } from '../token-registry-functions/utils'; +import { getTxOptions } from '../token-registry-functions/utils'; import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; -import { StatusActionParams, Status } from './types'; -import { StatusRules } from './StatusRules'; +import { StatusActionParams } from './types'; /** * Beta. Holder rejects (dishonours) a TitleEscrow, moving its status from Issued to Rejected — * terminal. Status-only: it does not revert the holder role, use the existing * `rejectTransferHolder` for that. Not gated on document type. - * Calls the on-chain `reject(bytes)` method. + * Calls the on-chain `reject(bytes)` method. Role and status preconditions are enforced by the + * contract (surfaced via callStatic). * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. Must be the current holder. * @param {StatusActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. * @param {TransactionOptions} options - Includes optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. * @throws if the title escrow address or signer provider is missing. - * @throws if the version is not V5 compatible, or the TitleEscrow predates the status lifecycle. - * @throws if the signer is not the current holder, if owner and holder are the same address, or if the current status isn't Issued. + * @throws if the version is not V5 compatible. * @throws if the dry-run (`callStatic`) fails. * @returns {Promise} The transaction response of the reject call. */ @@ -75,25 +74,6 @@ const reject = async ( throw new Error('Only Token Registry V5 is supported'); } - const [currentBeneficiary, currentHolder] = await Promise.all([ - titleEscrowContract.beneficiary(), - titleEscrowContract.holder(), - ]); - - let currentStatus: Status; - try { - currentStatus = Number(await titleEscrowContract.status()) as Status; - } catch (e) { - console.error('status() failed:', e); - throw new Error( - 'This TitleEscrow does not support the status lifecycle (status()) — it likely predates the eBOE contract upgrade.', - ); - } - - const signerAddress = await getSignerAddressSafe(signer); - StatusRules.assertReject({ currentBeneficiary, currentHolder, currentStatus, signerAddress }); - - // Check callStatic (dry run) try { if (isV6EthersProvider(signer.provider)) { await (titleEscrowContract as ContractV6).reject.staticCall(encryptedRemarks); From 1e87c8d5a6376acc53f87265d28492a50717435f Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Sun, 12 Jul 2026 23:49:15 +0530 Subject: [PATCH 05/10] refactor: simplify accept, discharge, and reject functions by utilizing runStatusAction --- package.json | 1 + src/status/accept.ts | 75 +++------------------------- src/status/discharge.ts | 75 +++------------------------- src/status/reject.ts | 75 +++------------------------- src/status/runStatusAction.ts | 93 +++++++++++++++++++++++++++++++++++ src/status/types.ts | 5 +- 6 files changed, 115 insertions(+), 209 deletions(-) create mode 100644 src/status/runStatusAction.ts diff --git a/package.json b/package.json index 1886eb0..42137ed 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "dependencies": { "@tradetrust-tt/dnsprove": "^2.18.0", "@tradetrust-tt/ethers-aws-kms-signer": "^2.1.4", + "@tradetrust-tt/token-registry": "file:.yalc/@tradetrust-tt/token-registry", "@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/tradetrust": "^6.10.3", diff --git a/src/status/accept.ts b/src/status/accept.ts index 0f8029c..01170c8 100644 --- a/src/status/accept.ts +++ b/src/status/accept.ts @@ -1,16 +1,8 @@ -import { - encrypt, - getTitleEscrowAddress, - isTitleEscrowVersion, - TitleEscrowInterface, -} from '../core'; -import { v5Contracts } from '../token-registry-v5'; -import { Signer as SignerV6, Contract as ContractV6 } from 'ethersV6'; -import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; -import { getTxOptions } from '../token-registry-functions/utils'; +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; -import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; import { StatusActionParams } from './types'; +import { runStatusAction } from './runStatusAction'; /** * Beta. Holder accepts a TitleEscrow, moving its status from Issued to Accepted. Callable on any @@ -26,67 +18,12 @@ import { StatusActionParams } from './types'; * @throws if the dry-run (`callStatic`) fails. * @returns {Promise} The transaction response of the accept call. */ -const accept = async ( +const accept = ( contractOptions: ContractOptions, signer: Signer | SignerV6, params: StatusActionParams, options: TransactionOptions, -): Promise => { - const { tokenRegistryAddress, tokenId } = contractOptions; - let { titleEscrowAddress } = contractOptions; - const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; - - if (!titleEscrowAddress) { - if (!tokenRegistryAddress) throw new Error('Token registry address is required'); - if (!tokenId) throw new Error('Token ID is required'); - titleEscrowAddress = await getTitleEscrowAddress( - tokenRegistryAddress, - tokenId as string, - signer.provider, - {}, - ); - } - - if (!titleEscrowAddress) throw new Error('Title escrow address is required'); - if (!signer.provider) throw new Error('Provider is required'); - const { remarks } = params; - - const Contract = getEthersContractFromProvider(signer.provider); - const titleEscrowContract: ContractV5 | ContractV6 = new Contract( - titleEscrowAddress, - v5Contracts.TitleEscrow__factory.abi, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - signer as any, - ); - const encryptedRemarks = remarks ? `0x${encrypt(remarks, options.id!)}` : '0x'; - - let isV5TT = titleEscrowVersion === 'v5'; - if (titleEscrowVersion === undefined) { - isV5TT = await isTitleEscrowVersion({ - titleEscrowAddress, - versionInterface: TitleEscrowInterface.V5, - provider: signer.provider, - }); - } - - if (!isV5TT) { - throw new Error('Only Token Registry V5 is supported'); - } - - try { - if (isV6EthersProvider(signer.provider)) { - await (titleEscrowContract as ContractV6).accept.staticCall(encryptedRemarks); - } else { - await (titleEscrowContract as ContractV5).callStatic.accept(encryptedRemarks); - } - } catch (e) { - console.error('callStatic failed:', e); - throw new Error('Pre-check (callStatic) for accept failed'); - } - - const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); - - return await titleEscrowContract.accept(encryptedRemarks, txOptions); -}; +): Promise => + runStatusAction('accept', contractOptions, signer, params, options); export { accept }; diff --git a/src/status/discharge.ts b/src/status/discharge.ts index ce01ef7..c0f9ce5 100644 --- a/src/status/discharge.ts +++ b/src/status/discharge.ts @@ -1,16 +1,8 @@ -import { - encrypt, - getTitleEscrowAddress, - isTitleEscrowVersion, - TitleEscrowInterface, -} from '../core'; -import { v5Contracts } from '../token-registry-v5'; -import { Signer as SignerV6, Contract as ContractV6 } from 'ethersV6'; -import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; -import { getTxOptions } from '../token-registry-functions/utils'; +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; -import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; import { StatusActionParams } from './types'; +import { runStatusAction } from './runStatusAction'; /** * Beta. Beneficiary (owner) discharges a TitleEscrow, moving its status from Accepted to @@ -27,67 +19,12 @@ import { StatusActionParams } from './types'; * @throws if the dry-run (`callStatic`) fails. * @returns {Promise} The transaction response of the discharge call. */ -const discharge = async ( +const discharge = ( contractOptions: ContractOptions, signer: Signer | SignerV6, params: StatusActionParams, options: TransactionOptions, -): Promise => { - const { tokenRegistryAddress, tokenId } = contractOptions; - let { titleEscrowAddress } = contractOptions; - const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; - - if (!titleEscrowAddress) { - if (!tokenRegistryAddress) throw new Error('Token registry address is required'); - if (!tokenId) throw new Error('Token ID is required'); - titleEscrowAddress = await getTitleEscrowAddress( - tokenRegistryAddress, - tokenId as string, - signer.provider, - {}, - ); - } - - if (!titleEscrowAddress) throw new Error('Title escrow address is required'); - if (!signer.provider) throw new Error('Provider is required'); - const { remarks } = params; - - const Contract = getEthersContractFromProvider(signer.provider); - const titleEscrowContract: ContractV5 | ContractV6 = new Contract( - titleEscrowAddress, - v5Contracts.TitleEscrow__factory.abi, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - signer as any, - ); - const encryptedRemarks = remarks ? `0x${encrypt(remarks, options.id!)}` : '0x'; - - let isV5TT = titleEscrowVersion === 'v5'; - if (titleEscrowVersion === undefined) { - isV5TT = await isTitleEscrowVersion({ - titleEscrowAddress, - versionInterface: TitleEscrowInterface.V5, - provider: signer.provider, - }); - } - - if (!isV5TT) { - throw new Error('Only Token Registry V5 is supported'); - } - - try { - if (isV6EthersProvider(signer.provider)) { - await (titleEscrowContract as ContractV6).discharge.staticCall(encryptedRemarks); - } else { - await (titleEscrowContract as ContractV5).callStatic.discharge(encryptedRemarks); - } - } catch (e) { - console.error('callStatic failed:', e); - throw new Error('Pre-check (callStatic) for discharge failed'); - } - - const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); - - return await titleEscrowContract.discharge(encryptedRemarks, txOptions); -}; +): Promise => + runStatusAction('discharge', contractOptions, signer, params, options); export { discharge }; diff --git a/src/status/reject.ts b/src/status/reject.ts index fd4224b..6347579 100644 --- a/src/status/reject.ts +++ b/src/status/reject.ts @@ -1,16 +1,8 @@ -import { - encrypt, - getTitleEscrowAddress, - isTitleEscrowVersion, - TitleEscrowInterface, -} from '../core'; -import { v5Contracts } from '../token-registry-v5'; -import { Signer as SignerV6, Contract as ContractV6 } from 'ethersV6'; -import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; -import { getTxOptions } from '../token-registry-functions/utils'; +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; -import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; import { StatusActionParams } from './types'; +import { runStatusAction } from './runStatusAction'; /** * Beta. Holder rejects (dishonours) a TitleEscrow, moving its status from Issued to Rejected — @@ -27,67 +19,12 @@ import { StatusActionParams } from './types'; * @throws if the dry-run (`callStatic`) fails. * @returns {Promise} The transaction response of the reject call. */ -const reject = async ( +const reject = ( contractOptions: ContractOptions, signer: Signer | SignerV6, params: StatusActionParams, options: TransactionOptions, -): Promise => { - const { tokenRegistryAddress, tokenId } = contractOptions; - let { titleEscrowAddress } = contractOptions; - const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; - - if (!titleEscrowAddress) { - if (!tokenRegistryAddress) throw new Error('Token registry address is required'); - if (!tokenId) throw new Error('Token ID is required'); - titleEscrowAddress = await getTitleEscrowAddress( - tokenRegistryAddress, - tokenId as string, - signer.provider, - {}, - ); - } - - if (!titleEscrowAddress) throw new Error('Title escrow address is required'); - if (!signer.provider) throw new Error('Provider is required'); - const { remarks } = params; - - const Contract = getEthersContractFromProvider(signer.provider); - const titleEscrowContract: ContractV5 | ContractV6 = new Contract( - titleEscrowAddress, - v5Contracts.TitleEscrow__factory.abi, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - signer as any, - ); - const encryptedRemarks = remarks ? `0x${encrypt(remarks, options.id!)}` : '0x'; - - let isV5TT = titleEscrowVersion === 'v5'; - if (titleEscrowVersion === undefined) { - isV5TT = await isTitleEscrowVersion({ - titleEscrowAddress, - versionInterface: TitleEscrowInterface.V5, - provider: signer.provider, - }); - } - - if (!isV5TT) { - throw new Error('Only Token Registry V5 is supported'); - } - - try { - if (isV6EthersProvider(signer.provider)) { - await (titleEscrowContract as ContractV6).reject.staticCall(encryptedRemarks); - } else { - await (titleEscrowContract as ContractV5).callStatic.reject(encryptedRemarks); - } - } catch (e) { - console.error('callStatic failed:', e); - throw new Error('Pre-check (callStatic) for reject failed'); - } - - const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); - - return await titleEscrowContract.reject(encryptedRemarks, txOptions); -}; +): Promise => + runStatusAction('reject', contractOptions, signer, params, options); export { reject }; diff --git a/src/status/runStatusAction.ts b/src/status/runStatusAction.ts new file mode 100644 index 0000000..a0a67f8 --- /dev/null +++ b/src/status/runStatusAction.ts @@ -0,0 +1,93 @@ +import { + encrypt, + getTitleEscrowAddress, + isTitleEscrowVersion, + TitleEscrowInterface, +} from '../core'; +import { v5Contracts } from '../token-registry-v5'; +import { Signer as SignerV6, Contract as ContractV6 } from 'ethersV6'; +import { Contract as ContractV5, ContractTransaction, Signer } from 'ethers'; +import { getTxOptions } from '../token-registry-functions/utils'; +import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; +import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; +import { StatusActionParams } from './types'; + +export type StatusActionName = 'accept' | 'reject' | 'discharge'; + +/** + * Shared implementation behind `accept`, `reject`, and `discharge` — they differ only in which + * on-chain method they call. Role and status preconditions are enforced by the contract + * (surfaced via callStatic). + * @param {StatusActionName} action - Which on-chain method to call: 'accept', 'reject', or 'discharge'. + * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. + * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. + * @param {StatusActionParams} params - Contains the optional `remarks` field, encrypted and sent with the transaction. + * @param {TransactionOptions} options - Includes optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. + * @throws if the title escrow address or signer provider is missing. + * @throws if the version is not V5 compatible. + * @throws if the dry-run (`callStatic`) fails. + * @returns {Promise} The transaction response of the action call. + */ +export const runStatusAction = async ( + action: StatusActionName, + contractOptions: ContractOptions, + signer: Signer | SignerV6, + params: StatusActionParams, + options: TransactionOptions, +): Promise => { + const { tokenRegistryAddress, tokenId } = contractOptions; + let { titleEscrowAddress } = contractOptions; + const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; + + if (!titleEscrowAddress) { + if (!tokenRegistryAddress) throw new Error('Token registry address is required'); + if (!tokenId) throw new Error('Token ID is required'); + titleEscrowAddress = await getTitleEscrowAddress( + tokenRegistryAddress, + tokenId as string, + signer.provider, + {}, + ); + } + + if (!titleEscrowAddress) throw new Error('Title escrow address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { remarks } = params; + + const Contract = getEthersContractFromProvider(signer.provider); + const titleEscrowContract: ContractV5 | ContractV6 = new Contract( + titleEscrowAddress, + v5Contracts.TitleEscrow__factory.abi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer as any, + ); + const encryptedRemarks = remarks ? `0x${encrypt(remarks, options.id!)}` : '0x'; + + let isV5TT = titleEscrowVersion === 'v5'; + if (titleEscrowVersion === undefined) { + isV5TT = await isTitleEscrowVersion({ + titleEscrowAddress, + versionInterface: TitleEscrowInterface.V5, + provider: signer.provider, + }); + } + + if (!isV5TT) { + throw new Error('Only Token Registry V5 is supported'); + } + + try { + if (isV6EthersProvider(signer.provider)) { + await (titleEscrowContract as ContractV6)[action].staticCall(encryptedRemarks); + } else { + await (titleEscrowContract as ContractV5).callStatic[action](encryptedRemarks); + } + } catch (e) { + console.error('callStatic failed:', e); + throw new Error(`Pre-check (callStatic) for ${action} failed`); + } + + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + return await titleEscrowContract[action](encryptedRemarks, txOptions); +}; diff --git a/src/status/types.ts b/src/status/types.ts index 3fed67e..30ec616 100644 --- a/src/status/types.ts +++ b/src/status/types.ts @@ -1,6 +1,7 @@ -import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; +import { ContractOptions } from '../token-registry-functions/types'; -export type { ContractOptions, TransactionOptions }; +export type { ContractOptions }; +export type { TransactionOptions } from '../token-registry-functions/types'; export interface StatusActionParams { remarks?: string; From 57952a6d7d0aaa7da5b07d7e11c8e8cc3136e897 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Sun, 12 Jul 2026 23:52:25 +0530 Subject: [PATCH 06/10] chore: remove local dependency on @tradetrust-tt/token-registry from package.json --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 42137ed..1886eb0 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,6 @@ "dependencies": { "@tradetrust-tt/dnsprove": "^2.18.0", "@tradetrust-tt/ethers-aws-kms-signer": "^2.1.4", - "@tradetrust-tt/token-registry": "file:.yalc/@tradetrust-tt/token-registry", "@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/tradetrust": "^6.10.3", From b99e6db47555c1225305e6339a423f393c6ff88d Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Mon, 13 Jul 2026 00:21:15 +0530 Subject: [PATCH 07/10] Update src/status/status.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/status/status.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/status/status.ts b/src/status/status.ts index 797dd0a..44a3b84 100644 --- a/src/status/status.ts +++ b/src/status/status.ts @@ -26,6 +26,12 @@ const getStatus = async ( let { titleEscrowAddress } = contractOptions; const { titleEscrowVersion } = options; + const { tokenRegistryAddress, tokenId } = contractOptions; + let { titleEscrowAddress } = contractOptions; + const { titleEscrowVersion } = options; + + if (!signer.provider) throw new Error('Provider is required'); + if (!titleEscrowAddress) { if (!tokenRegistryAddress) throw new Error('Token registry address is required'); if (!tokenId) throw new Error('Token ID is required'); @@ -38,7 +44,6 @@ const getStatus = async ( } if (!titleEscrowAddress) throw new Error('Title escrow address is required'); - if (!signer.provider) throw new Error('Provider is required'); let isV5TT = titleEscrowVersion === 'v5'; if (titleEscrowVersion === undefined) { From 0f5df6930d4666ef8caf2bc20c82d25e6cfc1ae7 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Mon, 13 Jul 2026 07:33:47 +0530 Subject: [PATCH 08/10] refactor: enhance endorsement chain handling for legacy V5 TitleEscrow - Updated README to clarify on-chain preconditions for accept, reject, and discharge functions. --- README.md | 16 +++-- .../core/endorsement-chain-legacy-v5.test.ts | 69 +++++++++++++++++++ src/__tests__/fixtures/endorsement-chain.ts | 6 +- .../endorsement-chain/useEndorsementChain.ts | 27 +++++++- src/status/status.ts | 4 -- 5 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/core/endorsement-chain-legacy-v5.test.ts diff --git a/README.md b/README.md index ec72cb2..98a230c 100644 --- a/README.md +++ b/README.md @@ -1272,11 +1272,15 @@ console.log(StatusLabel[status]); // e.g. "Issued" > - **reject** — holder-only, requires `status === Issued`, moves to `Rejected` (terminal — nothing can move it again). Status-only: it does not revert the holder role, use the existing `rejectTransferHolder` for that. Emits `StatusRejected`. > - **discharge** — beneficiary(owner)-only, requires `status === Accepted`, moves to `Discharged` (terminal). Never callable from `Rejected`. Emits `StatusDischarged`. > -> All three additionally require `beneficiary != holder` at the moment they're called, and run client-side pre-flight checks — in this order — before sending the transaction, so failures surface as a specific message instead of a raw revert: -> -> 1. **Caller-role check** — signer must be the current holder (accept/reject) or beneficiary (discharge). -> 2. **Owner ≠ holder check**. -> 3. **Status-precondition check**, with a tailored message per terminal case (e.g. *"This TitleEscrow was rejected and can never be discharged — surrender it (returnToIssuer) and reissue a new one instead."*). +> All three additionally require `beneficiary != holder` at the moment they're called. These +> preconditions (caller role, owner ≠ holder, status transition) are enforced entirely on-chain by +> the contract — there is no client-side role/status validation in the SDK. Before sending, each +> function runs a `callStatic` dry-run of the same call; if that dry-run reverts for any reason +> (wrong caller, owner == holder, wrong status, etc.), the SDK throws a generic +> `Pre-check (callStatic) for accept failed` (or `reject`/`discharge`) rather than a message +> tailored to the specific precondition that failed. Inspect the underlying revert reason (e.g. +> `CallerNotHolder`, `CallerNotBeneficiary`, `OwnerHolderMustDiffer`, `InvalidStatusTransition` from +> `TitleEscrowErrors.sol`) if you need to distinguish which precondition was violated. #### Parameters @@ -1288,7 +1292,7 @@ Same shape for all three: `contractOptions` (as above), `signer`, `params: { rem #### Throws -Any of the pre-flight checks above, or a failed `callStatic` dry-run. +A failed `callStatic` dry-run (see above), or if the title escrow address/signer provider is missing, or the title escrow isn't V5. #### Example diff --git a/src/__tests__/core/endorsement-chain-legacy-v5.test.ts b/src/__tests__/core/endorsement-chain-legacy-v5.test.ts new file mode 100644 index 0000000..2a5719b --- /dev/null +++ b/src/__tests__/core/endorsement-chain-legacy-v5.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ethers as ethersV6 } from 'ethersV6'; +import * as ethersUtils from '../../utils/ethers'; +import { fetchEndorsementChain } from '../../core/endorsement-chain/useEndorsementChain'; + +const TITLE_ESCROW_ADDRESS = '0x1111111111111111111111111111111111111111'; +const REGISTRY_ADDRESS = '0x2222222222222222222222222222222222222222'; + +describe('fetchEndorsementChain - pre-BOE (legacy) V5 TitleEscrow fallback', () => { + it('still resolves as V5 via prevBeneficiary() duck-typing when supportsInterface fails for both V4 and the current V5 interfaceId', async () => { + const fakeContract = { + supportsInterface: vi.fn().mockResolvedValue(false), + prevBeneficiary: vi.fn().mockResolvedValue('0x000000000000000000000000000000000000dEaD'), + registry: vi.fn().mockResolvedValue(REGISTRY_ADDRESS), + getAddress: vi.fn().mockResolvedValue(TITLE_ESCROW_ADDRESS), + address: TITLE_ESCROW_ADDRESS, + filters: new Proxy( + {}, + { + get: () => vi.fn(() => ({})), + }, + ), + queryFilter: vi.fn().mockResolvedValue([]), + interface: { parseLog: vi.fn() }, + }; + + vi.spyOn(ethersUtils, 'getEthersContractFromProvider').mockReturnValue( + vi.fn(() => fakeContract) as any, + ); + + const provider = new ethersV6.JsonRpcProvider('http://localhost:1', 11155111, { + staticNetwork: true, + }); + + const result = await fetchEndorsementChain( + REGISTRY_ADDRESS, + '1', + provider, + undefined, + TITLE_ESCROW_ADDRESS, + ); + + expect(result).toEqual([]); + expect(fakeContract.prevBeneficiary).toHaveBeenCalled(); + + vi.restoreAllMocks(); + }); + + it('throws when neither V4/V5 interfaces match nor prevBeneficiary() succeeds (genuinely unsupported registry)', async () => { + const fakeContract = { + supportsInterface: vi.fn().mockResolvedValue(false), + prevBeneficiary: vi.fn().mockRejectedValue(new Error('no such function')), + }; + + vi.spyOn(ethersUtils, 'getEthersContractFromProvider').mockReturnValue( + vi.fn(() => fakeContract) as any, + ); + + const provider = new ethersV6.JsonRpcProvider('http://localhost:1', 11155111, { + staticNetwork: true, + }); + + await expect( + fetchEndorsementChain(REGISTRY_ADDRESS, '1', provider, undefined, TITLE_ESCROW_ADDRESS), + ).rejects.toThrow('Only Token Registry V4/V5 is supported'); + + vi.restoreAllMocks(); + }); +}); diff --git a/src/__tests__/fixtures/endorsement-chain.ts b/src/__tests__/fixtures/endorsement-chain.ts index 5ca503d..9fc0564 100644 --- a/src/__tests__/fixtures/endorsement-chain.ts +++ b/src/__tests__/fixtures/endorsement-chain.ts @@ -3366,7 +3366,7 @@ export const testCases = [ params: [ { address: '0x24c9C688cf919D133abB512A41163972dA150f1b', - topics: ['0x99b8651fe6a15f20e29f103ad21eef0362603a62c0d4b96e7a0f493d101dc0a0'], + topics: ['0x29ab698c427c31fa4320edd82b1a5ed0be38d752324ceb1a7a8658f71ff380ac'], fromBlock: 0, toBlock: 'latest', }, @@ -3378,7 +3378,7 @@ export const testCases = [ params: [ { address: '0x24c9C688cf919D133abB512A41163972dA150f1b', - topics: ['0xcb2ddde652b648da9d5396742a3bf19fafe4566e74c425d5480193b3dc1ee570'], + topics: ['0x4273e3f9fa628dca95157f3f3e43f55a7b8a8e5a85596670d99f3b2f5d32578b'], fromBlock: 0, toBlock: 'latest', }, @@ -3390,7 +3390,7 @@ export const testCases = [ params: [ { address: '0x24c9C688cf919D133abB512A41163972dA150f1b', - topics: ['0x3c6f2526f3431eedd04ae7bfc6d1dc1fdaea2bc9eef72e6a647912ef8f2621ec'], + topics: ['0xcde6b978de664fc7c3c45ec359bc419b7e601021747927764218fa9af45238d5'], fromBlock: 0, toBlock: 'latest', }, diff --git a/src/core/endorsement-chain/useEndorsementChain.ts b/src/core/endorsement-chain/useEndorsementChain.ts index 5d8695d..3dfa244 100644 --- a/src/core/endorsement-chain/useEndorsementChain.ts +++ b/src/core/endorsement-chain/useEndorsementChain.ts @@ -161,6 +161,25 @@ export const checkSupportsInterface = async ( } }; +// Duck-types prevBeneficiary(), a view function that has existed on every V5 TitleEscrow since +// before the BOE (status/accept/reject/discharge) upgrade and isn't part of the interfaceId that +// upgrade changed. Used only as a fallback when supportsInterface(TitleEscrowInterface.V5) fails. +const isLegacyV5TitleEscrow = async ( + titleEscrowAddress: string, + provider: Provider | ethersV6.Provider, +): Promise => { + try { + const Contract = getEthersContractFromProvider(provider); + const abi = ['function prevBeneficiary() external view returns (address)']; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contract = new Contract(titleEscrowAddress, abi, provider as any); + await contract.prevBeneficiary(); + return true; + } catch { + return false; + } +}; + interface TitleEscrowVersionParams { tokenRegistryAddress?: string; tokenId?: string; @@ -206,7 +225,7 @@ export const fetchEndorsementChain = async ( const resolvedTitleEscrowAddress = titleEscrowAddress ?? (await getTitleEscrowAddress(tokenRegistryAddress, tokenId, provider)); - const [isV4, isV5] = await Promise.all([ + const [isV4, isV5ByInterface] = await Promise.all([ isTitleEscrowVersion({ titleEscrowAddress: resolvedTitleEscrowAddress, versionInterface: TitleEscrowInterface.V4, @@ -219,6 +238,12 @@ export const fetchEndorsementChain = async ( }), ]); + // Adding status()/accept/reject/discharge to ITitleEscrow changed its ERC165 interfaceId, so + // TitleEscrowInterface.V5 only matches contracts deployed after that change — a V5 TitleEscrow + const isV5 = + isV5ByInterface || + (!isV4 && (await isLegacyV5TitleEscrow(resolvedTitleEscrowAddress, provider))); + if (!isV4 && !isV5) { throw new Error('Only Token Registry V4/V5 is supported'); } diff --git a/src/status/status.ts b/src/status/status.ts index 44a3b84..2494ec6 100644 --- a/src/status/status.ts +++ b/src/status/status.ts @@ -26,10 +26,6 @@ const getStatus = async ( let { titleEscrowAddress } = contractOptions; const { titleEscrowVersion } = options; - const { tokenRegistryAddress, tokenId } = contractOptions; - let { titleEscrowAddress } = contractOptions; - const { titleEscrowVersion } = options; - if (!signer.provider) throw new Error('Provider is required'); if (!titleEscrowAddress) { From 23ae8948e0131597d8f04f190291656c948adba4 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Mon, 13 Jul 2026 07:37:54 +0530 Subject: [PATCH 09/10] refactor: remove legacy V5 TitleEscrow test and update fallback logic - Deleted the legacy V5 TitleEscrow test file. --- ...ment-chain-v5-prev-beneficiary-fallback.test.ts} | 2 +- src/core/endorsement-chain/useEndorsementChain.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) rename src/__tests__/core/{endorsement-chain-legacy-v5.test.ts => endorsement-chain-v5-prev-beneficiary-fallback.test.ts} (96%) diff --git a/src/__tests__/core/endorsement-chain-legacy-v5.test.ts b/src/__tests__/core/endorsement-chain-v5-prev-beneficiary-fallback.test.ts similarity index 96% rename from src/__tests__/core/endorsement-chain-legacy-v5.test.ts rename to src/__tests__/core/endorsement-chain-v5-prev-beneficiary-fallback.test.ts index 2a5719b..ea8990e 100644 --- a/src/__tests__/core/endorsement-chain-legacy-v5.test.ts +++ b/src/__tests__/core/endorsement-chain-v5-prev-beneficiary-fallback.test.ts @@ -6,7 +6,7 @@ import { fetchEndorsementChain } from '../../core/endorsement-chain/useEndorseme const TITLE_ESCROW_ADDRESS = '0x1111111111111111111111111111111111111111'; const REGISTRY_ADDRESS = '0x2222222222222222222222222222222222222222'; -describe('fetchEndorsementChain - pre-BOE (legacy) V5 TitleEscrow fallback', () => { +describe('fetchEndorsementChain - V5 detection via prevBeneficiary() fallback', () => { it('still resolves as V5 via prevBeneficiary() duck-typing when supportsInterface fails for both V4 and the current V5 interfaceId', async () => { const fakeContract = { supportsInterface: vi.fn().mockResolvedValue(false), diff --git a/src/core/endorsement-chain/useEndorsementChain.ts b/src/core/endorsement-chain/useEndorsementChain.ts index 3dfa244..75d3664 100644 --- a/src/core/endorsement-chain/useEndorsementChain.ts +++ b/src/core/endorsement-chain/useEndorsementChain.ts @@ -161,10 +161,11 @@ export const checkSupportsInterface = async ( } }; -// Duck-types prevBeneficiary(), a view function that has existed on every V5 TitleEscrow since -// before the BOE (status/accept/reject/discharge) upgrade and isn't part of the interfaceId that -// upgrade changed. Used only as a fallback when supportsInterface(TitleEscrowInterface.V5) fails. -const isLegacyV5TitleEscrow = async ( +// Duck-types prevBeneficiary(), a view function every V5 TitleEscrow has always had, regardless +// of whether it was deployed before or after status()/accept/reject/discharge were added to +// ITitleEscrow (which changed the interface's ERC165 id). Used only as a fallback when +// supportsInterface(TitleEscrowInterface.V5) fails. +const isV5ByPrevBeneficiary = async ( titleEscrowAddress: string, provider: Provider | ethersV6.Provider, ): Promise => { @@ -240,9 +241,11 @@ export const fetchEndorsementChain = async ( // Adding status()/accept/reject/discharge to ITitleEscrow changed its ERC165 interfaceId, so // TitleEscrowInterface.V5 only matches contracts deployed after that change — a V5 TitleEscrow + // deployed before it fails supportsInterface(V5) even though it's still genuinely V5. Fall back + // to duck-typing prevBeneficiary() before concluding the registry is unsupported. const isV5 = isV5ByInterface || - (!isV4 && (await isLegacyV5TitleEscrow(resolvedTitleEscrowAddress, provider))); + (!isV4 && (await isV5ByPrevBeneficiary(resolvedTitleEscrowAddress, provider))); if (!isV4 && !isV5) { throw new Error('Only Token Registry V4/V5 is supported'); From c4ba77d0484d8b51c269af85498358b4b79f505e Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Mon, 13 Jul 2026 07:51:45 +0530 Subject: [PATCH 10/10] refactor: remove legacy V5 endorsement chain test file - updated testcases of the v4 / v5 and new v5 status changes in endorsment chain --- ...chain-v5-prev-beneficiary-fallback.test.ts | 69 --------- ...ndorsement-chain-version-detection.test.ts | 132 ++++++++++++++++++ 2 files changed, 132 insertions(+), 69 deletions(-) delete mode 100644 src/__tests__/core/endorsement-chain-v5-prev-beneficiary-fallback.test.ts create mode 100644 src/__tests__/core/endorsement-chain-version-detection.test.ts diff --git a/src/__tests__/core/endorsement-chain-v5-prev-beneficiary-fallback.test.ts b/src/__tests__/core/endorsement-chain-v5-prev-beneficiary-fallback.test.ts deleted file mode 100644 index ea8990e..0000000 --- a/src/__tests__/core/endorsement-chain-v5-prev-beneficiary-fallback.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { ethers as ethersV6 } from 'ethersV6'; -import * as ethersUtils from '../../utils/ethers'; -import { fetchEndorsementChain } from '../../core/endorsement-chain/useEndorsementChain'; - -const TITLE_ESCROW_ADDRESS = '0x1111111111111111111111111111111111111111'; -const REGISTRY_ADDRESS = '0x2222222222222222222222222222222222222222'; - -describe('fetchEndorsementChain - V5 detection via prevBeneficiary() fallback', () => { - it('still resolves as V5 via prevBeneficiary() duck-typing when supportsInterface fails for both V4 and the current V5 interfaceId', async () => { - const fakeContract = { - supportsInterface: vi.fn().mockResolvedValue(false), - prevBeneficiary: vi.fn().mockResolvedValue('0x000000000000000000000000000000000000dEaD'), - registry: vi.fn().mockResolvedValue(REGISTRY_ADDRESS), - getAddress: vi.fn().mockResolvedValue(TITLE_ESCROW_ADDRESS), - address: TITLE_ESCROW_ADDRESS, - filters: new Proxy( - {}, - { - get: () => vi.fn(() => ({})), - }, - ), - queryFilter: vi.fn().mockResolvedValue([]), - interface: { parseLog: vi.fn() }, - }; - - vi.spyOn(ethersUtils, 'getEthersContractFromProvider').mockReturnValue( - vi.fn(() => fakeContract) as any, - ); - - const provider = new ethersV6.JsonRpcProvider('http://localhost:1', 11155111, { - staticNetwork: true, - }); - - const result = await fetchEndorsementChain( - REGISTRY_ADDRESS, - '1', - provider, - undefined, - TITLE_ESCROW_ADDRESS, - ); - - expect(result).toEqual([]); - expect(fakeContract.prevBeneficiary).toHaveBeenCalled(); - - vi.restoreAllMocks(); - }); - - it('throws when neither V4/V5 interfaces match nor prevBeneficiary() succeeds (genuinely unsupported registry)', async () => { - const fakeContract = { - supportsInterface: vi.fn().mockResolvedValue(false), - prevBeneficiary: vi.fn().mockRejectedValue(new Error('no such function')), - }; - - vi.spyOn(ethersUtils, 'getEthersContractFromProvider').mockReturnValue( - vi.fn(() => fakeContract) as any, - ); - - const provider = new ethersV6.JsonRpcProvider('http://localhost:1', 11155111, { - staticNetwork: true, - }); - - await expect( - fetchEndorsementChain(REGISTRY_ADDRESS, '1', provider, undefined, TITLE_ESCROW_ADDRESS), - ).rejects.toThrow('Only Token Registry V4/V5 is supported'); - - vi.restoreAllMocks(); - }); -}); diff --git a/src/__tests__/core/endorsement-chain-version-detection.test.ts b/src/__tests__/core/endorsement-chain-version-detection.test.ts new file mode 100644 index 0000000..550b10d --- /dev/null +++ b/src/__tests__/core/endorsement-chain-version-detection.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ethers as ethersV6 } from 'ethersV6'; +import * as ethersUtils from '../../utils/ethers'; +import { + fetchEndorsementChain, + TitleEscrowInterface, +} from '../../core/endorsement-chain/useEndorsementChain'; + +const TITLE_ESCROW_ADDRESS = '0x1111111111111111111111111111111111111111'; +const REGISTRY_ADDRESS = '0x2222222222222222222222222222222222222222'; + +const MINT_LOG = { + args: { from: '0x0000000000000000000000000000000000000000', to: TITLE_ESCROW_ADDRESS }, + blockNumber: 1, + transactionHash: `0x${'11'.repeat(32)}`, + transactionIndex: 0, +}; + +// The title escrow contract: covers version detection (supportsInterface, prevBeneficiary) and +// both V4/V5 escrow-side event filters (all empty — this suite only cares which branch runs, not +// its event content, which is covered elsewhere). +function buildEscrowContract(supportsInterfaceFor: string[]) { + return { + supportsInterface: vi.fn((interfaceId: string) => + Promise.resolve(supportsInterfaceFor.includes(interfaceId)), + ), + prevBeneficiary: vi.fn().mockResolvedValue('0x000000000000000000000000000000000000dEaD'), + registry: vi.fn().mockResolvedValue(REGISTRY_ADDRESS), + getAddress: vi.fn().mockResolvedValue(TITLE_ESCROW_ADDRESS), + address: TITLE_ESCROW_ADDRESS, + filters: new Proxy({}, { get: () => vi.fn(() => ({})) }), + queryFilter: vi.fn().mockResolvedValue([]), + interface: { parseLog: vi.fn() }, + }; +} + +// The token registry contract: only needed for the V4 branch, which separately fetches the +// mint/transfer history straight from the registry (fetchTokenTransfers) and throws if it can't +// find the mint log. +function buildRegistryContract() { + return { + filters: { Transfer: vi.fn(() => ({})) }, + queryFilter: vi.fn().mockResolvedValue([MINT_LOG]), + interface: { parseLog: vi.fn(() => ({ name: 'Transfer', args: MINT_LOG.args })) }, + }; +} + +function mockContracts(escrowContract: ReturnType) { + const registryContract = buildRegistryContract(); + vi.spyOn(ethersUtils, 'getEthersContractFromProvider').mockReturnValue( + vi.fn((address: string) => + address === REGISTRY_ADDRESS ? registryContract : escrowContract, + ) as any, + ); +} + +function makeProvider() { + const provider = new ethersV6.JsonRpcProvider('http://localhost:1', 11155111, { + staticNetwork: true, + }); + vi.spyOn(provider, 'getBlock').mockResolvedValue({ timestamp: 1700000000 } as any); + return provider; +} + +describe('fetchEndorsementChain - version detection covers V4, old V5, and new (eBOE) V5', () => { + it('resolves V4 via its own interfaceId, without falling back to prevBeneficiary', async () => { + const escrowContract = buildEscrowContract([TitleEscrowInterface.V4]); + mockContracts(escrowContract); + + const result = await fetchEndorsementChain( + REGISTRY_ADDRESS, + '1', + makeProvider(), + undefined, + TITLE_ESCROW_ADDRESS, + ); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('INITIAL'); + expect(escrowContract.prevBeneficiary).not.toHaveBeenCalled(); + + vi.restoreAllMocks(); + }); + + it('resolves new (eBOE) V5 directly via its current interfaceId, without falling back to prevBeneficiary', async () => { + const escrowContract = buildEscrowContract([TitleEscrowInterface.V5]); + mockContracts(escrowContract); + + const result = await fetchEndorsementChain( + REGISTRY_ADDRESS, + '1', + makeProvider(), + undefined, + TITLE_ESCROW_ADDRESS, + ); + + expect(result).toEqual([]); + expect(escrowContract.prevBeneficiary).not.toHaveBeenCalled(); + + vi.restoreAllMocks(); + }); + + it('resolves old (pre-eBOE) V5 via the prevBeneficiary() fallback when neither interfaceId matches', async () => { + const escrowContract = buildEscrowContract([]); // matches neither V4 nor the current V5 interfaceId + mockContracts(escrowContract); + + const result = await fetchEndorsementChain( + REGISTRY_ADDRESS, + '1', + makeProvider(), + undefined, + TITLE_ESCROW_ADDRESS, + ); + + expect(result).toEqual([]); + expect(escrowContract.prevBeneficiary).toHaveBeenCalled(); + + vi.restoreAllMocks(); + }); + + it('throws when neither V4/V5 interfaces match nor prevBeneficiary() succeeds (genuinely unsupported registry)', async () => { + const escrowContract = buildEscrowContract([]); + escrowContract.prevBeneficiary = vi.fn().mockRejectedValue(new Error('no such function')); + mockContracts(escrowContract); + + await expect( + fetchEndorsementChain(REGISTRY_ADDRESS, '1', makeProvider(), undefined, TITLE_ESCROW_ADDRESS), + ).rejects.toThrow('Only Token Registry V4/V5 is supported'); + + vi.restoreAllMocks(); + }); +});