diff --git a/electron/db/migrations.ts b/electron/db/migrations.ts index c8f052d1..c4c8f813 100644 --- a/electron/db/migrations.ts +++ b/electron/db/migrations.ts @@ -1,5 +1,5 @@ import type Database from 'better-sqlite3' -import { SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, SCHEMA_V8, SCHEMA_V9, SCHEMA_V10, SCHEMA_V11, SCHEMA_V12, SCHEMA_V13, SCHEMA_V14, SCHEMA_V15, SCHEMA_V16, SCHEMA_V17, SCHEMA_V18, SCHEMA_V19, SCHEMA_V20, SCHEMA_V21, SCHEMA_V22, SCHEMA_V23, SCHEMA_V24, SCHEMA_V25, SCHEMA_V26, SCHEMA_V27, SCHEMA_V28, SCHEMA_V29, SCHEMA_V30, SCHEMA_V31, SCHEMA_V32, SCHEMA_V33, SCHEMA_V34, SCHEMA_V35, SCHEMA_V36, SCHEMA_V37, SCHEMA_V38, SCHEMA_V39, SCHEMA_V40, SCHEMA_V41, SCHEMA_V42, SCHEMA_V43, SCHEMA_V44, SCHEMA_V45, SCHEMA_V46, SCHEMA_V47, SCHEMA_V48, SCHEMA_V49, SCHEMA_V50, SCHEMA_V51, SCHEMA_V52, SCHEMA_V53, SCHEMA_V54, SCHEMA_V55, SCHEMA_V56, SCHEMA_V57 } from './schema' +import { SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, SCHEMA_V8, SCHEMA_V9, SCHEMA_V10, SCHEMA_V11, SCHEMA_V12, SCHEMA_V13, SCHEMA_V14, SCHEMA_V15, SCHEMA_V16, SCHEMA_V17, SCHEMA_V18, SCHEMA_V19, SCHEMA_V20, SCHEMA_V21, SCHEMA_V22, SCHEMA_V23, SCHEMA_V24, SCHEMA_V25, SCHEMA_V26, SCHEMA_V27, SCHEMA_V28, SCHEMA_V29, SCHEMA_V30, SCHEMA_V31, SCHEMA_V32, SCHEMA_V33, SCHEMA_V34, SCHEMA_V35, SCHEMA_V36, SCHEMA_V37, SCHEMA_V38, SCHEMA_V39, SCHEMA_V40, SCHEMA_V41, SCHEMA_V42, SCHEMA_V43, SCHEMA_V44, SCHEMA_V45, SCHEMA_V46, SCHEMA_V47, SCHEMA_V48, SCHEMA_V49, SCHEMA_V50, SCHEMA_V51, SCHEMA_V52, SCHEMA_V53, SCHEMA_V54, SCHEMA_V55, SCHEMA_V56, SCHEMA_V57, SCHEMA_RECEIPTS_V61 } from './schema' import { CLAUDE_MODEL_IDS } from '../../packages/shared/src/constants' export function runMigrations(db: Database.Database) { @@ -728,6 +728,19 @@ export function runMigrations(db: Database.Database) { })() } + if (currentVersion < 61) { + db.transaction(() => { + const stmts = SCHEMA_RECEIPTS_V61.split(';').map((s) => s.trim()).filter(Boolean) + for (const stmt of stmts) { + try { db.exec(stmt) } catch (err) { + const msg = (err instanceof Error ? err.message : String(err)).toLowerCase() + if (!msg.includes('already exists')) throw err + } + } + db.prepare('INSERT INTO _migrations (version) VALUES (?)').run(61) + })() + } + // Ensure Solana agent exists (idempotent — handles existing DBs before it was seeded) try { const hasSolanaAgent = db.prepare("SELECT id FROM agents WHERE id = 'solana-agent'").get() diff --git a/electron/db/schema.ts b/electron/db/schema.ts index 38e7f6f8..d52dfefd 100644 --- a/electron/db/schema.ts +++ b/electron/db/schema.ts @@ -1559,3 +1559,24 @@ CREATE INDEX IF NOT EXISTS idx_garrison_commission_payouts_referrer export const SCHEMA_V57 = ` ALTER TABLE autopilot_mandates ADD COLUMN bought_raw_tokens TEXT NOT NULL DEFAULT '0'; ` + +// Migration 61: local ledger for attested execution receipts. Every emitted receipt records +// only its content hash (sha256 of the canonical receipt JSON) plus the on-chain +// signature and cluster — never file contents, keys, prompts, or PII. Off by +// default; a row exists only after the operator opts in and an emission lands. +export const SCHEMA_RECEIPTS_V61 = ` +CREATE TABLE IF NOT EXISTS receipts ( + id TEXT PRIMARY KEY, + content_hash TEXT NOT NULL, + source TEXT NOT NULL, + action_type TEXT NOT NULL, + cluster TEXT NOT NULL, + policy_verdict TEXT NOT NULL, + execution_signature TEXT, + anchor_kind TEXT NOT NULL, + anchor_signature TEXT, + created_at INTEGER DEFAULT (CAST(unixepoch('now') * 1000 AS INTEGER)) +); +CREATE INDEX IF NOT EXISTS idx_receipts_created ON receipts(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_receipts_hash ON receipts(content_hash); +` diff --git a/electron/ipc/receipts.ts b/electron/ipc/receipts.ts new file mode 100644 index 00000000..c73523e9 --- /dev/null +++ b/electron/ipc/receipts.ts @@ -0,0 +1,25 @@ +import { ipcMain } from 'electron' +import { ipcHandler } from '../services/IpcHandlerFactory' +import * as ReceiptService from '../services/receipts/ReceiptService' +import type { ReceiptsSettings } from '../services/receipts/ReceiptService' + +export function registerReceiptHandlers() { + ipcMain.handle('receipts:get-settings', ipcHandler(async () => { + return ReceiptService.getReceiptsSettings() + })) + + ipcMain.handle('receipts:set-settings', ipcHandler(async (_event, next: Partial) => { + if (!next || typeof next !== 'object') throw new Error('Invalid receipts settings') + return ReceiptService.setReceiptsSettings(next) + })) + + // Aggregates only — raw receipt rows summarized to a count + latest timestamp. + ipcMain.handle('receipts:summary', ipcHandler(async () => { + return ReceiptService.summarizeReceipts() + })) + + // Hash + signature ledger rows for the local "receipts emitted" view. + ipcMain.handle('receipts:list', ipcHandler(async (_event, limit?: number) => { + return ReceiptService.listReceipts(Number(limit) || 50) + })) +} diff --git a/electron/main/index.ts b/electron/main/index.ts index b6f4e0d8..47131566 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -57,6 +57,7 @@ import { registerAllowanceHandlers } from '../ipc/allowances' import { registerSignalhouseHandlers } from '../ipc/signalhouse' import { registerFlywheelHandlers } from '../ipc/flywheel' import { registerFeeHandlers } from '../ipc/fees' +import { registerReceiptHandlers } from '../ipc/receipts' import { registerColosseumHandlers } from '../ipc/colosseum' import { registerIdleHandlers } from '../ipc/idle' import { registerMeterflowHandlers } from '../ipc/meterflow' @@ -387,6 +388,7 @@ function registerAllIpc() { registerSynapseHandlers() registerAllowanceHandlers() registerFeeHandlers() + registerReceiptHandlers() registerValidatorHandlers() registerSeekerHandlers() registerFeedbackHandlers() diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 6d41e270..a6e2cdbb 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -762,6 +762,13 @@ contextBridge.exposeInMainWorld('daemon', { summary: (sinceMs: number) => ipcRenderer.invoke('fees:summary', sinceMs), }, + receipts: { + getSettings: () => ipcRenderer.invoke('receipts:get-settings'), + setSettings: (next: unknown) => ipcRenderer.invoke('receipts:set-settings', next), + summary: () => ipcRenderer.invoke('receipts:summary'), + list: (limit?: number) => ipcRenderer.invoke('receipts:list', limit), + }, + forensics: { scan: (input: object) => ipcRenderer.invoke('forensics:scan', input), expand: (input: object) => ipcRenderer.invoke('forensics:expand', input), diff --git a/electron/services/AriaAgentService.ts b/electron/services/AriaAgentService.ts index 88719c9c..e77c4cda 100644 --- a/electron/services/AriaAgentService.ts +++ b/electron/services/AriaAgentService.ts @@ -20,6 +20,7 @@ import { assembleSystemPrompt } from './aria/contextAssembler' import { clusterMark } from './aria/tools/shared' import { toAnthropicTools, type AriaTool, type AriaContextSnapshot, type AriaUiEffect } from './aria/AriaTool' import { laneToClaudeModel, buildPlanSteps, buildPatchProposal } from './aria/patchUtils' +import { emitReceiptSafe } from './receipts/ReceiptService' import type { AgentMessage, AgentToolUse } from './providers/agentTurn' import type { ProviderId } from './providers/ProviderInterface' import type { @@ -630,6 +631,19 @@ async function executeTool( kind: 'tool-call', callId: use.id, name: tool.name, label: tool.name, toolKind: tool.kind, risk: tool.risk, status: result.ok ? 'done' : 'error', meta: result.summary, }) + // Attested receipt: after a successful write/sensitive tool, emit the content + // hash on-chain (behind the toggle + devnet guard). Fire-and-forget — a + // receipt failure can never touch the tool result we just returned. + if (result.ok && tool.risk !== 'read') { + emitReceiptSafe({ + source: 'aria', + agentId: tool.name, + actionType: `tool.${tool.risk}`, + summary: tool.name, + verdict: needsApproval ? 'approved' : 'auto', + riskTier: tool.risk, + }) + } return { ...base, status: result.ok ? 'done' : 'error', summary: result.summary, result: result.data ?? result.summary } } catch (err) { const message = (err as Error).message diff --git a/electron/services/AutopilotService.ts b/electron/services/AutopilotService.ts index fe1df52c..71bed81a 100644 --- a/electron/services/AutopilotService.ts +++ b/electron/services/AutopilotService.ts @@ -5,6 +5,7 @@ import { getDb } from '../db/db' import { getConnectionStrict } from './SolanaService' import { getSwapQuote, executeSwap, getMintDecimals, getServerSwapImpactPct } from './WalletService' import { getWalletInfrastructureSettings } from './SettingsService' +import { emitReceiptSafe } from './receipts/ReceiptService' import type { Mandate, MandateAction, @@ -668,6 +669,19 @@ export async function tickMandate(mandateId: string): Promise= mandate.maxExposureLamports // Track the tokens this buy accumulated (quoted human out * 10^decimals) so exit sells diff --git a/electron/services/receipts/ReceiptService.ts b/electron/services/receipts/ReceiptService.ts new file mode 100644 index 00000000..229ec215 --- /dev/null +++ b/electron/services/receipts/ReceiptService.ts @@ -0,0 +1,260 @@ +import { randomUUID } from 'node:crypto' +import { getDb } from '../../db/db' +import { getJsonSetting, setJsonSetting, getWalletInfrastructureSettings } from '../SettingsService' +import { LogService } from '../LogService' +import { + buildReceipt, + receiptContentHash, + memoPayload, + type Receipt, + type ReceiptRecord, +} from './receiptBuilder' +import { defaultReceiptChain, type ReceiptChain, type AnchorResult } from './receiptChain' + +/** + * Attested execution receipts. + * + * When enabled AND on devnet, DAEMON emits ONLY the SHA-256 of a canonical + * receipt JSON on-chain (memo-anchored) after a gated action completes, and + * records the emission in a local ledger. Never file contents, keys, prompts, + * or PII — only the hash + agent id go on chain. + * + * Off by default. Emission is fire-and-forget: any failure is swallowed and + * logged so a receipt problem can NEVER block or reverse the underlying action. + * + * Mainnet emission is deliberately NOT wired here — see `isEmissionCluster`. + * Enabling receipts on a mainnet cluster is a clearly-gated follow-up; this + * slice scopes actual on-chain emission to devnet only. + */ + +const RECEIPTS_SETTINGS_KEY = 'receipts_settings' + +/** The only cluster this slice will emit on. Mainnet emission is a follow-up. */ +const EMISSION_CLUSTER = 'devnet' + +export type ReceiptAnchorKind = 'memo' + +export interface ReceiptsSettings { + enabled: boolean +} + +const DEFAULT_SETTINGS: ReceiptsSettings = { enabled: false } + +export function getReceiptsSettings(): ReceiptsSettings { + const raw = getJsonSetting>(RECEIPTS_SETTINGS_KEY, DEFAULT_SETTINGS) + return { enabled: raw?.enabled === true } +} + +export function setReceiptsSettings(next: Partial): ReceiptsSettings { + const current = getReceiptsSettings() + const merged: ReceiptsSettings = { + enabled: typeof next.enabled === 'boolean' ? next.enabled : current.enabled, + } + setJsonSetting(RECEIPTS_SETTINGS_KEY, merged) + LogService.info('ReceiptService', 'Receipt settings updated', { enabled: merged.enabled }) + return getReceiptsSettings() +} + +function liveCluster(): string { + try { + return getWalletInfrastructureSettings().cluster + } catch { + return 'unknown' + } +} + +/** + * The complete gate: receipts are enabled AND the live cluster is the sole + * emission cluster (devnet). Any other cluster — mainnet included — returns + * false, so no on-chain emission ever happens off devnet in this slice. + */ +export function isEmissionCluster(cluster: string): boolean { + return cluster === EMISSION_CLUSTER +} + +/** True only when a receipt should actually be emitted for the given cluster. */ +export function shouldEmit(cluster: string, chain: ReceiptChain = defaultReceiptChain): boolean { + if (!getReceiptsSettings().enabled) return false + if (!isEmissionCluster(cluster)) return false + if (!chain.hasSigner()) return false + return true +} + +interface EmittedReceiptRow { + id: string + contentHash: string + source: string + actionType: string + cluster: string + policyVerdict: string + executionSignature: string | null + anchorKind: ReceiptAnchorKind + anchorSignature: string | null +} + +/** Most-recent receipts kept in the local ledger. Older rows are pruned on insert. */ +export const RECEIPT_RETENTION = 1000 + +function writeLedger(row: EmittedReceiptRow): void { + const db = getDb() + db.prepare( + `INSERT INTO receipts + (id, content_hash, source, action_type, cluster, policy_verdict, execution_signature, anchor_kind, anchor_signature) + VALUES (?,?,?,?,?,?,?,?,?)`, + ).run( + row.id, + row.contentHash, + row.source, + row.actionType, + row.cluster, + row.policyVerdict, + row.executionSignature, + row.anchorKind, + row.anchorSignature, + ) + pruneReceipts(db) +} + +/** + * Cap ledger growth: keep only the most recent RECEIPT_RETENTION rows. The + * ledger feeds a "receipts emitted" count + a recent list, so unbounded history + * has no value — this bounds disk without touching the aggregate count meaning + * beyond the retention window. Called opportunistically after every insert. + */ +export function pruneReceipts(db = getDb()): number { + const result = db.prepare( + `DELETE FROM receipts WHERE id NOT IN ( + SELECT id FROM receipts ORDER BY created_at DESC, id DESC LIMIT ? + )`, + ).run(RECEIPT_RETENTION) as { changes?: number } | undefined + return result?.changes ?? 0 +} + +export interface EmitResult { + emitted: boolean + contentHash: string + anchorSignature: string | null +} + +/** + * Build, hash, anchor, and ledger a receipt for a completed execution. Returns + * `{ emitted: false }` (with the computed hash) whenever the gate is closed — + * this is the normal path when receipts are off. THROWS only if called past the + * gate and the chain layer fails; callers use `emitReceiptSafe` to swallow that. + */ +export async function emitReceipt( + record: ReceiptRecord, + chain: ReceiptChain = defaultReceiptChain, +): Promise { + const receipt: Receipt = buildReceipt(record) + const contentHash = receiptContentHash(receipt) + + if (!shouldEmit(record.cluster, chain)) { + return { emitted: false, contentHash, anchorSignature: null } + } + + const anchor: AnchorResult = await chain.anchorMemo(memoPayload(contentHash), record.cluster) + + writeLedger({ + id: randomUUID(), + contentHash, + source: record.source, + actionType: record.actionType, + cluster: record.cluster, + policyVerdict: record.verdict, + executionSignature: record.executionTxSignature ?? null, + anchorKind: 'memo', + anchorSignature: anchor.signature, + }) + + LogService.info('ReceiptService', 'Receipt anchored', { + source: record.source, + cluster: record.cluster, + anchorSignature: anchor.signature, + }) + return { emitted: true, contentHash, anchorSignature: anchor.signature } +} + +/** + * Fire-and-forget hook wrapper. Returns to the caller IMMEDIATELY — every piece + * of work, including the SQLite-backed settings read, the gate check, build, + * hash, anchor, and ledger write, runs on a deferred `setImmediate` tick so a + * receipt can never delay the ARIA tool call or autopilot swap that produced it. + * Never throws, never rejects: a receipt failure must not touch the real action. + */ +export function emitReceiptSafe( + record: Omit & { cluster?: string }, + chain: ReceiptChain = defaultReceiptChain, +): void { + // Nothing here touches the DB or settings — we only schedule. The caller's hot + // path is off after this line; all real work happens on the deferred tick. + setImmediate(() => { + void (async () => { + try { + if (!getReceiptsSettings().enabled) return + const cluster = record.cluster ?? liveCluster() + await emitReceipt({ ...record, cluster }, chain) + } catch (err) { + try { + LogService.warn('ReceiptService', 'Receipt emission failed (action unaffected)', { + source: record.source, + error: err instanceof Error ? err.message : String(err), + }) + } catch { /* logging must never throw here either */ } + } + })() + }) +} + +export interface ReceiptLedgerSummary { + totalReceipts: number + latestAt: number | null +} + +export interface ReceiptLedgerEntry { + id: string + contentHash: string + source: string + actionType: string + cluster: string + policyVerdict: string + anchorSignature: string | null + createdAt: number +} + +/** Aggregate count for /stats and the "receipts emitted" surface. */ +export function summarizeReceipts(): ReceiptLedgerSummary { + const db = getDb() + const row = db.prepare( + 'SELECT COUNT(*) AS n, MAX(created_at) AS latest FROM receipts', + ).get() as { n: number; latest: number | null } + return { totalReceipts: row.n, latestAt: row.latest ?? null } +} + +/** Recent ledger rows (hash + signature only) for a local receipts view. */ +export function listReceipts(limit = 50): ReceiptLedgerEntry[] { + const capped = Math.min(Math.max(Math.floor(limit) || 0, 1), 200) + const rows = getDb().prepare( + `SELECT id, content_hash, source, action_type, cluster, policy_verdict, anchor_signature, created_at + FROM receipts ORDER BY created_at DESC LIMIT ?`, + ).all(capped) as Array<{ + id: string + content_hash: string + source: string + action_type: string + cluster: string + policy_verdict: string + anchor_signature: string | null + created_at: number + }> + return rows.map((r) => ({ + id: r.id, + contentHash: r.content_hash, + source: r.source, + actionType: r.action_type, + cluster: r.cluster, + policyVerdict: r.policy_verdict, + anchorSignature: r.anchor_signature, + createdAt: r.created_at, + })) +} diff --git a/electron/services/receipts/receiptBuilder.ts b/electron/services/receipts/receiptBuilder.ts new file mode 100644 index 00000000..63902c60 --- /dev/null +++ b/electron/services/receipts/receiptBuilder.ts @@ -0,0 +1,104 @@ +import { createHash } from 'node:crypto' + +/** + * Pure receipt shaping + hashing. No chain, no DB, no settings — every function + * here is deterministic and side-effect free so the canonical JSON and its + * content hash can be unit-tested in isolation. + * + * PRIVACY (hard rule): a receipt attests only that an action happened and how + * the policy gate ruled on it. It carries NO file contents, keys, prompts, + * balances, counterparties, or PII. `summary` is a sanitized one-liner and is + * length-capped here as a second line of defence. + */ + +export const RECEIPT_SPEC = 'daemon-receipt/1' + +/** Where the metered action originated. */ +export type ReceiptSource = 'aria' | 'autopilot' + +/** How the policy gate ruled on the action. */ +export type ReceiptVerdict = 'approved' | 'rejected' | 'auto' + +/** The risk tier the tool/action carried through the gate. */ +export type ReceiptRiskTier = 'read' | 'write' | 'sensitive' + +/** Immutable facts about a completed execution, handed to the builder. */ +export interface ReceiptRecord { + source: ReceiptSource + /** Agent / mandate identifier (e.g. tool name, mandate id). Non-secret. */ + agentId: string + /** Coarse action type, e.g. "swap.execute", "tool.write". Non-secret. */ + actionType: string + /** Sanitized one-line description. Never include paths, keys, or amounts of PII. */ + summary: string + cluster: string + verdict: ReceiptVerdict + riskTier: ReceiptRiskTier + /** On-chain signature of the metered action itself, if any. */ + executionTxSignature?: string | null + /** Operator public key (public data). */ + operator?: string | null + /** Milliseconds since epoch; defaults to now when omitted. */ + timestamp?: number +} + +/** The canonical receipt object. Only its hash is ever placed on-chain. */ +export interface Receipt { + spec: typeof RECEIPT_SPEC + source: ReceiptSource + agent: { id: string } + action: { type: string; summary: string } + cluster: string + policy: { verdict: ReceiptVerdict; riskTier: ReceiptRiskTier } + executionTxSignature: string | null + operator: string | null + timestamp: string +} + +const SUMMARY_MAX = 200 + +function sanitizeSummary(value: string): string { + return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, SUMMARY_MAX) +} + +/** Build the canonical receipt object from a completed execution record. */ +export function buildReceipt(record: ReceiptRecord): Receipt { + const ts = typeof record.timestamp === 'number' && Number.isFinite(record.timestamp) + ? record.timestamp + : Date.now() + return { + spec: RECEIPT_SPEC, + source: record.source, + agent: { id: String(record.agentId ?? '') }, + action: { type: String(record.actionType ?? ''), summary: sanitizeSummary(record.summary) }, + cluster: String(record.cluster ?? ''), + policy: { verdict: record.verdict, riskTier: record.riskTier }, + executionTxSignature: record.executionTxSignature ?? null, + operator: record.operator ?? null, + timestamp: new Date(ts).toISOString(), + } +} + +/** + * Deterministic, key-sorted JSON serialization. Two receipts with identical + * fields serialize byte-for-byte identically regardless of key insertion order, + * so the SHA-256 below is stable and reproducible off-chain for verification. + */ +export function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + if (value && typeof value === 'object') { + const keys = Object.keys(value as Record).sort() + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson((value as Record)[k])}`).join(',')}}` + } + return JSON.stringify(value) +} + +/** SHA-256 (hex) of the canonical receipt JSON. This is the only value we anchor. */ +export function receiptContentHash(receipt: Receipt): string { + return createHash('sha256').update(canonicalJson(receipt), 'utf8').digest('hex') +} + +/** The exact bytes placed in the on-chain memo. Hash-only, agent id for lookup. */ +export function memoPayload(contentHash: string): string { + return `DAEMON-RECEIPT v1 sha256=${contentHash}` +} diff --git a/electron/services/receipts/receiptChain.ts b/electron/services/receipts/receiptChain.ts new file mode 100644 index 00000000..1ff2ca0d --- /dev/null +++ b/electron/services/receipts/receiptChain.ts @@ -0,0 +1,100 @@ +import { + Connection, + Keypair, + PublicKey, + Transaction, + TransactionInstruction, + sendAndConfirmTransaction, +} from '@solana/web3.js' +import bs58 from 'bs58' +import * as SecureKey from '../SecureKeyService' +import { + getPublicRpcEndpoint, + getHeliusApiKey, + getHeliusRpcEndpoint, + type SolanaCluster, +} from '../SolanaRuntimeConfigService' + +/** + * The on-chain leg of a receipt: a single SPL Memo instruction carrying the + * content hash. This is the spike's "always available" anchor path — zero + * account bootstrap, one signature-fee cost, and it never moves funds beyond + * the network fee of the memo tx itself. + * + * The signer is a DEDICATED receipt key (never the trading vault / wallet + * keypairs). If no receipt key is provisioned, emission is a no-op — a fresh + * install never signs anything until the operator opts in and provisions one. + */ + +// Well-known SPL Memo program. No dependency needed — the instruction is a +// single UTF-8 data buffer with the memo program as its only key. +const MEMO_PROGRAM_ID = new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr') + +// The one cluster this emitter will ever sign against. This is enforced at the +// emit boundary (defense in depth) AND the endpoint is derived from this +// constant, never from caller input — so no caller, present or future, can +// steer a receipt tx onto mainnet even if ReceiptService.shouldEmit is bypassed. +const RECEIPT_CLUSTER: SolanaCluster = 'devnet' + +/** SecureKey name for the receipt signer. Prefixed so it is treated as a private key. */ +export const RECEIPT_SIGNER_KEY_NAME = 'WALLET_KEYPAIR_RECEIPT_SIGNER' + +export interface AnchorResult { + signature: string + signer: string +} + +export interface ReceiptChain { + /** Whether a receipt signer key is provisioned (emission is a no-op without one). */ + hasSigner(): boolean + /** Send the memo-anchored hash on the given cluster. Resolves to the signature. */ + anchorMemo(memo: string, cluster: string): Promise +} + +function loadReceiptSigner(): Keypair | null { + const secret = SecureKey.getKey(RECEIPT_SIGNER_KEY_NAME) + if (!secret) return null + return Keypair.fromSecretKey(bs58.decode(secret.trim())) +} + +function devnetConnection(): Connection { + // Endpoint is hard-locked to the devnet constant — caller input never selects + // the cluster the tx lands on. Prefer Helius when configured; else public devnet. + const heliusKey = getHeliusApiKey() + const endpoint = heliusKey + ? getHeliusRpcEndpoint(RECEIPT_CLUSTER, heliusKey) + : getPublicRpcEndpoint(RECEIPT_CLUSTER) + return new Connection(endpoint, 'confirmed') +} + +/** The default, real chain implementation. Tests inject a mock instead. */ +export const defaultReceiptChain: ReceiptChain = { + hasSigner(): boolean { + try { + return SecureKey.getKey(RECEIPT_SIGNER_KEY_NAME) != null + } catch { + return false + } + }, + async anchorMemo(memo: string, cluster: string): Promise { + // Hard devnet invariant AT the emit boundary — enforced before a signer is + // loaded or a connection is built. Off-devnet emission is impossible here + // even if the orchestrator's shouldEmit gate is bypassed or wrong. + if (cluster !== RECEIPT_CLUSTER) { + throw new Error(`Receipt emission is devnet-only; refusing cluster "${cluster}"`) + } + const signer = loadReceiptSigner() + if (!signer) throw new Error('No receipt signer provisioned') + const connection = devnetConnection() + const ix = new TransactionInstruction({ + keys: [{ pubkey: signer.publicKey, isSigner: true, isWritable: false }], + programId: MEMO_PROGRAM_ID, + data: Buffer.from(memo, 'utf8'), + }) + const tx = new Transaction().add(ix) + const signature = await sendAndConfirmTransaction(connection, tx, [signer], { + commitment: 'confirmed', + }) + return { signature, signer: signer.publicKey.toBase58() } + }, +} diff --git a/src/types/daemon.d.ts b/src/types/daemon.d.ts index b3b89d9e..80c21d6c 100644 --- a/src/types/daemon.d.ts +++ b/src/types/daemon.d.ts @@ -1842,6 +1842,7 @@ declare global { signalhouse: DaemonSignalhouse flywheel: DaemonFlywheel fees: DaemonFees + receipts: DaemonReceipts registry: DaemonRegistry colosseum: DaemonColosseum idle: DaemonIdle @@ -2085,6 +2086,22 @@ declare global { summary: (sinceMs: number) => Promise> } + interface DaemonReceipts { + getSettings: () => Promise> + setSettings: (next: { enabled?: boolean }) => Promise> + summary: () => Promise> + list: (limit?: number) => Promise>> + } + interface DaemonBrowser { navigate: (url: string) => Promise> capture: (pageId: string, url: string, title: string, content: string) => Promise> diff --git a/test/services/MigrationRunner.test.ts b/test/services/MigrationRunner.test.ts index 660cc963..22dc8763 100644 --- a/test/services/MigrationRunner.test.ts +++ b/test/services/MigrationRunner.test.ts @@ -51,12 +51,16 @@ function maxVersion(db: Database.Database): number { return (db.prepare('SELECT MAX(version) v FROM _migrations').get() as { v: number }).v } +function tableColumns(db: Database.Database, table: string): string[] { + return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((c) => c.name) +} + describe('runMigrations — fresh install', () => { it('reaches the current schema version and seeds only sanctioned Claude model IDs', () => { const db = makeDb() runMigrations(db) - expect(maxVersion(db)).toBeGreaterThanOrEqual(60) + expect(maxVersion(db)).toBeGreaterThanOrEqual(61) const sanctioned = new Set(Object.values(CLAUDE_MODEL_IDS)) const rows = agentModels(db) @@ -66,6 +70,52 @@ describe('runMigrations — fresh install', () => { expect(row.model in SUPERSEDED_AGENT_MODELS).toBe(false) } }) + + it('creates the receipts ledger (migration 61) with the hash-only columns', () => { + const db = makeDb() + runMigrations(db) + + const cols = tableColumns(db, 'receipts') + // Attests the action + policy + anchor only — never a contents/prompt/key column. + expect(cols).toEqual(expect.arrayContaining([ + 'id', 'content_hash', 'source', 'action_type', 'cluster', + 'policy_verdict', 'execution_signature', 'anchor_kind', 'anchor_signature', 'created_at', + ])) + expect(cols).not.toContain('contents') + expect(cols).not.toContain('prompt') + // Insert/read round-trips against a real SQLite engine. + db.prepare( + `INSERT INTO receipts (id, content_hash, source, action_type, cluster, policy_verdict, anchor_kind) + VALUES (?,?,?,?,?,?,?)`, + ).run('r1', 'a'.repeat(64), 'aria', 'tool.write', 'devnet', 'approved', 'memo') + const count = (db.prepare('SELECT COUNT(*) n FROM receipts').get() as { n: number }).n + expect(count).toBe(1) + }) + + it('retention query keeps only the most recent N receipts (real SQLite)', () => { + const db = makeDb() + runMigrations(db) + + // Mirror ReceiptService.pruneReceipts semantics against a real engine. + const KEEP = 5 + const insert = db.prepare( + `INSERT INTO receipts (id, content_hash, source, action_type, cluster, policy_verdict, anchor_kind, created_at) + VALUES (?,?,?,?,?,?,?,?)`, + ) + for (let i = 0; i < 20; i++) { + insert.run(`r${i}`, 'a'.repeat(64), 'aria', 'tool.write', 'devnet', 'approved', 'memo', 1000 + i) + } + db.prepare( + `DELETE FROM receipts WHERE id NOT IN ( + SELECT id FROM receipts ORDER BY created_at DESC, id DESC LIMIT ? + )`, + ).run(KEEP) + + const remaining = db.prepare('SELECT id FROM receipts ORDER BY created_at DESC').all() as Array<{ id: string }> + expect(remaining).toHaveLength(KEEP) + // Newest survive, oldest pruned. + expect(remaining.map((r) => r.id)).toEqual(['r19', 'r18', 'r17', 'r16', 'r15']) + }) }) describe('runMigrations — upgraded install', () => { diff --git a/test/services/ReceiptService.test.ts b/test/services/ReceiptService.test.ts new file mode 100644 index 00000000..8d87321d --- /dev/null +++ b/test/services/ReceiptService.test.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ReceiptChain } from '../../electron/services/receipts/receiptChain' +import type { ReceiptRecord } from '../../electron/services/receipts/receiptBuilder' + +const state = vi.hoisted(() => ({ + stored: undefined as unknown, + cluster: 'devnet' as string, + throwOnSettingsRead: false, +})) + +const { mockRun, mockGet, mockAll, mockPrepare } = vi.hoisted(() => ({ + mockRun: vi.fn(), + mockGet: vi.fn(), + mockAll: vi.fn(), + mockPrepare: vi.fn(), +})) + +vi.mock('../../electron/db/db', () => ({ + getDb: () => ({ prepare: mockPrepare }), +})) + +vi.mock('../../electron/services/SettingsService', () => ({ + getJsonSetting: (_key: string, fallback: unknown) => { + if (state.throwOnSettingsRead) throw new Error('db locked') + return state.stored ?? fallback + }, + setJsonSetting: vi.fn((_key: string, value: unknown) => { state.stored = value }), + getWalletInfrastructureSettings: () => ({ cluster: state.cluster }), +})) + +vi.mock('../../electron/services/LogService', () => ({ + LogService: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +import { + getReceiptsSettings, + setReceiptsSettings, + isEmissionCluster, + shouldEmit, + emitReceipt, + emitReceiptSafe, + summarizeReceipts, + listReceipts, + pruneReceipts, + RECEIPT_RETENTION, +} from '../../electron/services/receipts/ReceiptService' + +/** A chain double that records calls; anchorMemo can be made to throw. */ +function makeChain(opts: { hasSigner?: boolean; throwOnAnchor?: boolean } = {}): ReceiptChain & { + anchorCalls: string[] +} { + const anchorCalls: string[] = [] + return { + anchorCalls, + hasSigner: () => opts.hasSigner ?? true, + anchorMemo: vi.fn(async (memo: string) => { + anchorCalls.push(memo) + if (opts.throwOnAnchor) throw new Error('rpc down') + return { signature: 'ANCHOR_SIG', signer: 'SIGNER_PUBKEY' } + }), + } +} + +const RECORD: Omit = { + source: 'aria', + agentId: 'propose_patch', + actionType: 'tool.write', + summary: 'edited file', + verdict: 'approved', + riskTier: 'write', +} + +function enable(): void { + state.stored = { enabled: true } +} + +beforeEach(() => { + vi.clearAllMocks() + state.stored = undefined + state.cluster = 'devnet' + state.throwOnSettingsRead = false + mockPrepare.mockReturnValue({ run: mockRun, get: mockGet, all: mockAll }) +}) + +describe('settings', () => { + it('defaults to disabled', () => { + expect(getReceiptsSettings()).toEqual({ enabled: false }) + }) + + it('persists the toggle', () => { + expect(setReceiptsSettings({ enabled: true })).toEqual({ enabled: true }) + expect(getReceiptsSettings().enabled).toBe(true) + }) +}) + +describe('isEmissionCluster (devnet-only guard)', () => { + it('accepts devnet only', () => { + expect(isEmissionCluster('devnet')).toBe(true) + expect(isEmissionCluster('mainnet-beta')).toBe(false) + expect(isEmissionCluster('localnet')).toBe(false) + expect(isEmissionCluster('unknown')).toBe(false) + }) +}) + +describe('shouldEmit', () => { + it('is false while disabled even on devnet', () => { + expect(shouldEmit('devnet', makeChain())).toBe(false) + }) + + it('is false on non-devnet clusters even when enabled', () => { + enable() + expect(shouldEmit('mainnet-beta', makeChain())).toBe(false) + }) + + it('is false when no signer is provisioned', () => { + enable() + expect(shouldEmit('devnet', makeChain({ hasSigner: false }))).toBe(false) + }) + + it('is true only when enabled + devnet + signer', () => { + enable() + expect(shouldEmit('devnet', makeChain())).toBe(true) + }) +}) + +describe('emitReceipt', () => { + it('makes ZERO on-chain calls and writes no ledger row while disabled', async () => { + const chain = makeChain() + const res = await emitReceipt({ ...RECORD, cluster: 'devnet' }, chain) + expect(res.emitted).toBe(false) + expect(res.contentHash).toMatch(/^[0-9a-f]{64}$/) + expect(chain.anchorCalls).toHaveLength(0) + expect(mockRun).not.toHaveBeenCalled() + }) + + it('makes ZERO on-chain calls on mainnet even when enabled', async () => { + enable() + const chain = makeChain() + const res = await emitReceipt({ ...RECORD, cluster: 'mainnet-beta' }, chain) + expect(res.emitted).toBe(false) + expect(chain.anchorCalls).toHaveLength(0) + expect(mockRun).not.toHaveBeenCalled() + }) + + it('anchors the hash and writes the ledger on devnet when enabled', async () => { + enable() + const chain = makeChain() + const res = await emitReceipt({ ...RECORD, cluster: 'devnet' }, chain) + expect(res.emitted).toBe(true) + expect(res.anchorSignature).toBe('ANCHOR_SIG') + // The memo carries only the hash — never file contents/prompt/summary body. + expect(chain.anchorCalls[0]).toBe(`DAEMON-RECEIPT v1 sha256=${res.contentHash}`) + expect(chain.anchorCalls[0]).not.toContain('edited file') + // Insert is the first prepared statement; prune follows it. + const insertSql = mockPrepare.mock.calls[0][0] as string + expect(insertSql).toMatch(/INSERT INTO receipts/) + const args = mockRun.mock.calls[0] + expect(args).toContain(res.contentHash) + expect(args).toContain('ANCHOR_SIG') + expect(args).toContain('memo') + // Retention prune runs opportunistically after the insert. + const pruneSql = mockPrepare.mock.calls[1][0] as string + expect(pruneSql).toMatch(/DELETE FROM receipts/) + }) + + it('propagates a chain failure to emitReceipt callers (past the gate)', async () => { + enable() + const chain = makeChain({ throwOnAnchor: true }) + await expect(emitReceipt({ ...RECORD, cluster: 'devnet' }, chain)).rejects.toThrow(/rpc down/) + expect(mockRun).not.toHaveBeenCalled() + }) +}) + +/** Drain the deferred setImmediate tick (and any promise chained off it). */ +function flush(): Promise { + return new Promise((resolve) => setImmediate(() => setImmediate(() => resolve()))) +} + +describe('emitReceiptSafe (does not block the caller)', () => { + it('consults NO settings/DB synchronously — the caller path returns before any read', () => { + state.throwOnSettingsRead = true // any sync settings touch would throw here + enable() // (ignored while throwOnSettingsRead is set) + const chain = makeChain() + // If the settings read were on the hot path it would throw synchronously. + expect(() => emitReceiptSafe({ ...RECORD }, chain)).not.toThrow() + // And nothing happened synchronously: no anchor, no ledger write. + expect(chain.anchorCalls).toHaveLength(0) + expect(mockRun).not.toHaveBeenCalled() + }) + + it('defers the settings read to the deferred tick, then no-ops safely when it throws', async () => { + state.throwOnSettingsRead = true + const chain = makeChain() + emitReceiptSafe({ ...RECORD }, chain) + await flush() + // The deferred read threw and was swallowed — no emission, no throw escaped. + expect(chain.anchorCalls).toHaveLength(0) + expect(mockRun).not.toHaveBeenCalled() + }) + + it('never throws when the chain layer throws (failure isolation)', async () => { + enable() + const chain = makeChain({ throwOnAnchor: true }) + expect(() => emitReceiptSafe({ ...RECORD }, chain)).not.toThrow() + await flush() + expect(mockRun).not.toHaveBeenCalled() + }) + + it('does no on-chain work while disabled', async () => { + const chain = makeChain() + emitReceiptSafe({ ...RECORD }, chain) + await flush() + expect(chain.anchorCalls).toHaveLength(0) + expect(mockRun).not.toHaveBeenCalled() + }) + + it('reads the live cluster and emits on devnet when enabled', async () => { + enable() + state.cluster = 'devnet' + const chain = makeChain() + emitReceiptSafe({ ...RECORD }, chain) + await flush() + expect(chain.anchorCalls).toHaveLength(1) + // insert + prune both run against the mocked prepare().run(). + expect(mockRun).toHaveBeenCalled() + }) + + it('reads the live cluster and stays silent on mainnet', async () => { + enable() + state.cluster = 'mainnet-beta' + const chain = makeChain() + emitReceiptSafe({ ...RECORD }, chain) + await flush() + expect(chain.anchorCalls).toHaveLength(0) + expect(mockRun).not.toHaveBeenCalled() + }) +}) + +describe('ledger reads', () => { + it('summarizes the count + latest timestamp', () => { + mockGet.mockReturnValue({ n: 3, latest: 1_700_000_000_000 }) + expect(summarizeReceipts()).toEqual({ totalReceipts: 3, latestAt: 1_700_000_000_000 }) + }) + + it('maps ledger rows to camelCase entries', () => { + mockAll.mockReturnValue([ + { + id: 'r1', content_hash: 'h', source: 'aria', action_type: 'tool.write', + cluster: 'devnet', policy_verdict: 'approved', anchor_signature: 'sig', created_at: 1, + }, + ]) + const rows = listReceipts(10) + expect(rows[0]).toEqual({ + id: 'r1', contentHash: 'h', source: 'aria', actionType: 'tool.write', + cluster: 'devnet', policyVerdict: 'approved', anchorSignature: 'sig', createdAt: 1, + }) + }) +}) + +describe('retention (bounded ledger growth)', () => { + it('prunes to the RECEIPT_RETENTION bound with a DELETE-keep-newest query', () => { + mockRun.mockReturnValue({ changes: 5 }) + const removed = pruneReceipts() + const sql = mockPrepare.mock.calls[0][0] as string + expect(sql).toMatch(/DELETE FROM receipts/) + expect(sql).toMatch(/ORDER BY created_at DESC/) + expect(mockRun).toHaveBeenCalledWith(RECEIPT_RETENTION) + expect(removed).toBe(5) + }) + + it('caps at a sane bound (not unbounded)', () => { + expect(RECEIPT_RETENTION).toBeGreaterThan(0) + expect(RECEIPT_RETENTION).toBeLessThanOrEqual(10_000) + }) +}) diff --git a/test/services/receiptBuilder.test.ts b/test/services/receiptBuilder.test.ts new file mode 100644 index 00000000..e64752d2 --- /dev/null +++ b/test/services/receiptBuilder.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { + RECEIPT_SPEC, + buildReceipt, + canonicalJson, + receiptContentHash, + memoPayload, + type ReceiptRecord, +} from '../../electron/services/receipts/receiptBuilder' + +const BASE: ReceiptRecord = { + source: 'aria', + agentId: 'read_file', + actionType: 'tool.write', + summary: 'wrote a file', + cluster: 'devnet', + verdict: 'approved', + riskTier: 'write', + executionTxSignature: null, + operator: null, + timestamp: 1_700_000_000_000, +} + +describe('buildReceipt', () => { + it('produces the canonical receipt shape', () => { + const receipt = buildReceipt(BASE) + expect(receipt).toEqual({ + spec: RECEIPT_SPEC, + source: 'aria', + agent: { id: 'read_file' }, + action: { type: 'tool.write', summary: 'wrote a file' }, + cluster: 'devnet', + policy: { verdict: 'approved', riskTier: 'write' }, + executionTxSignature: null, + operator: null, + timestamp: new Date(1_700_000_000_000).toISOString(), + }) + }) + + it('sanitizes and length-caps the summary (no PII leakage vector)', () => { + const long = 'x'.repeat(500) + const receipt = buildReceipt({ ...BASE, summary: `line one\n\tline two ${long}` }) + expect(receipt.action.summary.length).toBe(200) + expect(receipt.action.summary).not.toContain('\n') + expect(receipt.action.summary).not.toContain('\t') + }) + + it('never carries fields beyond the whitelisted shape', () => { + const receipt = buildReceipt(BASE) + // The only keys allowed on-chain-adjacent — no prompt/file/key fields. + expect(Object.keys(receipt).sort()).toEqual( + ['action', 'agent', 'cluster', 'executionTxSignature', 'operator', 'policy', 'source', 'spec', 'timestamp'], + ) + }) +}) + +describe('canonicalJson + receiptContentHash', () => { + it('is deterministic regardless of key insertion order', () => { + const a = canonicalJson({ b: 1, a: 2, c: { y: 1, x: 2 } }) + const b = canonicalJson({ c: { x: 2, y: 1 }, a: 2, b: 1 }) + expect(a).toBe(b) + }) + + it('hashes identical receipts to the same digest', () => { + const h1 = receiptContentHash(buildReceipt(BASE)) + const h2 = receiptContentHash(buildReceipt({ ...BASE })) + expect(h1).toBe(h2) + expect(h1).toMatch(/^[0-9a-f]{64}$/) + }) + + it('changes the digest when any attested field changes', () => { + const base = receiptContentHash(buildReceipt(BASE)) + expect(receiptContentHash(buildReceipt({ ...BASE, cluster: 'mainnet-beta' }))).not.toBe(base) + expect(receiptContentHash(buildReceipt({ ...BASE, verdict: 'rejected' }))).not.toBe(base) + expect(receiptContentHash(buildReceipt({ ...BASE, executionTxSignature: 'sig123' }))).not.toBe(base) + }) +}) + +describe('memoPayload', () => { + it('carries only the hash, never receipt contents', () => { + const hash = 'a'.repeat(64) + expect(memoPayload(hash)).toBe(`DAEMON-RECEIPT v1 sha256=${hash}`) + }) +}) diff --git a/test/services/receiptChain.test.ts b/test/services/receiptChain.test.ts new file mode 100644 index 00000000..e47a1c7d --- /dev/null +++ b/test/services/receiptChain.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// The real chain module transitively imports electron `safeStorage` and the +// Solana runtime config; stub both so the module loads under plain Node. The +// devnet guard we are testing throws BEFORE any of these are consulted, so the +// stubs never actually run for the blocked path — their presence just lets the +// module import. +const secureKey = vi.hoisted(() => ({ getKey: vi.fn(() => null) })) +const runtime = vi.hoisted(() => ({ + getKey: vi.fn(), +})) + +vi.mock('../../electron/services/SecureKeyService', () => ({ + getKey: secureKey.getKey, +})) + +vi.mock('../../electron/services/SolanaRuntimeConfigService', () => ({ + getPublicRpcEndpoint: vi.fn(() => { + throw new Error('endpoint builder must not run on the blocked path') + }), + getHeliusApiKey: vi.fn(() => { + throw new Error('helius key read must not run on the blocked path') + }), + getHeliusRpcEndpoint: vi.fn(() => { + throw new Error('endpoint builder must not run on the blocked path') + }), +})) + +import { defaultReceiptChain, RECEIPT_SIGNER_KEY_NAME } from '../../electron/services/receipts/receiptChain' + +beforeEach(() => { + vi.clearAllMocks() + secureKey.getKey.mockReturnValue(null) +}) + +describe('defaultReceiptChain.anchorMemo — devnet-only invariant at the emit boundary', () => { + const memo = `DAEMON-RECEIPT v1 sha256=${'a'.repeat(64)}` + + it('refuses mainnet-beta and touches nothing (no signer load, no connection)', async () => { + await expect(defaultReceiptChain.anchorMemo(memo, 'mainnet-beta')).rejects.toThrow(/devnet-only/) + // The guard fires before any signer key is read or endpoint is built. + expect(secureKey.getKey).not.toHaveBeenCalled() + }) + + it('refuses every non-devnet cluster, however it is spelled', async () => { + for (const cluster of ['mainnet-beta', 'mainnet', 'localnet', 'testnet', 'DEVNET', '', 'unknown']) { + await expect(defaultReceiptChain.anchorMemo(memo, cluster)).rejects.toThrow(/devnet-only/) + } + expect(secureKey.getKey).not.toHaveBeenCalled() + }) + + it('passes the devnet guard, then fails only for the missing signer', async () => { + // On devnet the guard is satisfied; with no signer provisioned it must stop + // at the signer check (never reaching the network) — proving the guard order. + await expect(defaultReceiptChain.anchorMemo(memo, 'devnet')).rejects.toThrow(/No receipt signer/) + expect(secureKey.getKey).toHaveBeenCalledWith(RECEIPT_SIGNER_KEY_NAME) + }) +})