Custodial financial storage

This commit is contained in:
2026-10-20 15:21:47 -04:00
parent e48081f041
commit c254c4c537
19 changed files with 1041 additions and 14 deletions
+2 -1
View File
@@ -17,7 +17,7 @@ ENV VITE_WIKIDECK_NETWORK=$WIKIDECK_NETWORK
RUN npm run build
FROM build AS test
RUN apk add --no-cache libstdc++
RUN apk add --no-cache libstdc++ postgresql-client
COPY --from=randomx-build /native/build/wikideck-randomx /app/native/wikideck-randomx
ENV RANDOMX_BINARY=/app/native/wikideck-randomx
CMD ["npm", "test"]
@@ -42,6 +42,7 @@ COPY server ./server
COPY shared ./shared
COPY scripts/node-backup.ts ./scripts/node-backup.ts
COPY scripts/ops-check.ts ./scripts/ops-check.ts
COPY scripts/custody-db.ts ./scripts/custody-db.ts
COPY --from=randomx-build /native/build/wikideck-randomx ./native/wikideck-randomx
RUN mkdir -p /app/data && chown node:node /app/data
USER node
+2 -2
View File
@@ -62,9 +62,9 @@ For an existing host install, keep the old data directory intact and set `WIKIDE
### Optional custody accounting preview
The marketplace's **Custody preview** tab is separate from existing mock checkout. Explicit `WIKIDECK_CUSTODY_MODE=sandbox` on the server enables fictitious test balances, signed fixed-price reservations and withdrawal holds on testnet only. Default is `off`; live modes and mainnet sandbox fail closed. Use a disposable data directory and configure `PUBLIC_ORIGIN` to the exact browser origin. No Monero addresses, wallet RPC or real withdrawals are available.
The marketplace's **Custody preview** tab is separate from existing mock checkout. Explicit `WIKIDECK_CUSTODY_MODE=sandbox` on the server enables fictitious test balances, signed fixed-price reservations and withdrawal holds on testnet only. Default is `off`; live modes and mainnet sandbox fail closed. Use a disposable data directory and configure `PUBLIC_ORIGIN` to the exact browser origin. No Monero addresses, wallet RPC or real withdrawals are available. [PostgreSQL financial storage](docs/postgres-financial-storage.md) is selectable with `WIKIDECK_CUSTODY_STORAGE=postgres`, explicit schema/role setup and a secret-file connection configuration; existing SQLite custody requires a stopped-node import, never automatic fallback.
Reservations stop at `awaiting-lock` because reviewed NFT sale-lock consensus rules are not implemented. They can be cancelled while their outbox intent is blocked; they do not deliver cards. The isolated custody database is included by stopped-node backup/restore when present. PostgreSQL treasury accounting, real deposit observation, NFT settlement, signing controls and production review remain future work. See [the implementation record](docs/custodial-market-design.md).
Reservations stop at `awaiting-lock` because reviewed NFT sale-lock consensus rules are not implemented. They can be cancelled while their outbox intent is blocked; they do not deliver cards. Local SQLite custody is included by stopped-node backup/restore when present; PostgreSQL-bound nodes require coordinated database/node backups and the SQLite-only backup command refuses them. Real treasury observation/settlement, signing controls and production review remain future work. See [the implementation record](docs/custodial-market-design.md).
Requirements: Node.js 24, npm, Git, CMake 3.24+, and a C/C++17 toolchain. The first native-engine configuration downloads the pinned RandomX source over HTTPS. On Windows, install a compatible toolchain (for example, MinGW-w64 and make) and use the matching CMake generator. Do not change generators in an existing `native/build` directory; use a fresh build directory when switching toolchains.
+28
View File
@@ -0,0 +1,28 @@
# Disposable isolated integration fixture ONLY. No persistent/host database ports or credentials.
# Trust auth is confined to this temporary internal network and is never a deployment example.
services:
database:
image: postgres:17-alpine
environment:
POSTGRES_DB: custody_test
POSTGRES_HOST_AUTH_METHOD: trust
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d custody_test"]
interval: 1s
timeout: 3s
retries: 30
networks: [fixture]
tests:
image: wikideck-custody-test
network_mode: service:database
depends_on:
database:
condition: service_healthy
environment:
WIKIDECK_PG_DISPOSABLE_TEST: "1"
command: ["npx", "tsx", "--test", "--test-concurrency=1", "tests/postgres/*.test.ts"]
networks:
fixture:
internal: true
+4 -2
View File
@@ -15,7 +15,9 @@ The operator has authorized proceeding with implementation instead of the previo
- [Custody UI](../src/CustodyPanel.tsx): marketplace “Custody preview” tab with reviewed signatures, private/paged balances and activity, test funding, listing/reservation and withdrawal-hold cancellation. A lost response retains the original signed envelope for replay rather than creating a new debit nonce.
- HTTP endpoints `/api/custody` and `/api/custody/command` are off by default; explicit `WIKIDECK_CUSTODY_MODE=sandbox` enables testnet only. All other enabled modes and mainnet sandbox are rejected before node resources open. Requests use the node's origin/admission/body limits, per-route mutation throttling and sanitized errors. No observer/admin settlement or balance-credit endpoint exists.
- [Stopped-node recovery](../server/recovery.ts) includes the optional custody sandbox database and its identity while continuing to accept existing two-database backups. Financial receipts are not pruned. Restore/startup checks do not substitute for production reconciliation.
- Offline fixture methods exercise fee allocation and prepared-withdrawal identity/retry/cancellation boundaries. They never access Monero and are not callable through HTTP. Public reservations stop at `awaiting-lock`; there is **no automatic NFT delivery or settled sale** in this increment. No real-address withdrawal, deposit subaddress, wallet RPC, signer, author payout, reorg compensation or PostgreSQL adapter is implemented yet.
- Offline SQLite fixture methods exercise fee allocation and prepared-withdrawal identity/retry/cancellation boundaries. They never access Monero and are not callable through HTTP. Public reservations stop at `awaiting-lock`; there is **no automatic NFT delivery or settled sale** in this increment. No real-address withdrawal, deposit subaddress, wallet RPC, signer, author payout or reorg compensation is implemented yet.
**Financial storage update:** [ADR 003](postgres-financial-storage.md) selects PostgreSQL and records the integrated asynchronous custody backend, restricted runtime/schema roles, deferred accounting constraints, explicit SQLite freeze/import, and full dump/restore rehearsal. SQLite remains an opt-in-compatible local sandbox default, not an automatic fallback. Real custody and production operating acceptance remain blocked.
Validation: TypeScript/Vite build and 144 offline tests passed in the serial native-RandomX test run; the initial parallel run encountered a peer-gossip timeout. Browser smoke verified a disposable wallet's signed test credit, withdrawal hold and cancellation restoring the exact balance, plus a 390px viewport without horizontal overflow. No real funds were used. Tests and remaining acceptance gates are below; this is not launch readiness.
@@ -40,7 +42,7 @@ Validation: TypeScript/Vite build and 144 offline tests passed in the serial nat
| Treasury worker / isolated signer | Serializes spend construction, validates approved withdrawal intents, destination, amount, fee cap and policy; persists prepared transactions before relay. Independent limits and approvals, not blind signing for API requests. |
| Reconciler and operations | Independently compares ledger, wallet and chain observations; alerts/halts affected operations; administers reviewed compensations, not arbitrary balance edits. |
Recommend PostgreSQL for the eventual production financial journal: exact integer `NUMERIC(...,0)` amounts, row locking/serializable transactions, unique constraints and managed point-in-time backups. This is a **proposed database decision**, not a dependency change. SQLite remains suitable for disposable models; the existing market SQLite and disconnected settlement lab are not a production custody ledger. Keep financial order transitions and journal entries in one database transaction; bridge external chain/wallet effects with a durable outbox, never a cross-database pretend transaction. One active writer/treasury worker initially; failover must fence the old worker before spending resumes.
PostgreSQL is now selected and integrated for the custody sandbox under [ADR 003](postgres-financial-storage.md): exact `NUMERIC` with explicit integer checks (avoiding silent scale rounding), transactional row/advisory locking, unique constraints and separately reviewed backup/PITR operations. SQLite remains suitable for disposable compatibility models; no automatic promotion/fallback occurs. Financial order transitions and journal entries share one PostgreSQL transaction; external chain/wallet effects require a durable outbox, never a cross-database pretend transaction. One financial writer at a time initially; readers use coherent snapshots. Future treasury failover must fence the old worker before spending resumes.
Minimal entities: custodians/network bindings, authenticated customer identities/recovery enrollment, deposit address mappings, observed outputs and inclusion history, ledger accounts/journals/entries, immutable sale terms, purchase reservations, chain events/cursors, withdrawal intents/attempts/prepared transactions, outbox/inbox, signed receipts and audited cases/approvals. Mutable projections are rebuildable; history and external-effect identity are not discarded.
+84
View File
@@ -0,0 +1,84 @@
# ADR 003 — PostgreSQL financial storage
Status: **selected and integrated for the custody sandbox; real-money release not authorized**. Evidence date: 2026-10-20. Related: [custody design](custodial-market-design.md), [production plan](production-plan.md).
## Decision and scope
Use **PostgreSQL** for financial state. Exact arithmetic, transactional constraints, concurrent coherent reads, cross-process locks, established backup/restore tooling and future WAL/PITR operations make it preferable to extending an embedded financial database indefinitely. PostgreSQL 17.11 was exercised in the disposable fixture; pin and review the intended deployed release/image separately.
This is an integrated backend, not a second disconnected balance demo: [custody storage selection](../server/market/custody-storage.ts) supplies [the existing HTTP custody routes](../server/testnet/http.ts) with [PostgreSQL custody](../server/market/postgres-custody.ts). Signed command domains, user identities, amounts and UI stay unchanged. SQLite remains the default disposable/local sandbox for compatibility; choose PostgreSQL explicitly. Chain DAG/canonical indexes and legacy mock marketplace remain in their original SQLite databases. Moving those independent concerns is not needed to use PostgreSQL for customer liabilities.
**All balances still represent fictitious sandbox XMR.** No deposit observer/subaddress allocation, Monero signer, real-address withdrawal, NFT sale witness or production payout is introduced. The PostgreSQL backend supports public listing/reservation/cancellation/withdrawal-hold commands; old prepared fixture withdrawals are preserved by import but cannot be cancelled or paid through HTTP. Old SQLite fixture settlement methods are not production PostgreSQL payment workers.
## Storage and transaction guarantees
- Dedicated `wk_custody` schema with version/digest-checked **explicit migration**, not DDL at HTTP startup. A runtime connection with administrative privileges or schema-owner membership is rejected.
- Money uses unconstrained-precision PostgreSQL `NUMERIC` plus checks for integral, nonnegative, bounded atomic values. Using `NUMERIC(30,0)` alone could silently round a fractional input before checking; this implementation rejects fractions instead. Account balances and individual entry legs remain below $10^{30}$ atomic units. The driver returns monetary values as strings; no global floating-point parser override.
- Journals/entries and replay receipts are immutable to the application. Deferred constraint triggers reject empty/unbalanced journals at commit, including entry inserts. Committed journals reject appended entries. An isolated `SECURITY DEFINER` entry trigger with a fixed schema search path locks accounts and updates balance projections; the runtime cannot directly update balances.
- Reservations, listing/order state, withdrawal holds, journal entries, blocked outbox and receipts commit in the **same database transaction**. Exactly the same signed nonce returns its original receipt; changed terms fail. No receipt pruning or reinterpretation of mock balances as live deposits.
- One writer per financial domain initially: transaction-scoped advisory lock shared across pools/processes, plus row locks and constraints. Reads use a repeatable-read snapshot. This improves separation/concurrency of readers and durable server operations, **not a claim of unlimited concurrent writers**. Sharding/partitioning or finer write locking requires measurements and another reviewed change.
- Pool maximum four connections per service; per-instance active admission sixteen; finite connect/statement/lock/idle transaction timeouts; synchronous commit explicitly on. Scale the total pool budget across instances deliberately. No wallet/network side effect is performed in a database transaction.
- No automatic retry after an uncertain commit. A client refreshes or resends the original signed envelope, letting durable receipts establish whether it committed. PostgreSQL failure never causes SQLite fallback. Startup audits compare every account projection with exact journal sums and check backing totals; these are internal accounting checks, not proof that real Monero assets exist.
These controls do not make a compromised application harmless: it still has posting privileges, and an administrative database owner can alter schema/history. Restricted roles prevent accidental/direct mutation, not fraudulent balanced postings by a fully compromised service. Independent monitoring, statement review, external backups and authorization/treasury isolation remain required.
## Configuration and operator workflow
The following are configuration values, not credentials:
| Setting | Behavior |
| --- | --- |
| `WIKIDECK_CUSTODY_MODE=sandbox` | Enables the existing testnet-only sandbox. Live/mainnet custody stays disabled. |
| `WIKIDECK_CUSTODY_STORAGE=postgres` | Selects PostgreSQL; default `sqlite` is retained for existing sandbox users. |
| `WIKIDECK_CUSTODY_PG_URL_FILE` | Absolute path to a private secret file containing a PostgreSQL URL. Never a Vite/browser variable. |
| `WIKIDECK_CUSTODY_PG_CA_FILE` | Optional private file with trusted PEM CA certificate(s); otherwise use system trust. TLS hostname/certificate verification remains enabled. |
| `WIKIDECK_CUSTODY_PG_LOCAL_TEST=1` | **Disposable loopback tests only:** disables TLS solely for literal loopback/localhost. Never use for production. |
URL query parameters/fragments are rejected, preventing `sslmode` options from overriding transport policy. Missing/malformed configuration fails with a generic error, not a URL/password dump. Secret files must be regular non-symlink bounded files; mount them read-only with restricted filesystem ACLs. Production network firewall, verified TLS, password/secret rotation and external database logs need an operator review. The automated fixture tests configuration policy, not an actual production certificate renewal/handshake drill.
The operator CLI is [scripts/custody-db.ts](../scripts/custody-db.ts), available through `npm run custody:db -- ACTION`. Credentials are provided only by the secret-file environment setting, never positional arguments or browser UI:
1. Provision a dedicated database, a migration/schema-owner identity, and a separate existing login role without superuser/create-role/create-db/replication/bypass-RLS privileges. Create passwords privately outside this tool; no credentials are generated or displayed by the CLI.
2. With migration credentials, run action `migrate`. It checks the pinned version/digest or creates a fresh schema transactionally. Unknown versions are refused; no reset/drop/rebuild mode exists.
3. With migration credentials, run action `grant-runtime ROLE`. It validates the literal role identifier and grants schema usage, reads, account/journal column-specific inserts, entry/receipt inserts and operational record updates. It never grants direct balance updates, history deletion, schema ownership or migrations. Use a **new dedicated role**; the helper grants required privileges but is not a scrubber for inherited privileges from an existing unrelated role.
4. If the data directory has existing SQLite custody, stop the node, preserve a stopped-node backup, and run `import-sqlite DATA_DIR` against a migrated **empty** target using migration credentials. See cutover behavior below.
5. Switch the server to runtime credentials and explicit PostgreSQL selection. Startup verifies schema, restricted identity, custodian binding and accounting before serving. A new directory can initialize a fresh sandbox identity; it does not inherit another host's credit.
6. Action `audit`, using runtime credentials, prints exact aggregate sandbox totals only. It does not print customer histories or database credentials. Running it on a previously uninitialized schema initializes a sandbox custodian identity just like startup, so do not use it as a supposedly read-only probe of an empty migration target.
The CLI is included in the runtime image. No existing deployment, database secret or fund wallet was changed automatically by this implementation.
## Explicit SQLite cutover and rollback limits
[Importer](../server/market/custody-import.ts) is bounded to the existing sandbox source size (128 MiB), not a general live-money migration utility. The CLI takes the same exclusive stopped-node directory lock as node startup. Calling the underlying library assumes the caller has already stopped all source writers.
1. Acquire SQLite immediate transaction and take a consistent snapshot. Verify source identity/integrity and canonical amount encoding. Hash identity and all logical rows in deterministic insertion order.
2. **Freeze source first:** commit its `postgres_cutover` digest before contacting the target. SQLite startup refuses this marker. This closes the crash window where PostgreSQL might commit while SQLite remains writable.
3. Lock PostgreSQL financial domain. Require an empty migrated target. Import accounts initially at zero and replay original journal entries through the database posting trigger; compare rebuilt balances to source projections. Preserve custodian identity, all receipts/digests, fictitious-credit claims, listing/order/withdrawal order, prepared attempt/transaction identities and blocked outbox state.
4. Validate accounting, then commit the imported source digest. Repeating the **same** frozen-source import returns success without duplicating balances. A changed source/different imported target is rejected. Failure before commit rolls target rows back; the source stays frozen. Correct the privately diagnosed issue and resume the same import—do not delete markers to restart SQLite.
5. PostgreSQL startup records a private fsynced local binding of custodian ID. A directory with that binding refuses SQLite even if no source database exists. An imported source must match the target's stored source digest.
There is no live dual-write, automatic migration or automatic rollback. An old pre-cutover backup cannot know about a later cutover: restoring it and running SQLite would fork accounting operationally. Fence old hosts/credentials and archive old backups as **historical, non-spending**. This cannot be solved by a local marker alone; coordinated restore and authority controls are release gates.
## Backup, restore and failure handling
Existing node backup packages cover local SQLite databases only. They now **refuse** a PostgreSQL-bound data directory or frozen cutover source instead of claiming to capture all customer liabilities. No automatic cross-database backup coordinator is implemented.
Recommended operations before any production release:
- Managed PostgreSQL storage with encrypted backups, WAL archival/PITR, monitored replica/backup lag, checked restore retention and selected RPO/RTO. Define synchronous-replica requirements based on acceptable loss/failover risk; `synchronous_commit=on` alone does not configure a synchronous replica.
- Fence writers/treasury services during backup/restore cutover and record matching node ledger/market checkpoints, custodian identity, database dump/backup identity and external withdrawal state. Do not restore a financial snapshot in isolation and start spending.
- Use full-database dumps that include schema/functions/triggers/ACLs, plus separately protected role configuration; a schema-filtered dump may omit necessary trigger dependencies. Restore into an isolated database first, using administrative credentials; connect only the restricted runtime for audit/replay checks after restore.
- Reconcile Monero external history before resuming real spending once that integration exists. No local backup can reverse an external payment. Database timeouts/connection loss leave requests failed closed; retry the original signed command, not a new withdrawal.
- Monitor application and database errors without logging statements/parameters containing private customer data in production. Fixture logs deliberately show failed constraint probes on fictitious data; do not adopt their logging/auth configuration.
The integration suite performs a real full `pg_dump`/isolated `pg_restore` rehearsal preserving ID, exact liabilities and receipt replay. It does **not** certify managed backup schedules, PITR, encrypted off-host custody, power-loss/fsync behavior or production failover.
## Evidence and remaining gates
[Disposable test compose](../compose.postgres-test.yaml) runs PostgreSQL on tmpfs in an isolated internal network, no published ports; test runner shares the database network namespace and uses loopback. Trust authentication is **fixture-only**, not a deployment template. [Tests](../tests/postgres/custody.test.ts) are a separate explicit suite and refuse to run without `WIKIDECK_PG_DISPOSABLE_TEST=1`.
Verified: restricted runtime authorization; deferred balance constraints; fractional/negative/oversized rejection; committed journal append rejection; independent-pool simultaneous nonce/listing/withdrawal races; insufficient-balance rollback; injected receipt failure atomicity; exact amounts beyond JavaScript safe integer; reconnect/replay; import of prepared withdrawals and pending orders; failed-import freeze/rollback/resume; real HTTP backend integration; configuration policy; fallback/backup refusal; full dump/restore audit.
Final evidence: TypeScript/Vite test-image build passed; **149 existing regression tests and 10 isolated PostgreSQL integration tests passed**, zero failures/skips. PostgreSQL fixture resources were removed after testing. Existing user data directories and deployed node database settings were not switched. This verifies the current sandbox implementation, not production acceptance.
**Not closed:** live Monero and NFT settlement; production dataset/SLOs and pool contention benchmarks; audit scheduling and very-large-history startup cost; normalized durable real-deposit/withdrawal/compensation states; account recovery/second factor; external security/accounting review; coordinated node/financial/treasury backup; deployment TLS drills, PITR and failover rehearsal; funded exposure/fee/loss policies; legal/operations sign-off. The scalable database choice does not turn the existing sandbox into a production custodian.
+2
View File
@@ -65,6 +65,8 @@ Can run alongside P0/P1. Addresses SEC-01/SEC-06 rather than adding more fixed c
**Implemented M2 increment, 2026-10-20:** [canonical indexes and durable market observation](canonical-indexes.md). Canonical ownership/transaction SQL indexes now serve NFT, deck, custody availability and mock-market queries; per-block undo and anchored connect/disconnect events commit with ledger updates. Market checkpoint/reorg holds persist atomically and bounded catch-up blocks new mutations. Final TypeScript/Vite build and 149 offline tests passed. A disposable 10,000-NFT SQL baseline is recorded, **not** a target-hardware SLO or complete M2 acceptance. Production financial storage, incremental order scheduling, retained-DAG capacity and operator dataset/latency targets remain open.
**Next financial-storage increment:** [ADR 003 — PostgreSQL](postgres-financial-storage.md) selects and integrates the custody SQL backend with existing HTTP callers, exact constrained accounting, explicit migration/runtime roles, source-freezing SQLite import and dump/restore rehearsal. The adapter remains on the fictitious sandbox domain; it is not a production deposit/withdrawal worker. SQLite node-only backup deliberately refuses PostgreSQL-bound financial state. Production hardware/SLO tests, coordinated backup/PITR/failover, real payment schema/worker controls and independent review remain open, so this does not close M2/M4 or authorize funds.
- Indexed canonical ownership, NFT/transfer lookup and paged wallet/explorer APIs: browser signing must not require loading all history. Tip-pinned queries detect reorgs and preserve complete ownership semantics.
- Incremental market reconciliation from durable canonical/reorg events. Maintain rollback-safe indexes and a recoverable cursor; catch-up after restart must not miss delivery or payout holds. Bound worker time/concurrency; move expensive work off the request event loop where appropriate.
- A versioned production market/settlement schema with append-only financial events and balanced accounting entries for buyer liabilities, host revenue, author liabilities, seller liabilities, paid/refunded amounts and actual network fees. Derived views are rebuildable; financial history and idempotency keys are not discarded to reclaim space.
+81
View File
@@ -13,6 +13,7 @@
"hash-wasm": "^4.12.0",
"helmet": "^8.1.0",
"lucide-react": "^0.468.0",
"pg": "^8.16.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zod": "^4.1.0"
@@ -20,6 +21,7 @@
"devDependencies": {
"@types/express": "^5.0.3",
"@types/node": "^24.0.0",
"@types/pg": "^8.15.5",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^5.0.0",
@@ -29,6 +31,85 @@
"vite": "^7.1.0"
}
},
"node_modules/@types/pg": {
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
"dev": true,
"license": "MIT",
"dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" }
},
"node_modules/pg": {
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
"license": "MIT",
"dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" },
"engines": { "node": ">= 16.0.0" },
"optionalDependencies": { "pg-cloudflare": "^1.4.1" },
"peerDependencies": { "pg-native": ">=3.0.1" },
"peerDependenciesMeta": { "pg-native": { "optional": true } }
},
"node_modules/pg-cloudflare": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
"license": "MIT", "optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC", "engines": { "node": ">=4.0.0" }
},
"node_modules/pg-pool": {
"version": "3.14.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT", "peerDependencies": { "pg": ">=8.0" }
},
"node_modules/pg-protocol": {
"version": "1.16.1", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", "license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT", "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" }, "engines": { "node": ">=4" }
},
"node_modules/pgpass": {
"version": "1.0.5", "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT", "dependencies": { "split2": "^4.1.0" }
},
"node_modules/postgres-array": {
"version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "license": "MIT", "engines": { "node": ">=4" }
},
"node_modules/postgres-bytea": {
"version": "1.0.1", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", "license": "MIT", "engines": { "node": ">=0.10.0" }
},
"node_modules/postgres-date": {
"version": "1.0.7", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", "engines": { "node": ">=0.10.0" }
},
"node_modules/postgres-interval": {
"version": "1.2.0", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", "dependencies": { "xtend": "^4.0.0" }, "engines": { "node": ">=0.10.0" }
},
"node_modules/split2": {
"version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", "engines": { "node": ">= 10.x" }
},
"node_modules/xtend": {
"version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "license": "MIT", "engines": { "node": ">=0.4" }
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+3
View File
@@ -12,6 +12,7 @@
"analyze:consensus": "node --experimental-strip-types scripts/consensus-analysis.ts",
"benchmark:randomx": "node --experimental-strip-types scripts/benchmark-randomx.ts",
"benchmark:index": "node --experimental-strip-types scripts/benchmark-canonical-index.ts",
"custody:db": "node --experimental-strip-types scripts/custody-db.ts",
"node:backup": "node --experimental-strip-types scripts/node-backup.ts",
"ops:check": "node --experimental-strip-types scripts/ops-check.ts",
"miner": "tsx server/testnet/miner.ts",
@@ -26,6 +27,7 @@
"hash-wasm": "^4.12.0",
"helmet": "^8.1.0",
"lucide-react": "^0.468.0",
"pg": "^8.16.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zod": "^4.1.0"
@@ -33,6 +35,7 @@
"devDependencies": {
"@types/express": "^5.0.3",
"@types/node": "^24.0.0",
"@types/pg": "^8.15.5",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^5.0.0",
+39
View File
@@ -0,0 +1,39 @@
import { Pool } from 'pg';
import { open, unlink, mkdir } from 'node:fs/promises';
import { resolve, join } from 'node:path';
import { postgresConfig } from '../server/market/postgres-config.ts';
import { migrateCustody, grantCustodyRuntime } from '../server/market/postgres-schema.ts';
import { importSqliteCustody } from '../server/market/custody-import.ts';
import { PostgresCustody } from '../server/market/postgres-custody.ts';
import { IS_MAINNET } from '../shared/network.ts';
// Explicit operator task, never invoked by node startup. No passwords or database URLs printed.
if (IS_MAINNET) throw new Error('Only sandbox financial schema operations are implemented');
const [action, argument] = process.argv.slice(2);
if (!['migrate', 'grant-runtime', 'import-sqlite', 'audit'].includes(action)) throw new Error('Expected migrate, grant-runtime ROLE, import-sqlite DATA_DIR, or audit');
try {
const config = await postgresConfig();
if (action === 'audit') {
const market = await PostgresCustody.open(config, { feeBps: 250, card: () => undefined });
try { console.log(JSON.stringify(await market.audit())); } finally { await market.close(); }
} else {
const pool = new Pool(config);
try {
if (action === 'migrate') { await migrateCustody(pool); console.log('Custody PostgreSQL schema verified/migrated. No funds activated.'); }
if (action === 'grant-runtime') { await grantCustodyRuntime(pool, argument ?? ''); console.log('Runtime grants applied; no credentials printed.'); }
if (action === 'import-sqlite') {
if (!argument) throw new Error('Stopped source node DATA_DIR required');
const directory = resolve(argument);
await mkdir(directory, { recursive: true, mode: 0o700 });
const lockPath = join(directory, 'testnet-node.lock'), lock = await open(lockPath, 'wx', 0o600);
try {
await lock.writeFile(`custody-import:${process.pid}`);
console.log(JSON.stringify(await importSqliteCustody(join(directory, 'testnet', 'custody-sandbox.sqlite'), pool)));
} finally { await lock.close(); await unlink(lockPath); }
}
} finally { await pool.end(); }
}
} catch {
console.error('Custody database operation failed. Check configuration, schema, stopped-node lock and migration state privately. No automatic fallback/reset was performed.');
process.exitCode = 1;
}
+89
View File
@@ -0,0 +1,89 @@
import { DatabaseSync } from 'node:sqlite';
import { createHash } from 'node:crypto';
import { lstat } from 'node:fs/promises';
import type { Pool } from 'pg';
import { CUSTODY_POLICY } from '../../shared/custody.ts';
import { NETWORK } from '../../shared/network.ts';
import { CHAIN_ID } from '../testnet/consensus.ts';
import { auditPostgres } from './postgres-custody.ts';
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
/** Explicit stopped-node SANDBOX import. Source freezes BEFORE target commit; failure requires resume, not dual writes. */
export async function importSqliteCustody(file: string, pool: Pool): Promise<{ imported: true; sourceDigest: string; custodianId: string }> {
const stat = await lstat(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128 * 1024 * 1024) throw new Error('Invalid sandbox source database');
const db = new DatabaseSync(file);
db.exec('PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000');
const names = ['accounts', 'journals', 'entries', 'listings', 'orders', 'withdrawals', 'receipts', 'outbox', 'credits'] as const;
type Row = Record<string, string | number>;
let snapshot: Record<typeof names[number], Row[]>;
let identityText: string;
let sourceDigest: string;
try {
db.exec('BEGIN IMMEDIATE');
try {
if (Object.values(db.prepare('PRAGMA quick_check').get()!)[0] !== 'ok') throw new Error('Invalid SQLite integrity');
identityText = (db.prepare("SELECT value FROM custody_meta WHERE key='identity'").get() as { value: string }).value;
const identity = JSON.parse(identityText);
if (identity.version !== 1 || identity.network !== NETWORK || identity.chainId !== CHAIN_ID || identity.mode !== 'sandbox' || identity.policy !== CUSTODY_POLICY) {
throw new Error('Source custody identity mismatch');
}
snapshot = Object.fromEntries(names.map(name => [name, db.prepare(`SELECT rowid AS import_sequence,* FROM ${name} ORDER BY rowid`).all()])) as typeof snapshot;
sourceDigest = createHash('sha256').update(JSON.stringify([identityText, snapshot])).digest('hex');
const existing = db.prepare("SELECT value FROM custody_meta WHERE key='postgres_cutover'").get() as { value: string } | undefined;
if (existing && existing.value !== sourceDigest) throw new Error('Frozen SQLite source changed; operator review required');
db.prepare('INSERT OR IGNORE INTO custody_meta VALUES(?,?)').run('postgres_cutover', sourceDigest);
db.exec('COMMIT'); // Source is now permanently ineligible for SQLite startup. Preserve for audit, NEVER delete/reset.
} catch (e) { db.exec('ROLLBACK'); throw e; }
} finally { db.close(); }
const c = await pool.connect();
try {
await c.query('BEGIN');
await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
await verifyCustodySchema(c);
const old = await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='import_source'");
if (old.rowCount) {
if (old.rows[0].value !== sourceDigest) throw new Error('Target already imported a different source');
await auditPostgres(c); await c.query('COMMIT');
return { imported: true, sourceDigest, custodianId: JSON.parse(identityText).id };
}
for (const table of ['custody_meta', ...names]) {
if ((await c.query(`SELECT 1 FROM wk_custody.${table} LIMIT 1`)).rowCount) throw new Error('Import requires a migrated EMPTY PostgreSQL custody schema');
}
for (const a of snapshot.accounts) {
if (!/^(0|[1-9]\d{0,29})$/.test(String(a.balance))) throw new Error('Invalid source atomic balance');
await c.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2)', [a.id, a.normal]);
}
// Rebuild balances from journal entries, never trust mutable projections as opening balances.
const byJournal = new Map<string, Row[]>();
for (const e of snapshot.entries) {
const rows = byJournal.get(String(e.journal)) ?? []; rows.push(e); byJournal.set(String(e.journal), rows);
}
for (const j of snapshot.journals) {
await c.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', [j.id, j.kind, j.created]);
const lines = (byJournal.get(String(j.id)) ?? []).sort((a, b) => Number(a.position) - Number(b.position));
for (const e of lines) {
if (!/^(0|[1-9]\d{0,29})$/.test(String(e.debit)) || !/^(0|[1-9]\d{0,29})$/.test(String(e.credit))) throw new Error('Invalid source entry encoding');
await c.query('INSERT INTO wk_custody.entries VALUES($1,$2,$3,$4,$5)', [e.journal, e.position, e.account, e.debit, e.credit]);
}
}
const journalIds = new Set(snapshot.journals.map(j => j.id));
if (snapshot.entries.some(e => !journalIds.has(e.journal))) throw new Error('Orphan source entry');
for (const a of snapshot.accounts) {
const rebuilt = await c.query('SELECT balance::text AS balance FROM wk_custody.accounts WHERE id=$1', [a.id]);
if (rebuilt.rows[0].balance !== a.balance) throw new Error('Source balance projection mismatch');
}
for (const l of snapshot.listings) await c.query('INSERT INTO wk_custody.listings(sequence,id,card,status,data) VALUES($1,$2,$3,$4,$5)', [l.import_sequence, l.id, l.card, l.status, l.data]);
for (const o of snapshot.orders) await c.query('INSERT INTO wk_custody.orders(sequence,id,buyer,seller,status,data) VALUES($1,$2,$3,$4,$5,$6)', [o.import_sequence, o.id, o.buyer, o.seller, o.status, o.data]);
for (const w of snapshot.withdrawals) await c.query('INSERT INTO wk_custody.withdrawals(sequence,id,actor,status,data) VALUES($1,$2,$3,$4,$5)', [w.import_sequence, w.id, w.actor, w.status, w.data]);
for (const r of snapshot.receipts) await c.query('INSERT INTO wk_custody.receipts VALUES($1,$2,$3)', [r.id, r.digest, r.data]);
for (const o of snapshot.outbox) await c.query('INSERT INTO wk_custody.outbox VALUES($1,$2,$3,$4)', [o.id, o.kind, o.status, o.data]);
for (const a of snapshot.credits) await c.query('INSERT INTO wk_custody.credits VALUES($1)', [a.actor]);
for (const table of ['listings', 'orders', 'withdrawals']) await c.query(`SELECT setval(pg_get_serial_sequence('wk_custody.${table}','sequence'),COALESCE((SELECT max(sequence) FROM wk_custody.${table}),0)+1,false)`);
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2),($3,$4)', ['identity', identityText, 'import_source', sourceDigest]);
await auditPostgres(c);
await c.query('COMMIT');
return { imported: true, sourceDigest, custodianId: JSON.parse(identityText).id };
} catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; }
finally { c.release(); }
}
+45
View File
@@ -0,0 +1,45 @@
import { DatabaseSync } from 'node:sqlite';
import { readFile, open, lstat, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { CustodyMarket } from './custody.ts';
import { PostgresCustody, type CustodyOptions } from './postgres-custody.ts';
import { custodyStorage, postgresConfig } from './postgres-config.ts';
/** No automatic SQLite reinterpretation or fallback after a database connection/configuration failure. */
export async function openCustodyStorage(directory: string, options: CustodyOptions, env = process.env): Promise<CustodyMarket | PostgresCustody> {
const storage = custodyStorage(env), marker = join(directory, 'testnet', 'custody-postgres.json');
await mkdir(join(directory, 'testnet'), { recursive: true, mode: 0o700 });
let existing: { version: number; custodianId: string } | undefined;
try { existing = JSON.parse(await readFile(marker, 'utf8')); }
catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
if (storage === 'sqlite') {
if (existing) throw new Error('Custody data directory is bound to PostgreSQL; SQLite fallback prohibited');
return CustodyMarket.open(directory, options);
}
const source = join(directory, 'testnet', 'custody-sandbox.sqlite');
let frozen: string | undefined;
try {
const info = await lstat(source);
if (!info.isFile() || info.isSymbolicLink()) throw new Error('Invalid custody source file');
const db = new DatabaseSync(source, { readOnly: true });
try {
frozen = (db.prepare("SELECT value FROM custody_meta WHERE key='postgres_cutover'").get() as { value: string } | undefined)?.value;
if (!frozen) throw new Error('Existing SQLite custody requires explicit stopped-node import before PostgreSQL activation');
} finally { db.close(); }
} catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
const market = await PostgresCustody.open(await postgresConfig(env), options);
try {
if (frozen && await market.importSource() !== frozen) throw new Error('PostgreSQL import does not match local frozen custody source');
if (existing && (existing.version !== 1 || existing.custodianId !== market.id)) throw new Error('PostgreSQL custodian differs from data-directory binding');
if (!existing) {
const handle = await open(marker, 'wx', 0o600);
try { await handle.writeFile(JSON.stringify({ version: 1, custodianId: market.id })); await handle.sync(); }
finally { await handle.close(); }
if (process.platform !== 'win32') {
const parent = await open(join(directory, 'testnet'), 'r');
try { await parent.sync(); } finally { await parent.close(); }
}
}
return market;
} catch (e) { await market.close(); throw e; }
}
+3
View File
@@ -76,6 +76,9 @@ export class CustodyMarket {
db.exec('BEGIN IMMEDIATE');
let id: string;
try {
if (db.prepare("SELECT 1 FROM custody_meta WHERE key='postgres_cutover'").get()) {
throw new Error('SQLite custody was frozen for PostgreSQL cutover; resume the import, never restart SQLite');
}
const row = db.prepare('SELECT value FROM custody_meta WHERE key=?').get('identity') as { value: string } | undefined;
if (row) {
const identity = JSON.parse(row.value);
+34
View File
@@ -0,0 +1,34 @@
import { lstat, readFile } from 'node:fs/promises';
import type { PoolConfig } from 'pg';
export function custodyStorage(env: NodeJS.ProcessEnv = process.env): 'sqlite' | 'postgres' {
const value = env.WIKIDECK_CUSTODY_STORAGE ?? 'sqlite';
if (value !== 'sqlite' && value !== 'postgres') throw new Error('Invalid custody storage backend');
return value;
}
async function secretFile(path: string | undefined): Promise<string> {
if (!path) throw new Error('Custody PostgreSQL secret file is required');
const info = await lstat(path);
if (!info.isFile() || info.isSymbolicLink() || info.size > 16384 || info.size === 0) throw new Error('Invalid PostgreSQL secret file');
return (await readFile(path, 'utf8')).trim();
}
/** No URL logging, no TLS options in a connection string overriding verified transport. */
export async function postgresConfig(env: NodeJS.ProcessEnv = process.env): Promise<PoolConfig> {
try {
const url = new URL(await secretFile(env.WIKIDECK_CUSTODY_PG_URL_FILE));
if (!['postgres:', 'postgresql:'].includes(url.protocol) || !url.hostname || url.search || url.hash ||
!/^\/[a-zA-Z0-9_-]+$/.test(url.pathname)) throw new Error('Invalid PostgreSQL URL');
const insecure = env.WIKIDECK_CUSTODY_PG_LOCAL_TEST === '1';
if (env.WIKIDECK_CUSTODY_PG_LOCAL_TEST !== undefined && !insecure) throw new Error('Invalid local-test switch');
if (insecure && !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) throw new Error('Non-TLS test connections must be loopback');
if (!insecure && (!url.username || !url.password)) throw new Error('PostgreSQL credentials required');
if (url.port && (!/^\d+$/.test(url.port) || Number(url.port) < 1 || Number(url.port) > 65535)) throw new Error('Invalid PostgreSQL port');
const ca = env.WIKIDECK_CUSTODY_PG_CA_FILE ? await secretFile(env.WIKIDECK_CUSTODY_PG_CA_FILE) : undefined;
return { host: url.hostname.replace(/^\[|\]$/g, ''), port: url.port ? Number(url.port) : 5432,
database: url.pathname.slice(1), user: decodeURIComponent(url.username), password: decodeURIComponent(url.password),
ssl: insecure ? false : { rejectUnauthorized: true, ...(ca ? { ca } : {}) },
max: 4, connectionTimeoutMillis: 5000, idleTimeoutMillis: 10000,
statement_timeout: 10000, lock_timeout: 3000, idle_in_transaction_session_timeout: 10000,
application_name: 'wikideck-custody', options: '-c search_path=wk_custody,pg_catalog' };
} catch { throw new Error('Invalid or inaccessible custody PostgreSQL configuration; check secret files and verified TLS settings'); }
}
+241
View File
@@ -0,0 +1,241 @@
import { Pool, type PoolClient, type PoolConfig } from 'pg';
import { createHash, randomUUID } from 'node:crypto';
import { IS_MAINNET, NETWORK } from '../../shared/network.ts';
import { CHAIN_ID, verifySignature } from '../testnet/consensus.ts';
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
import { parseAtomicXmr } from '../../shared/xmr-amount.ts';
import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessage,
type CustodyActivity, type CustodyCommand, type CustodyInfo, type CustodyListing,
type CustodyOrder, type CustodyPage, type CustodyWithdrawal } from '../../shared/custody.ts';
import { CustodyError } from './custody.ts';
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined; now?: () => number };
type Result = CustodyActivity | CustodyListing | CustodyOrder | CustodyWithdrawal;
export type CustodyAudit = { journals: number; assetsAtomicXmr: string; claimsAndRevenueAtomicXmr: string };
type Line = { account: string; normal?: 'debit' | 'credit'; debit?: bigint; credit?: bigint };
const available = (actor: string) => `customer:${actor}:available`;
const reserved = (o: CustodyOrder) => `customer:${o.buyer}:purchase:${o.id}`;
const held = (w: CustodyWithdrawal) => `customer:${w.actor}:withdrawal:${w.id}`;
function check(ok: unknown, message: string): asserts ok { if (!ok) throw new CustodyError(message); }
/** PostgreSQL-backed SAME sandbox protocol. No real XMR, NFT witness, or automatic schema migration. */
export class PostgresCustody {
readonly id: string;
private readonly pool: Pool;
private readonly options: CustodyOptions;
private active = 0;
private constructor(pool: Pool, options: CustodyOptions, id: string) { this.pool = pool; this.options = options; this.id = id; }
static async open(config: PoolConfig, options: CustodyOptions): Promise<PostgresCustody> {
if (IS_MAINNET) throw new Error('Custody sandbox cannot run on mainnet');
if (!Number.isSafeInteger(options.feeBps) || options.feeBps < 0 || options.feeBps > 5000) throw new Error('Invalid custody fee');
const pool = new Pool(config);
// Never log connection details or let idle-client socket errors become uncaught process exceptions.
pool.on('error', () => { console.error('Custody PostgreSQL connection unavailable; requests will fail closed.'); });
try {
const c = await pool.connect();
let id: string;
try {
await c.query('BEGIN');
await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
await verifyCustodySchema(c);
const privilege = await c.query(`SELECT r.rolsuper,r.rolcreatedb,r.rolcreaterole,r.rolreplication,r.rolbypassrls,
pg_has_role(current_user,n.nspowner,'MEMBER') AS owns_schema
FROM pg_roles r CROSS JOIN pg_namespace n WHERE r.rolname=current_user AND n.nspname='wk_custody'`);
if (!privilege.rowCount || Object.values(privilege.rows[0]).some(Boolean)) {
throw new Error('Custody runtime requires a non-administrative role separate from the schema owner');
}
const existing = await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
if (existing.rowCount) {
const v = JSON.parse(existing.rows[0].value);
if (v.version !== 1 || v.network !== NETWORK || v.chainId !== CHAIN_ID || v.mode !== 'sandbox' ||
v.policy !== CUSTODY_POLICY || !/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(v.id)) throw new Error('Custody PostgreSQL identity mismatch');
id = v.id;
} else {
id = randomUUID();
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', ['identity', JSON.stringify({ version: 1,
network: NETWORK, chainId: CHAIN_ID, mode: 'sandbox', policy: CUSTODY_POLICY, id })]);
}
await auditPostgres(c);
await c.query('COMMIT');
} catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; }
finally { c.release(); }
return new PostgresCustody(pool, options, id);
} catch (e) { await pool.end(); throw e; }
}
close(): Promise<void> { return this.pool.end(); }
info(): CustodyInfo {
return { enabled: true, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, chainId: CHAIN_ID,
custodianId: this.id, feeBps: this.options.feeBps, liveDeposits: false, liveWithdrawals: false, nftSettlement: false };
}
private async transaction<T>(write: boolean, fn: (c: PoolClient) => Promise<T>): Promise<T> {
check(this.active < 16, 'Custody database busy; retry the original command shortly');
this.active++;
let c: PoolClient | undefined;
try {
c = await this.pool.connect();
await c.query(write ? 'BEGIN' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
await c.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on");
// One writer per financial domain initially; other readers remain nonblocking. Lock is database-scoped,
// shared by every node/process, migrations and import, not an in-process promise queue.
if (write) await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
const result = await fn(c);
await c.query('COMMIT');
return result;
} catch (e) {
await c?.query('ROLLBACK').catch(() => {});
// Never retry after an ambiguous COMMIT. Signed receipts resolve the SAME nonce on a client retry.
if (e instanceof CustodyError) throw e;
if ((e as { code?: string }).code === '23514') throw new CustodyError('Insufficient balance or custody amount constraint');
throw new Error('Custody PostgreSQL operation failed; refresh or retry the original signed command');
} finally { c?.release(); this.active--; }
}
async browse(offset = 0): Promise<CustodyPage> {
check(Number.isSafeInteger(offset) && offset >= 0 && offset <= 10000, 'Invalid custody page');
return this.transaction(false, async c => {
const { rows } = await c.query("SELECT data FROM wk_custody.listings WHERE status IN ('open','reserved') ORDER BY sequence DESC LIMIT 25 OFFSET $1", [offset]);
return { info: this.info(), listings: rows.slice(0, 24).map(r => r.data), next: rows.length > 24 ? offset + 24 : null };
});
}
private async activity(c: PoolClient, actor: string, offset: number): Promise<CustodyActivity> {
const orders = await c.query('SELECT data FROM wk_custody.orders WHERE buyer=$1 OR seller=$1 ORDER BY sequence DESC LIMIT 25 OFFSET $2', [actor, offset]);
const withdrawals = await c.query('SELECT data FROM wk_custody.withdrawals WHERE actor=$1 ORDER BY sequence DESC LIMIT 25 OFFSET $2', [actor, offset]);
const sums = await c.query(`SELECT COALESCE(sum(balance) FILTER(WHERE id=$1),0)::text AS available,
COALESCE(sum(balance) FILTER(WHERE id LIKE $2),0)::text AS purchase,
COALESCE(sum(balance) FILTER(WHERE id LIKE $3),0)::text AS withdrawal FROM wk_custody.accounts WHERE id LIKE $4`,
[available(actor), `customer:${actor}:purchase:%`, `customer:${actor}:withdrawal:%`, `customer:${actor}:%`]);
const claimed = await c.query('SELECT 1 FROM wk_custody.credits WHERE actor=$1', [actor]);
return { availableAtomicXmr: sums.rows[0].available, purchaseReservedAtomicXmr: sums.rows[0].purchase,
withdrawalReservedAtomicXmr: sums.rows[0].withdrawal, mockCreditClaimed: !!claimed.rowCount,
orders: orders.rows.slice(0, 24).map(r => r.data), withdrawals: withdrawals.rows.slice(0, 24).map(r => r.data),
next: orders.rows.length > 24 || withdrawals.rows.length > 24 ? offset + 24 : null };
}
private async post(c: PoolClient, id: string, kind: string, lines: Line[]) {
const nonzero = lines.filter(l => (l.debit ?? 0n) !== 0n || (l.credit ?? 0n) !== 0n);
if (!nonzero.length || nonzero.reduce((s, l) => s + (l.debit ?? 0n) - (l.credit ?? 0n), 0n) !== 0n) throw new Error('Unbalanced custody posting');
await c.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', [id, kind, (this.options.now ?? Date.now)()]);
for (const [position, line] of nonzero.entries()) {
const normal = line.normal ?? 'credit';
await c.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2) ON CONFLICT(id) DO NOTHING', [line.account, normal]);
// Runtime intentionally has no UPDATE privilege on balances. The SECURITY DEFINER projection
// trigger takes the account row lock; global writer admission protects the preceding normal check.
const account = await c.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [line.account]);
if (account.rows[0].normal !== normal) throw new Error('Custody account normal mismatch');
// DB trigger updates projection; deferred trigger rejects an empty/unbalanced journal at COMMIT.
await c.query('INSERT INTO wk_custody.entries VALUES($1,$2,$3,$4,$5)',
[id, position, line.account, (line.debit ?? 0n).toString(), (line.credit ?? 0n).toString()]);
}
}
private async record<T>(c: PoolClient, table: 'listings' | 'orders' | 'withdrawals', id: string): Promise<T> {
const r = await c.query(`SELECT data FROM wk_custody.${table} WHERE id=$1 FOR UPDATE`, [id]);
check(r.rowCount, 'Custody record not found'); return r.rows[0].data as T;
}
private async update(c: PoolClient, table: 'listings' | 'orders' | 'withdrawals', record: { id: string; status: string }) {
await c.query(`UPDATE wk_custody.${table} SET status=$1,data=$2 WHERE id=$3`, [record.status, JSON.stringify(record), record.id]);
}
async execute(raw: unknown): Promise<Result> {
const cmd = custodyCommandSchema.parse(raw), message = custodyMessage(cmd), now = (this.options.now ?? Date.now)();
check(cmd.custodianId === this.id && cmd.chainId === CHAIN_ID && cmd.expiresAt >= now && cmd.expiresAt <= now + 300000 &&
verifySignature(cmd.publicKey, cmd.actor, message, cmd.signature), 'Invalid or expired custody signature');
const a = cmd.action;
if (a.type === 'activity') return this.transaction(false, c => this.activity(c, cmd.actor, a.offset));
return this.transaction(true, async c => {
const key = `${cmd.actor}:${cmd.nonce}`, hash = createHash('sha256').update(message).digest('hex');
const old = await c.query('SELECT digest,data FROM wk_custody.receipts WHERE id=$1', [key]);
if (old.rowCount) { check(old.rows[0].digest === hash, 'Nonce already used for different custody terms'); return old.rows[0].data as Result; }
// Sandbox admission budget matches SQLite. Receipt/history are NEVER pruned; cancellation stays available.
if (!['cancel-listing', 'cancel-reservation', 'cancel-withdrawal'].includes(a.type)) {
const count = await c.query('SELECT count(*)::integer AS n FROM (SELECT 1 FROM wk_custody.receipts LIMIT 10000) b');
check(count.rows[0].n < 10000, 'Custody sandbox command budget reached');
}
const result = await this.mutate(c, cmd);
await c.query('INSERT INTO wk_custody.receipts VALUES($1,$2,$3)', [key, hash, JSON.stringify(result)]);
return result;
});
}
private async mutate(c: PoolClient, cmd: CustodyCommand): Promise<Result> {
const a = cmd.action, actor = cmd.actor, createdAt = (this.options.now ?? Date.now)();
if (a.type === 'mock-credit') {
check(!(await c.query('SELECT 1 FROM wk_custody.credits WHERE actor=$1', [actor])).rowCount, 'Sandbox credit already claimed');
await this.post(c, `credit:${actor}`, 'mock-deposit', [
{ account: 'treasury:mock', normal: 'debit', debit: BigInt(MOCK_CREDIT_ATOMIC) },
{ account: available(actor), credit: BigInt(MOCK_CREDIT_ATOMIC) }]);
await c.query('INSERT INTO wk_custody.credits VALUES($1)', [actor]); return this.activity(c, actor, 0);
}
if (a.type === 'list') {
check(a.feeBps === this.options.feeBps, 'Custody hosting fee changed; review again');
const card = this.options.card(a.cardHash, actor, a.sequence);
check(card, 'Card is missing, stale, immature, or pending');
check(!(await c.query("SELECT 1 FROM wk_custody.listings WHERE card=$1 AND status IN ('open','reserved')", [a.cardHash])).rowCount, 'Card already has a custody listing');
const l: CustodyListing = { id: randomUUID(), seller: actor, cardHash: a.cardHash, sequence: a.sequence,
title: card.title, priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, status: 'open', createdAt };
await c.query('INSERT INTO wk_custody.listings(id,card,status,data) VALUES($1,$2,$3,$4)', [l.id, l.cardHash, l.status, JSON.stringify(l)]); return l;
}
if (a.type === 'reserve' || a.type === 'cancel-listing') {
const l = await this.record<CustodyListing>(c, 'listings', a.listingId);
check(l.status === 'open', 'Custody listing is no longer available');
if (a.type === 'cancel-listing') {
check(l.seller === actor, 'Only the seller can cancel this listing'); l.status = 'cancelled'; await this.update(c, 'listings', l); return l;
}
check(actor !== l.seller, 'Cannot buy your own card');
check(a.priceAtomicXmr === l.priceAtomicXmr && a.feeBps === l.feeBps, 'Custody sale terms changed');
check(this.options.card(l.cardHash, l.seller, l.sequence), 'Seller card is no longer available');
const allocation = allocateXmrSale(parseAtomicXmr(a.priceAtomicXmr), a.feeBps);
const o: CustodyOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: actor, cardHash: l.cardHash,
sequence: l.sequence, grossAtomicXmr: a.priceAtomicXmr, sellerAtomicXmr: allocation.sellerBeforeNetworkCosts.toString(),
authorAtomicXmr: allocation.author.toString(), hostAtomicXmr: allocation.hosting.toString(), authorAddress: ADDITIONAL_MARKET_FEE.address,
status: 'awaiting-lock', createdAt };
await this.post(c, `reserve:${o.id}`, 'purchase-reservation', [{ account: available(actor), debit: allocation.gross }, { account: reserved(o), credit: allocation.gross }]);
l.status = 'reserved'; await this.update(c, 'listings', l);
await c.query('INSERT INTO wk_custody.orders(id,buyer,seller,status,data) VALUES($1,$2,$3,$4,$5)', [o.id, actor, o.seller, o.status, JSON.stringify(o)]);
await c.query('INSERT INTO wk_custody.outbox VALUES($1,$2,$3,$4)', [o.id, 'nft-lock', 'blocked', JSON.stringify(o)]); return o;
}
if (a.type === 'cancel-reservation') {
const o = await this.record<CustodyOrder>(c, 'orders', a.orderId);
const intent = await c.query('SELECT status FROM wk_custody.outbox WHERE id=$1 FOR UPDATE', [o.id]);
check(o.buyer === actor && o.status === 'awaiting-lock' && intent.rows[0]?.status === 'blocked', 'Only an unstarted reservation can be cancelled by its buyer');
await this.post(c, `cancel:${o.id}`, 'purchase-cancellation', [{ account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, { account: available(actor), credit: BigInt(o.grossAtomicXmr) }]);
o.status = 'cancelled'; await this.update(c, 'orders', o);
const l = await this.record<CustodyListing>(c, 'listings', o.listingId); l.status = 'cancelled'; await this.update(c, 'listings', l);
await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [o.id]); return o;
}
if (a.type === 'withdraw') {
const w: CustodyWithdrawal = { id: randomUUID(), actor, destination: a.destination, amountAtomicXmr: a.amountAtomicXmr,
maxFeeAtomicXmr: a.maxFeeAtomicXmr, status: 'reserved', createdAt };
const total = BigInt(a.amountAtomicXmr) + BigInt(a.maxFeeAtomicXmr);
await this.post(c, `withdraw:${w.id}`, 'withdrawal-reservation', [{ account: available(actor), debit: total }, { account: held(w), credit: total }]);
await c.query('INSERT INTO wk_custody.withdrawals(id,actor,status,data) VALUES($1,$2,$3,$4)', [w.id, actor, w.status, JSON.stringify(w)]);
await c.query('INSERT INTO wk_custody.outbox VALUES($1,$2,$3,$4)', [w.id, 'withdrawal', 'blocked', JSON.stringify(w)]); return w;
}
if (a.type === 'cancel-withdrawal') {
const w = await this.record<CustodyWithdrawal>(c, 'withdrawals', a.withdrawalId);
check(w.actor === actor && w.status === 'reserved', 'Only an unprepared withdrawal can be cancelled by its owner');
const total = BigInt(w.amountAtomicXmr) + BigInt(w.maxFeeAtomicXmr);
await this.post(c, `withdraw-cancel:${w.id}`, 'withdrawal-cancellation', [{ account: held(w), debit: total }, { account: available(actor), credit: total }]);
w.status = 'cancelled'; await this.update(c, 'withdrawals', w);
await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [w.id]); return w;
}
throw new CustodyError('Unsupported custody action');
}
audit(): Promise<CustodyAudit> { return this.transaction(false, auditPostgres); }
importSource(): Promise<string | undefined> {
return this.transaction(false, async c => (await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='import_source'")).rows[0]?.value);
}
}
/** Exact NUMERIC/text aggregation in a coherent transaction; no Number conversion of money. */
export async function auditPostgres(c: PoolClient): Promise<CustodyAudit> {
const bad = await c.query(`SELECT 1 FROM wk_custody.journals j LEFT JOIN wk_custody.entries e ON e.journal=j.id
GROUP BY j.id HAVING count(e.position)=0 OR sum(e.debit-e.credit)<>0 LIMIT 1`);
if (bad.rowCount) throw new Error('Unbalanced PostgreSQL custody history');
const mismatch = await c.query(`SELECT 1 FROM wk_custody.accounts a LEFT JOIN wk_custody.entries e ON e.account=a.id
GROUP BY a.id HAVING a.balance<>COALESCE(sum(CASE WHEN a.normal='debit' THEN e.debit-e.credit ELSE e.credit-e.debit END),0) LIMIT 1`);
if (mismatch.rowCount) throw new Error('PostgreSQL custody balance projection mismatch');
const totals = await c.query(`SELECT COALESCE(sum(balance) FILTER(WHERE normal='debit'),0)::text AS assets,
COALESCE(sum(balance) FILTER(WHERE normal='credit'),0)::text AS claims FROM wk_custody.accounts`);
if (totals.rows[0].assets !== totals.rows[0].claims) throw new Error('PostgreSQL custody backing mismatch');
const n = await c.query('SELECT count(*)::text AS n FROM wk_custody.journals');
if (BigInt(n.rows[0].n) > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Journal count exceeds supported range');
return { journals: Number(n.rows[0].n), assetsAtomicXmr: totals.rows[0].assets, claimsAndRevenueAtomicXmr: totals.rows[0].claims };
}
+114
View File
@@ -0,0 +1,114 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 1;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const CUSTODY_SCHEMA = `
CREATE SCHEMA IF NOT EXISTS wk_custody;
REVOKE ALL ON SCHEMA wk_custody FROM PUBLIC;
SET LOCAL search_path = wk_custody, pg_catalog;
CREATE TABLE schema_versions(version integer PRIMARY KEY, digest text NOT NULL);
CREATE TABLE custody_meta(key text PRIMARY KEY, value text NOT NULL);
CREATE TABLE accounts(id text PRIMARY KEY, normal text NOT NULL CHECK(normal IN ('debit','credit')),
balance numeric NOT NULL DEFAULT 0 CHECK(balance>=0 AND balance<1e30 AND balance=trunc(balance)));
CREATE TABLE journals(sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE, id text PRIMARY KEY,
kind text NOT NULL, created bigint NOT NULL CHECK(created>=0), posting_tx bigint NOT NULL DEFAULT txid_current());
CREATE TABLE entries(journal text NOT NULL REFERENCES journals(id), position integer NOT NULL CHECK(position>=0),
account text NOT NULL REFERENCES accounts(id), debit numeric NOT NULL, credit numeric NOT NULL,
PRIMARY KEY(journal,position), CHECK(debit>=0 AND credit>=0 AND debit<1e30 AND credit<1e30),
CHECK(debit=trunc(debit) AND credit=trunc(credit) AND ((debit>0 AND credit=0) OR (credit>0 AND debit=0))));
CREATE INDEX entries_account ON entries(account,journal);
CREATE TABLE listings(sequence bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE, id uuid PRIMARY KEY, card text NOT NULL,
status text NOT NULL CHECK(status IN ('open','reserved','cancelled','sold')), data jsonb NOT NULL);
CREATE UNIQUE INDEX one_active_card ON listings(card) WHERE status IN ('open','reserved');
CREATE TABLE orders(sequence bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE, id uuid PRIMARY KEY,
buyer text NOT NULL, seller text NOT NULL, status text NOT NULL CHECK(status IN ('awaiting-lock','cancelled','settled')), data jsonb NOT NULL);
CREATE INDEX orders_buyer ON orders(buyer,sequence DESC);
CREATE INDEX orders_seller ON orders(seller,sequence DESC);
CREATE TABLE withdrawals(sequence bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE, id uuid PRIMARY KEY, actor text NOT NULL,
status text NOT NULL CHECK(status IN ('reserved','prepared','complete','cancelled')), data jsonb NOT NULL);
CREATE INDEX withdrawals_actor ON withdrawals(actor,sequence DESC);
CREATE TABLE receipts(id text PRIMARY KEY, digest text NOT NULL, data jsonb NOT NULL);
CREATE TABLE outbox(id uuid PRIMARY KEY, kind text NOT NULL CHECK(kind IN ('nft-lock','withdrawal')), status text NOT NULL, data text NOT NULL);
CREATE TABLE credits(actor text PRIMARY KEY);
CREATE FUNCTION immutable_history() RETURNS trigger LANGUAGE plpgsql SET search_path=wk_custody,pg_catalog AS $$
BEGIN RAISE EXCEPTION 'Immutable financial history'; END $$;
CREATE TRIGGER entries_immutable BEFORE UPDATE OR DELETE ON entries FOR EACH ROW EXECUTE FUNCTION immutable_history();
CREATE TRIGGER journals_immutable BEFORE UPDATE OR DELETE ON journals FOR EACH ROW EXECUTE FUNCTION immutable_history();
CREATE TRIGGER receipts_immutable BEFORE UPDATE OR DELETE ON receipts FOR EACH ROW EXECUTE FUNCTION immutable_history();
CREATE FUNCTION apply_entry() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path=wk_custody,pg_catalog AS $$
DECLARE n text; amount numeric;
BEGIN
IF NOT EXISTS(SELECT 1 FROM journals WHERE id=NEW.journal AND posting_tx=txid_current()) THEN
RAISE EXCEPTION 'Cannot append to a committed journal';
END IF;
SELECT normal INTO STRICT n FROM accounts WHERE id=NEW.account FOR UPDATE;
amount := CASE WHEN n='debit' THEN NEW.debit-NEW.credit ELSE NEW.credit-NEW.debit END;
UPDATE accounts SET balance=balance+amount WHERE id=NEW.account;
RETURN NEW;
END $$;
CREATE TRIGGER entry_projection AFTER INSERT ON entries FOR EACH ROW EXECUTE FUNCTION apply_entry();
CREATE FUNCTION balanced_journal() RETURNS trigger LANGUAGE plpgsql SET search_path=wk_custody,pg_catalog AS $$
DECLARE total numeric; n bigint; journal_id text;
BEGIN
IF TG_TABLE_NAME='entries' THEN journal_id := NEW.journal; ELSE journal_id := NEW.id; END IF;
SELECT count(*),sum(debit-credit) INTO n,total FROM entries WHERE journal=journal_id;
IF n=0 OR total<>0 THEN RAISE EXCEPTION 'Unbalanced financial journal'; END IF;
RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER journal_balance AFTER INSERT ON journals DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION balanced_journal();
CREATE CONSTRAINT TRIGGER entry_balance AFTER INSERT ON entries DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION balanced_journal();
REVOKE ALL ON ALL TABLES IN SCHEMA wk_custody FROM PUBLIC;
REVOKE ALL ON ALL SEQUENCES IN SCHEMA wk_custody FROM PUBLIC;
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA wk_custody FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_DIGEST = createHash('sha256').update(CUSTODY_SCHEMA).digest('hex');
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
export async function migrateCustody(pool: Pool): Promise<void> {
const c = await pool.connect();
try {
await c.query('BEGIN');
await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
const exists = await c.query("SELECT to_regclass('wk_custody.schema_versions') AS name");
if (exists.rows[0].name) await verifyCustodySchema(c);
else {
await c.query(CUSTODY_SCHEMA);
await c.query('INSERT INTO wk_custody.schema_versions VALUES($1,$2)', [CUSTODY_PG_VERSION, CUSTODY_SCHEMA_DIGEST]);
}
await c.query('COMMIT');
} catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; }
finally { c.release(); }
}
export async function verifyCustodySchema(c: PoolClient): Promise<void> {
const rows = await c.query('SELECT version,digest FROM wk_custody.schema_versions ORDER BY version');
if (rows.rowCount !== 1 || rows.rows[0].version !== CUSTODY_PG_VERSION || rows.rows[0].digest !== CUSTODY_SCHEMA_DIGEST) {
throw new Error('Custody PostgreSQL schema version/digest mismatch; explicit migration required');
}
}
/** Role must already exist (operator creates credentials out-of-band); never accept SQL identifiers from HTTP. */
export async function grantCustodyRuntime(pool: Pool, role: string): Promise<void> {
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid runtime role identifier');
const name = `"${role}"`;
const c = await pool.connect();
try {
await c.query('BEGIN');
await verifyCustodySchema(c);
const r = await c.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolreplication,rolbypassrls FROM pg_roles WHERE rolname=$1', [role]);
if (!r.rowCount || Object.values(r.rows[0]).some(Boolean)) throw new Error('Runtime role must exist without administrative privileges');
const owner = await c.query("SELECT pg_get_userbyid(nspowner) AS owner FROM pg_namespace WHERE nspname='wk_custody'");
if (owner.rows[0].owner === role) throw new Error('Runtime role must not own the custody schema');
await c.query(`GRANT USAGE ON SCHEMA wk_custody TO ${name};
GRANT SELECT ON ALL TABLES IN SCHEMA wk_custody TO ${name};
GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA wk_custody TO ${name};
GRANT INSERT(id,normal) ON wk_custody.accounts TO ${name};
GRANT INSERT(id,kind,created) ON wk_custody.journals TO ${name};
GRANT INSERT ON wk_custody.entries,wk_custody.receipts,wk_custody.credits,wk_custody.custody_meta TO ${name};
GRANT INSERT,UPDATE ON wk_custody.listings,wk_custody.orders,wk_custody.withdrawals,wk_custody.outbox TO ${name};`);
await c.query('COMMIT');
} catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; }
finally { c.release(); }
}
+6
View File
@@ -30,6 +30,9 @@ function inspect(file:string,name:typeof files[number]) {
const meta=(key:string)=>(db.prepare('SELECT value FROM metadata WHERE key=?').get(key) as {value:string}|undefined)?.value;
if(meta('schema_version')!=='1'||meta('network')!==NETWORK||meta('chain_id')!==CHAIN_ID)throw new Error('Ledger network/schema mismatch');
}else if(name==='custody-sandbox.sqlite'){
if(db.prepare("SELECT 1 FROM custody_meta WHERE key='postgres_cutover'").get()) {
throw new Error('PostgreSQL cutover source cannot be backed up as current custody; coordinated database backup required');
}
const row=db.prepare("SELECT value FROM custody_meta WHERE key='identity'").get() as {value:string}|undefined;
const identity=row?JSON.parse(row.value):undefined;
if(identity?.version!==1||identity.network!==NETWORK||identity.chainId!==CHAIN_ID||identity.mode!=='sandbox'||
@@ -53,6 +56,9 @@ export async function createBackup(dataDir:string,destination:string){
const rel=relative(source,dest);
if(!rel||(!rel.startsWith('..')&&!isAbsolute(rel)))throw new Error('Backup destination must be outside the node data directory');
return offlineLock(source,async()=>{
// File-only backups cannot represent an external financial database. Fail rather than omit liabilities.
try { await lstat(join(source,PROFILE,'custody-postgres.json')); throw new Error('PostgreSQL custody requires coordinated database and node backup; SQLite-only backup refused'); }
catch(e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
if(await realpath(join(source,PROFILE))!==join(source,PROFILE))throw new Error('Network data directory must not be symlinked');
await mkdir(dest,{mode:0o700}); // Exclusive: never overwrite or recursively create a surprising destination.
const entries=[];
+10 -8
View File
@@ -19,6 +19,8 @@ import { marketCommandSchema } from '../../shared/market.ts';
import { marketCommandV3Schema } from '../../shared/market-v3.ts';
import { custodyCommandSchema } from '../../shared/custody.ts';
import { CustodyMarket, CustodyError, custodyMode } from '../market/custody.ts';
import { openCustodyStorage } from '../market/custody-storage.ts';
import type { PostgresCustody } from '../market/postgres-custody.ts';
import { IS_MAINNET, PROFILE } from '../../shared/network.ts';
import { assertLaunchAllowed, launchManifest } from '../launch.ts';
import { resourceConfig, RpcBudget, RpcError } from '../resources.ts';
@@ -52,9 +54,9 @@ catch(e) {ledger.close();engine.close();await lock.close();await unlink(lockPath
let market: Market;
try { market = await Market.open(dataDir, ledger, paymentConfig); }
catch(e) {peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
let custody: CustodyMarket | undefined;
let custody: CustodyMarket | PostgresCustody | undefined;
try {
if (custodyConfig === 'sandbox') custody = await CustodyMarket.open(dataDir, { feeBps: paymentConfig.feeBps,
if (custodyConfig === 'sandbox') custody = await openCustodyStorage(dataDir, { feeBps: paymentConfig.feeBps,
card: (hash, owner, sequence) => {
const nft = ledger.nft(hash);
if (!nft || nft.owner !== owner || nft.confirmations < CONSENSUS.coinbaseMaturity || nft.sequence !== sequence ||
@@ -96,19 +98,19 @@ app.get('/api/network',(_req,res)=>res.json(launchManifest()));
app.get('/api/chain',(_req,res)=>res.json(ledger.legacySnapshot(limits.legacyBlocks)));
app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()}));
app.get('/api/market', (req,res)=>res.json(market.browse(Number(req.query.offset ?? 0))));
app.get('/api/custody', (req,res)=> {
app.get('/api/custody', async (req,res)=> {
if (!custody) { res.json({ enabled: false }); return; }
try { res.json(custody.browse(Number(req.query.offset ?? 0))); }
try { res.json(await custody.browse(Number(req.query.offset ?? 0))); }
catch (error) {
if (error instanceof CustodyError) res.status(400).json({ error: error.message });
else res.status(503).json({ error: 'Custody sandbox unavailable; operator review required.' });
}
});
app.post('/api/custody/command', rateLimit({windowMs:60000,limit:30,standardHeaders:'draft-8',legacyHeaders:false}), (req,res)=> {
app.post('/api/custody/command', rateLimit({windowMs:60000,limit:30,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res)=> {
if (!custody) { res.status(503).json({ error: 'Custody sandbox is disabled on this host.' }); return; }
const parsed = custodyCommandSchema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: 'Invalid custody command schema.' }); return; }
try { res.json(custody.execute(parsed.data)); }
try { res.json(await custody.execute(parsed.data)); }
catch (error) {
if (error instanceof CustodyError) res.status(400).json({ error: error.message });
else res.status(503).json({ error: 'Custody state conflict; refresh activity before retrying. Operator review may be required.' });
@@ -234,6 +236,6 @@ let stopping=false;
for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{
if(stopping)return;stopping=true;peers.close();operations.close();clearInterval(marketTimer);
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
server.close(async()=>{custody?.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
server.close(async()=>{await custody?.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
});
server.on('error',async()=>{clearInterval(marketTimer);operations.close();peers.close();custody?.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
server.on('error',async()=>{clearInterval(marketTimer);operations.close();peers.close();await custody?.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
+253
View File
@@ -0,0 +1,253 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Pool, type PoolConfig } from 'pg';
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
import { mkdtemp, rm, writeFile, mkdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { DatabaseSync } from 'node:sqlite';
import { PostgresCustody } from '../../server/market/postgres-custody.ts';
import { CustodyMarket } from '../../server/market/custody.ts';
import { migrateCustody, grantCustodyRuntime } from '../../server/market/postgres-schema.ts';
import { importSqliteCustody } from '../../server/market/custody-import.ts';
import { postgresConfig, custodyStorage } from '../../server/market/postgres-config.ts';
import { openCustodyStorage } from '../../server/market/custody-storage.ts';
import { createBackup } from '../../server/recovery.ts';
import { CUSTODY_POLICY, custodyMessage, type CustodyAction, type CustodyCommand, type CustodyActivity,
type CustodyListing, type CustodyOrder, type CustodyWithdrawal } from '../../shared/custody.ts';
import { NETWORK } from '../../shared/network.ts';
import { CHAIN_ID, address } from '../../server/testnet/consensus.ts';
if (process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1') throw new Error('PostgreSQL tests require the isolated disposable compose fixture');
const config: PoolConfig = { host: '127.0.0.1', database: 'custody_test', user: 'postgres', ssl: false, max: 4,
connectionTimeoutMillis: 5000, statement_timeout: 10000, lock_timeout: 3000 };
function identity() {
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
return { publicKey, actor: address(publicKey), sign: (message: string) => sign('sha256', Buffer.from(message),
{ key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') };
}
const buyer = identity(), seller = identity(), other = identity(), hash = 'a'.repeat(64);
const options = { feeBps: 250, card: (token: string, owner: string, sequence: number) =>
token === hash && owner === seller.actor && sequence === 0 ? { title: 'PostgreSQL fixture' } : undefined };
function envelope(id: string, who: typeof buyer, action: CustodyAction, nonce: string = randomUUID()): CustodyCommand {
const c: Omit<CustodyCommand, 'signature'> = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK,
chainId: CHAIN_ID, custodianId: id, actor: who.actor, publicKey: who.publicKey, nonce, expiresAt: Date.now() + 240000, action };
return { ...c, signature: who.sign(custodyMessage(c)) };
}
async function fixture(t: { after(fn: () => Promise<void>): void }) {
const admin = new Pool(config);
await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE'); // This database exists ONLY in isolated tmpfs fixture.
await migrateCustody(admin);
await admin.query('DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname=\'custody_runtime\') THEN CREATE ROLE custody_runtime LOGIN; END IF; END $$');
await grantCustodyRuntime(admin, 'custody_runtime');
const runtime = { ...config, user: 'custody_runtime' };
const market = await PostgresCustody.open(runtime, options);
t.after(async () => { await market.close(); await admin.end(); });
const execute = (who: typeof buyer, a: CustodyAction) => market.execute(envelope(market.id, who, a));
const activity = async (who = buyer) => await execute(who, { type: 'activity', offset: 0 }) as CustodyActivity;
return { admin, runtime, market, execute, activity };
}
test('explicit PostgreSQL migrations are idempotent and runtime role cannot alter financial history or schema', async t => {
const f = await fixture(t); await migrateCustody(f.admin);
await assert.rejects(PostgresCustody.open(config, options), /non-administrative/);
await f.execute(buyer, { type: 'mock-credit' });
const runtime = new Pool(f.runtime);
try {
for (const sql of ["UPDATE wk_custody.accounts SET balance=999", 'DELETE FROM wk_custody.entries', 'TRUNCATE wk_custody.journals',
"ALTER TABLE wk_custody.accounts ADD COLUMN bad text", "UPDATE wk_custody.receipts SET digest='bad'"]) await assert.rejects(runtime.query(sql));
await assert.rejects(grantCustodyRuntime(f.admin, 'postgres'), /administrative/);
await assert.rejects(grantCustodyRuntime(f.admin, 'bad;DROP SCHEMA'), /identifier/);
} finally { await runtime.end(); }
assert.equal((await f.market.audit()).assetsAtomicXmr, '1000000000000');
});
test('database rejects unbalanced, fractional, negative, historical append and oversized postings at commit', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const c = await f.admin.connect();
try {
await c.query('BEGIN');
await c.query("INSERT INTO wk_custody.journals(id,kind,created) VALUES('bad','test',0)");
await c.query("INSERT INTO wk_custody.entries VALUES('bad',0,'treasury:mock',1,0)");
await assert.rejects(c.query('COMMIT'), /Unbalanced/); await c.query('ROLLBACK');
await assert.rejects(c.query("INSERT INTO wk_custody.entries VALUES($1,9,'treasury:mock',1,0)", [`credit:${buyer.actor}`]), /committed/);
for (const amount of ['-1', '1.5', '1000000000000000000000000000000']) await assert.rejects(c.query('INSERT INTO wk_custody.accounts VALUES($1,$2,$3)', [randomUUID(), 'debit', amount]));
} finally { c.release(); }
assert.equal((await f.market.audit()).journals, 1);
});
test('two independent pools serialize duplicate receipts, competing reservations and withdrawals without overspending', async t => {
const f = await fixture(t), second = await PostgresCustody.open(f.runtime, options);
try {
const credit = envelope(f.market.id, buyer, { type: 'mock-credit' });
const credits = await Promise.all([f.market.execute(credit), second.execute(credit)]);
assert.deepEqual(credits[0], credits[1]);
const listing = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '800000000000', feeBps: 250 }) as CustodyListing;
await f.execute(other, { type: 'mock-credit' });
const races = await Promise.allSettled([f.market.execute(envelope(f.market.id, buyer, { type: 'reserve', listingId: listing.id, priceAtomicXmr: listing.priceAtomicXmr, feeBps: 250 })),
second.execute(envelope(f.market.id, other, { type: 'reserve', listingId: listing.id, priceAtomicXmr: listing.priceAtomicXmr, feeBps: 250 }))]);
assert.equal(races.filter(r => r.status === 'fulfilled').length, 1);
const winner = races.find(r => r.status === 'fulfilled') as PromiseFulfilledResult<CustodyOrder>;
assert.equal(winner.value.sellerAtomicXmr, '772000000000');
assert.equal((await f.activity(seller)).availableAtomicXmr, '0'); // No unimplemented NFT completion credit.
const loser = winner.value.buyer === buyer.actor ? other : buyer;
const withdrawals = await Promise.allSettled([f.market.execute(envelope(f.market.id, loser, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '900000000000', maxFeeAtomicXmr: '1000' })),
second.execute(envelope(f.market.id, loser, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '900000000000', maxFeeAtomicXmr: '1000' }))]);
assert.equal(withdrawals.filter(r => r.status === 'fulfilled').length, 1);
assert.equal((await f.activity(loser)).withdrawalReservedAtomicXmr, '900000001000');
assert.equal((await f.market.audit()).assetsAtomicXmr, '2000000000000');
assert.equal((await f.activity(identity())).orders.length, 0);
} finally { await second.close(); }
});
test('cancel/replay/reconnect preserves exact money, and receipt-insert failure rolls back balance and outbox', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const w = await f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '123', maxFeeAtomicXmr: '5' }) as CustodyWithdrawal;
const cancel = envelope(f.market.id, buyer, { type: 'cancel-withdrawal', withdrawalId: w.id });
await f.market.execute(cancel);
const reopened = await PostgresCustody.open(f.runtime, options);
try {
assert.equal(reopened.id, f.market.id); assert.deepEqual(await reopened.execute(cancel), await f.market.execute(cancel));
await assert.rejects(reopened.execute(envelope(f.market.id, buyer, { type: 'mock-credit' }, cancel.nonce)), /Nonce/);
await f.admin.query("CREATE FUNCTION wk_custody.inject_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture failure'; END $$; CREATE TRIGGER fail_receipt BEFORE INSERT ON wk_custody.receipts FOR EACH ROW EXECUTE FUNCTION wk_custody.inject_failure()");
await assert.rejects(f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '200', maxFeeAtomicXmr: '0' }));
assert.equal((await f.activity()).availableAtomicXmr, '1000000000000');
assert.equal((await f.activity()).withdrawals.length, 1);
await f.admin.query('DROP TRIGGER fail_receipt ON wk_custody.receipts');
assert.equal((await f.market.audit()).journals, 3);
} finally { await reopened.close(); }
});
test('exact amounts above JS safe integer survive NUMERIC, JSON signatures and audit', async t => {
const f = await fixture(t);
// Seed a balanced synthetic fixture through SQL, not an HTTP mint/admin endpoint.
const c = await f.admin.connect(), large = '9007199254740993';
try {
await c.query('BEGIN');
await c.query("INSERT INTO wk_custody.accounts(id,normal) VALUES('treasury:mock','debit'),($1,'credit')", [`customer:${buyer.actor}:available`]);
await c.query("INSERT INTO wk_custody.journals(id,kind,created) VALUES('large','fixture',0)");
await c.query("INSERT INTO wk_custody.entries VALUES('large',0,'treasury:mock',$1,0),('large',1,$2,0,$1)", [large, `customer:${buyer.actor}:available`]);
await c.query('COMMIT');
} finally { c.release(); }
assert.equal((await f.activity()).availableAtomicXmr, large);
await f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: large, maxFeeAtomicXmr: '0' });
assert.equal((await f.activity()).withdrawalReservedAtomicXmr, large); assert.equal((await f.market.audit()).assetsAtomicXmr, large);
});
test('SQLite cutover preserves identity, history, prepared attempts, ordering and replay; source cannot restart', async t => {
const f = await fixture(t);
// Import requires truly empty target, even a previously initialized empty custodian is rejected.
await f.admin.query('DELETE FROM wk_custody.custody_meta');
const dir = await mkdtemp(join(tmpdir(), 'custody-pg-import-'));
t.after(() => rm(dir, { recursive: true, force: true }));
const source = await CustodyMarket.open(dir, options);
const originalId = source.id, credit = envelope(originalId, buyer, { type: 'mock-credit' });
source.execute(credit);
const w = source.execute(envelope(originalId, buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '12345', maxFeeAtomicXmr: '100' })) as CustodyWithdrawal;
const attempt = randomUUID(), tx = `fixture:${'b'.repeat(64)}`;
source.prepareWithdrawalFixture(w.id, attempt, tx, '50');
const listing = source.execute(envelope(originalId, seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '10000', feeBps: 250 })) as CustodyListing;
const order = source.execute(envelope(originalId, buyer, { type: 'reserve', listingId: listing.id, priceAtomicXmr: listing.priceAtomicXmr, feeBps: 250 })) as CustodyOrder;
const oldAudit = source.audit(); source.close();
const file = join(dir, 'testnet', 'custody-sandbox.sqlite');
const imported = await importSqliteCustody(file, f.admin);
assert.equal(imported.custodianId, originalId);
assert.deepEqual(await importSqliteCustody(file, f.admin), imported);
await assert.rejects(CustodyMarket.open(dir, options), /frozen/);
const pg = await PostgresCustody.open(f.runtime, options);
try {
assert.deepEqual(await pg.audit(), oldAudit);
assert.equal((await pg.execute(credit) as CustodyActivity).availableAtomicXmr, '1000000000000'); // Original receipt, not current balance.
const activity = await pg.execute(envelope(originalId, buyer, { type: 'activity', offset: 0 })) as CustodyActivity;
assert.equal(activity.withdrawals[0].attemptId, attempt); assert.equal(activity.withdrawals[0].transactionId, tx);
assert.equal(activity.orders[0].id, order.id);
await pg.execute(envelope(originalId, buyer, { type: 'cancel-reservation', orderId: order.id }));
assert.equal((await pg.execute(envelope(originalId, buyer, { type: 'activity', offset: 0 })) as CustodyActivity).purchaseReservedAtomicXmr, '0');
await assert.rejects(pg.execute(envelope(originalId, buyer, { type: 'cancel-withdrawal', withdrawalId: w.id })), /unprepared/);
} finally { await pg.close(); }
const sqlite = new DatabaseSync(file); try { sqlite.prepare("UPDATE accounts SET balance='1' WHERE id=?").run(`customer:${buyer.actor}:available`); } finally { sqlite.close(); }
await assert.rejects(importSqliteCustody(file, f.admin), /source changed/);
});
test('PostgreSQL HTTP custody integration, safe storage binding and backup refusal use the real runtime role', { timeout: 45000 }, async t => {
const f = await fixture(t), dir = await mkdtemp(join(tmpdir(), 'custody-pg-http-'));
const urlFile = join(dir, 'pg-url'); await writeFile(urlFile, 'postgresql://custody_runtime@127.0.0.1/custody_test', { mode: 0o600 });
const env = { ...process.env, HOST: '127.0.0.1', PORT: '0', NODE_ENV: 'production', WIKIDECK_DATA_DIR: dir,
WIKIDECK_CUSTODY_MODE: 'sandbox', WIKIDECK_CUSTODY_STORAGE: 'postgres', WIKIDECK_CUSTODY_PG_URL_FILE: urlFile,
WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1', WIKIDECK_PAYMENT_MODE: 'mock', WIKIDECK_PEERS: '' };
const child = spawn(process.execPath, ['--experimental-strip-types', 'server/index.ts'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
const closed = new Promise<void>(resolve => child.once('close', () => resolve()));
t.after(async () => { if (child.exitCode === null) child.kill('SIGTERM'); await closed; await rm(dir, { recursive: true, force: true }); });
const base = await new Promise<string>((resolve, reject) => {
let log = ''; const timer = setTimeout(() => reject(new Error('HTTP startup timeout')), 25000);
const listener = (chunk: Buffer) => { log += chunk.toString(); const m = /Wikideck RandomX testnet: (http:\/\/127\.0\.0\.1:\d+)/.exec(log); if (m) { clearTimeout(timer); resolve(m[1]); } };
child.stdout.on('data', listener); child.stderr.on('data', listener);
child.once('exit', () => { clearTimeout(timer); reject(new Error('HTTP startup failed')); });
});
const page = await (await fetch(`${base}/api/custody`)).json(); assert.equal(page.info.custodianId, f.market.id);
const command = envelope(f.market.id, buyer, { type: 'mock-credit' });
const response = await fetch(`${base}/api/custody/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command) });
assert.equal(response.status, 200); assert.equal((await response.json()).availableAtomicXmr, '1000000000000');
assert.match(response.headers.get('cache-control') ?? '', /no-store/);
assert.equal(JSON.parse(await readFile(join(dir, 'testnet', 'custody-postgres.json'), 'utf8')).custodianId, f.market.id);
child.kill('SIGTERM'); await closed;
await assert.rejects(createBackup(dir, join(dir, '..', `refused-${randomUUID()}`)), /coordinated/);
await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'sqlite' }), /fallback prohibited/);
});
test('configuration requires secret files, verified TLS, literal backend, and loopback-only plaintext test transport', async t => {
const dir = await mkdtemp(join(tmpdir(), 'custody-pg-config-')); t.after(() => rm(dir, { recursive: true, force: true }));
const file = join(dir, 'connection');
assert.equal(custodyStorage({}), 'sqlite'); assert.throws(() => custodyStorage({ WIKIDECK_CUSTODY_STORAGE: 'other' }));
await assert.rejects(postgresConfig({}), /configuration/);
await writeFile(file, 'postgresql://user:secret@database.example/custody');
const secure = await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: file }); assert.equal((secure.ssl as { rejectUnauthorized: boolean }).rejectUnauthorized, true);
await assert.rejects(postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: file, WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1' }), /configuration/);
await writeFile(file, 'postgresql://user:secret@127.0.0.1/custody?sslmode=disable');
await assert.rejects(postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: file }), /configuration/);
});
test('full PostgreSQL dump/isolated restore preserves custody identity, receipts and journal backing', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const cmd = envelope(f.market.id, buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '500', maxFeeAtomicXmr: '20' });
const original = await f.market.execute(cmd), audit = await f.market.audit();
const dir = await mkdtemp(join(tmpdir(), 'custody-pg-restore-')), archive = join(dir, 'custody.dump');
const run = promisify(execFile), database = `restore_${randomUUID().replaceAll('-', '')}`;
try {
// Fixture role and TCP transport are local/trust-only on the isolated tmpfs network. No production credentials.
await run('pg_dump', ['-h', '127.0.0.1', '-U', 'postgres', '-d', 'custody_test', '-Fc', '-f', archive]);
await f.admin.query(`CREATE DATABASE "${database}"`);
await run('pg_restore', ['-h', '127.0.0.1', '-U', 'postgres', '-d', database, '--exit-on-error', archive]);
const restored = await PostgresCustody.open({ ...f.runtime, database }, options);
try {
assert.equal(restored.id, f.market.id); assert.deepEqual(await restored.audit(), audit);
assert.deepEqual(await restored.execute(cmd), original);
const balance = await restored.execute(envelope(restored.id, buyer, { type: 'activity', offset: 0 })) as CustodyActivity;
assert.equal(balance.availableAtomicXmr, '999999999480'); assert.equal(balance.withdrawalReservedAtomicXmr, '520');
} finally { await restored.close(); }
} finally {
await f.admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`);
await rm(dir, { recursive: true, force: true });
}
});
test('failed cutover freezes the source, rolls back the entire target, and resumes without duplicate balances', async t => {
const f = await fixture(t); await f.admin.query('DELETE FROM wk_custody.custody_meta');
const dir = await mkdtemp(join(tmpdir(), 'custody-pg-failed-import-')); t.after(() => rm(dir, { recursive: true, force: true }));
const source = await CustodyMarket.open(dir, options);
source.execute(envelope(source.id, buyer, { type: 'mock-credit' })); source.close();
const file = join(dir, 'testnet', 'custody-sandbox.sqlite');
await f.admin.query("CREATE FUNCTION wk_custody.import_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected import failure'; END $$; CREATE TRIGGER fail_import BEFORE INSERT ON wk_custody.receipts FOR EACH ROW EXECUTE FUNCTION wk_custody.import_failure()");
await assert.rejects(importSqliteCustody(file, f.admin), /injected import/);
assert.equal((await f.admin.query('SELECT count(*)::integer AS n FROM wk_custody.accounts')).rows[0].n, 0);
await assert.rejects(CustodyMarket.open(dir, options), /frozen/);
await f.admin.query('DROP TRIGGER fail_import ON wk_custody.receipts');
await importSqliteCustody(file, f.admin);
const restored = await PostgresCustody.open(f.runtime, options);
try { assert.equal((await restored.audit()).assetsAtomicXmr, '1000000000000'); }
finally { await restored.close(); }
});
+1 -1
View File
@@ -6,5 +6,5 @@
"allowImportingTsExtensions": true, "resolveJsonModule": true,
"noEmit": true, "types": ["node", "vite/client"]
},
"include": ["src", "server", "shared", "tests", "vite.config.ts"]
"include": ["src", "server", "shared", "tests", "scripts/custody-db.ts", "vite.config.ts"]
}