enigma-memory 0.1.18 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -24
- package/apps/cli/bin/enigma-desktop.mjs +140 -0
- package/apps/cli/bin/enigma-terminal.mjs +78 -0
- package/apps/cli/bin/enigma.mjs +1923 -285
- package/apps/desktop/electron-main.cjs +217 -0
- package/apps/desktop/package.json +12 -0
- package/apps/desktop/src/app.js +264 -7
- package/apps/desktop/src/index.html +3514 -1373
- package/apps/desktop/src/launch-electron.mjs +51 -0
- package/apps/desktop/src/server.mjs +2914 -0
- package/apps/desktop/src/styles.css +2972 -260
- package/apps/desktop/src/zk-browser-prove.mjs +53 -0
- package/apps/desktop/src/zk-state.mjs +1789 -0
- package/apps/gateway/bin/enigma-gateway.mjs +102 -5
- package/apps/gateway/src/server.mjs +271 -8
- package/apps/ios/EnigmaCore/Package.swift +12 -0
- package/apps/ios/EnigmaCore/Sources/EnigmaCore/EnigmaAPIClient.swift +227 -0
- package/apps/ios/EnigmaCore/Sources/EnigmaCore/Models.swift +278 -0
- package/apps/ios/EnigmaCore/Sources/EnigmaCore/PKCE.swift +96 -0
- package/apps/ios/EnigmaCore/Sources/EnigmaCore/PrivacyMinimizer.swift +187 -0
- package/apps/ios/EnigmaCore/Sources/EnigmaCore/ToolModels.swift +129 -0
- package/apps/ios/EnigmaCore/Tests/EnigmaCoreTests/EnigmaCoreTests.swift +42 -0
- package/apps/ios/EnigmaIOS/Enigma/AppModel.swift +346 -0
- package/apps/ios/EnigmaIOS/Enigma/Assets.xcassets/AccentColor.colorset/Contents.json +12 -0
- package/apps/ios/EnigmaIOS/Enigma/Assets.xcassets/AppIcon.appiconset/Contents.json +11 -0
- package/apps/ios/EnigmaIOS/Enigma/Assets.xcassets/AppIcon.appiconset/EnigmaAppIcon.png +0 -0
- package/apps/ios/EnigmaIOS/Enigma/Assets.xcassets/Contents.json +3 -0
- package/apps/ios/EnigmaIOS/Enigma/Assets.xcassets/LaunchBackground.colorset/Contents.json +12 -0
- package/apps/ios/EnigmaIOS/Enigma/ChatView.swift +181 -0
- package/apps/ios/EnigmaIOS/Enigma/CouncilView.swift +78 -0
- package/apps/ios/EnigmaIOS/Enigma/CreateView.swift +152 -0
- package/apps/ios/EnigmaIOS/Enigma/EnigmaApp.swift +52 -0
- package/apps/ios/EnigmaIOS/Enigma/Info.plist +52 -0
- package/apps/ios/EnigmaIOS/Enigma/NaturalLanguagePrivacyTagger.swift +26 -0
- package/apps/ios/EnigmaIOS/Enigma/OAuthClient.swift +321 -0
- package/apps/ios/EnigmaIOS/Enigma/OnboardingView.swift +105 -0
- package/apps/ios/EnigmaIOS/Enigma/PrivateVaultView.swift +275 -0
- package/apps/ios/EnigmaIOS/Enigma/SecureStore.swift +76 -0
- package/apps/ios/EnigmaIOS/Enigma/SettingsView.swift +60 -0
- package/apps/ios/EnigmaIOS/Enigma/Theme.swift +80 -0
- package/apps/ios/EnigmaIOS/EnigmaIOS.xcodeproj/project.pbxproj +211 -0
- package/apps/ios/EnigmaIOS/EnigmaIOS.xcodeproj/xcshareddata/xcschemes/Enigma.xcscheme +23 -0
- package/apps/native-host/README.md +19 -8
- package/apps/native-host/bin/enigma-native-host.mjs +229 -13
- package/apps/relay/bin/enigma-relay.mjs +103 -5
- package/apps/relay/src/federation-runtime.mjs +618 -0
- package/apps/relay/src/server.mjs +310 -9
- package/apps/verifier/bin/enigma-verify.mjs +327 -11
- package/cortex-v3/circuits/build/intent_vk_bytes.json +35 -0
- package/cortex-v3/circuits/build/sale_vk_bytes.json +35 -0
- package/cortex-v3/circuits/build/vk_bytes.json +32 -0
- package/cortex-v3/proving-assets.json +64 -0
- package/cortex-v3/zk/BUILD-CONTRACT.md +87 -0
- package/cortex-v3/zk/action-transition-vk.json +119 -0
- package/cortex-v3/zk/alias-adversarial.test.mjs +220 -0
- package/cortex-v3/zk/groth16-verify-child.mjs +17 -0
- package/cortex-v3/zk/intent-witness.mjs +365 -0
- package/cortex-v3/zk/intent-witness.test.mjs +485 -0
- package/cortex-v3/zk/proving-assets.mjs +203 -0
- package/cortex-v3/zk/sale-witness.mjs +783 -0
- package/cortex-v3/zk/sale-witness.test.mjs +784 -0
- package/cortex-v3/zk/sealed-sale-release-vk.json +119 -0
- package/cortex-v3/zk/settlement-evidence.mjs +722 -0
- package/cortex-v3/zk/setup-intent.mjs +688 -0
- package/cortex-v3/zk/setup-sale.mjs +666 -0
- package/cortex-v3/zk/setup.mjs +594 -0
- package/cortex-v3/zk/witness.mjs +184 -0
- package/cortex-v3/zk/zk-codec.mjs +232 -0
- package/cortex-v3/zk/zk-codec.test.mjs +293 -0
- package/cortex-v3/zk/zk-settle.mjs +370 -0
- package/cortex-v3/zk/zk-tree.mjs +256 -0
- package/cortex-v3/zk/zk-tree.test.mjs +419 -0
- package/deploy/docker-compose.local-production-simulation.yml +36 -0
- package/docs/browser-extension-install.md +8 -6
- package/docs/client-connectors.md +15 -11
- package/docs/developer-ecosystem.md +15 -13
- package/docs/enigma-memory-ready-conformance.md +11 -9
- package/docs/install-anywhere.md +61 -28
- package/docs/installers-and-desktop.md +8 -7
- package/docs/novelty-invention-candidates.md +161 -161
- package/docs/proof-network-claim-boundaries.md +320 -318
- package/examples/01-quickstart-agent/index.mjs +49 -0
- package/examples/01_agent_memory_quickstart.mjs +57 -0
- package/examples/02-multi-agent-swarm/index.mjs +57 -0
- package/examples/02_cross_model_passport.mjs +64 -0
- package/examples/03-langchain-memory/index.mjs +41 -0
- package/examples/03_poseidon_commitment_verification.mjs +71 -0
- package/examples/04-python-trading-agent/trader.py +49 -0
- package/examples/README.md +27 -0
- package/examples/ci/github-actions.yml +7 -2
- package/package.json +142 -11
- package/packages/adapters/PACKAGE_CONTRACT.md +1 -1
- package/packages/connectors/src/index.js +196 -4
- package/packages/connectors/swarm-router.mjs +168 -0
- package/packages/core/src/index.js +248 -1
- package/packages/core/src/version.mjs +7 -0
- package/packages/dev-tools/package.json +19 -0
- package/packages/dev-tools/src/index.js +4 -0
- package/packages/dev-tools/src/memory-benchmark-suite.js +112 -0
- package/packages/dev-tools/src/swarm-simulator.js +101 -0
- package/packages/dev-tools/src/vault-inspector.js +114 -0
- package/packages/dev-tools/src/vector-benchmark.js +100 -0
- package/packages/developer-platform/src/access-credentials.js +341 -0
- package/packages/developer-platform/src/http.js +132 -0
- package/packages/developer-platform/src/index.js +4 -0
- package/packages/developer-platform/src/usage-http.js +60 -0
- package/packages/developer-platform/src/usage.js +295 -0
- package/packages/enclave-runtime/attestation.mjs +159 -0
- package/packages/enclave-runtime/index.mjs +47 -0
- package/packages/enclave-runtime/session-manager.mjs +253 -0
- package/packages/enclave-runtime/zeroization-proof.mjs +227 -0
- package/packages/enigma-reflex/package.json +14 -0
- package/packages/enigma-reflex/src/index.js +204 -0
- package/packages/enigma-reflex/training/generate-dataset.mjs +40 -0
- package/packages/enigma-reflex/training/requirements.txt +8 -0
- package/packages/enigma-reflex/training/train.py +314 -0
- package/packages/enigma-weave/LICENSE +22 -0
- package/packages/enigma-weave/UPSTREAM.json +21 -0
- package/packages/enigma-weave/package.json +14 -0
- package/packages/enigma-weave/src/index.js +286 -0
- package/packages/hosted-cloud/src/index.js +80 -5
- package/packages/importers/src/index.js +432 -0
- package/packages/inference-runtime/src/browser.js +401 -0
- package/packages/inference-runtime/src/chat.js +265 -0
- package/packages/inference-runtime/src/code.js +407 -0
- package/packages/inference-runtime/src/contracts.js +162 -0
- package/packages/inference-runtime/src/http.js +232 -0
- package/packages/inference-runtime/src/image.js +186 -0
- package/packages/inference-runtime/src/index.js +10 -0
- package/packages/inference-runtime/src/model-router.js +320 -0
- package/packages/inference-runtime/src/platform.js +125 -0
- package/packages/inference-runtime/src/privacy.js +400 -0
- package/packages/inference-runtime/src/video.js +253 -0
- package/packages/mcp-server/README.md +22 -6
- package/packages/mcp-server/bin/enigma-mcp.mjs +2 -1
- package/packages/mcp-server/src/index.js +1418 -105
- package/packages/mcp-server/src/oauth.js +561 -0
- package/packages/mcp-server/src/private-handoff.js +84 -0
- package/packages/mcp-server/src/remote-http.js +273 -0
- package/packages/mcp-server/src/remote-policy.js +72 -0
- package/packages/mcp-server/swarm-bridge.mjs +361 -0
- package/packages/mesh/index.d.ts +283 -0
- package/packages/mesh/package.json +23 -0
- package/packages/mesh/src/crypto.js +189 -0
- package/packages/mesh/src/federation-packets.js +353 -0
- package/packages/mesh/src/gossip.js +311 -0
- package/packages/mesh/src/index.js +6 -0
- package/packages/mesh/src/protocol.js +255 -0
- package/packages/mesh/src/router.js +279 -0
- package/packages/mesh/src/transport.js +306 -0
- package/packages/passport/src/index.js +426 -1
- package/packages/private-economy/src/credits-http.js +100 -0
- package/packages/private-economy/src/credits.js +447 -0
- package/packages/private-economy/src/index.js +5 -0
- package/packages/private-economy/src/payments-http.js +120 -0
- package/packages/private-economy/src/payments.js +509 -0
- package/packages/private-economy/src/x402.js +346 -0
- package/packages/proof-network/PACKAGE_CONTRACT.md +21 -0
- package/packages/rag/index.d.ts +182 -0
- package/packages/rag/models/Xenova/all-MiniLM-L6-v2/THIRD_PARTY_LICENSES.txt +207 -0
- package/packages/rag/models/Xenova/all-MiniLM-L6-v2/config.json +25 -0
- package/packages/rag/models/Xenova/all-MiniLM-L6-v2/onnx/model_quantized.onnx +0 -0
- package/packages/rag/models/Xenova/all-MiniLM-L6-v2/sha256-manifest.json +28 -0
- package/packages/rag/models/Xenova/all-MiniLM-L6-v2/tokenizer.json +30686 -0
- package/packages/rag/models/Xenova/all-MiniLM-L6-v2/tokenizer_config.json +15 -0
- package/packages/rag/package.json +27 -0
- package/packages/rag/src/blinded-search.js +109 -0
- package/packages/rag/src/bm25.js +169 -0
- package/packages/rag/src/embeddings.js +459 -0
- package/packages/rag/src/hybrid.js +76 -0
- package/packages/rag/src/index.js +38 -0
- package/packages/rag/src/reranker.js +61 -0
- package/packages/rag/src/research.js +107 -0
- package/packages/rag/src/vector-store.js +430 -0
- package/packages/rag/src/verify-model-artifacts.mjs +4 -0
- package/packages/sdk/index.d.ts +760 -0
- package/packages/sdk/package.json +33 -0
- package/packages/sdk/python/README.md +24 -0
- package/packages/sdk/python/enigma_sdk.py +250 -0
- package/packages/sdk/python/pyproject.toml +34 -0
- package/packages/sdk/python/requirements.txt +1 -0
- package/packages/sdk/python/setup.py +20 -0
- package/packages/sdk/src/federation/capability-grant.js +389 -0
- package/packages/sdk/src/federation/federation-router.js +360 -0
- package/packages/sdk/src/federation/ghostmesh-bridge.js +497 -0
- package/packages/sdk/src/federation/index.js +3 -0
- package/packages/sdk/src/index.js +1796 -0
- package/packages/sdk/src/intelligence/contradiction.js +337 -0
- package/packages/sdk/src/intelligence/decision-engine.js +155 -0
- package/packages/sdk/src/intelligence/index.js +4 -0
- package/packages/sdk/src/intelligence/ontology.js +122 -0
- package/packages/sdk/src/intelligence/temporal.js +123 -0
- package/packages/sdk/src/market-client.js +142 -0
- package/packages/sdk/src/mesh-client.js +110 -0
- package/packages/sdk/src/middleware/index.js +3 -0
- package/packages/sdk/src/middleware/langchain.js +159 -0
- package/packages/sdk/src/middleware/llamaindex.js +101 -0
- package/packages/sdk/src/middleware/vercel-ai.js +112 -0
- package/packages/sdk/src/rag-client.js +85 -0
- package/packages/sdk/src/swarm-orchestrator.js +260 -0
- package/packages/settlement/PACKAGE_CONTRACT.md +1 -1
- package/packages/snapcompact/THIRD_PARTY_LICENSES.txt +40 -0
- package/packages/snapcompact/assets/8x13-latin1.bdf +3837 -0
- package/packages/snapcompact/index.d.ts +284 -0
- package/packages/snapcompact/package.json +25 -0
- package/packages/snapcompact/src/index.js +716 -0
- package/packages/storage/PACKAGE_CONTRACT.md +1 -1
- package/packages/terminal-console/animations.mjs +240 -0
- package/packages/terminal-console/auto-anchor.mjs +220 -0
- package/packages/terminal-console/banner.mjs +91 -0
- package/packages/terminal-console/commands.mjs +459 -0
- package/packages/terminal-console/delegation.mjs +152 -0
- package/packages/terminal-console/index.mjs +5 -0
- package/packages/terminal-console/outbox.mjs +143 -0
- package/packages/terminal-console/phantom-bridge.mjs +637 -0
- package/packages/terminal-console/repl.mjs +136 -0
- package/packages/terminal-console/signer-store.mjs +130 -0
- package/packages/terminal-console/solana-rpc.mjs +214 -0
- package/packages/terminal-console/solana-transport.mjs +189 -0
- package/packages/terminal-tui/dashboard.mjs +214 -0
- package/packages/terminal-tui/index.mjs +28 -0
- package/packages/terminal-tui/merkle-tree-renderer.mjs +268 -0
- package/packages/terminal-tui/telemetry-hud.mjs +137 -0
- package/packages/vault/index.d.ts +449 -0
- package/packages/vault/package.json +27 -0
- package/packages/vault/src/e2ee.mjs +393 -0
- package/packages/vault/src/enclave.js +481 -0
- package/packages/vault/src/erasure.js +207 -0
- package/packages/vault/src/index.js +1018 -155
- package/packages/vault/src/persistence.js +307 -0
- package/packages/vault/src/poseidon.js +354 -0
- package/packages/vault/src/receipt.js +459 -0
- package/scripts/benchmark-optical-context.mjs +166 -0
- package/scripts/bootstrap-enigma.mjs +502 -0
- package/scripts/build-edge-backend-workers.mjs +20 -5
- package/scripts/build-goal-completion-audit.mjs +72 -25
- package/scripts/build-hosted-api-key-lifecycle.mjs +26 -8
- package/scripts/build-hosted-customer-lifecycle.mjs +20 -3
- package/scripts/build-hosted-probe-worker.mjs +19 -4
- package/scripts/build-installer-assets.mjs +41 -21
- package/scripts/build-operator-evidence-starter.mjs +59 -1
- package/scripts/build-production-backend-env-kit.mjs +2 -0
- package/scripts/build-production-unblocker.mjs +3 -0
- package/scripts/check.mjs +17 -3
- package/scripts/collect-hosted-backend-live-evidence.mjs +49 -12
- package/scripts/install-enigma-local.mjs +18 -5
- package/scripts/release-audit.mjs +65 -109
- package/scripts/release-provenance.mjs +6 -0
- package/scripts/run-backend-readiness-smoke.mjs +112 -10
- package/scripts/scan-secrets.mjs +1 -0
- package/scripts/simulate-production-env.mjs +7 -2
- package/scripts/validate-hosted-backend-live.mjs +112 -1
- package/specs/antibody-pack-v1.schema.json +95 -0
- package/specs/antigen-envelope-v1.schema.json +81 -0
- package/specs/boundary-manifest-v1.schema.json +35 -35
- package/specs/capsule-v1.schema.json +55 -55
- package/specs/claim-boundary-manifest-v1.schema.json +22 -22
- package/specs/claim-ledger-v1.schema.json +291 -0
- package/specs/context-passport-v1.schema.json +59 -0
- package/specs/deletion-tombstone-v1.schema.json +26 -26
- package/specs/evidence-packet-v1.schema.json +177 -0
- package/specs/hosted-backend-live-evidence-v1.schema.json +72 -3
- package/specs/immune-scan-report-v1.schema.json +112 -0
- package/specs/lifecycle-receipt-log-v1.schema.json +67 -0
- package/specs/memory-atom-v1.schema.json +59 -0
- package/specs/memory-event-v1.schema.json +42 -42
- package/specs/passport-v1.schema.json +50 -50
- package/specs/proof-of-non-use-v1.schema.json +65 -0
- package/specs/quarantine-record-v1.schema.json +126 -0
- package/specs/receipt-v1.schema.json +61 -61
- package/specs/state-checkpoint-v1.schema.json +37 -37
- package/specs/trust-bundle-v1.schema.json +56 -56
- package/specs/trust-card-v1.schema.json +119 -0
- package/docs/proof-network-launch-plan.md +0 -421
- package/packages/metering/PACKAGE_CONTRACT.md +0 -20
- package/scripts/build-ai-orchestration-plan.mjs +0 -248
|
@@ -0,0 +1,1789 @@
|
|
|
1
|
+
// Persistence and client runtime for the desktop ZK proof layer.
|
|
2
|
+
//
|
|
3
|
+
// State file (default .enigma/zk-tree-state.json, next to the vault bundle),
|
|
4
|
+
// format v2:
|
|
5
|
+
// - PLAINTEXT section is public tree data only: noteLeaves, imtLeaves,
|
|
6
|
+
// nullifierValues, and per-note public metadata (index, scope, sealed
|
|
7
|
+
// ciphertext, tx signatures). None of it can decrypt a note or forge a
|
|
8
|
+
// revocation.
|
|
9
|
+
// - ALL secrets (install ownerKey, X25519 recipient secret key, and the
|
|
10
|
+
// per-note openings rho/payloadCommit) live exclusively inside
|
|
11
|
+
// `secretEnvelope`: Electron safeStorage (OS DPAPI/Keychain) when injected,
|
|
12
|
+
// otherwise host-bound AES-256-GCM (scrypt over hostname:username:
|
|
13
|
+
// platform:arch), matching packages/terminal-console/signer-store.mjs.
|
|
14
|
+
// - The sealed payload embeds a SHA-256 digest of the public section; the
|
|
15
|
+
// GCM AAD is 'enigma.zk_state_secrets.v1'. Envelopes therefore cannot be
|
|
16
|
+
// swapped across state files or survive public-state tampering: decryption
|
|
17
|
+
// fails closed.
|
|
18
|
+
//
|
|
19
|
+
// Legacy v1 plaintext state files are NEVER migrated. readZkState rejects them
|
|
20
|
+
// with a precise reset error.
|
|
21
|
+
//
|
|
22
|
+
// The local tree snapshot is not an indexer. If the chain is ahead of this
|
|
23
|
+
// file, historical Poseidon leaves cannot be recovered from roots, so actions
|
|
24
|
+
// fail closed until a matching snapshot is restored.
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import crypto from 'node:crypto';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
32
|
+
import { createZkSettlementEvidence } from '../../../cortex-v3/zk/settlement-evidence.mjs';
|
|
33
|
+
|
|
34
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
|
|
36
|
+
const DEFAULT_CORTEX_DIR = path.join(REPO_ROOT, 'cortex-v3');
|
|
37
|
+
const DEFAULT_BUILD_DIR = path.join(DEFAULT_CORTEX_DIR, 'circuits', 'build');
|
|
38
|
+
const STATE_VERSION = 2;
|
|
39
|
+
const FIELD_R = BigInt('0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001');
|
|
40
|
+
|
|
41
|
+
const PUBLIC_STATE_WARNING = 'Public ZK tree data only; note openings and recipient keys live encrypted in secretEnvelope';
|
|
42
|
+
const SECRET_AAD = Buffer.from('enigma.zk_state_secrets.v1', 'utf8');
|
|
43
|
+
const SAFESTORAGE_SCHEME = 'enigma.zk_state_safestorage.v1';
|
|
44
|
+
const HOSTBOUND_SCHEME = 'enigma.zk_state_hostbound.v1';
|
|
45
|
+
const HOST_KEY_SALT = 'enigma-zk-state-dpapi-salt-v1';
|
|
46
|
+
const CUSTOM_KEY_SALT = 'enigma-zk-state-custom-salt-v1';
|
|
47
|
+
|
|
48
|
+
export const ZK_HONESTY_NOTE = 'Localnet demo — dev ceremony; nullifiers prove non-use, not deletion.';
|
|
49
|
+
|
|
50
|
+
function zeroizeBuffer(buf) {
|
|
51
|
+
if (Buffer.isBuffer(buf)) {
|
|
52
|
+
try { buf.fill(0); } catch {}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function zeroizePrivateObject(value) {
|
|
57
|
+
if (!value || typeof value !== 'object') return;
|
|
58
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
59
|
+
try { value.fill(0); } catch {}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const key of Object.keys(value)) {
|
|
63
|
+
const entry = value[key];
|
|
64
|
+
if (entry && typeof entry === 'object') {
|
|
65
|
+
zeroizePrivateObject(entry);
|
|
66
|
+
} else if (typeof entry === 'bigint') {
|
|
67
|
+
value[key] = 0n;
|
|
68
|
+
} else if (typeof entry === 'number') {
|
|
69
|
+
value[key] = 0;
|
|
70
|
+
} else if (typeof entry === 'string') {
|
|
71
|
+
value[key] = '';
|
|
72
|
+
} else {
|
|
73
|
+
value[key] = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Custody: mirrors packages/terminal-console/signer-store.mjs -------------
|
|
79
|
+
|
|
80
|
+
function deriveHostBoundKey() {
|
|
81
|
+
const hostIdentity = [
|
|
82
|
+
os.hostname() || 'localhost',
|
|
83
|
+
os.userInfo()?.username || 'user',
|
|
84
|
+
process.platform || 'win32',
|
|
85
|
+
process.arch || 'x64',
|
|
86
|
+
].join(':');
|
|
87
|
+
return crypto.scryptSync(hostIdentity, HOST_KEY_SALT, 32);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function deriveCustomKey(customKey) {
|
|
91
|
+
if (Buffer.isBuffer(customKey) || customKey instanceof Uint8Array) {
|
|
92
|
+
const key = Buffer.from(customKey);
|
|
93
|
+
if (key.length !== 32) throw new RangeError('customKey bytes must be exactly 32 bytes (AES-256)');
|
|
94
|
+
return key;
|
|
95
|
+
}
|
|
96
|
+
if (typeof customKey === 'string' && customKey.length > 0) {
|
|
97
|
+
return crypto.scryptSync(customKey, CUSTOM_KEY_SALT, 32);
|
|
98
|
+
}
|
|
99
|
+
throw new TypeError('customKey must be a non-empty string or 32 bytes');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stableStringify(value) {
|
|
103
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
104
|
+
if (value && typeof value === 'object') {
|
|
105
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
|
|
106
|
+
}
|
|
107
|
+
return JSON.stringify(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function publicStateOf(state) {
|
|
111
|
+
return {
|
|
112
|
+
version: STATE_VERSION,
|
|
113
|
+
warning: PUBLIC_STATE_WARNING,
|
|
114
|
+
noteLeaves: state.noteLeaves,
|
|
115
|
+
imtLeaves: state.imtLeaves,
|
|
116
|
+
nullifierValues: state.nullifierValues,
|
|
117
|
+
notes: state.notes,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function publicDigest(publicState) {
|
|
122
|
+
return crypto.createHash('sha256').update(stableStringify(publicState), 'utf8').digest('hex');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Custody policy: OS safeStorage is production custody; customKey is for tests/
|
|
126
|
+
// operators; the deterministic host-bound derivation is demo-only and must be
|
|
127
|
+
// explicitly requested — silent fallback would be weak custody masquerading as
|
|
128
|
+
// secure storage (host key is derivable by any same-host attacker).
|
|
129
|
+
function assertCustodyExplicit(custody) {
|
|
130
|
+
if (custody.safeStorage && typeof custody.safeStorage.encryptString === 'function') return;
|
|
131
|
+
if (custody.customKey !== undefined) return;
|
|
132
|
+
if (custody.allowHostBoundDemo === true) return;
|
|
133
|
+
throw new Error(
|
|
134
|
+
'ZK secret custody not configured: provide safeStorage (OS DPAPI/Keychain), '
|
|
135
|
+
+ 'a customKey, or explicitly set allowHostBoundDemo:true for local demos'
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Encrypt the secrets payload for `state`'s current public section.
|
|
141
|
+
* custody: { safeStorage?, customKey?, allowHostBoundDemo? } — safeStorage wins when present.
|
|
142
|
+
*/
|
|
143
|
+
export function sealZkSecrets(secrets, state, custody = {}) {
|
|
144
|
+
assertCustodyExplicit(custody);
|
|
145
|
+
|
|
146
|
+
const payload = {
|
|
147
|
+
install: secrets.install,
|
|
148
|
+
noteOpenings: secrets.noteOpenings || {},
|
|
149
|
+
saleEnvelopes: secrets.saleEnvelopes || {},
|
|
150
|
+
publicDigest: publicDigest(publicStateOf(state)),
|
|
151
|
+
};
|
|
152
|
+
const plaintext = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
153
|
+
try {
|
|
154
|
+
if (custody.safeStorage && typeof custody.safeStorage.encryptString === 'function') {
|
|
155
|
+
// JS strings cannot be zeroized; the plaintext Buffer copy is.
|
|
156
|
+
const encrypted = custody.safeStorage.encryptString(plaintext.toString('utf8'));
|
|
157
|
+
return {
|
|
158
|
+
scheme: SAFESTORAGE_SCHEME,
|
|
159
|
+
encryptedHex: Buffer.from(encrypted).toString('hex'),
|
|
160
|
+
storage: 'electron-safestorage-dpapi',
|
|
161
|
+
created_at: new Date().toISOString(),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const key = custody.customKey !== undefined ? deriveCustomKey(custody.customKey) : deriveHostBoundKey();
|
|
165
|
+
try {
|
|
166
|
+
const iv = crypto.randomBytes(12);
|
|
167
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
168
|
+
cipher.setAAD(SECRET_AAD);
|
|
169
|
+
const data = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
170
|
+
const tag = cipher.getAuthTag();
|
|
171
|
+
return {
|
|
172
|
+
scheme: HOSTBOUND_SCHEME,
|
|
173
|
+
iv: iv.toString('hex'),
|
|
174
|
+
tag: tag.toString('hex'),
|
|
175
|
+
data: data.toString('hex'),
|
|
176
|
+
storage: custody.customKey !== undefined ? 'custom-key-aes-256-gcm' : 'host-bound-aes-256-gcm',
|
|
177
|
+
created_at: new Date().toISOString(),
|
|
178
|
+
};
|
|
179
|
+
} finally {
|
|
180
|
+
zeroizeBuffer(key);
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
zeroizeBuffer(plaintext);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Decrypt and verify `state.secretEnvelope`. Returns { install, noteOpenings, saleEnvelopes }.
|
|
190
|
+
* Throws precise fail-closed errors on wrong key, tampering, or a public-state
|
|
191
|
+
* digest mismatch (envelope swapped across state files).
|
|
192
|
+
*/
|
|
193
|
+
export function unlockZkState(state, custody = {}) {
|
|
194
|
+
assertCustodyExplicit(custody);
|
|
195
|
+
const envelope = state?.secretEnvelope;
|
|
196
|
+
if (!envelope) return null;
|
|
197
|
+
|
|
198
|
+
let decrypted;
|
|
199
|
+
if (envelope.scheme === SAFESTORAGE_SCHEME) {
|
|
200
|
+
if (!custody.safeStorage || typeof custody.safeStorage.decryptString !== 'function') {
|
|
201
|
+
throw new Error('Cannot decrypt safeStorage envelope outside an active Electron session');
|
|
202
|
+
}
|
|
203
|
+
const plainText = custody.safeStorage.decryptString(Buffer.from(envelope.encryptedHex, 'hex'));
|
|
204
|
+
decrypted = Buffer.from(String(plainText), 'utf8');
|
|
205
|
+
} else if (envelope.scheme === HOSTBOUND_SCHEME) {
|
|
206
|
+
const key = custody.customKey !== undefined ? deriveCustomKey(custody.customKey) : deriveHostBoundKey();
|
|
207
|
+
try {
|
|
208
|
+
const decipher = crypto.createDecipheriv(
|
|
209
|
+
'aes-256-gcm', key, Buffer.from(envelope.iv, 'hex'),
|
|
210
|
+
);
|
|
211
|
+
decipher.setAAD(SECRET_AAD);
|
|
212
|
+
decipher.setAuthTag(Buffer.from(envelope.tag, 'hex'));
|
|
213
|
+
decrypted = Buffer.concat([decipher.update(Buffer.from(envelope.data, 'hex')), decipher.final()]);
|
|
214
|
+
} catch {
|
|
215
|
+
throw new Error('secret envelope authentication failed (wrong key or tampered ZK state file)');
|
|
216
|
+
} finally {
|
|
217
|
+
zeroizeBuffer(key);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
throw new Error(`unsupported ZK secret envelope scheme ${JSON.stringify(envelope.scheme)}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const payload = JSON.parse(decrypted.toString('utf8'));
|
|
225
|
+
const expected = publicDigest(publicStateOf(state));
|
|
226
|
+
if (payload.publicDigest !== expected) {
|
|
227
|
+
throw new Error('secret envelope does not match the public tree state (digest mismatch: file swap or post-seal mutation); refusing to use openings');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!payload.install || typeof payload.noteOpenings !== 'object') {
|
|
231
|
+
throw new Error('secret envelope payload is malformed (missing install/noteOpenings)');
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
install: payload.install,
|
|
235
|
+
noteOpenings: payload.noteOpenings,
|
|
236
|
+
saleEnvelopes: payload.saleEnvelopes && typeof payload.saleEnvelopes === 'object' ? payload.saleEnvelopes : {},
|
|
237
|
+
};
|
|
238
|
+
} finally {
|
|
239
|
+
zeroizeBuffer(decrypted);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- Public state shape ------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
function rootHex(value) {
|
|
246
|
+
if (value === null || value === undefined) return null;
|
|
247
|
+
if (typeof value === 'bigint' || typeof value === 'number') {
|
|
248
|
+
return BigInt(value).toString(16).padStart(64, '0');
|
|
249
|
+
}
|
|
250
|
+
if (typeof value === 'string') {
|
|
251
|
+
if (/^[0-9a-f]{64}$/i.test(value)) return value.toLowerCase();
|
|
252
|
+
return BigInt(value).toString(16).padStart(64, '0');
|
|
253
|
+
}
|
|
254
|
+
return Buffer.from(value).toString('hex').padStart(64, '0');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function publicKeyString(value) {
|
|
258
|
+
if (!value) return null;
|
|
259
|
+
if (typeof value === 'string') return value;
|
|
260
|
+
if (typeof value.toBase58 === 'function') return value.toBase58();
|
|
261
|
+
return String(value);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function clusterForRpc(rpcUrl) {
|
|
265
|
+
if (/devnet/i.test(rpcUrl)) return 'devnet';
|
|
266
|
+
if (/mainnet/i.test(rpcUrl)) return 'mainnet-beta';
|
|
267
|
+
if (/testnet/i.test(rpcUrl)) return 'testnet';
|
|
268
|
+
return 'localnet';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function explorerUrlFor(sig, cluster) {
|
|
272
|
+
if (cluster === 'devnet' || cluster === 'testnet') {
|
|
273
|
+
return `https://explorer.solana.com/tx/${sig}?cluster=${cluster}`;
|
|
274
|
+
}
|
|
275
|
+
if (cluster === 'mainnet-beta') return `https://explorer.solana.com/tx/${sig}`;
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function randomFieldElement() {
|
|
280
|
+
const value = BigInt(`0x${crypto.randomBytes(32).toString('hex')}`) % (FIELD_R - 1n);
|
|
281
|
+
return (value + 1n).toString(10);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function emptySnapshot() {
|
|
285
|
+
return {
|
|
286
|
+
version: STATE_VERSION,
|
|
287
|
+
warning: PUBLIC_STATE_WARNING,
|
|
288
|
+
noteLeaves: [],
|
|
289
|
+
imtLeaves: null,
|
|
290
|
+
notes: [],
|
|
291
|
+
nullifierValues: [],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function defaultZkStatePath(bundlePath) {
|
|
296
|
+
return path.join(path.dirname(path.resolve(bundlePath)), 'zk-tree-state.json');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Read the public state section. opts.unlock (+ opts.safeStorage/customKey)
|
|
301
|
+
* also decrypts the envelope and attaches it as `state.secrets`.
|
|
302
|
+
* Legacy v1 plaintext files are rejected with a precise reset error.
|
|
303
|
+
*/
|
|
304
|
+
export function readZkState(statePath, opts = {}) {
|
|
305
|
+
if (!fs.existsSync(statePath)) return null;
|
|
306
|
+
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
307
|
+
if (!parsed || typeof parsed !== 'object') throw new Error('ZK tree state is not a JSON object');
|
|
308
|
+
if (parsed.version === 1 || parsed.version === undefined) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
`legacy v1 plaintext ZK state at ${statePath} is unsupported and insecure (plaintext note openings); ` +
|
|
311
|
+
'discard or reset the file — v1 secrets are never migrated',
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (parsed.version !== STATE_VERSION) {
|
|
315
|
+
throw new Error(`unsupported ZK tree state version ${parsed.version}`);
|
|
316
|
+
}
|
|
317
|
+
if (!Array.isArray(parsed.noteLeaves)) throw new Error('invalid ZK state: noteLeaves must be an array');
|
|
318
|
+
if (parsed.imtLeaves !== null && parsed.imtLeaves !== undefined && !Array.isArray(parsed.imtLeaves)) {
|
|
319
|
+
throw new Error('invalid ZK state: imtLeaves must be an array');
|
|
320
|
+
}
|
|
321
|
+
if (!Array.isArray(parsed.notes)) parsed.notes = [];
|
|
322
|
+
if (!Array.isArray(parsed.nullifierValues)) parsed.nullifierValues = [];
|
|
323
|
+
for (const note of parsed.notes) {
|
|
324
|
+
// Defense in depth: v2 notes are public metadata only.
|
|
325
|
+
delete note.ownerKey;
|
|
326
|
+
delete note.rho;
|
|
327
|
+
delete note.payloadCommit;
|
|
328
|
+
delete note.noteContext;
|
|
329
|
+
delete note.leaf;
|
|
330
|
+
}
|
|
331
|
+
if (opts.unlock && parsed.secretEnvelope) {
|
|
332
|
+
parsed.secrets = unlockZkState(parsed, opts);
|
|
333
|
+
}
|
|
334
|
+
return parsed;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Atomically persist state (0600 best-effort). When `state.secrets` is set it
|
|
339
|
+
* is sealed into `secretEnvelope` bound to the public section being written;
|
|
340
|
+
* in-memory secrets are never written in plaintext.
|
|
341
|
+
*/
|
|
342
|
+
export function writeZkState(statePath, state, custody = {}) {
|
|
343
|
+
const publicState = publicStateOf(state);
|
|
344
|
+
const envelope = state.secrets
|
|
345
|
+
? sealZkSecrets(state.secrets, publicState, custody)
|
|
346
|
+
: state.secretEnvelope;
|
|
347
|
+
const out = envelope ? { ...publicState, secretEnvelope: envelope } : publicState;
|
|
348
|
+
|
|
349
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
|
|
350
|
+
const tempPath = `${statePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
351
|
+
const body = `${JSON.stringify(out, null, 2)}\n`;
|
|
352
|
+
try {
|
|
353
|
+
fs.writeFileSync(tempPath, body, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
354
|
+
try { fs.chmodSync(tempPath, 0o600); } catch {}
|
|
355
|
+
fs.renameSync(tempPath, statePath);
|
|
356
|
+
try { fs.chmodSync(statePath, 0o600); } catch {}
|
|
357
|
+
} catch (error) {
|
|
358
|
+
try { fs.unlinkSync(tempPath); } catch {}
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
state.secretEnvelope = envelope;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function ensureInstallSecrets(state, e2ee) {
|
|
365
|
+
|
|
366
|
+
if (!state.secrets) {
|
|
367
|
+
state.secrets = { install: null, noteOpenings: {}, saleEnvelopes: {} };
|
|
368
|
+
}
|
|
369
|
+
if (!state.secrets.noteOpenings || typeof state.secrets.noteOpenings !== 'object') {
|
|
370
|
+
state.secrets.noteOpenings = {};
|
|
371
|
+
}
|
|
372
|
+
if (!state.secrets.saleEnvelopes || typeof state.secrets.saleEnvelopes !== 'object') {
|
|
373
|
+
state.secrets.saleEnvelopes = {};
|
|
374
|
+
}
|
|
375
|
+
const install = state.secrets.install;
|
|
376
|
+
if (install?.ownerKey && install?.recipientSecretKeyB64 && install?.recipientPublicKeyB64) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const recipient = e2ee.generateRecipientKeyPair();
|
|
380
|
+
state.secrets.install = {
|
|
381
|
+
ownerKey: randomFieldElement(),
|
|
382
|
+
recipientSecretKeyB64: recipient.secretKeyB64,
|
|
383
|
+
recipientPublicKeyB64: recipient.publicKeyB64,
|
|
384
|
+
createdAt: new Date().toISOString(),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function exportImtLeaves(tree) {
|
|
389
|
+
return tree.leaves.map((leaf) => ({
|
|
390
|
+
value: BigInt(leaf.value).toString(10),
|
|
391
|
+
nextIndex: BigInt(leaf.nextIndex).toString(10),
|
|
392
|
+
nextValue: BigInt(leaf.nextValue).toString(10),
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function rebuildZkTrees(state, zkTree) {
|
|
397
|
+
const noteTree = new zkTree.NoteTree(undefined, state.noteLeaves.map((leaf) => BigInt(leaf)));
|
|
398
|
+
const nullifierTree = new zkTree.NullifierIMT();
|
|
399
|
+
|
|
400
|
+
if (Array.isArray(state.imtLeaves) && state.imtLeaves.length > 0) {
|
|
401
|
+
nullifierTree.leaves = state.imtLeaves.map((leaf) => ({
|
|
402
|
+
value: BigInt(leaf.value),
|
|
403
|
+
nextIndex: BigInt(leaf.nextIndex),
|
|
404
|
+
nextValue: BigInt(leaf.nextValue),
|
|
405
|
+
}));
|
|
406
|
+
} else if (Array.isArray(state.nullifierValues)) {
|
|
407
|
+
for (const value of state.nullifierValues) nullifierTree.insert(BigInt(value));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return { noteTree, nullifierTree };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function snapshotFromTrees(state, noteTree, nullifierTree) {
|
|
414
|
+
state.noteLeaves = noteTree.leaves.map((leaf) => BigInt(leaf).toString(10));
|
|
415
|
+
state.imtLeaves = exportImtLeaves(nullifierTree);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function reconcileState(state, onChain, zkTree) {
|
|
419
|
+
let changed = false;
|
|
420
|
+
const chainNoteCount = Number(onChain.noteCount);
|
|
421
|
+
const chainNullCount = Number(onChain.nullCount);
|
|
422
|
+
|
|
423
|
+
if (chainNoteCount < 0 || chainNullCount < 1) {
|
|
424
|
+
return { ok: false, reason: 'on-chain ZK counters are invalid' };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (state.noteLeaves.length > chainNoteCount) {
|
|
428
|
+
state.noteLeaves = state.noteLeaves.slice(0, chainNoteCount);
|
|
429
|
+
state.notes = state.notes.filter((note) => Number(note.index) < chainNoteCount);
|
|
430
|
+
changed = true;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const currentNullCount = Array.isArray(state.imtLeaves) && state.imtLeaves.length > 0
|
|
434
|
+
? state.imtLeaves.length
|
|
435
|
+
: 1 + state.nullifierValues.length;
|
|
436
|
+
if (currentNullCount > chainNullCount) {
|
|
437
|
+
if (state.nullifierValues.length !== currentNullCount - 1) {
|
|
438
|
+
return {
|
|
439
|
+
ok: false,
|
|
440
|
+
reason: `state drift: local null_count=${currentNullCount} is ahead of chain=${chainNullCount}, but the insertion log is incomplete`,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const rebuilt = new zkTree.NullifierIMT();
|
|
444
|
+
state.nullifierValues = state.nullifierValues.slice(0, chainNullCount - 1);
|
|
445
|
+
for (const value of state.nullifierValues) rebuilt.insert(BigInt(value));
|
|
446
|
+
state.imtLeaves = exportImtLeaves(rebuilt);
|
|
447
|
+
changed = true;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const localNullCount = Array.isArray(state.imtLeaves) && state.imtLeaves.length > 0
|
|
451
|
+
? state.imtLeaves.length
|
|
452
|
+
: 1 + state.nullifierValues.length;
|
|
453
|
+
if (state.noteLeaves.length < chainNoteCount || localNullCount < chainNullCount) {
|
|
454
|
+
return {
|
|
455
|
+
ok: false,
|
|
456
|
+
reason: `state drift: chain is ahead of local persistence (note_count chain=${chainNoteCount} local=${state.noteLeaves.length}; null_count chain=${chainNullCount} local=${localNullCount}). Historical leaves are not recoverable without an indexer; restore a matching zk-tree-state.json`,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let trees;
|
|
461
|
+
try {
|
|
462
|
+
trees = rebuildZkTrees(state, zkTree);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
return { ok: false, reason: `invalid local ZK tree snapshot: ${error.message}` };
|
|
465
|
+
}
|
|
466
|
+
const localNoteRoot = rootHex(trees.noteTree.root());
|
|
467
|
+
const localNullRoot = rootHex(trees.nullifierTree.root());
|
|
468
|
+
const chainNoteRoot = rootHex(onChain.noteRoot);
|
|
469
|
+
const chainNullRoot = rootHex(onChain.nullRoot);
|
|
470
|
+
if (localNoteRoot !== chainNoteRoot || localNullRoot !== chainNullRoot) {
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
reason: `state drift: local roots do not match chain at equal counts (note ${localNoteRoot.slice(0, 12)}… != ${chainNoteRoot.slice(0, 12)}…; null ${localNullRoot.slice(0, 12)}… != ${chainNullRoot.slice(0, 12)}…)`,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return { ok: true, changed, trees, localNoteRoot, localNullRoot };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function withTimeout(promise, timeoutMs, label) {
|
|
481
|
+
let timer;
|
|
482
|
+
try {
|
|
483
|
+
return await Promise.race([
|
|
484
|
+
promise,
|
|
485
|
+
new Promise((_, reject) => {
|
|
486
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
487
|
+
}),
|
|
488
|
+
]);
|
|
489
|
+
} finally {
|
|
490
|
+
clearTimeout(timer);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export async function loadRealDeps(options) {
|
|
495
|
+
const cortexDir = path.resolve(options.cortexDir || DEFAULT_CORTEX_DIR);
|
|
496
|
+
const require = createRequire(path.join(cortexDir, 'package.json'));
|
|
497
|
+
|
|
498
|
+
const { Connection, Keypair, PublicKey } = require('@solana/web3.js');
|
|
499
|
+
const snarkjs = require('snarkjs');
|
|
500
|
+
const zkDir = path.join(cortexDir, 'zk');
|
|
501
|
+
const zkTree = await import(pathToFileURL(path.join(zkDir, 'zk-tree.mjs')).href);
|
|
502
|
+
const witness = await import(pathToFileURL(path.join(zkDir, 'witness.mjs')).href);
|
|
503
|
+
const codec = await import(pathToFileURL(path.join(zkDir, 'zk-codec.mjs')).href);
|
|
504
|
+
const settle = await import(pathToFileURL(path.join(zkDir, 'zk-settle.mjs')).href);
|
|
505
|
+
const sale = await import(pathToFileURL(path.join(zkDir, 'sale-witness.mjs')).href);
|
|
506
|
+
const provingAssets = await import(pathToFileURL(path.join(zkDir, 'proving-assets.mjs')).href);
|
|
507
|
+
const e2ee = await import(pathToFileURL(path.join(REPO_ROOT, 'packages', 'vault', 'src', 'e2ee.mjs')).href);
|
|
508
|
+
const connection = new Connection(options.rpcUrl, 'confirmed');
|
|
509
|
+
const payerPath = path.resolve(options.payerPath || path.join(os.homedir(), '.config', 'solana', 'id.json'));
|
|
510
|
+
const payer = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(fs.readFileSync(payerPath, 'utf8'))));
|
|
511
|
+
const buildDir = path.resolve(options.buildDir || DEFAULT_BUILD_DIR);
|
|
512
|
+
const wasmPath = path.join(buildDir, 'action_transition_js', 'action_transition.wasm');
|
|
513
|
+
const zkeyPath = path.join(buildDir, 'action_transition.zkey');
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
// VK GATE: never prove against a key the chain would reject. Before every
|
|
517
|
+
// fullProve the manifest-pinned VK must exactly equal the on-chain VK PDA.
|
|
518
|
+
const prover = async (witnessInput) => {
|
|
519
|
+
const { vk } = await provingAssets.resolveVerifyingKey('action_transition');
|
|
520
|
+
await settle.assertOnChainVerifyingKey(connection, 1, vk);
|
|
521
|
+
// Packaged installs: resolve via the checksum-pinned asset resolver
|
|
522
|
+
// (source checkout → verified cache → pinned download), never raw paths.
|
|
523
|
+
const assets = await provingAssets.resolveProvingAssets('action_transition');
|
|
524
|
+
const { proof, publicSignals } = await snarkjs.groth16.fullProve(witnessInput, assets.wasm, assets.zkey);
|
|
525
|
+
return {
|
|
526
|
+
proofBytes: codec.proofFromSnarkjs(proof),
|
|
527
|
+
publicInputs: codec.publicInputsFromSnarkjs(publicSignals),
|
|
528
|
+
};
|
|
529
|
+
};
|
|
530
|
+
// Sealed-sale prover. Proving keys are NOT shipped in the npm package, so
|
|
531
|
+
// the wasm/zkey resolve through the checksum-pinned asset resolver (source
|
|
532
|
+
// checkout → verified cache → pinned release download) instead of reading
|
|
533
|
+
// circuits/build directly; it fails closed with ProvingAssetError (code +
|
|
534
|
+
// remediation) when no verified artifact is available.
|
|
535
|
+
const saleProver = async (witnessInput) => {
|
|
536
|
+
const { vk } = await provingAssets.resolveVerifyingKey('sealed_sale_release');
|
|
537
|
+
await settle.assertOnChainVerifyingKey(connection, 3, vk);
|
|
538
|
+
const assets = await provingAssets.resolveProvingAssets('sealed_sale_release');
|
|
539
|
+
const { proof, publicSignals } = await snarkjs.groth16.fullProve(witnessInput, assets.wasm, assets.zkey);
|
|
540
|
+
return {
|
|
541
|
+
proofBytes: codec.proofFromSnarkjs(proof),
|
|
542
|
+
publicInputs: codec.publicInputsFromSnarkjs(publicSignals),
|
|
543
|
+
};
|
|
544
|
+
};
|
|
545
|
+
return { zkTree, witness, codec, settle, sale, e2ee, web3: { Keypair, PublicKey }, connection, payer, prover, saleProver, provingAssets };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export async function createZkRuntime(options = {}) {
|
|
549
|
+
const rpcUrl = options.rpcUrl || process.env.ENIGMA_ZK_RPC_URL || process.env.SOLANA_RPC_URL || 'http://127.0.0.1:8899';
|
|
550
|
+
const cluster = options.cluster || clusterForRpc(rpcUrl);
|
|
551
|
+
const buildDir = path.resolve(options.buildDir || DEFAULT_BUILD_DIR);
|
|
552
|
+
const statePath = path.resolve(options.statePath);
|
|
553
|
+
const custody = {
|
|
554
|
+
safeStorage: options.safeStorage,
|
|
555
|
+
customKey: options.customKey,
|
|
556
|
+
allowHostBoundDemo: options.allowHostBoundDemo === true,
|
|
557
|
+
};
|
|
558
|
+
const artifactPaths = {
|
|
559
|
+
wasm: path.join(buildDir, 'action_transition_js', 'action_transition.wasm'),
|
|
560
|
+
zkey: path.join(buildDir, 'action_transition.zkey'),
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// Packaged installs do not ship proving keys (40MB+, gitignored). The real
|
|
564
|
+
// action-circuit paths therefore resolve through the checksum-pinned asset
|
|
565
|
+
// resolver (source checkout → verified cache → pinned release download).
|
|
566
|
+
// With injected deps (tests) the source-checkout defaults are returned.
|
|
567
|
+
let resolvedActionAssets = null;
|
|
568
|
+
async function getProvingAssetPaths() {
|
|
569
|
+
if (options.deps) return artifactPaths;
|
|
570
|
+
if (resolvedActionAssets) return resolvedActionAssets;
|
|
571
|
+
const activeDeps = await ensureDeps();
|
|
572
|
+
resolvedActionAssets = await activeDeps.provingAssets.resolveProvingAssets('action_transition');
|
|
573
|
+
return resolvedActionAssets;
|
|
574
|
+
}
|
|
575
|
+
// Availability probe for status(): never downloads, never throws.
|
|
576
|
+
async function probeProvingAssets() {
|
|
577
|
+
if (options.deps) {
|
|
578
|
+
return fs.existsSync(artifactPaths.wasm) && fs.existsSync(artifactPaths.zkey);
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
const activeDeps = await ensureDeps();
|
|
582
|
+
await activeDeps.provingAssets.resolveProvingAssets('action_transition', { allowDownload: false });
|
|
583
|
+
return true;
|
|
584
|
+
} catch {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
let deps = options.deps || null;
|
|
590
|
+
let depsPromise = null;
|
|
591
|
+
const timeoutMs = Number(options.rpcTimeoutMs || 5_000);
|
|
592
|
+
// Sealed Sale (ZKCP) constants. Saleable notes commit a DEK-blinded
|
|
593
|
+
// Poseidon fold of the plaintext (payloadCommitPos) as their tree
|
|
594
|
+
// payloadCommit, so the sealed_sale_release circuit's Leg A membership
|
|
595
|
+
// check passes against the on-chain leaf. The plaintext itself stays in
|
|
596
|
+
// the E2EE sealed note for local custody recovery at listing time.
|
|
597
|
+
const SALE_PAYLOAD_CAPACITY = 992; // 32 chunks × 31-byte LE limbs
|
|
598
|
+
const WRAPPED_SOL_MINT = 'So11111111111111111111111111111111111111112';
|
|
599
|
+
let sharedStatePda = publicKeyString(deps?.settle?.SHARED_STATE_PDA || options.sharedStatePda);
|
|
600
|
+
let programId = publicKeyString(deps?.settle?.PROGRAM_ID || options.programId);
|
|
601
|
+
|
|
602
|
+
async function ensureDeps() {
|
|
603
|
+
if (deps) return deps;
|
|
604
|
+
if (!depsPromise) {
|
|
605
|
+
depsPromise = loadRealDeps({ ...options, rpcUrl, buildDir }).then((loaded) => {
|
|
606
|
+
deps = loaded;
|
|
607
|
+
sharedStatePda = publicKeyString(loaded.settle.SHARED_STATE_PDA || options.sharedStatePda);
|
|
608
|
+
programId = publicKeyString(loaded.settle.PROGRAM_ID || options.programId);
|
|
609
|
+
return loaded;
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
return depsPromise;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const statusBase = () => ({
|
|
616
|
+
available: false,
|
|
617
|
+
reason: null,
|
|
618
|
+
cluster,
|
|
619
|
+
sharedStatePda,
|
|
620
|
+
programId,
|
|
621
|
+
noteRoot: null,
|
|
622
|
+
nullRoot: null,
|
|
623
|
+
noteCount: null,
|
|
624
|
+
nullCount: null,
|
|
625
|
+
localNoteCount: null,
|
|
626
|
+
localNullCount: null,
|
|
627
|
+
provingArtifacts: null, // filled by status() via probeProvingAssets()
|
|
628
|
+
honesty: ZK_HONESTY_NOTE,
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
async function fetchOnChain(activeDeps) {
|
|
632
|
+
return withTimeout(
|
|
633
|
+
activeDeps.settle.fetchSharedState(activeDeps.connection),
|
|
634
|
+
timeoutMs,
|
|
635
|
+
'validator request',
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Shared chain/local reconciliation. READ-ONLY callers (status) get a report;
|
|
640
|
+
// operational callers (requireReady) get a hard failure — external writers
|
|
641
|
+
// (multi-party use) advance the on-chain tree, and witnesses built from a
|
|
642
|
+
// stale local snapshot would be rejected on-chain. Returns {state, reconciled}
|
|
643
|
+
// or throws ZK_UNAVAILABLE with the reason.
|
|
644
|
+
async function reconcileForOperation(activeDeps, onChain) {
|
|
645
|
+
const state = readZkState(statePath) || emptySnapshot();
|
|
646
|
+
const reconciled = reconcileState(state, onChain, activeDeps.zkTree);
|
|
647
|
+
if (!reconciled.ok) {
|
|
648
|
+
const error = new Error(reconciled.reason);
|
|
649
|
+
error.code = 'ZK_UNAVAILABLE';
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
if (reconciled.changed) {
|
|
653
|
+
if (state.secretEnvelope) {
|
|
654
|
+
const error = new Error('chain rolled back behind the sealed local snapshot (validator reset?); refusing to rewrite public state under a sealed envelope — reset zk-tree-state.json');
|
|
655
|
+
error.code = 'ZK_UNAVAILABLE';
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
658
|
+
writeZkState(statePath, state, custody);
|
|
659
|
+
}
|
|
660
|
+
return state;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Read-only by design: status NEVER decrypts the secret envelope, so the
|
|
664
|
+
// endpoint cannot leak openings even if the UI is compromised.
|
|
665
|
+
async function status() {
|
|
666
|
+
const out = statusBase();
|
|
667
|
+
out.provingArtifacts = await probeProvingAssets();
|
|
668
|
+
if (!out.provingArtifacts) {
|
|
669
|
+
out.reason = `proving artifacts missing for ${artifactPaths.wasm} / ${artifactPaths.zkey} — ` +
|
|
670
|
+
'packaged installs download them on first use (checksum-pinned), or run the setup script in a source checkout';
|
|
671
|
+
return out;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let activeDeps;
|
|
675
|
+
try {
|
|
676
|
+
activeDeps = await ensureDeps();
|
|
677
|
+
out.sharedStatePda = sharedStatePda;
|
|
678
|
+
out.programId = programId;
|
|
679
|
+
} catch (error) {
|
|
680
|
+
out.reason = `ZK client dependencies unavailable: ${error.message}`;
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
let onChain;
|
|
685
|
+
try {
|
|
686
|
+
onChain = await fetchOnChain(activeDeps);
|
|
687
|
+
} catch (error) {
|
|
688
|
+
out.reason = `validator unavailable at ${rpcUrl}: ${error.message}`;
|
|
689
|
+
return out;
|
|
690
|
+
}
|
|
691
|
+
if (!onChain) {
|
|
692
|
+
out.reason = `shared ZK state is not initialized at ${sharedStatePda || 'the configured PDA'}`;
|
|
693
|
+
return out;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
out.noteRoot = rootHex(onChain.noteRoot);
|
|
697
|
+
out.nullRoot = rootHex(onChain.nullRoot);
|
|
698
|
+
out.noteCount = Number(onChain.noteCount);
|
|
699
|
+
out.nullCount = Number(onChain.nullCount);
|
|
700
|
+
|
|
701
|
+
let state;
|
|
702
|
+
try {
|
|
703
|
+
state = readZkState(statePath) || emptySnapshot();
|
|
704
|
+
} catch (error) {
|
|
705
|
+
out.reason = `local ZK state unreadable: ${error.message}`;
|
|
706
|
+
return out;
|
|
707
|
+
}
|
|
708
|
+
out.localNoteCount = state.noteLeaves.length;
|
|
709
|
+
out.localNullCount = Array.isArray(state.imtLeaves) && state.imtLeaves.length > 0
|
|
710
|
+
? state.imtLeaves.length
|
|
711
|
+
: 1 + state.nullifierValues.length;
|
|
712
|
+
try {
|
|
713
|
+
state = await reconcileForOperation(activeDeps, onChain);
|
|
714
|
+
} catch (error) {
|
|
715
|
+
out.reason = error.message;
|
|
716
|
+
return out;
|
|
717
|
+
}
|
|
718
|
+
out.available = true;
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
// VK GATE (job acceptance): /api/zk/prepare, /api/zk/relay, and the sale
|
|
724
|
+
// endpoints refuse work up front when the on-chain verifying key for the
|
|
725
|
+
// circuit is absent or differs from the manifest-pinned VK — a proof made
|
|
726
|
+
// against the wrong ceremony could never settle. The provers in
|
|
727
|
+
// loadRealDeps re-assert the same gate immediately before fullProve.
|
|
728
|
+
const VK_GATE_CIRCUITS = {
|
|
729
|
+
action: { circuit: 'action_transition', version: 1 },
|
|
730
|
+
intent: { circuit: 'intent_attestation', version: 2 },
|
|
731
|
+
sale: { circuit: 'sealed_sale_release', version: 3 },
|
|
732
|
+
};
|
|
733
|
+
async function assertVkGate(activeDeps, mode) {
|
|
734
|
+
const spec = VK_GATE_CIRCUITS[mode];
|
|
735
|
+
try {
|
|
736
|
+
const { vk } = await activeDeps.provingAssets.resolveVerifyingKey(spec.circuit);
|
|
737
|
+
await activeDeps.settle.assertOnChainVerifyingKey(activeDeps.connection, spec.version, vk);
|
|
738
|
+
} catch (err) {
|
|
739
|
+
if (err.name === 'ProvingAssetError') throw err;
|
|
740
|
+
const wrapped = new Error(`verifying-key gate refused ${spec.circuit} (circuit version ${spec.version}): ${err.message}`);
|
|
741
|
+
wrapped.code = 'VK_GATE_REJECTED';
|
|
742
|
+
throw wrapped;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Operational readiness RESOLVES proving assets (downloading checksum-pinned
|
|
747
|
+
// artifacts on first use) instead of probing with downloads disabled — the
|
|
748
|
+
// status() probe is a read-only report and must not gate operations, or a
|
|
749
|
+
// fresh packaged install deadlocks (status says unavailable, operations
|
|
750
|
+
// never reach the resolver).
|
|
751
|
+
async function requireReady() {
|
|
752
|
+
const activeDeps = await ensureDeps();
|
|
753
|
+
try {
|
|
754
|
+
await getProvingAssetPaths();
|
|
755
|
+
} catch (err) {
|
|
756
|
+
const error = new Error(`proving assets unavailable: ${err.message}`);
|
|
757
|
+
error.code = 'ZK_UNAVAILABLE';
|
|
758
|
+
throw error;
|
|
759
|
+
}
|
|
760
|
+
let onChain;
|
|
761
|
+
try {
|
|
762
|
+
onChain = await fetchOnChain(activeDeps);
|
|
763
|
+
} catch (err) {
|
|
764
|
+
const error = new Error(`validator unavailable at ${rpcUrl}: ${err.message}`);
|
|
765
|
+
error.code = 'ZK_UNAVAILABLE';
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
if (!onChain) {
|
|
769
|
+
const error = new Error(`shared ZK state is not initialized at ${sharedStatePda || 'the configured PDA'}`);
|
|
770
|
+
error.code = 'ZK_UNAVAILABLE';
|
|
771
|
+
throw error;
|
|
772
|
+
}
|
|
773
|
+
// Reconcile external writers' on-chain growth into the local snapshot
|
|
774
|
+
// BEFORE rebuilding trees (multi-party invariant; shared with status()).
|
|
775
|
+
const state = await reconcileForOperation(activeDeps, onChain);
|
|
776
|
+
if (state.secretEnvelope) {
|
|
777
|
+
state.secrets = unlockZkState(state, custody);
|
|
778
|
+
}
|
|
779
|
+
ensureInstallSecrets(state, activeDeps.e2ee);
|
|
780
|
+
return { state, deps: activeDeps, onChain, ...rebuildZkTrees(state, activeDeps.zkTree) };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// --- Split-phase proving (browser prover + relayer) -------------------------
|
|
784
|
+
// prepare* builds the witness and captures the pending values WITHOUT
|
|
785
|
+
// settling or persisting; relayAction re-derives the witness
|
|
786
|
+
// deterministically, refuses to settle on any drift, and only then applies
|
|
787
|
+
// the exact state mutation the one-shot append/revoke paths use.
|
|
788
|
+
|
|
789
|
+
function expectedActionPublicInputs(witnessInput, codec) {
|
|
790
|
+
return [
|
|
791
|
+
witnessInput.oldNoteRoot,
|
|
792
|
+
witnessInput.newNoteRoot,
|
|
793
|
+
witnessInput.oldNullRoot,
|
|
794
|
+
witnessInput.newNullRoot,
|
|
795
|
+
witnessInput.nullifier,
|
|
796
|
+
witnessInput.packed,
|
|
797
|
+
].map((value) => codec.frToBytes32BE(value));
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function publicInputsEqual(left, right) {
|
|
801
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
802
|
+
return left.every((value, index) => Buffer.from(value).equals(Buffer.from(right[index])));
|
|
803
|
+
}
|
|
804
|
+
function assertProverPublicInputs(expected, actual, label) {
|
|
805
|
+
if (!publicInputsEqual(expected, actual)) {
|
|
806
|
+
const error = new Error(`${label} prover returned public inputs that do not match the locally built witness; refusing settlement and persistence`);
|
|
807
|
+
error.code = 'PROOF_STATE_MISMATCH';
|
|
808
|
+
throw error;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
function applyAppendOutcome(state, { scope, sealed, rho, sig, sale }, noteTree, nullifierTree) {
|
|
814
|
+
const index = state.noteLeaves.length;
|
|
815
|
+
// Public metadata only; openings go to the encrypted envelope.
|
|
816
|
+
state.notes.push({
|
|
817
|
+
index,
|
|
818
|
+
scope,
|
|
819
|
+
sealed: {
|
|
820
|
+
ciphertextB64: sealed.ciphertextB64,
|
|
821
|
+
nonceB64: sealed.nonceB64,
|
|
822
|
+
envelopes: sealed.envelopes,
|
|
823
|
+
},
|
|
824
|
+
appendSig: sig,
|
|
825
|
+
appendedAt: new Date().toISOString(),
|
|
826
|
+
...(sale ? { saleable: true } : {}),
|
|
827
|
+
});
|
|
828
|
+
state.secrets.noteOpenings[String(index)] = {
|
|
829
|
+
rho,
|
|
830
|
+
payloadCommit: sale ? sale.payloadCommitPos : sealed.payloadCommit,
|
|
831
|
+
scope,
|
|
832
|
+
...(sale ? { dekLo: sale.dekLo, dekHi: sale.dekHi } : {}),
|
|
833
|
+
};
|
|
834
|
+
snapshotFromTrees(state, noteTree, nullifierTree);
|
|
835
|
+
return index;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function applyRevokeOutcome(state, { noteIndex, nullifier, sig }, noteTree, nullifierTree) {
|
|
839
|
+
const note = state.notes.find((entry) => Number(entry.index) === noteIndex);
|
|
840
|
+
if (note) {
|
|
841
|
+
note.revokeSig = sig;
|
|
842
|
+
note.revokedAt = new Date().toISOString();
|
|
843
|
+
}
|
|
844
|
+
state.nullifierValues.push(nullifier);
|
|
845
|
+
snapshotFromTrees(state, noteTree, nullifierTree);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function publicSettlementState(onChain, extra = {}) {
|
|
849
|
+
return {
|
|
850
|
+
note_root: rootHex(onChain.noteRoot),
|
|
851
|
+
null_root: rootHex(onChain.nullRoot),
|
|
852
|
+
note_count: Number(onChain.noteCount),
|
|
853
|
+
null_count: Number(onChain.nullCount),
|
|
854
|
+
...extra,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function attachSettlementEvidence(result, state, activeDeps, {
|
|
859
|
+
event,
|
|
860
|
+
proofBytes = null,
|
|
861
|
+
publicInputs = [],
|
|
862
|
+
predecessorRefs = [],
|
|
863
|
+
evidenceState,
|
|
864
|
+
}) {
|
|
865
|
+
const settlementReceipt = createZkSettlementEvidence({
|
|
866
|
+
event,
|
|
867
|
+
programId,
|
|
868
|
+
cluster,
|
|
869
|
+
transactionSignature: result.sig ?? result.signature ?? null,
|
|
870
|
+
explorerUrl: result.explorerUrl ?? null,
|
|
871
|
+
proofBytes,
|
|
872
|
+
publicInputs,
|
|
873
|
+
state: evidenceState,
|
|
874
|
+
signerSecret: state.secrets.install.ownerKey,
|
|
875
|
+
signerId: publicKeyString(activeDeps.payer?.publicKey),
|
|
876
|
+
predecessorRefs,
|
|
877
|
+
});
|
|
878
|
+
return {
|
|
879
|
+
...result,
|
|
880
|
+
receiptRef: settlementReceipt.receipt_ref,
|
|
881
|
+
settlementReceipt,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function settledResult(activeDeps, state, sig, extra, evidence) {
|
|
886
|
+
const after = await fetchOnChain(activeDeps);
|
|
887
|
+
const result = {
|
|
888
|
+
sig,
|
|
889
|
+
explorerUrl: explorerUrlFor(sig, cluster),
|
|
890
|
+
cluster,
|
|
891
|
+
noteCount: Number(after.noteCount),
|
|
892
|
+
nullCount: Number(after.nullCount),
|
|
893
|
+
noteRoot: rootHex(after.noteRoot),
|
|
894
|
+
nullRoot: rootHex(after.nullRoot),
|
|
895
|
+
...extra,
|
|
896
|
+
};
|
|
897
|
+
const evidenceState = publicSettlementState(after, evidence.evidenceState);
|
|
898
|
+
if (evidence.event === 'action.append' || evidence.event === 'action.revoke') {
|
|
899
|
+
const appendMode = evidence.event === 'action.append';
|
|
900
|
+
evidenceState.previous_note_root = Buffer.from(evidence.publicInputs[0]).toString('hex');
|
|
901
|
+
evidenceState.previous_null_root = Buffer.from(evidence.publicInputs[2]).toString('hex');
|
|
902
|
+
evidenceState.previous_note_count = evidenceState.note_count - (appendMode ? 1 : 0);
|
|
903
|
+
evidenceState.previous_null_count = evidenceState.null_count - (appendMode ? 0 : 1);
|
|
904
|
+
}
|
|
905
|
+
return attachSettlementEvidence(result, state, activeDeps, {
|
|
906
|
+
...evidence,
|
|
907
|
+
evidenceState,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
// DEK-blinded plaintext commitment for a saleable note: fresh 128-bit DEK
|
|
913
|
+
// limbs per note; the blinded fold replaces the E2EE payloadCommit as the
|
|
914
|
+
// tree-committed value (the action circuit binds any field element).
|
|
915
|
+
function sealSalePayload(sale, plaintext) {
|
|
916
|
+
const plaintextBytes = Buffer.from(plaintext, 'utf8');
|
|
917
|
+
if (plaintextBytes.length < 1 || plaintextBytes.length > SALE_PAYLOAD_CAPACITY) {
|
|
918
|
+
const error = new Error(`saleable memories are limited to ${SALE_PAYLOAD_CAPACITY} bytes (sealed_sale_release circuit capacity); got ${plaintextBytes.length}`);
|
|
919
|
+
error.code = 'BAD_REQUEST';
|
|
920
|
+
throw error;
|
|
921
|
+
}
|
|
922
|
+
const dekLo = BigInt(`0x${crypto.randomBytes(16).toString('hex')}`).toString(10);
|
|
923
|
+
const dekHi = BigInt(`0x${crypto.randomBytes(16).toString('hex')}`).toString(10);
|
|
924
|
+
const payloadCommitPos = sale.foldChunksBlind(sale.bytesToChunks(plaintextBytes), dekLo, dekHi).toString(10);
|
|
925
|
+
return { dekLo, dekHi, payloadCommitPos };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
async function append({ plaintext, scope, saleable = false }) {
|
|
929
|
+
const { state, deps: activeDeps, noteTree, nullifierTree } = await requireReady();
|
|
930
|
+
const ownerKey = state.secrets.install.ownerKey;
|
|
931
|
+
// Fresh rho PER NOTE: reusing one rho would give every note the same
|
|
932
|
+
// nullifier Poseidon(DOMAIN_REVOCATION, ownerKey, rho) and make all but
|
|
933
|
+
// the first revocation unprovable.
|
|
934
|
+
const rho = randomFieldElement();
|
|
935
|
+
const noteContext = activeDeps.e2ee.deriveNoteContext({ ownerKey, rho, scope });
|
|
936
|
+
const sealed = await activeDeps.e2ee.e2eeEncrypt({
|
|
937
|
+
plaintext,
|
|
938
|
+
recipients: [state.secrets.install.recipientPublicKeyB64],
|
|
939
|
+
scope,
|
|
940
|
+
noteContext,
|
|
941
|
+
});
|
|
942
|
+
const sale = saleable ? sealSalePayload(activeDeps.sale, plaintext) : null;
|
|
943
|
+
|
|
944
|
+
const witnessInput = activeDeps.witness.buildActionWitness({
|
|
945
|
+
mode: 'append', noteTree, nullifierTree, ownerKey, rho,
|
|
946
|
+
payloadCommit: sale ? sale.payloadCommitPos : sealed.payloadCommit,
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
await assertVkGate(activeDeps, 'action');
|
|
950
|
+
const proveStartedAt = Date.now();
|
|
951
|
+
const { proofBytes, publicInputs } = await activeDeps.prover(witnessInput);
|
|
952
|
+
assertProverPublicInputs(
|
|
953
|
+
expectedActionPublicInputs(witnessInput, activeDeps.codec),
|
|
954
|
+
publicInputs,
|
|
955
|
+
'action append',
|
|
956
|
+
);
|
|
957
|
+
const proveMs = Date.now() - proveStartedAt;
|
|
958
|
+
const sig = await activeDeps.settle.settleAction(
|
|
959
|
+
activeDeps.connection,
|
|
960
|
+
activeDeps.payer,
|
|
961
|
+
proofBytes,
|
|
962
|
+
publicInputs,
|
|
963
|
+
1,
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
const index = applyAppendOutcome(state, { scope, sealed, rho, sig, sale }, noteTree, nullifierTree);
|
|
967
|
+
writeZkState(statePath, state, custody);
|
|
968
|
+
return settledResult(activeDeps, state, sig, { noteIndex: index, proveMs }, {
|
|
969
|
+
event: 'action.append',
|
|
970
|
+
proofBytes,
|
|
971
|
+
publicInputs,
|
|
972
|
+
evidenceState: {
|
|
973
|
+
note_root: Buffer.from(publicInputs[1]).toString('hex'),
|
|
974
|
+
null_root: Buffer.from(publicInputs[3]).toString('hex'),
|
|
975
|
+
note_index: index,
|
|
976
|
+
},
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function revoke({ noteIndex }) {
|
|
981
|
+
const { state, deps: activeDeps, noteTree, nullifierTree } = await requireReady();
|
|
982
|
+
const opening = state.secrets.noteOpenings[String(noteIndex)];
|
|
983
|
+
if (!opening?.rho || !opening?.payloadCommit) {
|
|
984
|
+
const error = new Error(`noteIndex ${noteIndex} has no local opening; only notes created by this persisted install can be revoked`);
|
|
985
|
+
error.code = 'NOTE_OPENING_MISSING';
|
|
986
|
+
throw error;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const witnessInput = activeDeps.witness.buildActionWitness({
|
|
990
|
+
mode: 'revoke', noteTree, nullifierTree,
|
|
991
|
+
// Per-note identity wins: third-party writers (e.g. the multi-agent
|
|
992
|
+
// demo) custody their own ownerKey per opening; desktop-created notes
|
|
993
|
+
// fall back to the install identity.
|
|
994
|
+
ownerKey: opening.ownerKey ?? state.secrets.install.ownerKey,
|
|
995
|
+
rho: opening.rho,
|
|
996
|
+
payloadCommit: opening.payloadCommit,
|
|
997
|
+
noteIndex,
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
await assertVkGate(activeDeps, 'action');
|
|
1001
|
+
const proveStartedAt = Date.now();
|
|
1002
|
+
const { proofBytes, publicInputs } = await activeDeps.prover(witnessInput);
|
|
1003
|
+
assertProverPublicInputs(
|
|
1004
|
+
expectedActionPublicInputs(witnessInput, activeDeps.codec),
|
|
1005
|
+
publicInputs,
|
|
1006
|
+
'action revoke',
|
|
1007
|
+
);
|
|
1008
|
+
const proveMs = Date.now() - proveStartedAt;
|
|
1009
|
+
const sig = await activeDeps.settle.settleAction(
|
|
1010
|
+
activeDeps.connection,
|
|
1011
|
+
activeDeps.payer,
|
|
1012
|
+
proofBytes,
|
|
1013
|
+
publicInputs,
|
|
1014
|
+
1,
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
applyRevokeOutcome(state, { noteIndex, nullifier: witnessInput.nullifier, sig }, noteTree, nullifierTree);
|
|
1018
|
+
writeZkState(statePath, state, custody);
|
|
1019
|
+
return settledResult(activeDeps, state, sig, { noteIndex, proveMs }, {
|
|
1020
|
+
event: 'action.revoke',
|
|
1021
|
+
proofBytes,
|
|
1022
|
+
publicInputs,
|
|
1023
|
+
evidenceState: {
|
|
1024
|
+
note_root: Buffer.from(publicInputs[1]).toString('hex'),
|
|
1025
|
+
null_root: Buffer.from(publicInputs[3]).toString('hex'),
|
|
1026
|
+
note_index: noteIndex,
|
|
1027
|
+
},
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// A fresh install generates its identity lazily inside requireReady. The
|
|
1032
|
+
// split-phase flow MUST persist that identity at prepare time: relayAction
|
|
1033
|
+
// re-reads the state file, and a regenerated ownerKey would make the
|
|
1034
|
+
// rebuilt witness diverge from the browser's proof. Only the sealed
|
|
1035
|
+
// envelope is written here — notes/nullifiers persist exclusively after a
|
|
1036
|
+
// confirmed settlement.
|
|
1037
|
+
function hasPersistedInstall() {
|
|
1038
|
+
try {
|
|
1039
|
+
return Boolean(readZkState(statePath)?.secretEnvelope);
|
|
1040
|
+
} catch {
|
|
1041
|
+
return false;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Split-phase append: build the private proving input and pending mutation
|
|
1046
|
+
// inside custody. The server proves it locally and exposes only an opaque
|
|
1047
|
+
// single-use handle to the renderer.
|
|
1048
|
+
|
|
1049
|
+
async function prepareAppend({ plaintext, scope, saleable = false }) {
|
|
1050
|
+
const installPersisted = hasPersistedInstall();
|
|
1051
|
+
const { state, deps: activeDeps, noteTree, nullifierTree } = await requireReady();
|
|
1052
|
+
if (!installPersisted) writeZkState(statePath, state, custody);
|
|
1053
|
+
const ownerKey = state.secrets.install.ownerKey;
|
|
1054
|
+
// Fresh rho PER NOTE, same invariant as the one-shot append path.
|
|
1055
|
+
const rho = randomFieldElement();
|
|
1056
|
+
const noteContext = activeDeps.e2ee.deriveNoteContext({ ownerKey, rho, scope });
|
|
1057
|
+
const sealed = await activeDeps.e2ee.e2eeEncrypt({
|
|
1058
|
+
plaintext,
|
|
1059
|
+
recipients: [state.secrets.install.recipientPublicKeyB64],
|
|
1060
|
+
scope,
|
|
1061
|
+
noteContext,
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
const sale = saleable ? sealSalePayload(activeDeps.sale, plaintext) : null;
|
|
1065
|
+
await assertVkGate(activeDeps, 'action');
|
|
1066
|
+
const witnessInput = activeDeps.witness.buildActionWitness({
|
|
1067
|
+
mode: 'append', noteTree, nullifierTree, ownerKey, rho,
|
|
1068
|
+
payloadCommit: sale ? sale.payloadCommitPos : sealed.payloadCommit,
|
|
1069
|
+
});
|
|
1070
|
+
return { witnessInput, pending: { mode: 'append', scope, rho, sealed, sale } };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async function prepareRevoke({ noteIndex }) {
|
|
1074
|
+
const installPersisted = hasPersistedInstall();
|
|
1075
|
+
const { state, deps: activeDeps, noteTree, nullifierTree } = await requireReady();
|
|
1076
|
+
if (!installPersisted) writeZkState(statePath, state, custody);
|
|
1077
|
+
const opening = state.secrets.noteOpenings[String(noteIndex)];
|
|
1078
|
+
if (!opening?.rho || !opening?.payloadCommit) {
|
|
1079
|
+
const error = new Error(`noteIndex ${noteIndex} has no local opening; only notes created by this persisted install can be revoked`);
|
|
1080
|
+
error.code = 'NOTE_OPENING_MISSING';
|
|
1081
|
+
throw error;
|
|
1082
|
+
}
|
|
1083
|
+
const witnessInput = activeDeps.witness.buildActionWitness({
|
|
1084
|
+
mode: 'revoke', noteTree, nullifierTree,
|
|
1085
|
+
ownerKey: opening.ownerKey ?? state.secrets.install.ownerKey,
|
|
1086
|
+
rho: opening.rho,
|
|
1087
|
+
|
|
1088
|
+
payloadCommit: opening.payloadCommit,
|
|
1089
|
+
noteIndex,
|
|
1090
|
+
});
|
|
1091
|
+
await assertVkGate(activeDeps, 'action');
|
|
1092
|
+
return { witnessInput, pending: { mode: 'revoke', noteIndex } };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async function provePreparedAction(witnessInput) {
|
|
1096
|
+
const activeDeps = await ensureDeps();
|
|
1097
|
+
try {
|
|
1098
|
+
const { proofBytes, publicInputs } = await activeDeps.prover(witnessInput);
|
|
1099
|
+
assertProverPublicInputs(
|
|
1100
|
+
expectedActionPublicInputs(witnessInput, activeDeps.codec),
|
|
1101
|
+
publicInputs,
|
|
1102
|
+
'prepared action',
|
|
1103
|
+
);
|
|
1104
|
+
return { proofBytes, publicInputs };
|
|
1105
|
+
} finally {
|
|
1106
|
+
zeroizePrivateObject(witnessInput);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Settlement for a custody-local action proof. The private proving input is
|
|
1111
|
+
// rebuilt deterministically from pending values; if shared state drifted
|
|
1112
|
+
// after preparation, the proof/public inputs are rejected before settlement.
|
|
1113
|
+
async function relayAction({ pending, proofBytes, publicInputs }) {
|
|
1114
|
+
if (!pending || (pending.mode !== 'append' && pending.mode !== 'revoke')) {
|
|
1115
|
+
throw new Error('relayAction requires a pending prepare session (mode append|revoke)');
|
|
1116
|
+
}
|
|
1117
|
+
const { state, deps: activeDeps, noteTree, nullifierTree } = await requireReady();
|
|
1118
|
+
|
|
1119
|
+
let witnessInput;
|
|
1120
|
+
if (pending.mode === 'append') {
|
|
1121
|
+
witnessInput = activeDeps.witness.buildActionWitness({
|
|
1122
|
+
mode: 'append', noteTree, nullifierTree,
|
|
1123
|
+
ownerKey: state.secrets.install.ownerKey,
|
|
1124
|
+
rho: pending.rho,
|
|
1125
|
+
payloadCommit: pending.sale ? pending.sale.payloadCommitPos : pending.sealed.payloadCommit,
|
|
1126
|
+
});
|
|
1127
|
+
} else {
|
|
1128
|
+
const opening = state.secrets.noteOpenings[String(pending.noteIndex)];
|
|
1129
|
+
if (!opening?.rho || !opening?.payloadCommit) {
|
|
1130
|
+
const error = new Error(`noteIndex ${pending.noteIndex} has no local opening; only notes created by this persisted install can be revoked`);
|
|
1131
|
+
error.code = 'NOTE_OPENING_MISSING';
|
|
1132
|
+
throw error;
|
|
1133
|
+
}
|
|
1134
|
+
witnessInput = activeDeps.witness.buildActionWitness({
|
|
1135
|
+
mode: 'revoke', noteTree, nullifierTree,
|
|
1136
|
+
ownerKey: opening.ownerKey ?? state.secrets.install.ownerKey,
|
|
1137
|
+
rho: opening.rho,
|
|
1138
|
+
payloadCommit: opening.payloadCommit,
|
|
1139
|
+
noteIndex: pending.noteIndex,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const expected = expectedActionPublicInputs(witnessInput, activeDeps.codec);
|
|
1144
|
+
if (!publicInputsEqual(expected, publicInputs)) {
|
|
1145
|
+
const error = new Error('proved public inputs do not match the current shared tree state (state drifted after prepare); re-run prepare and prove again');
|
|
1146
|
+
error.code = 'PROOF_STATE_MISMATCH';
|
|
1147
|
+
throw error;
|
|
1148
|
+
}
|
|
1149
|
+
await assertVkGate(activeDeps, 'action');
|
|
1150
|
+
const sig = await activeDeps.settle.settleAction(
|
|
1151
|
+
activeDeps.connection,
|
|
1152
|
+
activeDeps.payer,
|
|
1153
|
+
proofBytes,
|
|
1154
|
+
publicInputs,
|
|
1155
|
+
1,
|
|
1156
|
+
);
|
|
1157
|
+
|
|
1158
|
+
let extra;
|
|
1159
|
+
if (pending.mode === 'append') {
|
|
1160
|
+
const index = applyAppendOutcome(state, { scope: pending.scope, sealed: pending.sealed, rho: pending.rho, sig, sale: pending.sale || null }, noteTree, nullifierTree);
|
|
1161
|
+
extra = { noteIndex: index };
|
|
1162
|
+
} else {
|
|
1163
|
+
applyRevokeOutcome(state, { noteIndex: pending.noteIndex, nullifier: witnessInput.nullifier, sig }, noteTree, nullifierTree);
|
|
1164
|
+
extra = { noteIndex: pending.noteIndex };
|
|
1165
|
+
}
|
|
1166
|
+
writeZkState(statePath, state, custody);
|
|
1167
|
+
return settledResult(activeDeps, state, sig, extra, {
|
|
1168
|
+
event: pending.mode === 'append' ? 'action.append' : 'action.revoke',
|
|
1169
|
+
proofBytes,
|
|
1170
|
+
publicInputs,
|
|
1171
|
+
evidenceState: {
|
|
1172
|
+
note_root: Buffer.from(publicInputs[1]).toString('hex'),
|
|
1173
|
+
null_root: Buffer.from(publicInputs[3]).toString('hex'),
|
|
1174
|
+
note_index: extra.noteIndex,
|
|
1175
|
+
},
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Intent attestations carry no local tree mutation: the relayer simply pays
|
|
1180
|
+
// for settlement. publicInputs = 4 × 32B [noteRoot, actionHash, nullifier, currentSlot].
|
|
1181
|
+
|
|
1182
|
+
async function relayIntent({ proofBytes, publicInputs }) {
|
|
1183
|
+
const activeDeps = await ensureDeps();
|
|
1184
|
+
await assertVkGate(activeDeps, 'intent');
|
|
1185
|
+
const sig = await activeDeps.settle.settleIntent(
|
|
1186
|
+
activeDeps.connection,
|
|
1187
|
+
activeDeps.payer,
|
|
1188
|
+
proofBytes,
|
|
1189
|
+
publicInputs,
|
|
1190
|
+
);
|
|
1191
|
+
return {
|
|
1192
|
+
sig,
|
|
1193
|
+
explorerUrl: explorerUrlFor(sig, cluster),
|
|
1194
|
+
cluster,
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
// --- Sealed Sale (ZKCP) marketplace ----------------------------------------
|
|
1200
|
+
// Sovereignty invariant: ownerKey/rho/DEK/buyerSecret/plaintext never leave
|
|
1201
|
+
// this process. Everything returned below is proof bytes, public inputs,
|
|
1202
|
+
// pubkeys, or terms — the exact wire shape settle_sale/claim_sale accept.
|
|
1203
|
+
|
|
1204
|
+
// Buyer identities live in the sealed envelope next to the install secrets:
|
|
1205
|
+
// a Solana keypair (escrow signer) and labeled BabyJub key pairs (sale
|
|
1206
|
+
// protocol recipient keys). Created lazily, persisted immediately.
|
|
1207
|
+
function ensureBuyerSecrets(state, activeDeps) {
|
|
1208
|
+
const install = state.secrets.install;
|
|
1209
|
+
let mutated = false;
|
|
1210
|
+
if (!install.buyerSolanaSecretKeyB64) {
|
|
1211
|
+
const keypair = activeDeps.web3.Keypair.generate();
|
|
1212
|
+
install.buyerSolanaSecretKeyB64 = Buffer.from(keypair.secretKey).toString('base64');
|
|
1213
|
+
mutated = true;
|
|
1214
|
+
}
|
|
1215
|
+
if (!install.buyerKeys || typeof install.buyerKeys !== 'object') {
|
|
1216
|
+
install.buyerKeys = {};
|
|
1217
|
+
mutated = true;
|
|
1218
|
+
}
|
|
1219
|
+
if (!install.buyerKeys.default) {
|
|
1220
|
+
const pair = activeDeps.sale.generateBuyerKeyPair();
|
|
1221
|
+
install.buyerKeys.default = {
|
|
1222
|
+
secret: pair.secret.toString(10),
|
|
1223
|
+
publicKeyX: pair.publicKey.x.toString(10),
|
|
1224
|
+
publicKeyY: pair.publicKey.y.toString(10),
|
|
1225
|
+
createdAt: new Date().toISOString(),
|
|
1226
|
+
};
|
|
1227
|
+
mutated = true;
|
|
1228
|
+
}
|
|
1229
|
+
return mutated;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const zeroBytes = (value) => Buffer.from(value).every((byte) => byte === 0);
|
|
1233
|
+
const frFromBytes = (value) => BigInt(`0x${Buffer.from(value).toString('hex')}`);
|
|
1234
|
+
|
|
1235
|
+
function saleError(code, message) {
|
|
1236
|
+
const error = new Error(message);
|
|
1237
|
+
error.code = code;
|
|
1238
|
+
return error;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Read-only chain view of a sale receipt; never touches custody.
|
|
1242
|
+
async function saleReceipt({ keyCommitBytes, envelopeCommitBytes }) {
|
|
1243
|
+
const activeDeps = await ensureDeps();
|
|
1244
|
+
const receipt = await withTimeout(
|
|
1245
|
+
activeDeps.settle.fetchSaleReceipt(activeDeps.connection, keyCommitBytes, envelopeCommitBytes),
|
|
1246
|
+
timeoutMs,
|
|
1247
|
+
'validator request',
|
|
1248
|
+
);
|
|
1249
|
+
return {
|
|
1250
|
+
receipt,
|
|
1251
|
+
receiptPda: publicKeyString(activeDeps.settle.saleReceiptPda(keyCommitBytes, envelopeCommitBytes)),
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
// Phase 1 (seller): decrypt the note from local custody, build the sale
|
|
1257
|
+
// witness, prove locally. FAIR EXCHANGE: the DEK envelope triple
|
|
1258
|
+
// (epk/encDek/nonce) is sealed into seller custody keyed by the terms hash
|
|
1259
|
+
// and NEVER returned — the only way it leaves custody is claimSale, which
|
|
1260
|
+
// reveals it on-chain atomically with payment. The response carries only
|
|
1261
|
+
// the proof, the 6 public inputs, the ciphertext chunks, and commitments.
|
|
1262
|
+
|
|
1263
|
+
async function prepareSaleListing({ noteIndex, priceLamports, buyerPub, buyerKeyLabel, expirySlots, mint }) {
|
|
1264
|
+
const { state, deps: activeDeps, noteTree } = await requireReady();
|
|
1265
|
+
const opening = state.secrets.noteOpenings[String(noteIndex)];
|
|
1266
|
+
if (!opening?.rho || !opening?.payloadCommit) {
|
|
1267
|
+
throw saleError('NOTE_OPENING_MISSING', `noteIndex ${noteIndex} has no local opening; only notes created by this persisted install can be listed`);
|
|
1268
|
+
}
|
|
1269
|
+
if (!opening.dekLo || !opening.dekHi) {
|
|
1270
|
+
throw saleError('NOTE_NOT_SALEABLE', `noteIndex ${noteIndex} was not sealed for sale — its note commitment is not a DEK-blinded plaintext fold; append the memory with saleable:true first`);
|
|
1271
|
+
}
|
|
1272
|
+
const note = state.notes.find((entry) => Number(entry.index) === noteIndex);
|
|
1273
|
+
if (!note?.sealed?.ciphertextB64) {
|
|
1274
|
+
throw saleError('NOTE_OPENING_MISSING', `noteIndex ${noteIndex} has no local sealed payload; the plaintext cannot be recovered for proving`);
|
|
1275
|
+
}
|
|
1276
|
+
const ownerKey = opening.ownerKey ?? state.secrets.install.ownerKey;
|
|
1277
|
+
// Local-only plaintext recovery for witness building (AEAD-verified).
|
|
1278
|
+
const noteContext = activeDeps.e2ee.deriveNoteContext({ ownerKey, rho: opening.rho, scope: note.scope });
|
|
1279
|
+
const plaintextBytes = Buffer.from(await activeDeps.e2ee.e2eeDecrypt({
|
|
1280
|
+
ciphertextB64: note.sealed.ciphertextB64,
|
|
1281
|
+
nonceB64: note.sealed.nonceB64,
|
|
1282
|
+
envelopes: note.sealed.envelopes,
|
|
1283
|
+
recipientSecretKey: state.secrets.install.recipientSecretKeyB64,
|
|
1284
|
+
scope: note.scope,
|
|
1285
|
+
noteContext,
|
|
1286
|
+
}));
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
// Buyer keys (if used) and the sealed envelope are both persisted by the
|
|
1291
|
+
// writeZkState after proving — one sealed write covers both.
|
|
1292
|
+
let buyer = buyerPub;
|
|
1293
|
+
if (!buyer && !buyerKeyLabel) {
|
|
1294
|
+
throw saleError('BAD_REQUEST', 'buyerPubkeyX/buyerPubkeyY required — a listing must bind the buyer\'s BabyJub public key (the buyer exposes it via GET /api/zk/sale/buyer-identity); silently defaulting to this device\'s own key would create an unsellable self-bound listing');
|
|
1295
|
+
}
|
|
1296
|
+
if (!buyer) {
|
|
1297
|
+
const label = buyerKeyLabel;
|
|
1298
|
+
ensureBuyerSecrets(state, activeDeps);
|
|
1299
|
+
const key = state.secrets.install.buyerKeys[label];
|
|
1300
|
+
if (!key) {
|
|
1301
|
+
throw saleError('BAD_REQUEST', `unknown buyerKeyLabel "${label}"; this device custodies: ${Object.keys(state.secrets.install.buyerKeys).join(', ') || '(none)'}`);
|
|
1302
|
+
}
|
|
1303
|
+
buyer = { x: key.publicKeyX, y: key.publicKeyY };
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const currentSlot = await withTimeout(
|
|
1307
|
+
Promise.resolve(activeDeps.connection.getSlot()),
|
|
1308
|
+
timeoutMs,
|
|
1309
|
+
'validator request',
|
|
1310
|
+
);
|
|
1311
|
+
const expirySlot = BigInt(currentSlot) + BigInt(expirySlots);
|
|
1312
|
+
const nonce = randomFieldElement();
|
|
1313
|
+
// The desktop payer is the seller identity: claim_sale must be signed by
|
|
1314
|
+
// the seller, and this server already pays/signs with the payer key.
|
|
1315
|
+
const sellerPubkey = activeDeps.payer.publicKey.toBase58();
|
|
1316
|
+
const saleMint = mint || WRAPPED_SOL_MINT;
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
await assertVkGate(activeDeps, 'sale');
|
|
1320
|
+
let artifacts;
|
|
1321
|
+
try {
|
|
1322
|
+
artifacts = activeDeps.sale.buildSaleWitness({
|
|
1323
|
+
noteTree,
|
|
1324
|
+
noteIndex,
|
|
1325
|
+
ownerKey,
|
|
1326
|
+
rho: opening.rho,
|
|
1327
|
+
dekLo: opening.dekLo,
|
|
1328
|
+
dekHi: opening.dekHi,
|
|
1329
|
+
nonce,
|
|
1330
|
+
plaintextBytes,
|
|
1331
|
+
buyerPub: buyer,
|
|
1332
|
+
sellerPubkey,
|
|
1333
|
+
mint: saleMint,
|
|
1334
|
+
priceLamports,
|
|
1335
|
+
expirySlot,
|
|
1336
|
+
});
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
throw saleError('BAD_REQUEST', `sale witness rejected the inputs: ${err.message}`);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
const { proofBytes, publicInputs } = await activeDeps.saleProver(artifacts.input);
|
|
1342
|
+
assertProverPublicInputs(
|
|
1343
|
+
[
|
|
1344
|
+
artifacts.noteRoot,
|
|
1345
|
+
artifacts.payloadCommitPos,
|
|
1346
|
+
artifacts.cipherCommit,
|
|
1347
|
+
artifacts.keyCommit,
|
|
1348
|
+
artifacts.saleTermsHash,
|
|
1349
|
+
artifacts.envelopeCommit,
|
|
1350
|
+
].map((value) => activeDeps.codec.frToBytes32BE(value)),
|
|
1351
|
+
publicInputs,
|
|
1352
|
+
'sealed sale listing',
|
|
1353
|
+
);
|
|
1354
|
+
|
|
1355
|
+
// Seal the envelope triple into seller custody BEFORE responding; it is
|
|
1356
|
+
// re-sealed with the same v2 mechanism as every other ZK secret.
|
|
1357
|
+
// CUSTODY KEY = the unique receipt identity (keyCommit:envelopeCommit),
|
|
1358
|
+
// NOT the terms hash: two listings with identical buyer/seller/mint/price/
|
|
1359
|
+
// expiry/payloadLen share a terms-hash preimage and would overwrite each
|
|
1360
|
+
// other — the first would become unclaimable (commitment mismatch).
|
|
1361
|
+
const saleTermsHashHex = rootHex(artifacts.saleTermsHash);
|
|
1362
|
+
const keyCommitHex = rootHex(artifacts.keyCommit);
|
|
1363
|
+
const envelopeCommitHex = rootHex(artifacts.envelopeCommit);
|
|
1364
|
+
state.secrets.saleEnvelopes[`${keyCommitHex}:${envelopeCommitHex}`] = {
|
|
1365
|
+
epkX: artifacts.epk.x.toString(10),
|
|
1366
|
+
epkY: artifacts.epk.y.toString(10),
|
|
1367
|
+
encDekLo: artifacts.encDEK_lo.toString(10),
|
|
1368
|
+
encDekHi: artifacts.encDEK_hi.toString(10),
|
|
1369
|
+
nonce: nonce.toString(10),
|
|
1370
|
+
saleTermsHashHex,
|
|
1371
|
+
keyCommitHex,
|
|
1372
|
+
envelopeCommitHex,
|
|
1373
|
+
noteIndex,
|
|
1374
|
+
sellerPubkey,
|
|
1375
|
+
mint: saleMint,
|
|
1376
|
+
priceLamports: BigInt(priceLamports).toString(10),
|
|
1377
|
+
expirySlot: expirySlot.toString(10),
|
|
1378
|
+
payloadLen: plaintextBytes.length,
|
|
1379
|
+
createdAt: new Date().toISOString(),
|
|
1380
|
+
};
|
|
1381
|
+
writeZkState(statePath, state, custody);
|
|
1382
|
+
|
|
1383
|
+
return {
|
|
1384
|
+
proofBytes: {
|
|
1385
|
+
a: Buffer.from(proofBytes.a).toString('hex'),
|
|
1386
|
+
b: Buffer.from(proofBytes.b).toString('hex'),
|
|
1387
|
+
c: Buffer.from(proofBytes.c).toString('hex'),
|
|
1388
|
+
},
|
|
1389
|
+
publicInputs: publicInputs.map((value) => Buffer.from(value).toString('hex')),
|
|
1390
|
+
ciphertextChunks: artifacts.ciphertextChunks.map((chunk) => chunk.toString(10)),
|
|
1391
|
+
saleTermsHashHex,
|
|
1392
|
+
payloadCommitPosHex: rootHex(artifacts.payloadCommitPos),
|
|
1393
|
+
keyCommitHex,
|
|
1394
|
+
envelopeCommitHex,
|
|
1395
|
+
// Public terms preimage — the buyer MUST recompute saleTermsHash from
|
|
1396
|
+
// these fields with their own custodied buyer key before escrowing
|
|
1397
|
+
// (colluder-wrap defense; the chain cannot check the preimage).
|
|
1398
|
+
sellerPubkey,
|
|
1399
|
+
mint: saleMint,
|
|
1400
|
+
priceLamports: BigInt(priceLamports).toString(10),
|
|
1401
|
+
expirySlot: expirySlot.toString(10),
|
|
1402
|
+
payloadLen: plaintextBytes.length,
|
|
1403
|
+
receiptPda: publicKeyString(activeDeps.settle.saleReceiptPda(
|
|
1404
|
+
activeDeps.codec.frToBytes32BE(artifacts.keyCommit),
|
|
1405
|
+
activeDeps.codec.frToBytes32BE(artifacts.envelopeCommit),
|
|
1406
|
+
)),
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Seller claim: the ONLY path by which the sealed envelope leaves custody.
|
|
1411
|
+
// claim_sale verifies the (epk, encDek, nonce) triple against the on-chain
|
|
1412
|
+
// envelopeCommit (Poseidon5) and releases the buyer's escrow payment in the
|
|
1413
|
+
// same transaction — the reveal is atomic with getting paid.
|
|
1414
|
+
async function claimSale({ saleTermsHashBytes, buyerPubkey, keyCommitBytes, envelopeCommitBytes, predecessorReceiptRefs = [] }) {
|
|
1415
|
+
// Custody-only: claim builds no action-tree witness, so it must survive
|
|
1416
|
+
// unrelated appends advancing the chain after listing — otherwise paid
|
|
1417
|
+
// escrow would strand until refund. The program enforces claim validity.
|
|
1418
|
+
const { state, deps: activeDeps } = await requireCustody();
|
|
1419
|
+
const saleTermsHashHex = Buffer.from(saleTermsHashBytes).toString('hex');
|
|
1420
|
+
const custodyKey = `${Buffer.from(keyCommitBytes).toString('hex')}:${Buffer.from(envelopeCommitBytes).toString('hex')}`;
|
|
1421
|
+
const envelope = state.secrets.saleEnvelopes?.[custodyKey];
|
|
1422
|
+
if (!envelope) {
|
|
1423
|
+
throw saleError('SALE_ENVELOPE_MISSING', `no sealed sale envelope in custody for receipt ${custodyKey.slice(0, 24)}…; only the listing device can claim`);
|
|
1424
|
+
}
|
|
1425
|
+
if (envelope.saleTermsHashHex !== saleTermsHashHex) {
|
|
1426
|
+
throw saleError('SALE_ENVELOPE_MISMATCH', 'the supplied saleTermsHash does not match the sealed envelope for this receipt; refusing to claim');
|
|
1427
|
+
}
|
|
1428
|
+
// A spent listing cannot be claimed twice; fail closed before paying fees.
|
|
1429
|
+
const { receipt } = await saleReceipt({ keyCommitBytes, envelopeCommitBytes });
|
|
1430
|
+
if (!receipt) {
|
|
1431
|
+
throw saleError('SALE_RECEIPT_MISSING', 'no sale receipt on-chain for this keyCommit + envelopeCommit pair; submit the listing first');
|
|
1432
|
+
}
|
|
1433
|
+
if (!zeroBytes(receipt.epk) || !zeroBytes(receipt.encDek) || !zeroBytes(receipt.nonce)) {
|
|
1434
|
+
throw saleError('SALE_ALREADY_CLAIMED', 'this sale was already claimed (envelope revealed on-chain)');
|
|
1435
|
+
}
|
|
1436
|
+
const sharedState = await fetchOnChain(activeDeps);
|
|
1437
|
+
let buyer;
|
|
1438
|
+
try {
|
|
1439
|
+
buyer = new activeDeps.web3.PublicKey(buyerPubkey);
|
|
1440
|
+
} catch {
|
|
1441
|
+
throw saleError('BAD_REQUEST', 'buyerPubkey must be a base58 Solana public key');
|
|
1442
|
+
}
|
|
1443
|
+
const fr = activeDeps.codec.frToBytes32BE;
|
|
1444
|
+
const epkBytes = Buffer.concat([fr(BigInt(envelope.epkX)), fr(BigInt(envelope.epkY))]);
|
|
1445
|
+
const encDekBytes = Buffer.concat([fr(BigInt(envelope.encDekLo)), fr(BigInt(envelope.encDekHi))]);
|
|
1446
|
+
const nonceBytes = Buffer.from(fr(BigInt(envelope.nonce)));
|
|
1447
|
+
// The desktop payer IS the seller identity (it signed the listing's terms
|
|
1448
|
+
// hash), so the same custodied key signs the claim.
|
|
1449
|
+
const sig = await activeDeps.settle.claimSale(
|
|
1450
|
+
activeDeps.connection,
|
|
1451
|
+
activeDeps.payer,
|
|
1452
|
+
buyer,
|
|
1453
|
+
saleTermsHashBytes,
|
|
1454
|
+
keyCommitBytes,
|
|
1455
|
+
envelopeCommitBytes,
|
|
1456
|
+
epkBytes,
|
|
1457
|
+
encDekBytes,
|
|
1458
|
+
nonceBytes,
|
|
1459
|
+
);
|
|
1460
|
+
const result = {
|
|
1461
|
+
signature: sig,
|
|
1462
|
+
explorerUrl: explorerUrlFor(sig, cluster),
|
|
1463
|
+
cluster,
|
|
1464
|
+
};
|
|
1465
|
+
return attachSettlementEvidence(result, state, activeDeps, {
|
|
1466
|
+
event: 'sale.claim',
|
|
1467
|
+
predecessorRefs: predecessorReceiptRefs,
|
|
1468
|
+
evidenceState: publicSettlementState(sharedState, {
|
|
1469
|
+
sale_terms_hash: saleTermsHashHex,
|
|
1470
|
+
key_commit: Buffer.from(keyCommitBytes).toString('hex'),
|
|
1471
|
+
envelope_commit: Buffer.from(envelopeCommitBytes).toString('hex'),
|
|
1472
|
+
sale_receipt_pda: publicKeyString(activeDeps.settle.saleReceiptPda(keyCommitBytes, envelopeCommitBytes)),
|
|
1473
|
+
buyer_pubkey: buyerPubkey,
|
|
1474
|
+
}),
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// Phase 2 (seller): attest the sale on-chain. The proof binds the note root,
|
|
1479
|
+
// commitments, and terms, and settle_sale verifies them against shared
|
|
1480
|
+
// state. No tree snapshot is mutated; only the encrypted local receipt
|
|
1481
|
+
// signing identity is persisted when this is a fresh install.
|
|
1482
|
+
|
|
1483
|
+
async function submitSaleListing({ proofBytes, publicInputs, predecessorReceiptRefs = [] }) {
|
|
1484
|
+
const { state, deps: activeDeps } = await requireCustody();
|
|
1485
|
+
if (!hasPersistedInstall()) writeZkState(statePath, state, custody);
|
|
1486
|
+
const sharedState = await fetchOnChain(activeDeps);
|
|
1487
|
+
await assertVkGate(activeDeps, 'sale');
|
|
1488
|
+
const sig = await activeDeps.settle.settleSale(
|
|
1489
|
+
activeDeps.connection,
|
|
1490
|
+
activeDeps.payer,
|
|
1491
|
+
proofBytes,
|
|
1492
|
+
publicInputs,
|
|
1493
|
+
);
|
|
1494
|
+
const receiptPda = activeDeps.settle.saleReceiptPda(
|
|
1495
|
+
publicInputs[3],
|
|
1496
|
+
publicInputs[5],
|
|
1497
|
+
);
|
|
1498
|
+
const result = {
|
|
1499
|
+
sig,
|
|
1500
|
+
signature: sig,
|
|
1501
|
+
txSignature: sig,
|
|
1502
|
+
receiptPda: publicKeyString(receiptPda),
|
|
1503
|
+
explorerUrl: explorerUrlFor(sig, cluster),
|
|
1504
|
+
cluster,
|
|
1505
|
+
};
|
|
1506
|
+
return attachSettlementEvidence(result, state, activeDeps, {
|
|
1507
|
+
event: 'sale.list',
|
|
1508
|
+
proofBytes,
|
|
1509
|
+
publicInputs,
|
|
1510
|
+
predecessorRefs: predecessorReceiptRefs,
|
|
1511
|
+
evidenceState: publicSettlementState(sharedState, {
|
|
1512
|
+
note_root: Buffer.from(publicInputs[0]).toString('hex'),
|
|
1513
|
+
payload_commit_pos: Buffer.from(publicInputs[1]).toString('hex'),
|
|
1514
|
+
cipher_commit: Buffer.from(publicInputs[2]).toString('hex'),
|
|
1515
|
+
key_commit: Buffer.from(publicInputs[3]).toString('hex'),
|
|
1516
|
+
sale_terms_hash: Buffer.from(publicInputs[4]).toString('hex'),
|
|
1517
|
+
envelope_commit: Buffer.from(publicInputs[5]).toString('hex'),
|
|
1518
|
+
sale_receipt_pda: publicKeyString(receiptPda),
|
|
1519
|
+
}),
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// Phase 3 (buyer): verify an offered listing against the attested receipt.
|
|
1524
|
+
// The buyer calculates cipherCommit from delivered ciphertext chunks and
|
|
1525
|
+
// confirms it matches the receipt BEFORE paying — the proof ensures the
|
|
1526
|
+
// seller holds a matching plaintext.
|
|
1527
|
+
async function verifySale({ saleTermsHashBytes, keyCommitBytes, envelopeCommitBytes, ciphertextChunks }) {
|
|
1528
|
+
const activeDeps = await ensureDeps();
|
|
1529
|
+
const { receipt, receiptPda } = await saleReceipt({ keyCommitBytes, envelopeCommitBytes });
|
|
1530
|
+
if (!receipt) {
|
|
1531
|
+
throw saleError('SALE_RECEIPT_MISSING', 'no sale receipt on-chain for this keyCommit + envelopeCommit pair; the listing has not been attested');
|
|
1532
|
+
}
|
|
1533
|
+
const deliveredFold = activeDeps.sale.foldChunks(ciphertextChunks.map((chunk) => BigInt(chunk)));
|
|
1534
|
+
const checks = {
|
|
1535
|
+
receiptExists: true,
|
|
1536
|
+
keyCommitMatch: Buffer.from(receipt.keyCommit).equals(Buffer.from(keyCommitBytes)),
|
|
1537
|
+
envelopeCommitMatch: Buffer.from(receipt.envelopeCommit).equals(Buffer.from(envelopeCommitBytes)),
|
|
1538
|
+
cipherFoldMatch: deliveredFold === frFromBytes(receipt.cipherCommit),
|
|
1539
|
+
saleTermsHashMatch: Buffer.from(receipt.saleTermsHash).equals(Buffer.from(saleTermsHashBytes)),
|
|
1540
|
+
envelopeCommitNonzero: !zeroBytes(receipt.envelopeCommit),
|
|
1541
|
+
envelopeZeroOnChain: zeroBytes(receipt.epk) && zeroBytes(receipt.encDek) && zeroBytes(receipt.nonce),
|
|
1542
|
+
};
|
|
1543
|
+
return { ok: Object.values(checks).every(Boolean), checks, receiptPda, slot: receipt.slot };
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Buyer escrow, pinned to the attested receipt. The buyer Solana keypair
|
|
1547
|
+
// comes from local custody; it never appears in any request/response.
|
|
1548
|
+
async function createSaleEscrow({ saleTermsHashBytes, priceLamports, expirySlot, sellerPubkey, keyCommitBytes, envelopeCommitBytes, mint, payloadLen, buyerKeyLabel, ciphertextChunks }) {
|
|
1549
|
+
const installPersisted = hasPersistedInstall();
|
|
1550
|
+
const { state, deps: activeDeps } = await requireCustody();
|
|
1551
|
+
const mutated = ensureBuyerSecrets(state, activeDeps);
|
|
1552
|
+
if (!installPersisted || mutated) writeZkState(statePath, state, custody);
|
|
1553
|
+
|
|
1554
|
+
const { receipt } = await saleReceipt({ keyCommitBytes, envelopeCommitBytes });
|
|
1555
|
+
if (!receipt) {
|
|
1556
|
+
throw saleError('SALE_RECEIPT_MISSING', 'no sale receipt on-chain for this keyCommit + envelopeCommit pair');
|
|
1557
|
+
}
|
|
1558
|
+
if (!zeroBytes(receipt.epk) || !zeroBytes(receipt.encDek) || !zeroBytes(receipt.nonce)) {
|
|
1559
|
+
throw saleError('SALE_ALREADY_CLAIMED', 'this sale was already claimed (envelope revealed on-chain); do not escrow against a spent listing');
|
|
1560
|
+
}
|
|
1561
|
+
if (!Buffer.from(receipt.saleTermsHash).equals(Buffer.from(saleTermsHashBytes))) {
|
|
1562
|
+
throw saleError('SALE_TERMS_MISMATCH', 'the advertised saleTermsHash does not match the on-chain receipt; refusing to escrow');
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// CIPHERTEXT FOLD: the separate VERIFY step is advisory and bypassable —
|
|
1566
|
+
// the fold check is enforced HERE, atomically before any transfer. Junk
|
|
1567
|
+
// ciphertext (fold != the on-chain cipherCommit) can never be escrowed.
|
|
1568
|
+
// Canonical limbs only (same rules as OPEN's parser): a non-canonical or
|
|
1569
|
+
// out-of-field limb would fold mod r here but be rejected at open time —
|
|
1570
|
+
// the buyer would pay for a listing the UI refuses to decrypt.
|
|
1571
|
+
const FR = FIELD_R;
|
|
1572
|
+
if (!Array.isArray(ciphertextChunks) || ciphertextChunks.length !== 32) {
|
|
1573
|
+
throw saleError('BAD_REQUEST', 'ciphertextChunks must be an array of 32 decimal strings (fold enforced at escrow)');
|
|
1574
|
+
}
|
|
1575
|
+
for (let i = 0; i < 32; i += 1) {
|
|
1576
|
+
const chunk = ciphertextChunks[i];
|
|
1577
|
+
if (typeof chunk !== 'string' || !/^(0|[1-9][0-9]*)$/.test(chunk)) {
|
|
1578
|
+
throw saleError('BAD_REQUEST', `ciphertextChunks[${i}] must be a canonical decimal string`);
|
|
1579
|
+
}
|
|
1580
|
+
if (BigInt(chunk) >= FR) {
|
|
1581
|
+
throw saleError('BAD_REQUEST', `ciphertextChunks[${i}] is not a canonical BN254 scalar field element`);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
const deliveredFold = activeDeps.sale.foldChunks(ciphertextChunks.map((c) => BigInt(c)));
|
|
1585
|
+
const deliveredFoldHex = deliveredFold.toString(16).padStart(64, '0');
|
|
1586
|
+
if (deliveredFoldHex !== Buffer.from(receipt.cipherCommit).toString('hex')) {
|
|
1587
|
+
throw saleError(
|
|
1588
|
+
'SALE_CIPHER_MISMATCH',
|
|
1589
|
+
'the delivered ciphertext does not fold to the on-chain cipherCommit — junk delivery; refusing to escrow',
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// COLLUDER-WRAP DEFENSE: the on-chain program cannot know the saleTermsHash
|
|
1594
|
+
// preimage — only the buyer can check the listing binds THEIR key. Recompute
|
|
1595
|
+
// the terms hash locally from the FULL listing (seller, mint, price, expiry,
|
|
1596
|
+
// payloadLen) with THIS device's custodied BabyJub public key, and require it
|
|
1597
|
+
// equals both the advertised hash and the on-chain receipt. A listing whose
|
|
1598
|
+
// envelope wraps to a colluder key fails here, before any lamports move.
|
|
1599
|
+
const label = buyerKeyLabel || 'default';
|
|
1600
|
+
const buyerKey = state.secrets.install.buyerKeys?.[label];
|
|
1601
|
+
if (!buyerKey) {
|
|
1602
|
+
throw saleError('BAD_REQUEST', `unknown buyerKeyLabel "${label}"`);
|
|
1603
|
+
}
|
|
1604
|
+
if (typeof mint !== 'string' || !mint.trim()) {
|
|
1605
|
+
throw saleError('BAD_REQUEST', 'mint must be a non-empty base58 string (required for the terms recompute)');
|
|
1606
|
+
}
|
|
1607
|
+
const payloadLenN = Number(payloadLen);
|
|
1608
|
+
if (!Number.isSafeInteger(payloadLenN) || payloadLenN < 1 || payloadLenN > SALE_PAYLOAD_CAPACITY) {
|
|
1609
|
+
throw saleError('BAD_REQUEST', `payloadLen must be an integer 1..${SALE_PAYLOAD_CAPACITY} (required for the terms recompute)`);
|
|
1610
|
+
}
|
|
1611
|
+
let recomputed;
|
|
1612
|
+
try {
|
|
1613
|
+
recomputed = activeDeps.sale.computeSaleTermsHash({
|
|
1614
|
+
sellerPubkey,
|
|
1615
|
+
buyerPub: [BigInt(buyerKey.publicKeyX), BigInt(buyerKey.publicKeyY)],
|
|
1616
|
+
mint: mint.trim(),
|
|
1617
|
+
priceLamports: BigInt(priceLamports),
|
|
1618
|
+
expirySlot: BigInt(expirySlot),
|
|
1619
|
+
payloadLen: BigInt(payloadLenN),
|
|
1620
|
+
});
|
|
1621
|
+
} catch (err) {
|
|
1622
|
+
throw saleError('BAD_REQUEST', `listing terms failed validation: ${err.message}`);
|
|
1623
|
+
}
|
|
1624
|
+
const recomputedHex = recomputed.saleTermsHash.toString(16).padStart(64, '0');
|
|
1625
|
+
if (recomputedHex !== Buffer.from(saleTermsHashBytes).toString('hex')) {
|
|
1626
|
+
throw saleError(
|
|
1627
|
+
'SALE_TERMS_NOT_BUYER_BOUND',
|
|
1628
|
+
'the listing terms do not hash to the advertised saleTermsHash with THIS device\'s buyer key — ' +
|
|
1629
|
+
'the envelope is likely wrapped to a different (colluder) key; refusing to escrow',
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
let seller;
|
|
1634
|
+
try {
|
|
1635
|
+
seller = new activeDeps.web3.PublicKey(sellerPubkey);
|
|
1636
|
+
} catch {
|
|
1637
|
+
throw saleError('BAD_REQUEST', 'sellerPubkey must be a base58 Solana public key');
|
|
1638
|
+
}
|
|
1639
|
+
const buyer = activeDeps.web3.Keypair.fromSecretKey(
|
|
1640
|
+
new Uint8Array(Buffer.from(state.secrets.install.buyerSolanaSecretKeyB64, 'base64')),
|
|
1641
|
+
);
|
|
1642
|
+
const receiptPda = activeDeps.settle.saleReceiptPda(keyCommitBytes, envelopeCommitBytes);
|
|
1643
|
+
const sig = await activeDeps.settle.createSaleEscrow(
|
|
1644
|
+
activeDeps.connection,
|
|
1645
|
+
buyer,
|
|
1646
|
+
seller,
|
|
1647
|
+
saleTermsHashBytes,
|
|
1648
|
+
BigInt(priceLamports),
|
|
1649
|
+
BigInt(expirySlot),
|
|
1650
|
+
receiptPda,
|
|
1651
|
+
);
|
|
1652
|
+
return {
|
|
1653
|
+
signature: sig,
|
|
1654
|
+
buyerPubkey: buyer.publicKey.toBase58(),
|
|
1655
|
+
escrowPda: publicKeyString(activeDeps.settle.saleEscrowPda(buyer.publicKey, saleTermsHashBytes)),
|
|
1656
|
+
receiptPda: publicKeyString(receiptPda),
|
|
1657
|
+
explorerUrl: explorerUrlFor(sig, cluster),
|
|
1658
|
+
cluster,
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// Buyer post-claim open: everything decryptive comes from the on-chain
|
|
1663
|
+
// receipt (envelope + nonce revealed atomically at claim) plus the local
|
|
1664
|
+
// buyer secret. Fails closed while the envelope is still zero.
|
|
1665
|
+
async function openSale({ keyCommitBytes, envelopeCommitBytes, ciphertextChunks, payloadLen, buyerKeyLabel, predecessorReceiptRefs = [] }) {
|
|
1666
|
+
const { state, deps: activeDeps } = await requireCustody();
|
|
1667
|
+
const label = buyerKeyLabel || 'default';
|
|
1668
|
+
const key = state.secrets.install?.buyerKeys?.[label];
|
|
1669
|
+
if (!key?.secret) {
|
|
1670
|
+
throw saleError('BUYER_KEY_MISSING', `no buyer key labeled "${label}" in local custody; only the buyer key a listing was bound to can open it`);
|
|
1671
|
+
}
|
|
1672
|
+
const { receipt } = await saleReceipt({ keyCommitBytes, envelopeCommitBytes });
|
|
1673
|
+
if (!receipt) {
|
|
1674
|
+
throw saleError('SALE_RECEIPT_MISSING', 'no sale receipt on-chain for this keyCommit + envelopeCommit pair');
|
|
1675
|
+
}
|
|
1676
|
+
if (zeroBytes(receipt.epk) && zeroBytes(receipt.encDek) && zeroBytes(receipt.nonce)) {
|
|
1677
|
+
throw saleError('SALE_NOT_CLAIMED', 'sale receipt envelope is still zero on-chain — the seller has not claimed payment yet; the DEK envelope + nonce are revealed atomically at claim');
|
|
1678
|
+
}
|
|
1679
|
+
const sharedState = await fetchOnChain(activeDeps);
|
|
1680
|
+
let revealed;
|
|
1681
|
+
try {
|
|
1682
|
+
revealed = activeDeps.sale.openAndVerifySale({
|
|
1683
|
+
buyerSecret: key.secret,
|
|
1684
|
+
epk: { x: frFromBytes(receipt.epk.subarray(0, 32)), y: frFromBytes(receipt.epk.subarray(32, 64)) },
|
|
1685
|
+
encDEK_lo: frFromBytes(receipt.encDek.subarray(0, 32)),
|
|
1686
|
+
encDEK_hi: frFromBytes(receipt.encDek.subarray(32, 64)),
|
|
1687
|
+
ciphertext: ciphertextChunks.map((chunk) => BigInt(chunk)),
|
|
1688
|
+
cipherCommit: frFromBytes(receipt.cipherCommit),
|
|
1689
|
+
nonce: frFromBytes(receipt.nonce),
|
|
1690
|
+
payloadCommitPos: frFromBytes(receipt.payloadCommitPos),
|
|
1691
|
+
});
|
|
1692
|
+
} catch (err) {
|
|
1693
|
+
throw saleError('SALE_OPEN_REJECTED', `sealed sale open failed verification: ${err.message}`);
|
|
1694
|
+
}
|
|
1695
|
+
const full = Buffer.from(activeDeps.sale.chunksToBytes(revealed.plaintextChunks));
|
|
1696
|
+
let byteLen = Number.isInteger(payloadLen) && payloadLen >= 0 && payloadLen <= full.length ? payloadLen : null;
|
|
1697
|
+
if (byteLen === null) {
|
|
1698
|
+
// Payload length is terms-bound, not separately on-chain; the default
|
|
1699
|
+
// strips the zero padding bytesToChunks added to fill the fixed chunks.
|
|
1700
|
+
byteLen = full.length;
|
|
1701
|
+
while (byteLen > 0 && full[byteLen - 1] === 0) byteLen -= 1;
|
|
1702
|
+
}
|
|
1703
|
+
const result = { plaintextUtf8: full.subarray(0, byteLen).toString('utf8'), payloadLen: byteLen };
|
|
1704
|
+
return attachSettlementEvidence(result, state, activeDeps, {
|
|
1705
|
+
event: 'sale.open',
|
|
1706
|
+
predecessorRefs: predecessorReceiptRefs,
|
|
1707
|
+
evidenceState: publicSettlementState(sharedState, {
|
|
1708
|
+
sale_terms_hash: Buffer.from(receipt.saleTermsHash).toString('hex'),
|
|
1709
|
+
key_commit: Buffer.from(receipt.keyCommit).toString('hex'),
|
|
1710
|
+
envelope_commit: Buffer.from(receipt.envelopeCommit).toString('hex'),
|
|
1711
|
+
cipher_commit: Buffer.from(receipt.cipherCommit).toString('hex'),
|
|
1712
|
+
payload_commit_pos: Buffer.from(receipt.payloadCommitPos).toString('hex'),
|
|
1713
|
+
sale_receipt_pda: publicKeyString(activeDeps.settle.saleReceiptPda(keyCommitBytes, envelopeCommitBytes)),
|
|
1714
|
+
receipt_slot: String(receipt.slot),
|
|
1715
|
+
ciphertext_digest: `sha256:${crypto.createHash('sha256').update(JSON.stringify(ciphertextChunks.map(String))).digest('hex')}`,
|
|
1716
|
+
opened_payload_bytes: byteLen,
|
|
1717
|
+
}),
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
// Public-only buyer identity: the addresses a fresh install must fund (SOL
|
|
1723
|
+
// escrow keypair) and hand to sellers (BabyJub public key). Generates on
|
|
1724
|
+
// first call so there is always something to fund; NEVER returns secrets.
|
|
1725
|
+
// Custody-only readiness for BUYER-side sale operations (escrow, open,
|
|
1726
|
+
// identity): these build NO action-tree witnesses, so they must not require
|
|
1727
|
+
// historical leaves or chain/local reconciliation — a fresh buyer with zero
|
|
1728
|
+
// local notes can purchase from a seller whose appends advanced the chain.
|
|
1729
|
+
async function requireCustody() {
|
|
1730
|
+
const activeDeps = await ensureDeps();
|
|
1731
|
+
const state = readZkState(statePath) || emptySnapshot();
|
|
1732
|
+
if (state.secretEnvelope) {
|
|
1733
|
+
state.secrets = unlockZkState(state, custody);
|
|
1734
|
+
}
|
|
1735
|
+
ensureInstallSecrets(state, activeDeps.e2ee);
|
|
1736
|
+
return { state, deps: activeDeps };
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
async function buyerIdentity() {
|
|
1740
|
+
const installPersisted = hasPersistedInstall();
|
|
1741
|
+
// Identity is public-only and INDEPENDENT of proof/chain readiness: a fresh
|
|
1742
|
+
// install must be able to show (and fund) its buyer address before proving
|
|
1743
|
+
// assets are downloaded or the validator is reachable.
|
|
1744
|
+
const activeDeps = await ensureDeps();
|
|
1745
|
+
const state = readZkState(statePath) || emptySnapshot();
|
|
1746
|
+
if (state.secretEnvelope) {
|
|
1747
|
+
state.secrets = unlockZkState(state, custody);
|
|
1748
|
+
}
|
|
1749
|
+
ensureInstallSecrets(state, activeDeps.e2ee);
|
|
1750
|
+
const mutated = ensureBuyerSecrets(state, activeDeps);
|
|
1751
|
+
if (!installPersisted || mutated) writeZkState(statePath, state, custody);
|
|
1752
|
+
const install = state.secrets.install;
|
|
1753
|
+
const solanaPubkey = activeDeps.web3.Keypair.fromSecretKey(
|
|
1754
|
+
new Uint8Array(Buffer.from(install.buyerSolanaSecretKeyB64, 'base64')),
|
|
1755
|
+
).publicKey;
|
|
1756
|
+
let solBalanceLamports = null;
|
|
1757
|
+
try {
|
|
1758
|
+
solBalanceLamports = await activeDeps.connection.getBalance(solanaPubkey);
|
|
1759
|
+
} catch { /* cluster unreachable — identity still returned */ }
|
|
1760
|
+
const babyJubKeys = {};
|
|
1761
|
+
for (const [label, key] of Object.entries(install.buyerKeys ?? {})) {
|
|
1762
|
+
babyJubKeys[label] = { publicKeyX: key.publicKeyX, publicKeyY: key.publicKeyY };
|
|
1763
|
+
}
|
|
1764
|
+
return {
|
|
1765
|
+
solanaPubkey: solanaPubkey.toBase58(),
|
|
1766
|
+
solBalanceLamports,
|
|
1767
|
+
babyJubKeys,
|
|
1768
|
+
defaultBuyerKeyLabel: 'default',
|
|
1769
|
+
cluster,
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
async function syncNoteLeaves(leaves) {
|
|
1774
|
+
if (!Array.isArray(leaves)) throw new Error('leaves must be an array of leaf strings');
|
|
1775
|
+
const activeDeps = await ensureDeps();
|
|
1776
|
+
const state = readZkState(statePath, { unlock: true, ...custody }) || emptySnapshot();
|
|
1777
|
+
state.noteLeaves = leaves.map((l) => String(l));
|
|
1778
|
+
const onChain = await fetchOnChain(activeDeps);
|
|
1779
|
+
if (onChain) {
|
|
1780
|
+
const reconciled = reconcileState(state, onChain, activeDeps.zkTree);
|
|
1781
|
+
if (!reconciled.ok) throw new Error(`sync failed: ${reconciled.reason}`);
|
|
1782
|
+
}
|
|
1783
|
+
ensureInstallSecrets(state, activeDeps.e2ee);
|
|
1784
|
+
writeZkState(statePath, state, custody);
|
|
1785
|
+
return { ok: true, noteCount: state.noteLeaves.length };
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
return { status, append, revoke, syncNoteLeaves, prepareAppend, prepareRevoke, provePreparedAction, relayAction, relayIntent, prepareSaleListing, submitSaleListing, verifySale, createSaleEscrow, claimSale, saleReceipt, openSale, buyerIdentity, getProvingAssetPaths, statePath, artifactPaths, cluster, rpcUrl };
|
|
1789
|
+
}
|