@supercuts-walrus/backend 0.0.0-stage → 0.1.0
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 +29 -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 +449 -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/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 +82 -0
- package/dist/live-runtime.js +570 -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 +178 -0
- package/dist/mcp-bootstrap.d.ts +2 -0
- package/dist/mcp-bootstrap.js +39 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +187 -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 +76 -0
- package/dist/phase3.d.ts +100 -0
- package/dist/phase3.js +1144 -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 +67 -0
- package/dist/remote-buyer.js +231 -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/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-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 +416 -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 +34 -0
- package/dist/x402-resource-service.js +154 -0
- package/dist/x402.d.ts +46 -0
- package/dist/x402.js +136 -0
- package/package.json +60 -3
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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve, join } from "node:path";
|
|
4
|
+
import { loadStateKey } from "./config.js";
|
|
5
|
+
import { Phase3App } from "./phase3.js";
|
|
6
|
+
import { LiveRuntime } from "./live-runtime.js";
|
|
7
|
+
import { RoleStore } from "./store.js";
|
|
8
|
+
import { V2ExitQueue } from "./v2-exits.js";
|
|
9
|
+
import { V2ExitRelayer } from "./v2-exit-relayer.js";
|
|
10
|
+
const args = new Map();
|
|
11
|
+
for (let index = 2; index < process.argv.length; index += 2) {
|
|
12
|
+
const name = process.argv[index], value = process.argv[index + 1];
|
|
13
|
+
if (!name?.startsWith("--") || !value)
|
|
14
|
+
throw Error("expected --name value arguments");
|
|
15
|
+
args.set(name.slice(2), value);
|
|
16
|
+
}
|
|
17
|
+
const required = (name) => args.get(name) ?? (() => { throw Error(`--${name} is required`); })();
|
|
18
|
+
const sellerHome = resolve(required("seller-home")), relayerHome = resolve(required("relayer-home"));
|
|
19
|
+
const pin = JSON.parse(readFileSync(resolve(required("config")), "utf8"));
|
|
20
|
+
const operationId = required("operation-id"), evidencePath = resolve(required("evidence-file"));
|
|
21
|
+
const pause = (milliseconds) => new Promise((done) => setTimeout(done, milliseconds));
|
|
22
|
+
function requestFromStore(app) {
|
|
23
|
+
const encoded = app.store.getSecret(`v2-exit-request:${operationId}`);
|
|
24
|
+
if (!encoded)
|
|
25
|
+
throw Error("queued exit request is unavailable in the seller store");
|
|
26
|
+
const raw = JSON.parse(encoded.toString());
|
|
27
|
+
const bytes32 = (name) => Buffer.from(String(raw[name] ?? "").replace(/^0x/, ""), "hex");
|
|
28
|
+
return { operationId: String(raw.operationId), root: bytes32("root"), noteNullifier: bytes32("noteNullifier"),
|
|
29
|
+
recipient: String(raw.recipient), amount: BigInt(String(raw.amount)), exactExit: raw.exactExit === true,
|
|
30
|
+
changeCommitment: bytes32("changeCommitment"), newRoot: bytes32("newRoot"), newIndex: BigInt(String(raw.newIndex)),
|
|
31
|
+
proof: Buffer.from(String(raw.proof), "base64") };
|
|
32
|
+
}
|
|
33
|
+
async function attempt() {
|
|
34
|
+
if (existsSync(evidencePath))
|
|
35
|
+
return true;
|
|
36
|
+
const seller = new Phase3App(sellerHome);
|
|
37
|
+
const relayerStore = new RoleStore(join(relayerHome, "state.sqlite"), loadStateKey(join(relayerHome, "state.key")));
|
|
38
|
+
try {
|
|
39
|
+
const queue = new V2ExitQueue(seller.store), scheduled = queue.get(operationId);
|
|
40
|
+
if (!scheduled)
|
|
41
|
+
throw Error("queued exit does not exist");
|
|
42
|
+
if (scheduled.status === "queued")
|
|
43
|
+
queue.assertDue(operationId);
|
|
44
|
+
else if (scheduled.status !== "finalized")
|
|
45
|
+
throw Error("exit is awaiting an unresolved submission");
|
|
46
|
+
if (!seller.profile.live)
|
|
47
|
+
throw Error("seller profile has no live Testnet deployment");
|
|
48
|
+
const relayer = new V2ExitRelayer(relayerStore, pin), runtime = await LiveRuntime.create(seller.profile, seller.store);
|
|
49
|
+
let request = requestFromStore(seller);
|
|
50
|
+
const existing = relayerStore.db.prepare("SELECT digest FROM v2_relay_journal WHERE operation_id=?").get(request.operationId);
|
|
51
|
+
if (!existing) {
|
|
52
|
+
const state = await runtime.index.sync();
|
|
53
|
+
if (Buffer.from(request.root).toString("hex") !== state.root.toString(16).padStart(64, "0")) {
|
|
54
|
+
await runtime.refreshV2Exit(operationId);
|
|
55
|
+
request = requestFromStore(seller);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const relayed = await relayer.submit(request);
|
|
59
|
+
const parameters = { "exit-id": operationId, digest: relayed.digest };
|
|
60
|
+
const token = seller.approve(seller.preview("wallet.exit.finalize", parameters).challengeId);
|
|
61
|
+
const finalized = (await seller.execute("wallet.exit.finalize", { ...parameters, "approval-token": token.token })).data;
|
|
62
|
+
const evidence = { schema: "supercuts.v2.exit-completion.v1", operationId, relayer: relayed.relayer, digest: relayed.digest,
|
|
63
|
+
checkpoint: relayed.checkpoint, payoutAddress: scheduled.payoutAddress, amount: scheduled.amount.toString(),
|
|
64
|
+
exactExit: scheduled.exactExit, tatumVerified: finalized.tatumVerified, finalized: finalized.finalized, completedAt: new Date().toISOString() };
|
|
65
|
+
const temporary = `${evidencePath}.tmp`;
|
|
66
|
+
writeFileSync(temporary, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600 });
|
|
67
|
+
renameSync(temporary, evidencePath);
|
|
68
|
+
process.stdout.write(`${JSON.stringify(evidence)}\n`);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
relayerStore.close();
|
|
73
|
+
seller.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const window = new Phase3App(sellerHome);
|
|
77
|
+
let windowEnd;
|
|
78
|
+
try {
|
|
79
|
+
windowEnd = new V2ExitQueue(window.store).get(operationId)?.windowEnd ?? 0;
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
window.close();
|
|
83
|
+
}
|
|
84
|
+
if (!windowEnd)
|
|
85
|
+
throw Error("queued exit does not exist");
|
|
86
|
+
while (Date.now() < windowEnd) {
|
|
87
|
+
try {
|
|
88
|
+
if (await attempt())
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const message = error instanceof Error ? error.message : "unknown error";
|
|
93
|
+
process.stderr.write(`${JSON.stringify({ operationId, error: message, at: new Date().toISOString() })}\n`);
|
|
94
|
+
}
|
|
95
|
+
await pause(Math.min(20_000, Math.max(1_000, windowEnd - Date.now())));
|
|
96
|
+
}
|
|
97
|
+
throw Error("approved exit window closed before finality was recorded");
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RoleStore } from "./store.js";
|
|
2
|
+
export interface QueuedExit {
|
|
3
|
+
operationId: string;
|
|
4
|
+
commitment: string;
|
|
5
|
+
payoutAddress: string;
|
|
6
|
+
amount: bigint;
|
|
7
|
+
exactExit: boolean;
|
|
8
|
+
windowStart: number;
|
|
9
|
+
executeAt: number;
|
|
10
|
+
windowEnd: number;
|
|
11
|
+
status: "queued" | "submitted" | "finalized";
|
|
12
|
+
}
|
|
13
|
+
/** Owner-side queue. Only the address and proof are sent to a separate relayer. */
|
|
14
|
+
export declare class V2ExitQueue {
|
|
15
|
+
private readonly store;
|
|
16
|
+
constructor(store: RoleStore);
|
|
17
|
+
queue(request: {
|
|
18
|
+
operationId: string;
|
|
19
|
+
commitment: string;
|
|
20
|
+
amount: bigint;
|
|
21
|
+
exactExit: boolean;
|
|
22
|
+
windowStart: number;
|
|
23
|
+
windowEnd: number;
|
|
24
|
+
}, approved: boolean, now?: number): QueuedExit;
|
|
25
|
+
get(operationId: string): QueuedExit | undefined;
|
|
26
|
+
due(now?: number): QueuedExit[];
|
|
27
|
+
assertDue(operationId: string, now?: number): QueuedExit;
|
|
28
|
+
recordSubmission(operationId: string, digest: string): void;
|
|
29
|
+
recordFinalized(operationId: string, digest: string): void;
|
|
30
|
+
}
|
package/dist/v2-exits.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
2
|
+
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
|
|
3
|
+
import { fail } from "./errors.js";
|
|
4
|
+
const MAX_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
5
|
+
/** Owner-side queue. Only the address and proof are sent to a separate relayer. */
|
|
6
|
+
export class V2ExitQueue {
|
|
7
|
+
store;
|
|
8
|
+
constructor(store) {
|
|
9
|
+
this.store = store;
|
|
10
|
+
store.db.exec(`CREATE TABLE IF NOT EXISTS v2_exit_queue (
|
|
11
|
+
operation_id TEXT PRIMARY KEY, commitment TEXT NOT NULL UNIQUE, payout_address TEXT NOT NULL UNIQUE,
|
|
12
|
+
amount TEXT NOT NULL, exact_exit INTEGER NOT NULL, window_start INTEGER NOT NULL, execute_at INTEGER NOT NULL,
|
|
13
|
+
window_end INTEGER NOT NULL, status TEXT NOT NULL, transaction_digest TEXT UNIQUE
|
|
14
|
+
)`);
|
|
15
|
+
}
|
|
16
|
+
queue(request, approved, now = Date.now()) {
|
|
17
|
+
if (!approved)
|
|
18
|
+
fail("OWNER_APPROVAL_REQUIRED", "owner approval is required to schedule a withdrawal");
|
|
19
|
+
if (!request.operationId || request.operationId.length > 256 || !/^[0-9a-f]{64}$/i.test(request.commitment))
|
|
20
|
+
fail("EXIT_REQUEST_INVALID", "operation ID or note commitment is invalid");
|
|
21
|
+
if (request.amount <= 0n || request.amount > (1n << 64n) - 1n || (!request.exactExit && request.amount !== 1000000n))
|
|
22
|
+
fail("EXIT_AMOUNT_INVALID", "standard exit must be 1,000,000 MIST; exact exit reveals its amount");
|
|
23
|
+
if (!Number.isSafeInteger(request.windowStart) || !Number.isSafeInteger(request.windowEnd) || request.windowStart < now || request.windowEnd <= request.windowStart || request.windowEnd - request.windowStart > MAX_WINDOW_MS)
|
|
24
|
+
fail("EXIT_WINDOW_INVALID", "exit window must be in the future and no longer than seven days");
|
|
25
|
+
const prior = this.get(request.operationId);
|
|
26
|
+
if (prior) {
|
|
27
|
+
if (prior.commitment !== request.commitment || prior.amount !== request.amount || prior.exactExit !== request.exactExit || prior.windowStart !== request.windowStart || prior.windowEnd !== request.windowEnd)
|
|
28
|
+
fail("OPERATION_ID_CONFLICT", "exit operation ID is already bound to another request");
|
|
29
|
+
return prior;
|
|
30
|
+
}
|
|
31
|
+
// Node's randomInt uses a cryptographic random source and avoids modulo bias.
|
|
32
|
+
const executeAt = randomInt(request.windowStart, request.windowEnd);
|
|
33
|
+
const payout = Ed25519Keypair.generate();
|
|
34
|
+
const payoutAddress = payout.toSuiAddress();
|
|
35
|
+
this.store.db.exec("BEGIN IMMEDIATE");
|
|
36
|
+
try {
|
|
37
|
+
this.store.putSecretOnce(`v2-exit-payout:${request.operationId}`, Buffer.from(payout.getSecretKey()));
|
|
38
|
+
this.store.db.prepare("INSERT INTO v2_exit_queue(operation_id,commitment,payout_address,amount,exact_exit,window_start,execute_at,window_end,status) VALUES(?,?,?,?,?,?,?,?,'queued')")
|
|
39
|
+
.run(request.operationId, request.commitment, payoutAddress, request.amount.toString(), Number(request.exactExit), request.windowStart, executeAt, request.windowEnd);
|
|
40
|
+
this.store.db.exec("COMMIT");
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
this.store.db.exec("ROLLBACK");
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
return this.get(request.operationId);
|
|
47
|
+
}
|
|
48
|
+
get(operationId) {
|
|
49
|
+
const row = this.store.db.prepare("SELECT operation_id,commitment,payout_address,amount,exact_exit,window_start,execute_at,window_end,status FROM v2_exit_queue WHERE operation_id=?").get(operationId);
|
|
50
|
+
return row && { operationId: row.operation_id, commitment: row.commitment, payoutAddress: row.payout_address, amount: BigInt(row.amount), exactExit: Boolean(row.exact_exit), windowStart: row.window_start, executeAt: row.execute_at, windowEnd: row.window_end, status: row.status };
|
|
51
|
+
}
|
|
52
|
+
due(now = Date.now()) {
|
|
53
|
+
const rows = this.store.db.prepare("SELECT operation_id FROM v2_exit_queue WHERE status='queued' AND execute_at<=? AND window_end>? ORDER BY execute_at").all(now, now);
|
|
54
|
+
return rows.map((row) => this.get(row.operation_id));
|
|
55
|
+
}
|
|
56
|
+
assertDue(operationId, now = Date.now()) {
|
|
57
|
+
const exit = this.get(operationId) ?? fail("OPERATION_ID_CONFLICT", "exit has no journal entry");
|
|
58
|
+
if (exit.status !== "queued" || now < exit.executeAt || now >= exit.windowEnd)
|
|
59
|
+
fail("EXIT_WINDOW_CLOSED", "exit can only be submitted inside its scheduled window");
|
|
60
|
+
return exit;
|
|
61
|
+
}
|
|
62
|
+
recordSubmission(operationId, digest) {
|
|
63
|
+
if (!/^[1-9A-HJ-NP-Za-km-z]{32,64}$/.test(digest))
|
|
64
|
+
fail("EXIT_DIGEST_INVALID", "relayer transaction digest is invalid");
|
|
65
|
+
const row = this.store.db.prepare("SELECT status,transaction_digest FROM v2_exit_queue WHERE operation_id=?").get(operationId);
|
|
66
|
+
if (!row)
|
|
67
|
+
return fail("OPERATION_ID_CONFLICT", "exit submission has no journal entry");
|
|
68
|
+
if (row.transaction_digest && row.transaction_digest !== digest)
|
|
69
|
+
fail("OPERATION_ID_CONFLICT", "exit submission conflicts with the journal");
|
|
70
|
+
this.store.db.prepare("UPDATE v2_exit_queue SET status='submitted',transaction_digest=? WHERE operation_id=? AND status='queued'").run(digest, operationId);
|
|
71
|
+
}
|
|
72
|
+
recordFinalized(operationId, digest) {
|
|
73
|
+
const row = this.store.db.prepare("SELECT transaction_digest FROM v2_exit_queue WHERE operation_id=?").get(operationId);
|
|
74
|
+
if (!row || row.transaction_digest !== digest)
|
|
75
|
+
fail("OPERATION_ID_CONFLICT", "finality does not match the journaled relayer transaction");
|
|
76
|
+
this.store.db.prepare("UPDATE v2_exit_queue SET status='finalized' WHERE operation_id=?").run(operationId);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RoleStore } from "./store.js";
|
|
2
|
+
export interface PrefundPlan {
|
|
3
|
+
operationId: string;
|
|
4
|
+
amount: bigint;
|
|
5
|
+
scheduledAt: number;
|
|
6
|
+
status: "queued" | "finalized";
|
|
7
|
+
transactionDigest?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Buyer-side scheduling; the callback must be the live, finalized deposit path. */
|
|
10
|
+
export declare class V2PrefundQueue {
|
|
11
|
+
private readonly store;
|
|
12
|
+
constructor(store: RoleStore);
|
|
13
|
+
queue(operationId: string, amount: bigint, scheduledAt: number, approved: boolean, now?: number): PrefundPlan;
|
|
14
|
+
get(operationId: string): PrefundPlan | undefined;
|
|
15
|
+
runDue(now: number, deposit: (amount: bigint, operationId: string) => Promise<{
|
|
16
|
+
transactionDigest: string;
|
|
17
|
+
finalized: boolean;
|
|
18
|
+
}>): Promise<PrefundPlan[]>;
|
|
19
|
+
}
|