@supercuts-walrus/backend 0.0.0-stage → 0.1.1
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/LICENSE +201 -0
- package/README.md +249 -2
- package/dist/audit.d.ts +52 -0
- package/dist/audit.js +157 -0
- package/dist/backend.d.ts +27 -0
- package/dist/backend.js +62 -0
- package/dist/buyer-setup.d.ts +6 -0
- package/dist/buyer-setup.js +241 -0
- package/dist/capability.d.ts +32 -0
- package/dist/capability.js +87 -0
- package/dist/claude.d.ts +44 -0
- package/dist/claude.js +98 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +32 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +58 -0
- package/dist/dashboard.d.ts +5 -0
- package/dist/dashboard.js +21 -0
- package/dist/deal-adapter.d.ts +3 -0
- package/dist/deal-adapter.js +39 -0
- package/dist/deal-engine.d.ts +62 -0
- package/dist/deal-engine.js +211 -0
- package/dist/deal-wire.d.ts +16 -0
- package/dist/deal-wire.js +65 -0
- package/dist/deal.d.ts +79 -0
- package/dist/deal.js +228 -0
- package/dist/demo.d.ts +2 -0
- package/dist/demo.js +511 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +13 -0
- package/dist/events.d.ts +27 -0
- package/dist/events.js +68 -0
- package/dist/facilitator.d.ts +21 -0
- package/dist/facilitator.js +173 -0
- package/dist/finality.d.ts +2 -0
- package/dist/finality.js +27 -0
- package/dist/funding.d.ts +38 -0
- package/dist/funding.js +78 -0
- package/dist/hosted-exit-cli.d.ts +2 -0
- package/dist/hosted-exit-cli.js +37 -0
- package/dist/hosted-exit.d.ts +24 -0
- package/dist/hosted-exit.js +70 -0
- package/dist/hosted-seller-import.d.ts +2 -0
- package/dist/hosted-seller-import.js +70 -0
- package/dist/hosted-seller-repository.d.ts +31 -0
- package/dist/hosted-seller-repository.js +214 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +37 -0
- package/dist/indexer.d.ts +63 -0
- package/dist/indexer.js +60 -0
- package/dist/live-deal-ports.d.ts +41 -0
- package/dist/live-deal-ports.js +212 -0
- package/dist/live-index.d.ts +28 -0
- package/dist/live-index.js +149 -0
- package/dist/live-runtime.d.ts +85 -0
- package/dist/live-runtime.js +644 -0
- package/dist/live-seller-transport.d.ts +30 -0
- package/dist/live-seller-transport.js +169 -0
- package/dist/live-seller-verifier.d.ts +11 -0
- package/dist/live-seller-verifier.js +65 -0
- package/dist/mandate.d.ts +34 -0
- package/dist/mandate.js +179 -0
- package/dist/mcp-bootstrap.d.ts +2 -0
- package/dist/mcp-bootstrap.js +61 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +207 -0
- package/dist/memory-worker.d.ts +2 -0
- package/dist/memory-worker.js +38 -0
- package/dist/memory.d.ts +15 -0
- package/dist/memory.js +17 -0
- package/dist/memwal.d.ts +35 -0
- package/dist/memwal.js +83 -0
- package/dist/negotiation.d.ts +23 -0
- package/dist/negotiation.js +58 -0
- package/dist/notes.d.ts +11 -0
- package/dist/notes.js +26 -0
- package/dist/onboarding-doctor.d.ts +1 -0
- package/dist/onboarding-doctor.js +87 -0
- package/dist/phase3.d.ts +100 -0
- package/dist/phase3.js +1174 -0
- package/dist/procurement.d.ts +8 -0
- package/dist/procurement.js +19 -0
- package/dist/protocol-engine.d.ts +121 -0
- package/dist/protocol-engine.js +149 -0
- package/dist/prover.d.ts +28 -0
- package/dist/prover.js +70 -0
- package/dist/relayer.d.ts +32 -0
- package/dist/relayer.js +43 -0
- package/dist/remote-buyer.d.ts +85 -0
- package/dist/remote-buyer.js +285 -0
- package/dist/resource-server.d.ts +2 -0
- package/dist/resource-server.js +24 -0
- package/dist/resource.d.ts +22 -0
- package/dist/resource.js +55 -0
- package/dist/role-lock.d.ts +8 -0
- package/dist/role-lock.js +54 -0
- package/dist/seam.d.ts +2 -0
- package/dist/seam.js +117 -0
- package/dist/seller-catalog-generate.d.ts +2 -0
- package/dist/seller-catalog-generate.js +68 -0
- package/dist/seller-database.d.ts +41 -0
- package/dist/seller-database.js +80 -0
- package/dist/seller-directory-cli.d.ts +2 -0
- package/dist/seller-directory-cli.js +24 -0
- package/dist/seller-directory.d.ts +5 -0
- package/dist/seller-directory.js +50 -0
- package/dist/seller-live-cli.d.ts +2 -0
- package/dist/seller-live-cli.js +53 -0
- package/dist/seller-package.d.ts +9 -0
- package/dist/seller-package.js +50 -0
- package/dist/seller-provision.d.ts +2 -0
- package/dist/seller-provision.js +84 -0
- package/dist/seller-register-cli.d.ts +2 -0
- package/dist/seller-register-cli.js +40 -0
- package/dist/seller-runtime.d.ts +60 -0
- package/dist/seller-runtime.js +96 -0
- package/dist/seller-service.d.ts +34 -0
- package/dist/seller-service.js +422 -0
- package/dist/seller-state.d.ts +19 -0
- package/dist/seller-state.js +60 -0
- package/dist/seller-stdio.d.ts +6 -0
- package/dist/seller-stdio.js +29 -0
- package/dist/seller-v2-provision.d.ts +2 -0
- package/dist/seller-v2-provision.js +71 -0
- package/dist/seller-watch.d.ts +2 -0
- package/dist/seller-watch.js +45 -0
- package/dist/settlement.d.ts +95 -0
- package/dist/settlement.js +141 -0
- package/dist/store.d.ts +36 -0
- package/dist/store.js +193 -0
- package/dist/sui.d.ts +49 -0
- package/dist/sui.js +77 -0
- package/dist/tatum.d.ts +17 -0
- package/dist/tatum.js +83 -0
- package/dist/transcript.d.ts +31 -0
- package/dist/transcript.js +52 -0
- package/dist/v2-exit-relayer.d.ts +34 -0
- package/dist/v2-exit-relayer.js +102 -0
- package/dist/v2-exit-worker.d.ts +2 -0
- package/dist/v2-exit-worker.js +97 -0
- package/dist/v2-exits.d.ts +30 -0
- package/dist/v2-exits.js +78 -0
- package/dist/v2-prefund.d.ts +19 -0
- package/dist/v2-prefund.js +43 -0
- package/dist/v2-relayer-cli.d.ts +2 -0
- package/dist/v2-relayer-cli.js +39 -0
- package/dist/wallet.d.ts +32 -0
- package/dist/wallet.js +90 -0
- package/dist/workflow-memory.d.ts +22 -0
- package/dist/workflow-memory.js +49 -0
- package/dist/x402-resource-service.d.ts +36 -0
- package/dist/x402-resource-service.js +173 -0
- package/dist/x402.d.ts +46 -0
- package/dist/x402.js +136 -0
- package/package.json +60 -3
package/dist/store.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { fail } from "./errors.js";
|
|
6
|
+
const statuses = new Set(["draft", "reserved", "proof_persisted", "broadcast", "finalized", "confirmed", "delivered", "unresolved"]);
|
|
7
|
+
function encrypt(key, value, aad) {
|
|
8
|
+
const nonce = randomBytes(12);
|
|
9
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
10
|
+
cipher.setAAD(Buffer.from(aad));
|
|
11
|
+
return { nonce, value: Buffer.concat([cipher.update(value), cipher.final()]), tag: cipher.getAuthTag() };
|
|
12
|
+
}
|
|
13
|
+
function decrypt(key, encrypted, aad) {
|
|
14
|
+
try {
|
|
15
|
+
const cipher = createDecipheriv("aes-256-gcm", key, encrypted.nonce);
|
|
16
|
+
cipher.setAAD(Buffer.from(aad));
|
|
17
|
+
cipher.setAuthTag(encrypted.tag);
|
|
18
|
+
return Buffer.concat([cipher.update(encrypted.value), cipher.final()]);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
return fail("STATE_DECRYPT_FAILED", `could not decrypt ${aad}`, error);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class RoleStore {
|
|
25
|
+
db;
|
|
26
|
+
#key;
|
|
27
|
+
constructor(path, key) {
|
|
28
|
+
if (key.length !== 32)
|
|
29
|
+
fail("STATE_KEY_INVALID", "role-store key must contain exactly 32 bytes");
|
|
30
|
+
this.#key = Buffer.from(key);
|
|
31
|
+
const directory = dirname(path);
|
|
32
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
33
|
+
if ((statSync(directory).mode & 0o077) !== 0)
|
|
34
|
+
fail("STATE_DIRECTORY_PERMISSIONS", `${directory} must not be group/world accessible`);
|
|
35
|
+
if (existsSync(path) && (statSync(path).mode & 0o077) !== 0)
|
|
36
|
+
fail("STATE_PERMISSIONS", `${path} must not be group/world accessible`);
|
|
37
|
+
this.db = new DatabaseSync(path);
|
|
38
|
+
// SQLite may create the main database before its first transaction. Ensure
|
|
39
|
+
// the database and any persistent WAL sidecars cannot expose encrypted
|
|
40
|
+
// ciphertext or public operation metadata to another local account.
|
|
41
|
+
for (const candidate of [path, `${path}-wal`, `${path}-shm`]) {
|
|
42
|
+
if (existsSync(candidate))
|
|
43
|
+
chmodSync(candidate, 0o600);
|
|
44
|
+
}
|
|
45
|
+
this.db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
|
|
46
|
+
this.db.exec(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS secrets (name TEXT PRIMARY KEY, nonce BLOB NOT NULL, tag BLOB NOT NULL, value BLOB NOT NULL);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS operations (id TEXT PRIMARY KEY, status TEXT NOT NULL, payload_hash TEXT NOT NULL, tx_digest TEXT, updated_at INTEGER NOT NULL);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS reservations (note_commitment TEXT PRIMARY KEY, operation_id TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS replay_ledger (scope TEXT NOT NULL, value TEXT NOT NULL, result TEXT, PRIMARY KEY(scope, value));
|
|
51
|
+
CREATE TABLE IF NOT EXISTS checkpoints (stream TEXT PRIMARY KEY, checkpoint TEXT NOT NULL, cursor BLOB, root TEXT NOT NULL, leaf_index TEXT NOT NULL);
|
|
52
|
+
CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, sequence INTEGER NOT NULL, ciphertext_hash TEXT NOT NULL, blob_id TEXT, UNIQUE(session_id, sequence));
|
|
53
|
+
CREATE TABLE IF NOT EXISTS deliveries (request_id TEXT PRIMARY KEY, payment_key TEXT NOT NULL, response BLOB NOT NULL, finalized INTEGER NOT NULL);
|
|
54
|
+
CREATE UNIQUE INDEX IF NOT EXISTS deliveries_payment_key ON deliveries(payment_key);
|
|
55
|
+
CREATE TABLE IF NOT EXISTS x402_challenges (request_id TEXT PRIMARY KEY, nonce TEXT NOT NULL, requirements_hash TEXT NOT NULL, created_at INTEGER NOT NULL);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS delivery_jobs (request_id TEXT PRIMARY KEY, status TEXT NOT NULL, lease_until INTEGER NOT NULL DEFAULT 0);
|
|
57
|
+
CREATE TABLE IF NOT EXISTS notes (commitment TEXT PRIMARY KEY, operation_id TEXT, role TEXT NOT NULL, leaf_index TEXT, nullifier TEXT, spent INTEGER NOT NULL DEFAULT 0);
|
|
58
|
+
CREATE TABLE IF NOT EXISTS memory_jobs (operation_id TEXT PRIMARY KEY, kind TEXT NOT NULL, fact_hash TEXT NOT NULL, blob_id TEXT, status TEXT NOT NULL, created_at INTEGER NOT NULL);
|
|
59
|
+
CREATE TABLE IF NOT EXISTS memory_authority (blob_id TEXT PRIMARY KEY, fact_hash TEXT NOT NULL, provenance TEXT NOT NULL, evidence_hash TEXT, created_at INTEGER NOT NULL);
|
|
60
|
+
CREATE TABLE IF NOT EXISTS memory_inventory (blob_id TEXT PRIMARY KEY, memory_id TEXT NOT NULL, namespace TEXT NOT NULL, status TEXT NOT NULL, expires_at TEXT, deleted INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL);
|
|
61
|
+
CREATE TABLE IF NOT EXISTS memory_inventory_cursor (namespace TEXT PRIMARY KEY, cursor TEXT, updated_at INTEGER NOT NULL);
|
|
62
|
+
CREATE TABLE IF NOT EXISTS deal_operations (operation_id TEXT PRIMARY KEY, deal_id TEXT NOT NULL UNIQUE, input_hash TEXT NOT NULL, state TEXT NOT NULL, rail TEXT, plan_hash TEXT, payment_operation_id TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS deal_steps (operation_id TEXT NOT NULL, step TEXT NOT NULL, input_hash TEXT NOT NULL, state TEXT NOT NULL, result_hash TEXT, updated_at INTEGER NOT NULL, PRIMARY KEY(operation_id,step), FOREIGN KEY(operation_id) REFERENCES deal_operations(operation_id));
|
|
64
|
+
CREATE TABLE IF NOT EXISTS deal_blobs (operation_id TEXT NOT NULL, kind TEXT NOT NULL, ciphertext_hash TEXT NOT NULL, blob_id TEXT, status TEXT NOT NULL, PRIMARY KEY(operation_id,kind), FOREIGN KEY(operation_id) REFERENCES deal_operations(operation_id));
|
|
65
|
+
CREATE TABLE IF NOT EXISTS deal_memory_outbox (operation_id TEXT NOT NULL, event_kind TEXT NOT NULL, payload_hash TEXT NOT NULL, blob_id TEXT, status TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(operation_id,event_kind), FOREIGN KEY(operation_id) REFERENCES deal_operations(operation_id));
|
|
66
|
+
CREATE TABLE IF NOT EXISTS deal_timings (operation_id TEXT NOT NULL, stage TEXT NOT NULL, duration_ms INTEGER NOT NULL, PRIMARY KEY(operation_id,stage), FOREIGN KEY(operation_id) REFERENCES deal_operations(operation_id));
|
|
67
|
+
CREATE TABLE IF NOT EXISTS settlement_plans (plan_hash TEXT PRIMARY KEY, operation_id TEXT NOT NULL UNIQUE, plan_json TEXT NOT NULL, status TEXT NOT NULL, expires_at INTEGER NOT NULL, consumed_at INTEGER, created_at INTEGER NOT NULL);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS settlement_execution_bindings (operation_id TEXT PRIMARY KEY, plan_hash TEXT NOT NULL UNIQUE, binding_hash TEXT NOT NULL, binding_json TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(plan_hash) REFERENCES settlement_plans(plan_hash));
|
|
69
|
+
CREATE TABLE IF NOT EXISTS settlement_agreement_bindings (session_id TEXT PRIMARY KEY, plan_hash TEXT NOT NULL UNIQUE, binding_hash TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(plan_hash) REFERENCES settlement_plans(plan_hash));
|
|
70
|
+
CREATE TABLE IF NOT EXISTS settlement_decision_traces (plan_hash TEXT PRIMARY KEY, mandate_hash TEXT NOT NULL, manifest_hash TEXT NOT NULL, risk_level TEXT NOT NULL, reason_codes TEXT NOT NULL, citations TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(plan_hash) REFERENCES settlement_plans(plan_hash));
|
|
71
|
+
`);
|
|
72
|
+
const memoryColumns = this.db.prepare("PRAGMA table_info(memory_jobs)").all();
|
|
73
|
+
if (!memoryColumns.some((column) => column.name === "fact_hash"))
|
|
74
|
+
this.db.exec("ALTER TABLE memory_jobs ADD COLUMN fact_hash TEXT");
|
|
75
|
+
if (!memoryColumns.some((column) => column.name === "claimed_at"))
|
|
76
|
+
this.db.exec("ALTER TABLE memory_jobs ADD COLUMN claimed_at INTEGER");
|
|
77
|
+
for (const candidate of [path, `${path}-wal`, `${path}-shm`])
|
|
78
|
+
if (existsSync(candidate))
|
|
79
|
+
chmodSync(candidate, 0o600);
|
|
80
|
+
}
|
|
81
|
+
close() { this.db.close(); }
|
|
82
|
+
putSecret(name, value) {
|
|
83
|
+
const encrypted = encrypt(this.#key, Buffer.from(value), `secret:${name}`);
|
|
84
|
+
this.db.prepare("INSERT OR REPLACE INTO secrets(name, nonce, tag, value) VALUES (?, ?, ?, ?)")
|
|
85
|
+
.run(name, encrypted.nonce, encrypted.tag, encrypted.value);
|
|
86
|
+
}
|
|
87
|
+
putSecretOnce(name, value) {
|
|
88
|
+
const existing = this.getSecret(name);
|
|
89
|
+
if (existing) {
|
|
90
|
+
if (!existing.equals(Buffer.from(value)))
|
|
91
|
+
fail("SECRET_ID_CONFLICT", `secret ${name} already contains different bytes`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.putSecret(name, value);
|
|
95
|
+
}
|
|
96
|
+
getSecret(name) {
|
|
97
|
+
const row = this.db.prepare("SELECT nonce, tag, value FROM secrets WHERE name = ?").get(name);
|
|
98
|
+
return row ? decrypt(this.#key, row, `secret:${name}`) : undefined;
|
|
99
|
+
}
|
|
100
|
+
beginOperation(id, payloadHash) {
|
|
101
|
+
const existing = this.getOperation(id);
|
|
102
|
+
if (existing) {
|
|
103
|
+
if (existing.payloadHash !== payloadHash)
|
|
104
|
+
fail("OPERATION_ID_CONFLICT", `operation ${id} already has a different payload`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.db.prepare("INSERT INTO operations(id, status, payload_hash, updated_at) VALUES (?, 'draft', ?, ?)").run(id, payloadHash, Date.now());
|
|
108
|
+
}
|
|
109
|
+
getOperation(id) {
|
|
110
|
+
const row = this.db.prepare("SELECT id, status, payload_hash, tx_digest, updated_at FROM operations WHERE id = ?").get(id);
|
|
111
|
+
return row ? { id: row.id, status: row.status, payloadHash: row.payload_hash, ...(row.tx_digest ? { txDigest: row.tx_digest } : {}), updatedAt: row.updated_at } : undefined;
|
|
112
|
+
}
|
|
113
|
+
transitionOperation(id, next, txDigest) {
|
|
114
|
+
if (!statuses.has(next))
|
|
115
|
+
fail("OPERATION_STATUS_INVALID", `unknown operation status ${next}`);
|
|
116
|
+
const row = this.db.prepare("SELECT status, tx_digest FROM operations WHERE id = ?").get(id);
|
|
117
|
+
const current = row?.status ?? fail("OPERATION_NOT_FOUND", `operation ${id} does not exist`);
|
|
118
|
+
if (row?.tx_digest && txDigest && row.tx_digest !== txDigest)
|
|
119
|
+
fail("OPERATION_DIGEST_CONFLICT", "operation cannot transition with a different transaction digest");
|
|
120
|
+
if (current === next)
|
|
121
|
+
return;
|
|
122
|
+
const allowed = {
|
|
123
|
+
draft: ["reserved", "unresolved"], reserved: ["proof_persisted", "unresolved"],
|
|
124
|
+
proof_persisted: ["broadcast", "unresolved"], broadcast: ["finalized", "unresolved"],
|
|
125
|
+
finalized: ["confirmed", "unresolved"], confirmed: ["delivered", "unresolved"], delivered: [],
|
|
126
|
+
unresolved: ["broadcast", "finalized", "confirmed"],
|
|
127
|
+
};
|
|
128
|
+
if (!allowed[current].includes(next))
|
|
129
|
+
fail("OPERATION_TRANSITION_INVALID", `${current} cannot transition to ${next}`);
|
|
130
|
+
this.db.prepare("UPDATE operations SET status = ?, tx_digest = COALESCE(?, tx_digest), updated_at = ? WHERE id = ?")
|
|
131
|
+
.run(next, txDigest ?? null, Date.now(), id);
|
|
132
|
+
}
|
|
133
|
+
reserveNote(noteCommitment, operationId) {
|
|
134
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
135
|
+
try {
|
|
136
|
+
const existing = this.db.prepare("SELECT operation_id FROM reservations WHERE note_commitment = ?").get(noteCommitment);
|
|
137
|
+
if (existing && existing.operation_id !== operationId)
|
|
138
|
+
fail("NOTE_RESERVED", "note is already reserved by another operation");
|
|
139
|
+
this.db.prepare("INSERT OR IGNORE INTO reservations(note_commitment, operation_id, created_at) VALUES (?, ?, ?)").run(noteCommitment, operationId, Date.now());
|
|
140
|
+
if (this.getOperation(operationId)?.status === "draft")
|
|
141
|
+
this.transitionOperation(operationId, "reserved");
|
|
142
|
+
this.db.exec("COMMIT");
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
this.db.exec("ROLLBACK");
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
claimOnce(scope, value, result) {
|
|
150
|
+
const info = this.db.prepare("INSERT OR IGNORE INTO replay_ledger(scope, value, result) VALUES (?, ?, ?)").run(scope, value, result ?? null);
|
|
151
|
+
return info.changes === 1;
|
|
152
|
+
}
|
|
153
|
+
prepareMessage(message) {
|
|
154
|
+
const existing = this.getMessage(message.id);
|
|
155
|
+
if (existing) {
|
|
156
|
+
if (existing.sessionId !== message.sessionId || existing.sequence !== message.sequence || existing.ciphertextHash !== message.ciphertextHash) {
|
|
157
|
+
fail("MESSAGE_ID_CONFLICT", `message ${message.id} already identifies different ciphertext`);
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
this.db.prepare("INSERT INTO messages(id, session_id, sequence, ciphertext_hash, blob_id) VALUES (?, ?, ?, ?, NULL)")
|
|
163
|
+
.run(message.id, message.sessionId, message.sequence, message.ciphertextHash);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
fail("MESSAGE_SEQUENCE_CONFLICT", "session sequence is already occupied", error);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
attachMessageBlob(id, ciphertextHash, blobId) {
|
|
170
|
+
const row = this.getMessage(id) ?? fail("MESSAGE_NOT_FOUND", `message ${id} was not prepared`);
|
|
171
|
+
if (row.ciphertextHash !== ciphertextHash)
|
|
172
|
+
fail("CIPHERTEXT_TAMPERED", "uploaded ciphertext differs from the durable message");
|
|
173
|
+
if (row.blobId && row.blobId !== blobId)
|
|
174
|
+
fail("MESSAGE_BLOB_CONFLICT", "message already resolved to another blob");
|
|
175
|
+
this.db.prepare("UPDATE messages SET blob_id = ? WHERE id = ?").run(blobId, id);
|
|
176
|
+
}
|
|
177
|
+
getMessage(id) {
|
|
178
|
+
const row = this.db.prepare("SELECT id, session_id, sequence, ciphertext_hash, blob_id FROM messages WHERE id = ?").get(id);
|
|
179
|
+
return row ? { id: row.id, sessionId: row.session_id, sequence: row.sequence, ciphertextHash: row.ciphertext_hash, ...(row.blob_id ? { blobId: row.blob_id } : {}) } : undefined;
|
|
180
|
+
}
|
|
181
|
+
putNote(commitment, role, operationId, leafIndex, nullifier) {
|
|
182
|
+
const existing = this.db.prepare("SELECT operation_id, role, leaf_index, nullifier FROM notes WHERE commitment = ?").get(commitment);
|
|
183
|
+
const values = { operationId: operationId ?? null, role, leafIndex: leafIndex?.toString() ?? null, nullifier: nullifier ?? null };
|
|
184
|
+
if (existing && (existing.operation_id !== values.operationId || existing.role !== role || (existing.leaf_index && values.leafIndex && existing.leaf_index !== values.leafIndex) || (existing.nullifier && values.nullifier && existing.nullifier !== values.nullifier)))
|
|
185
|
+
fail("NOTE_ID_CONFLICT", "note commitment is already associated with different recovery data");
|
|
186
|
+
this.db.prepare("INSERT INTO notes(commitment, operation_id, role, leaf_index, nullifier, spent) VALUES (?, ?, ?, ?, ?, 0) ON CONFLICT(commitment) DO UPDATE SET leaf_index=COALESCE(notes.leaf_index, excluded.leaf_index), nullifier=COALESCE(notes.nullifier, excluded.nullifier)")
|
|
187
|
+
.run(commitment, values.operationId, role, values.leafIndex, values.nullifier);
|
|
188
|
+
}
|
|
189
|
+
markNoteSpent(nullifier) { this.db.prepare("UPDATE notes SET spent = 1 WHERE nullifier = ?").run(nullifier); }
|
|
190
|
+
hasUnspentNote(commitment) {
|
|
191
|
+
return Boolean(this.db.prepare("SELECT 1 AS found FROM notes WHERE commitment = ? AND spent = 0").get(commitment));
|
|
192
|
+
}
|
|
193
|
+
}
|
package/dist/sui.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { SuiGrpcClient } from "@mysten/sui/grpc";
|
|
2
|
+
import { Transaction } from "@mysten/sui/transactions";
|
|
3
|
+
export interface TransferCall {
|
|
4
|
+
packageId: string;
|
|
5
|
+
poolId: string;
|
|
6
|
+
coinType: string;
|
|
7
|
+
poolModule?: "pool" | "pool_v2";
|
|
8
|
+
expiryMs: bigint;
|
|
9
|
+
newIndex: bigint;
|
|
10
|
+
dealCommitment: Uint8Array;
|
|
11
|
+
dealNullifier: Uint8Array;
|
|
12
|
+
inputNullifier: Uint8Array;
|
|
13
|
+
paymentCommitment: Uint8Array;
|
|
14
|
+
changeCommitment: Uint8Array;
|
|
15
|
+
newRoot: Uint8Array;
|
|
16
|
+
proof: Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
export interface DepositCall {
|
|
19
|
+
packageId: string;
|
|
20
|
+
poolId: string;
|
|
21
|
+
coinType: string;
|
|
22
|
+
poolModule?: "pool" | "pool_v2";
|
|
23
|
+
amount: bigint;
|
|
24
|
+
newIndex: bigint;
|
|
25
|
+
noteCommitment: Uint8Array;
|
|
26
|
+
newRoot: Uint8Array;
|
|
27
|
+
proof: Uint8Array;
|
|
28
|
+
}
|
|
29
|
+
export interface WithdrawCall {
|
|
30
|
+
packageId: string;
|
|
31
|
+
poolId: string;
|
|
32
|
+
coinType: string;
|
|
33
|
+
amount: bigint;
|
|
34
|
+
recipient: string;
|
|
35
|
+
root: Uint8Array;
|
|
36
|
+
noteNullifier: Uint8Array;
|
|
37
|
+
proof: Uint8Array;
|
|
38
|
+
}
|
|
39
|
+
export interface WithdrawV2Call extends WithdrawCall {
|
|
40
|
+
exactExit: boolean;
|
|
41
|
+
changeCommitment: Uint8Array;
|
|
42
|
+
newRoot: Uint8Array;
|
|
43
|
+
newIndex: bigint;
|
|
44
|
+
}
|
|
45
|
+
export declare function buildDeposit(call: DepositCall): Transaction;
|
|
46
|
+
export declare function buildPrivateTransfer(call: TransferCall): Transaction;
|
|
47
|
+
export declare function buildWithdraw(call: WithdrawCall): Transaction;
|
|
48
|
+
export declare function buildWithdrawV2(call: WithdrawV2Call): Transaction;
|
|
49
|
+
export declare function writeClient(endpoint: string): SuiGrpcClient;
|
package/dist/sui.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { SuiGrpcClient } from "@mysten/sui/grpc";
|
|
2
|
+
import { Transaction } from "@mysten/sui/transactions";
|
|
3
|
+
import { fail } from "./errors.js";
|
|
4
|
+
const bytes = (value) => Array.from(value);
|
|
5
|
+
const validateCommon = (call) => {
|
|
6
|
+
if (!/^0x[0-9a-f]{64}$/i.test(call.packageId) || !/^0x[0-9a-f]{64}$/i.test(call.poolId) || !/^[^\s:]+(?:::[^\s:]+){2}(?:<.*>)?$/.test(call.coinType))
|
|
7
|
+
fail("POOL_ARGUMENT_INVALID", "package, pool, or coin type is invalid");
|
|
8
|
+
if (!call.proof.length || call.proof.length > 4096)
|
|
9
|
+
fail("POOL_ARGUMENT_INVALID", "proof length is invalid");
|
|
10
|
+
};
|
|
11
|
+
const validateU64 = (value, name, positive = false) => {
|
|
12
|
+
if (value < (positive ? 1n : 0n) || value > (1n << 64n) - 1n)
|
|
13
|
+
fail("POOL_ARGUMENT_INVALID", `${name} must be ${positive ? "a positive " : "an "}u64 value`);
|
|
14
|
+
};
|
|
15
|
+
const validate32 = (values) => {
|
|
16
|
+
for (const [name, value] of Object.entries(values))
|
|
17
|
+
if (value.length !== 32)
|
|
18
|
+
fail("POOL_ARGUMENT_INVALID", `${name} must contain 32 bytes`);
|
|
19
|
+
};
|
|
20
|
+
export function buildDeposit(call) {
|
|
21
|
+
validateCommon(call);
|
|
22
|
+
validateU64(call.amount, "amount", true);
|
|
23
|
+
validateU64(call.newIndex, "new index");
|
|
24
|
+
validate32({ noteCommitment: call.noteCommitment, newRoot: call.newRoot });
|
|
25
|
+
const tx = new Transaction();
|
|
26
|
+
const [coin] = tx.splitCoins(tx.gas, [tx.pure.u64(call.amount)]);
|
|
27
|
+
tx.moveCall({ target: `${call.packageId}::${call.poolModule ?? "pool"}::deposit`, typeArguments: [call.coinType], arguments: [
|
|
28
|
+
tx.object(call.poolId), coin, tx.pure.vector("u8", bytes(call.noteCommitment)), tx.pure.vector("u8", bytes(call.newRoot)),
|
|
29
|
+
tx.pure.u64(call.newIndex), tx.pure.vector("u8", bytes(call.proof)),
|
|
30
|
+
] });
|
|
31
|
+
return tx;
|
|
32
|
+
}
|
|
33
|
+
export function buildPrivateTransfer(call) {
|
|
34
|
+
validateCommon(call);
|
|
35
|
+
validateU64(call.expiryMs, "expiry");
|
|
36
|
+
validateU64(call.newIndex, "new index");
|
|
37
|
+
validate32({ dealCommitment: call.dealCommitment, dealNullifier: call.dealNullifier, inputNullifier: call.inputNullifier, paymentCommitment: call.paymentCommitment, changeCommitment: call.changeCommitment, newRoot: call.newRoot });
|
|
38
|
+
const tx = new Transaction();
|
|
39
|
+
tx.moveCall({ target: `${call.packageId}::${call.poolModule ?? "pool"}::transfer_private`, typeArguments: [call.coinType], arguments: [
|
|
40
|
+
tx.object(call.poolId), tx.object("0x6"), tx.pure.vector("u8", bytes(call.dealCommitment)), tx.pure.vector("u8", bytes(call.dealNullifier)),
|
|
41
|
+
tx.pure.vector("u8", bytes(call.inputNullifier)), tx.pure.vector("u8", bytes(call.paymentCommitment)), tx.pure.vector("u8", bytes(call.changeCommitment)),
|
|
42
|
+
tx.pure.u64(call.expiryMs), tx.pure.vector("u8", bytes(call.newRoot)), tx.pure.u64(call.newIndex), tx.pure.vector("u8", bytes(call.proof)),
|
|
43
|
+
] });
|
|
44
|
+
return tx;
|
|
45
|
+
}
|
|
46
|
+
export function buildWithdraw(call) {
|
|
47
|
+
validateCommon(call);
|
|
48
|
+
validateU64(call.amount, "amount", true);
|
|
49
|
+
if (!/^0x[0-9a-f]{64}$/i.test(call.recipient))
|
|
50
|
+
fail("POOL_ARGUMENT_INVALID", "recipient must be a 32-byte Sui address");
|
|
51
|
+
validate32({ root: call.root, noteNullifier: call.noteNullifier });
|
|
52
|
+
const tx = new Transaction();
|
|
53
|
+
tx.moveCall({ target: `${call.packageId}::pool::withdraw`, typeArguments: [call.coinType], arguments: [
|
|
54
|
+
tx.object(call.poolId), tx.pure.vector("u8", bytes(call.root)), tx.pure.vector("u8", bytes(call.noteNullifier)),
|
|
55
|
+
tx.pure.address(call.recipient), tx.pure.u64(call.amount), tx.pure.vector("u8", bytes(call.proof)),
|
|
56
|
+
] });
|
|
57
|
+
return tx;
|
|
58
|
+
}
|
|
59
|
+
export function buildWithdrawV2(call) {
|
|
60
|
+
validateCommon(call);
|
|
61
|
+
validateU64(call.amount, "amount", true);
|
|
62
|
+
validateU64(call.newIndex, "new index", true);
|
|
63
|
+
if (!call.exactExit && call.amount !== 1000000n)
|
|
64
|
+
fail("POOL_ARGUMENT_INVALID", "standard v2 exits are 1,000,000 MIST");
|
|
65
|
+
if (!/^0x[0-9a-f]{64}$/i.test(call.recipient))
|
|
66
|
+
fail("POOL_ARGUMENT_INVALID", "recipient must be a 32-byte Sui address");
|
|
67
|
+
validate32({ root: call.root, noteNullifier: call.noteNullifier, changeCommitment: call.changeCommitment, newRoot: call.newRoot });
|
|
68
|
+
const tx = new Transaction();
|
|
69
|
+
tx.moveCall({ target: `${call.packageId}::pool_v2::withdraw`, typeArguments: [call.coinType], arguments: [
|
|
70
|
+
tx.object(call.poolId), tx.pure.vector("u8", bytes(call.root)), tx.pure.vector("u8", bytes(call.noteNullifier)),
|
|
71
|
+
tx.pure.address(call.recipient), tx.pure.u64(call.amount), tx.pure.bool(call.exactExit),
|
|
72
|
+
tx.pure.vector("u8", bytes(call.changeCommitment)), tx.pure.vector("u8", bytes(call.newRoot)),
|
|
73
|
+
tx.pure.u64(call.newIndex), tx.pure.vector("u8", bytes(call.proof)),
|
|
74
|
+
] });
|
|
75
|
+
return tx;
|
|
76
|
+
}
|
|
77
|
+
export function writeClient(endpoint) { return new SuiGrpcClient({ baseUrl: endpoint, network: "testnet" }); }
|
package/dist/tatum.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type BinaryMessage = {
|
|
2
|
+
toBinary(value: object): Uint8Array;
|
|
3
|
+
fromBinary(value: Uint8Array): unknown;
|
|
4
|
+
};
|
|
5
|
+
/** Minimal native HTTP/2 adapter because Tatum exposes gRPC, not gRPC-web. */
|
|
6
|
+
export declare class TatumLedger {
|
|
7
|
+
private readonly endpoint;
|
|
8
|
+
private readonly apiKey;
|
|
9
|
+
constructor(endpoint: string, apiKey: string);
|
|
10
|
+
unary<Request extends object, Response>(method: string, requestType: BinaryMessage, responseType: BinaryMessage, message: Request): Promise<Response>;
|
|
11
|
+
stream<Request extends object, Response>(method: string, requestType: BinaryMessage, responseType: BinaryMessage, message: Request): Promise<Response[]>;
|
|
12
|
+
private call;
|
|
13
|
+
getServiceInfo(): Promise<unknown>;
|
|
14
|
+
getTransaction(digest: string): Promise<unknown>;
|
|
15
|
+
getObject(objectId: string): Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
export {};
|
package/dist/tatum.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import http2 from "node:http2";
|
|
2
|
+
import { GrpcTypes } from "@mysten/sui/grpc";
|
|
3
|
+
import { fail, SupercutsError } from "./errors.js";
|
|
4
|
+
function authority(endpoint) {
|
|
5
|
+
const url = new URL(endpoint);
|
|
6
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "::1"].includes(url.hostname)))
|
|
7
|
+
fail("TATUM_ENDPOINT_INVALID", "Tatum endpoint must use HTTPS except on loopback");
|
|
8
|
+
return url.origin;
|
|
9
|
+
}
|
|
10
|
+
function frame(bytes) {
|
|
11
|
+
const value = Buffer.from(bytes);
|
|
12
|
+
const result = Buffer.alloc(5 + value.length);
|
|
13
|
+
result.writeUInt32BE(value.length, 1);
|
|
14
|
+
value.copy(result, 5);
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
function unframe(body) {
|
|
18
|
+
const values = [];
|
|
19
|
+
let offset = 0;
|
|
20
|
+
while (offset < body.length) {
|
|
21
|
+
if (body[offset] !== 0 || offset + 5 > body.length)
|
|
22
|
+
fail("TATUM_GRPC_FRAME", "compressed or truncated gRPC response");
|
|
23
|
+
const size = body.readUInt32BE(offset + 1);
|
|
24
|
+
offset += 5;
|
|
25
|
+
if (offset + size > body.length)
|
|
26
|
+
fail("TATUM_GRPC_FRAME", "truncated gRPC response payload");
|
|
27
|
+
values.push(body.subarray(offset, offset + size));
|
|
28
|
+
offset += size;
|
|
29
|
+
}
|
|
30
|
+
return values;
|
|
31
|
+
}
|
|
32
|
+
/** Minimal native HTTP/2 adapter because Tatum exposes gRPC, not gRPC-web. */
|
|
33
|
+
export class TatumLedger {
|
|
34
|
+
endpoint;
|
|
35
|
+
apiKey;
|
|
36
|
+
constructor(endpoint, apiKey) {
|
|
37
|
+
this.endpoint = endpoint;
|
|
38
|
+
this.apiKey = apiKey;
|
|
39
|
+
if (!apiKey || apiKey.length > 4096)
|
|
40
|
+
fail("TATUM_CONFIG_INVALID", "Tatum API key is missing or too large");
|
|
41
|
+
authority(endpoint);
|
|
42
|
+
}
|
|
43
|
+
async unary(method, requestType, responseType, message) {
|
|
44
|
+
const result = await this.call(method, frame(requestType.toBinary(message)));
|
|
45
|
+
const messages = unframe(result.body);
|
|
46
|
+
if (messages.length !== 1)
|
|
47
|
+
fail("TATUM_GRPC_FRAME", `${method} returned ${messages.length} messages`);
|
|
48
|
+
return responseType.fromBinary(messages[0]);
|
|
49
|
+
}
|
|
50
|
+
async stream(method, requestType, responseType, message) {
|
|
51
|
+
const result = await this.call(method, frame(requestType.toBinary(message)));
|
|
52
|
+
return unframe(result.body).map((value) => responseType.fromBinary(value));
|
|
53
|
+
}
|
|
54
|
+
async call(method, payload) {
|
|
55
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(method))
|
|
56
|
+
fail("TATUM_METHOD_INVALID", "invalid gRPC method name");
|
|
57
|
+
const client = http2.connect(authority(this.endpoint));
|
|
58
|
+
return await new Promise((resolve, reject) => {
|
|
59
|
+
const request = client.request({ ":method": "POST", ":path": `/sui.rpc.v2.LedgerService/${method}`, "content-type": "application/grpc", te: "trailers", "x-api-key": this.apiKey });
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let size = 0;
|
|
62
|
+
let status;
|
|
63
|
+
let grpcStatus;
|
|
64
|
+
let done = false;
|
|
65
|
+
const rejectOnce = (error) => { if (done)
|
|
66
|
+
return; done = true; request.close(http2.constants.NGHTTP2_CANCEL); client.close(); reject(error); };
|
|
67
|
+
request.setTimeout(20_000, () => rejectOnce(new SupercutsError("TATUM_TIMEOUT", `${method} timed out`)));
|
|
68
|
+
request.on("response", (headers) => { status = Number(headers[":status"]); if (headers["grpc-status"] !== undefined)
|
|
69
|
+
grpcStatus = String(headers["grpc-status"]); });
|
|
70
|
+
request.on("trailers", (headers) => { grpcStatus = String(headers["grpc-status"]); });
|
|
71
|
+
request.on("data", (chunk) => { size += chunk.length; if (size > 32 * 1024 * 1024)
|
|
72
|
+
return rejectOnce(new SupercutsError("TATUM_RESPONSE_TOO_LARGE", `${method} exceeded 32 MiB`)); chunks.push(chunk); });
|
|
73
|
+
request.on("error", (error) => rejectOnce(error));
|
|
74
|
+
request.on("end", () => { if (done)
|
|
75
|
+
return; done = true; client.close(); if (status !== 200 || grpcStatus !== "0")
|
|
76
|
+
return reject(new SupercutsError("TATUM_GRPC_ERROR", `${method} failed: HTTP ${status}, gRPC ${grpcStatus}`)); resolve({ body: Buffer.concat(chunks) }); });
|
|
77
|
+
request.end(payload);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
getServiceInfo() { return this.unary("GetServiceInfo", GrpcTypes.GetServiceInfoRequest, GrpcTypes.GetServiceInfoResponse, {}); }
|
|
81
|
+
getTransaction(digest) { return this.unary("GetTransaction", GrpcTypes.GetTransactionRequest, GrpcTypes.GetTransactionResponse, { digest, readMask: { paths: ["digest", "checkpoint", "effects", "events", "balance_changes"] } }); }
|
|
82
|
+
getObject(objectId) { return this.unary("GetObject", GrpcTypes.GetObjectRequest, GrpcTypes.GetObjectResponse, { objectId, readMask: { paths: ["object_id", "content", "type"] } }); }
|
|
83
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type MessageType = "offer" | "counteroffer" | "accept" | "recovery" | "cancel";
|
|
2
|
+
export interface MessageEnvelope {
|
|
3
|
+
id: string;
|
|
4
|
+
sessionId: string;
|
|
5
|
+
revision: number;
|
|
6
|
+
sequence: number;
|
|
7
|
+
parentHash: string | null;
|
|
8
|
+
author: string;
|
|
9
|
+
type: MessageType;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
ciphertext: string;
|
|
12
|
+
ciphertextHash: string;
|
|
13
|
+
signature: string;
|
|
14
|
+
}
|
|
15
|
+
export interface TranscriptConstraints {
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
participants?: ReadonlySet<string>;
|
|
18
|
+
now?: number;
|
|
19
|
+
allowExpired?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export type SignatureVerifier = (author: string, bytes: Uint8Array, signature: string) => Promise<boolean>;
|
|
22
|
+
export type CiphertextLoader = (blobId: string) => Promise<string>;
|
|
23
|
+
export declare function canonicalEnvelope(envelope: Omit<MessageEnvelope, "signature">): Uint8Array;
|
|
24
|
+
export declare function sha256(value: string | Uint8Array): string;
|
|
25
|
+
export declare function validateTranscript(messages: MessageEnvelope[], verify: SignatureVerifier, constraints?: number | TranscriptConstraints): Promise<string>;
|
|
26
|
+
export declare function hydrateTranscript(manifest: Array<Omit<MessageEnvelope, "ciphertext"> & {
|
|
27
|
+
blobId: string;
|
|
28
|
+
}>, load: CiphertextLoader, verify: SignatureVerifier, constraints?: number | TranscriptConstraints): Promise<{
|
|
29
|
+
messages: MessageEnvelope[];
|
|
30
|
+
root: string;
|
|
31
|
+
}>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { fail } from "./errors.js";
|
|
3
|
+
export function canonicalEnvelope(envelope) {
|
|
4
|
+
return Buffer.from(JSON.stringify({
|
|
5
|
+
id: envelope.id, sessionId: envelope.sessionId, revision: envelope.revision, sequence: envelope.sequence,
|
|
6
|
+
parentHash: envelope.parentHash, author: envelope.author, type: envelope.type, expiresAt: envelope.expiresAt,
|
|
7
|
+
ciphertextHash: envelope.ciphertextHash,
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
10
|
+
export function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
|
|
11
|
+
export async function validateTranscript(messages, verify, constraints = {}) {
|
|
12
|
+
if (!messages.length)
|
|
13
|
+
fail("TRANSCRIPT_EMPTY", "a settlement transcript cannot be empty");
|
|
14
|
+
const options = typeof constraints === "number" ? { now: constraints } : constraints;
|
|
15
|
+
const now = options.now ?? Date.now();
|
|
16
|
+
const sessionId = options.sessionId ?? messages[0].sessionId;
|
|
17
|
+
let previous = null;
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
let revision = 0;
|
|
20
|
+
for (let position = 0; position < messages.length; position++) {
|
|
21
|
+
const message = messages[position];
|
|
22
|
+
if (!message.id || message.id.length > 256 || !message.sessionId || message.sessionId.length > 256 || !message.author || message.author.length > 256 || !Number.isSafeInteger(message.sequence) || !Number.isSafeInteger(message.expiresAt) || message.ciphertext.length > 2_000_000)
|
|
23
|
+
fail("TRANSCRIPT_MESSAGE_INVALID", "transcript message fields are invalid or too large");
|
|
24
|
+
if (!["offer", "counteroffer", "accept", "recovery", "cancel"].includes(message.type))
|
|
25
|
+
fail("TRANSCRIPT_MESSAGE_INVALID", "transcript message type is invalid");
|
|
26
|
+
if (message.sequence !== position || seen.has(message.id))
|
|
27
|
+
fail("TRANSCRIPT_ORDER_INVALID", "transcript has a gap, duplicate, or replay");
|
|
28
|
+
if (message.sessionId !== sessionId)
|
|
29
|
+
fail("TRANSCRIPT_SESSION_MISMATCH", "message belongs to another session");
|
|
30
|
+
if (options.participants && !options.participants.has(message.author))
|
|
31
|
+
fail("TRANSCRIPT_PEER_MISMATCH", "message author is not a session participant");
|
|
32
|
+
if (!Number.isSafeInteger(message.revision) || message.revision < 1 || message.revision < revision || message.revision > revision + 1)
|
|
33
|
+
fail("TRANSCRIPT_REVISION_INVALID", "message revision is not monotonic");
|
|
34
|
+
if (message.parentHash !== previous)
|
|
35
|
+
fail("TRANSCRIPT_FORK", "transcript parent hash does not match its predecessor");
|
|
36
|
+
if (!options.allowExpired && message.expiresAt < now && message.type !== "cancel")
|
|
37
|
+
fail("OFFER_EXPIRED", "transcript contains an expired message");
|
|
38
|
+
const ciphertext = Buffer.from(message.ciphertext, "base64");
|
|
39
|
+
if (ciphertext.toString("base64") !== message.ciphertext || sha256(ciphertext) !== message.ciphertextHash)
|
|
40
|
+
fail("CIPHERTEXT_TAMPERED", "ciphertext encoding or hash does not match");
|
|
41
|
+
if (!(await verify(message.author, canonicalEnvelope(message), message.signature)))
|
|
42
|
+
fail("MESSAGE_SIGNATURE_INVALID", "message signature is invalid");
|
|
43
|
+
previous = sha256(Buffer.concat([canonicalEnvelope(message), Buffer.from(message.signature)]));
|
|
44
|
+
revision = message.revision;
|
|
45
|
+
seen.add(message.id);
|
|
46
|
+
}
|
|
47
|
+
return previous;
|
|
48
|
+
}
|
|
49
|
+
export async function hydrateTranscript(manifest, load, verify, constraints = {}) {
|
|
50
|
+
const messages = await Promise.all(manifest.map(async ({ blobId, ...message }) => ({ ...message, ciphertext: await load(blobId) })));
|
|
51
|
+
return { messages, root: await validateTranscript(messages, verify, constraints) };
|
|
52
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RoleStore } from "./store.js";
|
|
2
|
+
import { type WithdrawV2Call } from "./sui.js";
|
|
3
|
+
export interface V2RelayerPin {
|
|
4
|
+
packageId: string;
|
|
5
|
+
poolId: string;
|
|
6
|
+
coinType: string;
|
|
7
|
+
relayerAddress: string;
|
|
8
|
+
writeGrpcEndpoint: string;
|
|
9
|
+
tatumGrpcEndpoint: string;
|
|
10
|
+
}
|
|
11
|
+
export interface V2ExitRequest extends Omit<WithdrawV2Call, "packageId" | "poolId" | "coinType"> {
|
|
12
|
+
operationId: string;
|
|
13
|
+
}
|
|
14
|
+
/** Runs in a separate, funded relayer home. It never receives a note opening. */
|
|
15
|
+
export declare class V2ExitRelayer {
|
|
16
|
+
private readonly store;
|
|
17
|
+
private readonly pin;
|
|
18
|
+
private readonly client;
|
|
19
|
+
private readonly signer;
|
|
20
|
+
private readonly tatum;
|
|
21
|
+
constructor(store: RoleStore, pin: V2RelayerPin);
|
|
22
|
+
doctor(): Promise<{
|
|
23
|
+
relayer: string;
|
|
24
|
+
gasBalanceMist: string;
|
|
25
|
+
funded: true;
|
|
26
|
+
}>;
|
|
27
|
+
submit(request: V2ExitRequest): Promise<{
|
|
28
|
+
digest: string;
|
|
29
|
+
checkpoint: string;
|
|
30
|
+
relayer: string;
|
|
31
|
+
resumed: boolean;
|
|
32
|
+
tatumVerified: true;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
|
|
3
|
+
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
|
|
4
|
+
import { fail } from "./errors.js";
|
|
5
|
+
import { assertSuiFinality, assertTatumFinality } from "./finality.js";
|
|
6
|
+
import { buildWithdrawV2, writeClient } from "./sui.js";
|
|
7
|
+
import { TatumLedger } from "./tatum.js";
|
|
8
|
+
/** Runs in a separate, funded relayer home. It never receives a note opening. */
|
|
9
|
+
export class V2ExitRelayer {
|
|
10
|
+
store;
|
|
11
|
+
pin;
|
|
12
|
+
client;
|
|
13
|
+
signer;
|
|
14
|
+
tatum;
|
|
15
|
+
constructor(store, pin) {
|
|
16
|
+
this.store = store;
|
|
17
|
+
this.pin = pin;
|
|
18
|
+
const encoded = store.getSecret("live:relayer-sui-private-key")?.toString() ?? fail("RELAYER_CREDENTIAL_MISSING", "separate relayer signer is missing");
|
|
19
|
+
const decoded = decodeSuiPrivateKey(encoded);
|
|
20
|
+
if (decoded.scheme !== "ED25519")
|
|
21
|
+
fail("SIGNER_UNSUPPORTED", "relayer signer must use ED25519");
|
|
22
|
+
this.signer = Ed25519Keypair.fromSecretKey(decoded.secretKey);
|
|
23
|
+
if (this.signer.toSuiAddress().toLowerCase() !== pin.relayerAddress.toLowerCase())
|
|
24
|
+
fail("RELAYER_SIGNER_MISMATCH", "relayer signer differs from the pinned funded address");
|
|
25
|
+
this.client = writeClient(pin.writeGrpcEndpoint);
|
|
26
|
+
const tatumKey = store.getSecret("live:tatum-api-key")?.toString() ?? fail("RELAYER_CREDENTIAL_MISSING", "relayer Tatum read credential is missing");
|
|
27
|
+
this.tatum = new TatumLedger(pin.tatumGrpcEndpoint, tatumKey);
|
|
28
|
+
store.db.exec(`CREATE TABLE IF NOT EXISTS v2_relay_journal (
|
|
29
|
+
operation_id TEXT PRIMARY KEY, request_hash TEXT NOT NULL, note_nullifier TEXT NOT NULL UNIQUE,
|
|
30
|
+
transaction_bytes BLOB NOT NULL, signature TEXT NOT NULL, digest TEXT NOT NULL UNIQUE,
|
|
31
|
+
status TEXT NOT NULL, updated_at INTEGER NOT NULL
|
|
32
|
+
)`);
|
|
33
|
+
}
|
|
34
|
+
async doctor() {
|
|
35
|
+
const { balance } = await this.client.getBalance({ owner: this.signer.toSuiAddress(), coinType: "0x2::sui::SUI" });
|
|
36
|
+
const available = BigInt(balance.balance);
|
|
37
|
+
if (available < 10000000n)
|
|
38
|
+
fail("RELAYER_GAS_INSUFFICIENT", "separate Testnet relayer needs at least 10,000,000 MIST for the live exit gate");
|
|
39
|
+
return { relayer: this.signer.toSuiAddress(), gasBalanceMist: available.toString(), funded: true };
|
|
40
|
+
}
|
|
41
|
+
async submit(request) {
|
|
42
|
+
if (!request.operationId || request.operationId.length > 256)
|
|
43
|
+
fail("RELAY_REQUEST_INVALID", "operation ID is invalid");
|
|
44
|
+
if ([request.root, request.noteNullifier, request.changeCommitment, request.newRoot].some((value) => value.length !== 32) ||
|
|
45
|
+
!/^0x[0-9a-f]{64}$/i.test(request.recipient) || request.amount <= 0n || request.amount >= 1n << 64n ||
|
|
46
|
+
(!request.exactExit && request.amount !== 1000000n) || request.newIndex <= 0n ||
|
|
47
|
+
!request.proof.length || request.proof.length > 128 * 1024)
|
|
48
|
+
fail("RELAY_REQUEST_INVALID", "v2 exit fields are malformed or outside protocol bounds");
|
|
49
|
+
const normalized = {
|
|
50
|
+
operationId: request.operationId, root: Buffer.from(request.root).toString("hex"),
|
|
51
|
+
noteNullifier: Buffer.from(request.noteNullifier).toString("hex"), recipient: request.recipient.toLowerCase(),
|
|
52
|
+
amount: request.amount.toString(), exactExit: request.exactExit,
|
|
53
|
+
changeCommitment: Buffer.from(request.changeCommitment).toString("hex"),
|
|
54
|
+
newRoot: Buffer.from(request.newRoot).toString("hex"), newIndex: request.newIndex.toString(),
|
|
55
|
+
proofHash: createHash("sha256").update(request.proof).digest("hex"),
|
|
56
|
+
};
|
|
57
|
+
const requestHash = createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
|
|
58
|
+
const existing = this.store.db.prepare("SELECT request_hash,transaction_bytes,signature,digest FROM v2_relay_journal WHERE operation_id=?").get(request.operationId);
|
|
59
|
+
if (existing && existing.request_hash !== requestHash)
|
|
60
|
+
fail("OPERATION_ID_CONFLICT", "relayer operation ID identifies another proof or payout");
|
|
61
|
+
let signed;
|
|
62
|
+
if (existing)
|
|
63
|
+
signed = { transaction: existing.transaction_bytes, signature: existing.signature, digest: existing.digest };
|
|
64
|
+
else {
|
|
65
|
+
await this.doctor();
|
|
66
|
+
const tx = buildWithdrawV2({ ...request, packageId: this.pin.packageId, poolId: this.pin.poolId, coinType: this.pin.coinType });
|
|
67
|
+
tx.setSender(this.signer.toSuiAddress());
|
|
68
|
+
const simulation = await this.client.simulateTransaction({ transaction: tx, include: { effects: true } });
|
|
69
|
+
if (!(simulation.Transaction ?? simulation.FailedTransaction)?.status.success)
|
|
70
|
+
fail("RELAY_SIMULATION_FAILED", "v2 withdrawal proof or current root was rejected");
|
|
71
|
+
const transaction = await tx.build({ client: this.client });
|
|
72
|
+
const digest = await tx.getDigest({ client: this.client });
|
|
73
|
+
const signature = (await this.signer.signTransaction(transaction)).signature;
|
|
74
|
+
try {
|
|
75
|
+
this.store.db.prepare("INSERT INTO v2_relay_journal(operation_id,request_hash,note_nullifier,transaction_bytes,signature,digest,status,updated_at) VALUES(?,?,?,?,?,?,'signed',?)")
|
|
76
|
+
.run(request.operationId, requestHash, normalized.noteNullifier, Buffer.from(transaction), signature, digest, Date.now());
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
fail("RELAY_REPLAY", "nullifier or operation has already been submitted through this relayer", error);
|
|
80
|
+
}
|
|
81
|
+
signed = { transaction, signature, digest };
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
await this.client.executeTransaction({ transaction: signed.transaction, signatures: [signed.signature], include: { effects: true } });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// An ambiguous broadcast is resolved by the digest. The exact signed
|
|
88
|
+
// bytes remain journaled, so a retry cannot change the payout.
|
|
89
|
+
}
|
|
90
|
+
let finalized;
|
|
91
|
+
try {
|
|
92
|
+
finalized = await this.client.waitForTransaction({ digest: signed.digest, timeout: 60_000, include: { effects: true } });
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return fail("RELAY_BROADCAST_UNRESOLVED", "relayer transaction outcome is unresolved; retry the same operation ID", error);
|
|
96
|
+
}
|
|
97
|
+
const checkpoint = assertSuiFinality(finalized, signed.digest);
|
|
98
|
+
assertTatumFinality(await this.tatum.getTransaction(signed.digest), signed.digest, checkpoint);
|
|
99
|
+
this.store.db.prepare("UPDATE v2_relay_journal SET status='finalized',updated_at=? WHERE operation_id=?").run(Date.now(), request.operationId);
|
|
100
|
+
return { digest: signed.digest, checkpoint, relayer: this.signer.toSuiAddress(), resumed: Boolean(existing), tatumVerified: true };
|
|
101
|
+
}
|
|
102
|
+
}
|