An interactive demo of cross-border trade between two parties: Alpha, the export side, and Beta, the import side. Organisations on each side register decentralised identifiers (DIDs), exchange and notarize trade documents, share consignments across the border and run simulated trade finance. In the default corridor the cast also includes the customs authority on each side.
This repository is for demonstration purposes only and is not intended for production use.
All organisations, people, addresses, documents and figures in the shipped corridor configs are fictitious, and any resemblance to real companies or authorities is coincidental. Countries, ports, laws and trade agreements are real, and their mention implies no involvement or endorsement by the governments or organisations concerned.
Each side runs its own demo server (Express API plus a React single-page app). In docker mode (ADAPTER_MODE=docker, the default, see Run modes) each demo server is backed by its own TWIN node, a ready-made service distributed as the Docker image twinfoundation/twin-node that provides DIDs, notarization, document storage, audit records and ODRL (W3C Open Digital Rights Language) access policies over REST, and anchors identities and proofs on the IOTA testnet. The testnet is a public test network of the IOTA ledger: its writes are real and publicly visible, and they cost test tokens (gas) that a faucet hands out for free. The UI and the ?node= parameter call each demo server a node (Node Alpha, Node Beta). This README says demo server for those and TWIN node for the TWIN service. docs/twin-node-overview.md explains what a TWIN node offers, and docs/architecture.md how the pieces talk to each other.
The demo is corridor-agnostic. Organisations, documents, map, credentials and branding come from one JSON file in configs/, so switching to another trade corridor is a change of CONFIG_FILE, plus a provisioning run and a seed in docker mode. The default corridor, vietnam-us-customs, tells a customs story: the exporter prepares one verified dossier, which supports two independent declarations (export and import). Every document, and the shipment as a whole, can be verified in three ways: inside the app, by scanning a printed QR code (no account needed), or through a system-to-system API. DEMO_GUIDE.md has the click-level script and docs/customs-demo-storytelling.md the narrative.
Every corridor config seeds consignments, documents and finance records at startup, so the demo runs without data entry. The right-hand column shows what changes when the demo servers run against TWIN nodes, and it uses the TWIN node's own terms. An immutable proof is a credential that the node signs over a document's hash and anchors on the testnet. The auditable item graph (AIG) keeps one versioned record (a vertex) per consignment with its change log, and the auditable item stream (AIS) is an append-only event log. A tenant is a partition of a TWIN node with its own API key (sent as x-api-key), users and data. Each node has a node tenant, and twin:provision-orgs adds a tenant with its own user account for every organisation. An attestation NFT is an on-chain token that an organisation mints with its own DID to vouch for a document, so only organisations that twin:provision-orgs gave their own funded wallet mint one.
| Area | What a user can do | In docker mode |
|---|---|---|
| Identity | Register an organisation's DID after a registration-number check that follows the rules in the config's credentials section |
The DID and its verification methods are created on the TWIN node and anchored on the testnet. The registration check stays simulated. |
| Consignments and documents | Create consignments identified by a UCR (Unique Consignment Reference), upload documents to them, view and download them (seeded documents are generated PDFs and XML) | Each consignment is an AIG vertex with its change log. Each document is stored on the TWIN node with an immutable proof and, for organisations provisioned with their own wallet, an attestation NFT. |
| Cross-node sharing and access control | Share a consignment and its documents with an organisation on the other side, revoke access, review the permission matrix (Access Control page) | The share also creates an ODRL access policy on the owner's TWIN node. The data itself still travels over the demo servers' WebSocket peer channel (see Known limitations). |
| Ledger (Analytics page) | Log of identity, document, permission, network and finance events, filterable by the first four, replicated to the other demo server while peered | Every event is also appended to the TWIN node's AIS, which the page reads from |
| Payments and trade finance | Payment records (Unpaid, Partially Paid, Paid, Overdue), letters of credit (Draft to Drawn), smart contracts that release payment once all their conditions are marked met | In memory in every mode, with random contract hashes |
Customs (vietnam-us-customs only) |
Customs Desk for the export customs officer, with green, yellow and red channel recommendations. Import Pre-clearance for the importer, with a draft import declaration. Customs Review for import customs. | Dossier status uses the documents' immutable proofs. Channel logic stays in memory. |
| Public verification | Document QR page /verify/:docId with an in-browser hash check, shipment QR page /shipment/:id with a printable Transport Pass, system-to-system API under /api/verify/* (Swagger at /api/verify/docs) |
Live check of the document's immutable proof on the TWIN node, plus a Swagger UI over the node's own proof API at /twin/docs |
The demo server builds both public QR pages from its own store, and the TWIN node itself allows no anonymous reads. The per-document-type publicAccess policy decides which documents the shipment page lists and which ones it serves in full. The document page /verify/:docId ignores the policy and always shows integrity metadata, never the file. docs/document-verification.md covers the verification paths and the policy.
ADAPTER_MODE selects the backend of each demo server. It defaults to docker, which needs a local TWIN node per side on localhost:3000 (Alpha) and localhost:3001 (Beta). simulated keeps everything in memory and needs no TWIN node. A third mode, hosted, points the demo servers at remote TWIN nodes through TWIN_ALPHA_URL and TWIN_BETA_URL (docs/architecture.md).
simulated |
docker |
|
|---|---|---|
| Backend | In-memory store in each demo server | Each demo server calls its own local TWIN node, which writes to the IOTA testnet |
| DIDs, proofs, attestations | Synthetic, documents carry a "Demo mode" badge | Real, with IOTA Explorer links |
| Needs | Node.js 24 or newer, npm | Also Docker with Compose v2 and about 16 GB of memory available to Docker (prerequisites), bash, curl and two node wallets funded with more than 20 testnet IOTA each |
| Setup | None | One-off wallet and node setup, then per corridor a provisioning run and a seed that takes 10 to 25 minutes |
One exception to the synthetic DIDs: the demo server loads organisation DIDs from data/seed-state.json in every mode. If that file comes from a docker-mode seed of the same corridor, simulated mode shows those organisations as registered, with their real testnet DIDs and Explorer links, while every document keeps the "Demo mode" badge.
npm ci
ADAPTER_MODE=simulated npm run demo
# another corridor:
ADAPTER_MODE=simulated CONFIG_FILE=configs/cambodia-eu.json npm run demonpm run demo compiles the TypeScript TWIN client wrapper into server/twin/dist (npm run build:twin) and the React client into server/public, then starts Alpha on port 4000, Beta on 4001 and the routing proxy on 4002 in one terminal. Ctrl+C stops all three. The ADAPTER_MODE=simulated prefix is required: ADAPTER_MODE defaults to docker, and without TWIN nodes on localhost:3000 and localhost:3001 the servers still start but every call to the nodes fails. Simulated mode makes no calls to TWIN or IOTA, although the browser still loads the map data from cdn.jsdelivr.net and fonts from Google Fonts.
The full procedure, with expected output, timings, corridor switching, resets and troubleshooting, is docs/running-the-demo.md. In short: create .env in the repository root with the lines CONFIG_FILE=configs/vietnam-us-customs.json, ADAPTER_MODE=docker and WALLET_PASSPHRASE='<a strong passphrase of your choice>', and keep it private because it holds the wallet passphrase. Then run from the repository root:
npm ci && npm run build:twin # the seed imports server/twin/dist, which a fresh copy of the code lacks
cp .env.example .env # copy environment variables
set -a; . ./.env; set +a # Node and the npm scripts do not load .env: repeat in every terminal
npm run twin:wallets:create -- --node=both # offline: one new wallet per node, prints both addresses
# fund both addresses at https://faucet.testnet.iota.cafe: each node needs more than 20 testnet IOTA
npm run twin:wallets:balance -- --node=both # repeat until both wallets hold funds
npm run twin:reset -- --yes # clean start: wipes node containers, volumes and local node state, keeps wallets
npm run twin:bootstrap -- --stage=anchor # creates the node identities on chain and starts the nodes
npm run twin:status
npm run twin:provision-orgs # one funded wallet, DID and tenant per organisation
npm run twin:seed -- --reset-state # consignments, documents, proofs and attestations
npm run demoThe repository contains no wallets. twin:wallets:create writes the encrypted bundle data/wallets.enc.json (AES-256-GCM, key derived from WALLET_PASSPHRASE) and data/wallet-addresses.json, and .gitignore excludes all of data/, so back up both files and keep the passphrase in a password manager: without them the funds cannot be spent. Each node wallet must hold more than 20 testnet IOTA before you initialise the infrastructure (anchor, provisioning and seed). Provisioning sends 2 IOTA to each organisation of the node in a single transaction, 20 IOTA for the 10 organisations of Alpha in the Vietnam corridors, plus gas, and the node wallet also pays the gas for the anchor and for every document proof. With less, npm run twin:provision-orgs stops with Insufficient coin balance for operation: top up the wallet from the faucet and run it again. Balances drain with use, so check them with twin:wallets:balance before a demo. Provisioning and seeding also write data/tenant-state.json and data/seed-state.json, which hold tenant API keys and organisation passwords in clear text, so keep both out of any copy you share. Wallet internals are in docs/wallet-funding-workflow.md.
On later days, load .env, run npm run twin:up and npm run demo, then press Connect again (see Open the demo). After twin:reset, repeat the anchor, provisioning and seed. To switch corridor, follow Switching corridors in docs/running-the-demo.md: a new provisioning run and seed with the new CONFIG_FILE is quicker but leaves the previous corridor's DIDs and consignments on the nodes, while npm run twin:reseed -- --yes --node=both wipes the nodes and re-anchors for a clean switch.
| URL | Opens |
|---|---|
| http://localhost:4000 | Alpha (export side), directly |
| http://localhost:4001 | Beta (import side), directly |
| http://localhost:4002/?node=alpha and http://localhost:4002/?node=beta | The same two demo servers through the routing proxy, the single URL used for tunnels and deployments |
The proxy sends a request to the demo server named by ?node= and otherwise to the one stored in an HttpOnly twin-node cookie, which it sets whenever it sees ?node=. Alpha is the default. The cookie is shared by all tabs of a browser, so opening the other side in a second tab re-routes the first one. Open Alpha and Beta through the proxy in two different browsers or browser profiles, or use ports 4000 and 4001 directly.
The login page lists every organisation of that demo server under Quick Sign-In. Clicking one fills in its username and the password, which is demo for every organisation in every shipped config, and Sign In completes the login. In the default corridor, lotusbay (the exporter) and vncustoms (export customs) are on Alpha, northpeak (the importer) and uscustoms (US customs) on Beta. DEMO_GUIDE.md has the full persona table. The UI keeps the login in browser memory only, so a page reload returns to the login screen.
The two demo servers do not peer by themselves. After logging in, press Connect in the Node Status card on the Dashboard (or Connect to peer on the Network page), then Connect next to the peer in the Discover & Connect to Nodes dialog. One side is enough. Peering is needed for the other side's organisations to appear and for cross-node sharing, and it must be repeated after either demo server restarts.
The repo map, with what each server, client and script directory holds, is in AGENTS.md, and docs/architecture.md maps the code by component. The backend runs on Node.js 24 with Express, ws and Multer, the frontend on React 18 and Vite, and the tests on Vitest and Supertest. The @twin.org/* REST clients are pinned at 0.9.0, matching the node image twinfoundation/twin-node:0.9.0. The only IOTA package, @iota/iota-sdk, is used by the wallet script.
Run the unit tests with npm run build:twin && npx vitest run tests/server (hermetic, simulated adapter, no network). npm test also runs tests/integration/, which writes to the IOTA testnet and spends wallet funds whenever a TWIN node answers on localhost:3000, so run it only when on-chain writes are intended (AGENTS.md has the details).
docs/deployment.md describes running the demo in docker mode on a single Linux VM behind a TLS reverse proxy: the webapp image, docker-compose.deploy.yml, the host script deploy/deploy-template.sh and an example GitHub Actions workflow. Read Before exposing an instance to the internet before putting an instance online.
The demo is built for presentations with synthetic data on the testnet. The limitations you meet first:
| Area | Behaviour | Consequence |
|---|---|---|
| Login and session | The login compares the password in plain text with the config (demo everywhere) and sets an HMAC-signed twin_session cookie valid for 8 hours. Its key, SESSION_SECRET, and the verify-API token list, VERIFY_API_TOKENS (default demo-customs-integration), have public defaults. |
Anyone who opens the login page can sign in as any organisation listed under Quick Sign-In, anyone who knows the default key can forge a session cookie, and anyone who knows the default token can call /api/verify/* |
| Access control | The cookie is required only for POST, PUT, PATCH and DELETE. Every GET is open, including document downloads, and routes trust the organisation ids that the client sends. | Anyone who reaches a demo server can read all data, and any logged-in user can act for any organisation |
| Demo credential endpoints | GET /twin/demo-session, GET /twin/demo-orgs and GET /twin/openapi.json need no login and return the node tenant API key, a freshly minted JWT with the email and password it was minted from (the node admin after twin:provision-orgs), and the seeded organisations' TWIN credentials |
Anyone who reaches the demo server can call the TWIN node through the /twin proxy, which forwards proof creation and password changes without a session, and spend testnet gas |
| Cross-node delivery | A share creates a real ODRL policy on the owner's TWIN node and then posts a share activity to the recipient TWIN node's dataspace inbox. In the Dataspace Protocol (DSP) a provider can push data only inside a transfer, which starts after the two parties negotiate a contract. TWIN node 0.9.0 enforces this and rejects the delivery unless a transfer between the parties is already in state STARTED. The demo performs neither the negotiation nor the transfer. |
Shared data reaches the other side over the WebSocket peer channel (docs/dsp-design.md) |
npm run demo binds ports 4000, 4001 and 4002 on all network interfaces, so everything in the table is reachable by anyone on the same network. Run the demo on a trusted network, and apply the steps in docs/deployment.md before any instance is reachable from outside. docs/authentication-architecture.md covers the auth model and the secrets inventory. docs/open-work.md is the complete list of known limitations, defects and next steps.
docs/README.md indexes every document, with a "start here" table by role. AI coding agents start at AGENTS.md. CONTRIBUTING.md explains how to propose a change, and SECURITY.md how to use the demo safely.