Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`.
95 changes: 95 additions & 0 deletions src/__tests__/core/endorsement-chain-status-events.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown[]>([
[
iface.getEvent('StatusAccepted')!.topicHash,

Check failure on line 35 in src/__tests__/core/endorsement-chain-status-events.test.ts

View workflow job for this annotation

GitHub Actions / Tests / Run Tests (20.x)

src/__tests__/core/endorsement-chain-status-events.test.ts

TypeError: Cannot read properties of null (reading 'topicHash') ❯ src/__tests__/core/endorsement-chain-status-events.test.ts:35:38

Check failure on line 35 in src/__tests__/core/endorsement-chain-status-events.test.ts

View workflow job for this annotation

GitHub Actions / Tests / Run Tests (22.x)

src/__tests__/core/endorsement-chain-status-events.test.ts

TypeError: Cannot read properties of null (reading 'topicHash') ❯ src/__tests__/core/endorsement-chain-status-events.test.ts:35:38
[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'),
}),
);
});
});
Loading
Loading