diff --git a/README.md b/README.md index fc2b368..98a230c 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 / Status (Beta)**](#11-bill-of-exchange--status-beta) + - [Minting](#minting) + - [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) ## Installation @@ -1203,3 +1209,181 @@ const replacementHash2 = await cancelTransaction(signer, { gasPrice: '25000000000', // 25 gwei in wei }); ``` + +--- + +## 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** `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 + +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' }, +); +``` + +### getStatus + +#### 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<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 status upgrade (no `status()` getter at all). + +#### Example + +```ts +import { getStatus, Status, StatusLabel } from '@trustvc/trustvc'; + +const status = await getStatus({ tokenRegistryAddress, tokenId }, signer); +console.log(StatusLabel[status]); // e.g. "Issued" +``` + +### accept / reject / discharge + +#### Description + +> Three dedicated functions, each mapping onto the on-chain `accept`/`reject`/`discharge` methods and the AC's lifecycle rules: +> +> - **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. 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 + +Same shape for all three: `contractOptions` (as above), `signer`, `params: { remarks?: string }`, `options` (as above). + +#### Returns + +**Promise<ContractTransaction>** + +#### Throws + +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 + +```ts +import { accept, reject, discharge } from '@trustvc/trustvc'; + +// Holder accepts +const acceptTx = await accept( + { 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 reject({ tokenRegistryAddress, tokenId }, holderSigner, {}, options); + +// Owner discharges once payment is received off-chain +const dischargeTx = await discharge( + { 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. +> + +#### Example: full happy path + +```ts +import { + transferHolder, + nominate, + transferBeneficiary, + returnToIssuer, + mint, + 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 status awareness involved +await (await transferHolder(contractOptions, signer, { holderAddress: draweeAddress }, 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. +await (await nominate(contractOptions, signer, { newBeneficiaryAddress: bankAddress }, options)).wait(); +await (await transferBeneficiary(contractOptions, signer, { newBeneficiaryAddress: bankAddress }, options)).wait(); + +// Owner discharges once paid +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(); +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, reject } from '@trustvc/trustvc'; + +await (await transferHolder(contractOptions, signer, { holderAddress: draweeAddress }, options)).wait(); +await (await reject(contractOptions, signer, { remarks: 'Declined' }, options)).wait(); + +// 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(); + +// 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. +- 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`. +- 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__/core/endorsement-chain-status-events.test.ts b/src/__tests__/core/endorsement-chain-status-events.test.ts new file mode 100644 index 0000000..470ea54 --- /dev/null +++ b/src/__tests__/core/endorsement-chain-status-events.test.ts @@ -0,0 +1,95 @@ +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 - Status 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('StatusAccepted')!.topicHash, + [encodeLog('StatusAccepted', [HOLDER, REGISTRY_ADDRESS, TOKEN_ID, remark('accepted')])], + ], + [ + iface.getEvent('StatusRejected')!.topicHash, + [encodeLog('StatusRejected', [HOLDER, REGISTRY_ADDRESS, TOKEN_ID, remark('rejected')])], + ], + [ + iface.getEvent('StatusDischarged')!.topicHash, + [ + encodeLog('StatusDischarged', [ + 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 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: 'STATUS_ACCEPTED', + holder: HOLDER, + remark: remark('accepted'), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'STATUS_REJECTED', + holder: HOLDER, + remark: remark('rejected'), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'STATUS_DISCHARGED', + owner: BENEFICIARY, + remark: remark('discharged'), + }), + ); + }); +}); 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(); + }); +}); 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..9fc0564 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: ['0x29ab698c427c31fa4320edd82b1a5ed0be38d752324ceb1a7a8658f71ff380ac'], + fromBlock: 0, + toBlock: 'latest', + }, + ], + result: [], + }, + { + function: 'getLogs', + params: [ + { + address: '0x24c9C688cf919D133abB512A41163972dA150f1b', + topics: ['0x4273e3f9fa628dca95157f3f3e43f55a7b8a8e5a85596670d99f3b2f5d32578b'], + fromBlock: 0, + toBlock: 'latest', + }, + ], + result: [], + }, + { + function: 'getLogs', + params: [ + { + address: '0x24c9C688cf919D133abB512A41163972dA150f1b', + topics: ['0xcde6b978de664fc7c3c45ec359bc419b7e601021747927764218fa9af45238d5'], + fromBlock: 0, + toBlock: 'latest', + }, + ], + result: [], + }, ], timeout: 180_000, }, diff --git a/src/__tests__/status/fixtures.ts b/src/__tests__/status/fixtures.ts new file mode 100644 index 0000000..e0d4ceb --- /dev/null +++ b/src/__tests__/status/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 { Status } from '../../status/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?: Status; +} + +export interface StatusTestContext { + wallet: ethersV5.Wallet | ethersV6.Wallet; + ethersVersion: 'v5' | 'v6'; +} + +export function installStatusMockContract(): 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(Status.Issued); +} + +export function configureSignerAsBeneficiary( + wallet: { address: string }, + holder = '0xsome_other_holder', +): void { + mockV5TitleEscrowContract.beneficiary.mockResolvedValue(wallet.address); + mockV5TitleEscrowContract.holder.mockResolvedValue(holder); + mockV5TitleEscrowContract.status.mockResolvedValue(Status.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 resetStatusCoreMocks(): 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 = Status.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 setupStatusTestContext( + Provider: ProviderInfo['Provider'], + ethersVersion: 'v5' | 'v6', +): StatusTestContext { + vi.clearAllMocks(); + 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..d7c903c --- /dev/null +++ b/src/__tests__/status/status.test.ts @@ -0,0 +1,211 @@ +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.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 }, + 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 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.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 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.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 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__/status/statusE2eDocument.test.ts b/src/__tests__/status/statusE2eDocument.test.ts new file mode 100644 index 0000000..283be69 --- /dev/null +++ b/src/__tests__/status/statusE2eDocument.test.ts @@ -0,0 +1,408 @@ +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 { accept, reject, discharge, getStatus } from '../../status'; +import { Status } from '../../status/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?: Status; + prevHolder?: string; +}) { + mockV5TitleEscrowContract.beneficiary.mockResolvedValue(params.beneficiary); + mockV5TitleEscrowContract.holder.mockResolvedValue(params.holder); + mockV5TitleEscrowContract.status.mockResolvedValue(params.status ?? Status.Issued); + mockV5TitleEscrowContract.prevHolder.mockResolvedValue( + params.prevHolder ?? '0x0000000000000000000000000000000000dEaD', + ); +} + +describe.each(providers)( + '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; + 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(); + // 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); + 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 getStatus(contractOptions, ownerWallet)).toEqual(Status.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 accept( + contractOptions, + holderWallet, + { remarks: 'Accepted, bound to pay at maturity' }, + options, + ); + expect(acceptTx).toEqual('v5_accept_tx_hash'); + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: Status.Accepted, + }); + expect(await getStatus(contractOptions, ownerWallet)).toEqual(Status.Accepted); + + // Step 3 — Owner discharges after payment + const dischargeTx = await discharge( + contractOptions, + ownerWallet, + { remarks: `Paid at maturity for ${boeRawDocument.credentialSubject.boeNumber}` }, + options, + ); + expect(dischargeTx).toEqual('v5_discharge_tx_hash'); + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: Status.Discharged, + }); + expect(await getStatus(contractOptions, ownerWallet)).toEqual(Status.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: Status.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 reject( + contractOptions, + holderWallet, + { remarks: 'Declined — goods not received' }, + options, + ); + expect(rejectTx).toEqual('v5_reject_tx_hash'); + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: Status.Rejected, + prevHolder: ownerAddress, + }); + expect(await getStatus(contractOptions, ownerWallet)).toEqual(Status.Rejected); + + // 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: Status.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: Status.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: Status.Accepted, + }); + + const dischargeTx = await discharge( + contractOptions, + bankWallet, + { remarks: 'Paid; bank discharges' }, + options, + ); + expect(dischargeTx).toEqual('v5_discharge_tx_hash'); + await setRoles({ + beneficiary: bankAddress, + holder: holderAddress, + status: Status.Discharged, + }); + expect(await getStatus(contractOptions, bankWallet)).toEqual(Status.Discharged); + }); + }); + + describe('edge case: invalid lifecycle transitions', () => { + it('allows ETR circulation after Accepted without reading or mutating status', async () => { + await setRoles({ + beneficiary: ownerAddress, + holder: holderAddress, + status: Status.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__/token-registry-functions/fixtures.ts b/src/__tests__/token-registry-functions/fixtures.ts index 3a1d35f..74c37b6 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(), + accept: vi.fn(), + reject: vi.fn(), + discharge: 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)), + accept: Object.assign( + // Direct call returns hash string + vi.fn(() => Promise.resolve('v5_accept_tx_hash')), + { + // Static call returns boolean + staticCall: vi.fn(() => Promise.resolve(true)), + }, + ), + reject: Object.assign( + // Direct call returns hash string + vi.fn(() => Promise.resolve('v5_reject_tx_hash')), + { + // Static call returns boolean + staticCall: vi.fn(() => Promise.resolve(true)), + }, + ), + discharge: Object.assign( + // Direct call returns hash string + vi.fn(() => Promise.resolve('v5_discharge_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/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..9b8b476 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.StatusAccepted, + titleEscrowContract.filters.StatusRejected, + titleEscrowContract.filters.StatusDischarged, ]; // 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 === 'StatusAccepted') { + return { + 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 === 'StatusRejected') { + return { + 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 === 'StatusDischarged') { + return { + type: 'STATUS_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/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 e13d430..3fc0b4f 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 + | '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/core/endorsement-chain/useEndorsementChain.ts b/src/core/endorsement-chain/useEndorsementChain.ts index 5d8695d..75d3664 100644 --- a/src/core/endorsement-chain/useEndorsementChain.ts +++ b/src/core/endorsement-chain/useEndorsementChain.ts @@ -161,6 +161,26 @@ export const checkSupportsInterface = 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 => { + 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 +226,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 +239,14 @@ 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 isV5ByPrevBeneficiary(resolvedTitleEscrowAddress, provider))); + if (!isV4 && !isV5) { throw new Error('Only Token Registry V4/V5 is supported'); } diff --git a/src/index.ts b/src/index.ts index 8a66ac0..808817d 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 './status'; export * from './core'; export * from './open-attestation'; export * from './verify'; diff --git a/src/status/accept.ts b/src/status/accept.ts new file mode 100644 index 0000000..01170c8 --- /dev/null +++ b/src/status/accept.ts @@ -0,0 +1,29 @@ +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; +import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; +import { StatusActionParams } from './types'; +import { runStatusAction } from './runStatusAction'; + +/** + * 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. 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. + * @throws if the dry-run (`callStatic`) fails. + * @returns {Promise} The transaction response of the accept call. + */ +const accept = ( + contractOptions: ContractOptions, + signer: Signer | SignerV6, + params: StatusActionParams, + options: TransactionOptions, +): Promise => + runStatusAction('accept', contractOptions, signer, params, options); + +export { accept }; diff --git a/src/status/discharge.ts b/src/status/discharge.ts new file mode 100644 index 0000000..c0f9ce5 --- /dev/null +++ b/src/status/discharge.ts @@ -0,0 +1,30 @@ +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; +import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; +import { StatusActionParams } from './types'; +import { runStatusAction } from './runStatusAction'; + +/** + * 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. 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. + * @throws if the dry-run (`callStatic`) fails. + * @returns {Promise} The transaction response of the discharge call. + */ +const discharge = ( + contractOptions: ContractOptions, + signer: Signer | SignerV6, + params: StatusActionParams, + options: TransactionOptions, +): Promise => + runStatusAction('discharge', contractOptions, signer, params, options); + +export { discharge }; diff --git a/src/status/index.ts b/src/status/index.ts new file mode 100644 index 0000000..f31364c --- /dev/null +++ b/src/status/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/status/reject.ts b/src/status/reject.ts new file mode 100644 index 0000000..6347579 --- /dev/null +++ b/src/status/reject.ts @@ -0,0 +1,30 @@ +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; +import { ContractOptions, TransactionOptions } from '../token-registry-functions/types'; +import { StatusActionParams } from './types'; +import { runStatusAction } from './runStatusAction'; + +/** + * 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. 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. + * @throws if the dry-run (`callStatic`) fails. + * @returns {Promise} The transaction response of the reject call. + */ +const reject = ( + contractOptions: ContractOptions, + signer: Signer | SignerV6, + params: StatusActionParams, + options: TransactionOptions, +): 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/status.ts b/src/status/status.ts new file mode 100644 index 0000000..2494ec6 --- /dev/null +++ b/src/status/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 { 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 `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 status lifecycle (no `status()` getter). + * @returns {Promise} The current status. + */ +const getStatus = async ( + contractOptions: StatusOptions, + signer: SignerV5 | SignerV6, + options: TransactionOptions = {}, +): Promise => { + 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'); + titleEscrowAddress = await getTitleEscrowAddress( + tokenRegistryAddress, + tokenId as string, + signer.provider, + {}, + ); + } + + if (!titleEscrowAddress) throw new Error('Title escrow address 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 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.', + ); + } +}; + +export { getStatus }; diff --git a/src/status/types.ts b/src/status/types.ts new file mode 100644 index 0000000..30ec616 --- /dev/null +++ b/src/status/types.ts @@ -0,0 +1,30 @@ +import { ContractOptions } from '../token-registry-functions/types'; + +export type { ContractOptions }; +export type { TransactionOptions } from '../token-registry-functions/types'; + +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', +};