aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-13 14:16:08 -0700
committerAdam Soltys <[email protected]>2026-03-13 15:14:51 -0700
commit7a241cfb13395ba2218411cb56d2b0a73e758a06 (patch)
tree7d2c304784d8730f2c656014faeb1e7dda6e2cd1 /lib
parentd3ee2bbc3baa33140f18a95c3e4b78a797330ad0 (diff)
downloadcoinos-server-7a241cfb13395ba2218411cb56d2b0a73e758a06.tar.gz
coinos-server-7a241cfb13395ba2218411cb56d2b0a73e758a06.zip
ark delegation
Diffstat (limited to 'lib')
-rw-r--r--lib/delegator.ts368
1 files changed, 368 insertions, 0 deletions
diff --git a/lib/delegator.ts b/lib/delegator.ts
new file mode 100644
index 00000000..6c79c0ea
--- /dev/null
+++ b/lib/delegator.ts
@@ -0,0 +1,368 @@
+import { EventSource } from "eventsource";
+if (!globalThis.EventSource) globalThis.EventSource = EventSource as any;
+
+import config from "$config";
+import {
+ SingleKey,
+ RestArkProvider,
+ Batch,
+ Transaction,
+ CSVMultisigTapscript,
+ validateVtxoTxGraph,
+ TxTree,
+} from "@arkade-os/sdk";
+import type {
+ BatchStartedEvent,
+ TreeSigningStartedEvent,
+ TreeNoncesEvent,
+ BatchFinalizationEvent,
+ SignerSession,
+} from "@arkade-os/sdk";
+import { hex, base64 } from "@scure/base";
+import { db } from "$lib/db";
+import { l, warn } from "$lib/logging";
+import { getArkAddress } from "$lib/ark";
+import { createHash } from "crypto";
+
+const sha256Hash = (data: Uint8Array): Uint8Array =>
+ new Uint8Array(createHash("sha256").update(data).digest());
+
+const { arkServerUrl, delegatorPrivateKey } = config.ark;
+
+let identity: any;
+let pubkey: string;
+let processing = false;
+
+const getIdentity = () => {
+ if (identity) return identity;
+ if (!delegatorPrivateKey) return null;
+ identity = SingleKey.fromHex(delegatorPrivateKey);
+ return identity;
+};
+
+const getPubkey = async () => {
+ if (pubkey) return pubkey;
+ const id = getIdentity();
+ if (!id) throw new Error("delegator not configured");
+ pubkey = hex.encode(await id.compressedPublicKey());
+ return pubkey;
+};
+
+export const getDelegateInfo = async () => {
+ const pk = await getPubkey();
+ const delegatorAddress = await getArkAddress();
+ return {
+ pubkey: pk,
+ fee: "0",
+ delegatorAddress,
+ };
+};
+
+export const receiveDelegation = async (body: any) => {
+ const { intent, forfeit_txs, reject_replace } = body;
+ const id = crypto.randomUUID();
+
+ // Parse valid_at from the intent message
+ let validAt = Date.now();
+ try {
+ const msg =
+ typeof intent.message === "string"
+ ? JSON.parse(intent.message)
+ : intent.message;
+ if (msg.valid_at) validAt = msg.valid_at * 1000;
+ } catch {}
+
+ const delegation = {
+ id,
+ intent,
+ forfeitTxs: forfeit_txs,
+ rejectReplace: reject_replace ?? false,
+ status: "pending",
+ createdAt: Date.now(),
+ validAt,
+ };
+
+ await db.set(`delegation:${id}`, JSON.stringify(delegation));
+ await db.zAdd("delegations:pending", [
+ { score: delegation.validAt, value: id },
+ ]);
+
+ l(
+ "delegation received:",
+ id,
+ "vtxos:",
+ forfeit_txs?.length ?? 0,
+ "valid_at:",
+ new Date(validAt).toISOString(),
+ );
+};
+
+export const processDelegations = async () => {
+ if (processing || !delegatorPrivateKey) return;
+
+ const now = Date.now();
+ const dueRaw = await db.zRangeByScore(
+ "delegations:pending",
+ "-inf",
+ String(now),
+ );
+ const dueIds = dueRaw.map(String);
+ if (dueIds.length === 0) return;
+
+ processing = true;
+ try {
+ for (const id of dueIds) {
+ try {
+ await processSingleDelegation(id);
+ await db.zRem("delegations:pending", id);
+ } catch (e: any) {
+ warn("delegation processing failed:", id, e.message);
+ const raw = await db.get(`delegation:${id}`);
+ if (raw) {
+ const d = JSON.parse(String(raw));
+ d.retries = (d.retries || 0) + 1;
+ if (d.retries > 5) {
+ d.status = "failed";
+ await db.set(`delegation:${id}`, JSON.stringify(d));
+ await db.zRem("delegations:pending", id);
+ warn("delegation permanently failed after 5 retries:", id);
+ } else {
+ const retryAt = Date.now() + 5 * 60_000;
+ await db.set(`delegation:${id}`, JSON.stringify(d));
+ await db.zAdd("delegations:pending", [
+ { score: retryAt, value: id },
+ ]);
+ }
+ }
+ }
+ }
+ } finally {
+ processing = false;
+ }
+};
+
+const processSingleDelegation = async (id: string) => {
+ const rawVal = await db.get(`delegation:${id}`);
+ if (!rawVal) throw new Error("delegation not found");
+
+ const delegation = JSON.parse(String(rawVal));
+ const provider = new RestArkProvider(arkServerUrl);
+ const delegatorId = getIdentity();
+
+ // The intent from the client is already encoded
+ const intent = {
+ proof: delegation.intent.proof,
+ message: delegation.intent.message,
+ };
+
+ // Register the intent with arkd
+ const intentId = await provider.registerIntent(intent as any);
+ l("delegation", id, "registered intent:", intentId);
+
+ // Create a signing session for the musig2 tree co-signing
+ const session = delegatorId.signerSession();
+ const delegatorPk = hex.encode(await session.getPublicKey());
+
+ // Build topics: our pubkey + input outpoints from the intent proof
+ const topics = [delegatorPk];
+
+ const abortController = new AbortController();
+ // Timeout after 5 minutes
+ const timeout = setTimeout(() => abortController.abort(), 5 * 60_000);
+
+ try {
+ const stream = provider.getEventStream(abortController.signal, topics);
+
+ const handler = createDelegatorBatchHandler(
+ intentId,
+ delegation,
+ delegatorId,
+ provider,
+ session,
+ );
+
+ const commitmentTxid = await Batch.join(stream, handler, {
+ abortController,
+ skipVtxoTreeSigning: false,
+ });
+
+ delegation.status = "completed";
+ delegation.commitmentTxid = commitmentTxid;
+ await db.set(`delegation:${id}`, JSON.stringify(delegation));
+
+ l("delegation", id, "completed, commitment:", commitmentTxid);
+ } finally {
+ clearTimeout(timeout);
+ abortController.abort();
+ }
+};
+
+const tapLeafHash = (script: Uint8Array): Uint8Array => {
+ const leafVersion = 0xc0;
+ const data = new Uint8Array(1 + script.length);
+ data[0] = leafVersion;
+ data.set(script, 1);
+
+ // Bitcoin tagged hash: SHA256(SHA256("TapLeaf") || SHA256("TapLeaf") || data)
+ const tag = new TextEncoder().encode("TapLeaf");
+ const tagHash = sha256Hash(tag);
+ const buf = new Uint8Array(tagHash.length * 2 + data.length);
+ buf.set(tagHash, 0);
+ buf.set(tagHash, tagHash.length);
+ buf.set(data, tagHash.length * 2);
+ return sha256Hash(buf);
+};
+
+function createDelegatorBatchHandler(
+ intentId: string,
+ delegation: any,
+ delegatorId: any,
+ provider: any,
+ session: SignerSession,
+): Batch.Handler {
+ let sweepTapTreeRoot: Uint8Array | undefined;
+ let forfeitPubkey: string;
+
+ return {
+ onBatchStarted: async (
+ event: BatchStartedEvent,
+ ): Promise<{ skip: boolean }> => {
+ const utf8IntentId = new TextEncoder().encode(intentId);
+ const intentIdHash = hex.encode(sha256Hash(utf8IntentId));
+
+ let found = false;
+ for (const idHash of event.intentIdHashes) {
+ if (idHash === intentIdHash) {
+ await provider.confirmRegistration(intentId);
+ found = true;
+ }
+ }
+
+ if (!found) return { skip: true };
+
+ // Build sweep tap tree root for validation
+ const info = await provider.getInfo();
+ forfeitPubkey = info.forfeitPubkey;
+
+ const sweepTapscript = CSVMultisigTapscript.encode({
+ timelock: {
+ value: event.batchExpiry,
+ type: event.batchExpiry >= 512n ? "seconds" : "blocks",
+ },
+ pubkeys: [hex.decode(forfeitPubkey)],
+ }).script;
+ sweepTapTreeRoot = tapLeafHash(sweepTapscript);
+
+ return { skip: false };
+ },
+
+ onTreeSigningStarted: async (
+ event: TreeSigningStartedEvent,
+ vtxoTree: TxTree,
+ ): Promise<{ skip: boolean }> => {
+ // Check if we're a cosigner in this round
+ const xOnlyPublicKeys = event.cosignersPublicKeys.map((k: string) =>
+ k.slice(2),
+ );
+ const signerPublicKey = await session.getPublicKey();
+ const xonlySignerPublicKey = signerPublicKey.subarray(1);
+
+ if (!xOnlyPublicKeys.includes(hex.encode(xonlySignerPublicKey))) {
+ return { skip: true };
+ }
+
+ if (!sweepTapTreeRoot) {
+ throw new Error("sweep tap tree root not set");
+ }
+
+ // Validate the vtxo tree
+ const commitmentTx = Transaction.fromPSBT(
+ base64.decode(event.unsignedCommitmentTx),
+ );
+ validateVtxoTxGraph(vtxoTree, commitmentTx, sweepTapTreeRoot);
+
+ const sharedOutput = commitmentTx.getOutput(0);
+ if (!sharedOutput?.amount) {
+ throw new Error("shared output not found");
+ }
+
+ await session.init(vtxoTree, sweepTapTreeRoot, sharedOutput.amount);
+
+ const pk = hex.encode(await session.getPublicKey());
+ const nonces = await session.getNonces();
+ await provider.submitTreeNonces(event.id, pk, nonces);
+
+ return { skip: false };
+ },
+
+ onTreeNonces: async (
+ event: TreeNoncesEvent,
+ ): Promise<{ fullySigned: boolean }> => {
+ const { hasAllNonces } = await session.aggregatedNonces(
+ event.txid,
+ event.nonces,
+ );
+ if (!hasAllNonces) return { fullySigned: false };
+
+ const signatures = await session.sign();
+ const pk = hex.encode(await session.getPublicKey());
+ await provider.submitTreeSignatures(event.id, pk, signatures);
+ return { fullySigned: true };
+ },
+
+ onBatchFinalization: async (
+ _event: BatchFinalizationEvent,
+ _vtxoTree?: TxTree,
+ connectorTree?: TxTree,
+ ): Promise<void> => {
+ // Co-sign the pre-signed forfeit transactions from the delegation
+ const signedForfeits: string[] = [];
+ const connectorsLeaves = connectorTree?.leaves() || [];
+ let connectorIndex = 0;
+
+ for (const forfeitPsbtB64 of delegation.forfeitTxs || []) {
+ try {
+ // Parse the user's pre-signed forfeit PSBT
+ const forfeitTx = Transaction.fromPSBT(base64.decode(forfeitPsbtB64));
+
+ // Add the connector input from the round
+ if (connectorIndex < connectorsLeaves.length) {
+ const connectorLeaf = connectorsLeaves[connectorIndex];
+ const connectorOutput = connectorLeaf.getOutput(0);
+ if (connectorOutput?.amount && connectorOutput?.script) {
+ forfeitTx.addInput({
+ txid: hex.decode(connectorLeaf.id),
+ index: 0,
+ witnessUtxo: {
+ amount: connectorOutput.amount,
+ script: connectorOutput.script,
+ },
+ });
+ }
+ connectorIndex++;
+ }
+
+ // Co-sign with the delegator's key (only the first input - the VTXO)
+ const signed = await delegatorId.sign(forfeitTx, [0]);
+ signedForfeits.push(base64.encode(signed.toPSBT()));
+ } catch (e: any) {
+ warn("failed to co-sign forfeit tx:", e.message);
+ }
+ }
+
+ if (signedForfeits.length > 0) {
+ await provider.submitSignedForfeitTxs(signedForfeits);
+ }
+ },
+ };
+}
+
+// Start processing loop (integrated with existing 60s interval)
+if (delegatorPrivateKey) {
+ setInterval(processDelegations, 60_000);
+ // Initial check after 30s
+ setTimeout(processDelegations, 30_000);
+ getPubkey()
+ .then((pk) => l("delegator service started, pubkey:", pk))
+ .catch((e) => warn("delegator service failed to start:", e.message));
+}