diff --git a/README.md b/README.md index 93ae0f9..1d93800 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS=20 If `STACKFLOW_CONTRACTS` is omitted, the stackflow-node automatically monitors any contract identifier matching `*.stackflow*`. +When set, `STACKFLOW_CONTRACTS` entries are trimmed of whitespace and matched case-sensitively (Stacks contract identifiers are case-sensitive). The current implementation uses Node's `node:sqlite` module for persistence. `STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE` supports `readonly` (default), `accept-all`, and `reject-all`. Non-`readonly` modes are intended for testing. @@ -276,6 +277,18 @@ objects received via `/new_block` for payload inspection/debugging. `x-forwarded-for` for client IP extraction (rate limiting and localhost checks). `STACKFLOW_NODE_HOST` defaults to `127.0.0.1` to reduce accidental network exposure. Use a public bind only with hardened ingress controls. +`STACKFLOW_NODE_PORT` must be a valid TCP port (`1-65535`) and fails fast on +invalid values. +`STACKFLOW_NODE_MAX_RECENT_EVENTS` is clamped to at least `1` so event pruning +cannot be disabled accidentally via negative values. +Boolean env vars accept `true/false`, `1/0`, `yes/no`, and `on/off` +(case-insensitive); invalid boolean text now fails fast to prevent silent +misconfiguration. +Integer env vars must be plain integer text (for example `10000`, not `10s` +or `12.5`); malformed numeric values fail fast instead of being silently +coerced. +`STACKS_NETWORK` accepts only `mainnet`, `testnet`, `devnet`, or `mocknet` +(case-insensitive); invalid values fail fast instead of silently defaulting. Observer ingress controls: - `STACKFLOW_NODE_OBSERVER_LOCALHOST_ONLY` defaults to `true` and restricts @@ -764,6 +777,28 @@ See: 1. `packages/stackflow-agent/README.md` 2. `server/STACKFLOW_AGENT_DESIGN.md` +## Testing commands + +Use these commands depending on what changed: + +1. Full project checks (Clarity + Node suites): + +```bash +npm test +``` + +2. Clarity contract tests only: + +```bash +npm run test:clarity +``` + +3. Node/agent/x402 suites only: + +```bash +npm run test:node +``` + Integration tests for the HTTP server are opt-in (they spawn a real process and bind a local port): diff --git a/docs/AGENT-PIPE-TEST-LOG.md b/docs/AGENT-PIPE-TEST-LOG.md new file mode 100644 index 0000000..f1ee807 --- /dev/null +++ b/docs/AGENT-PIPE-TEST-LOG.md @@ -0,0 +1,24 @@ +# Agent Pipe Test Log (Warm Idris) + +Purpose: capture real-world StackFlow pipe test outcomes so onboarding for other agents is based on observed behavior, not assumptions. + +## Per-test template + +- Timestamp (UTC): +- Counterparty: +- Pipe identifier / contract: +- Scenario: +- Preconditions: +- Action executed: +- Expected result: +- Observed result: +- Artifacts (txid, signatures, nonce, logs): +- Pass/Fail: +- Root cause (if fail): +- Fix / mitigation: +- Process improvement for future agents: + +--- + +## Run log + diff --git a/package-lock.json b/package-lock.json index 1e41846..830b47e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "vitest-environment-clarinet": "^3.0.2" }, "devDependencies": { + "@noble/hashes": "^1.1.5", "@types/node": "^25.2.3", "esbuild": "^0.25.12" } diff --git a/package.json b/package.json index 415d722..016b0a5 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,10 @@ "deploy:testnet": "node scripts/deploy.js", "init:stackflow": "node scripts/init-stackflow.js", "build:ui": "node scripts/build-ui.js", - "test": "vitest run", - "test:node-utils": "vitest run -c vitest.node.config.js tests/x402-client.test.ts tests/stackflow-agent.test.ts", + "test": "npm run test:clarity && npm run test:node", + "test:clarity": "vitest run tests/stackflow.test.ts tests/reservoir.test.ts", + "test:node": "vitest run -c vitest.node.config.js tests/stackflow-agent.test.ts tests/x402-client.test.ts tests/counterparty-service.test.ts tests/forwarding-service.test.ts tests/stackflow-node-config.test.ts tests/stackflow-node-dispute.test.ts tests/stackflow-node-state.test.ts tests/stackflow-node-observer.test.ts tests/stackflow-node-http.integration.test.ts", + "test:node-utils": "vitest run -c vitest.node.config.js tests/x402-client.test.ts tests/stackflow-agent.test.ts tests/stackflow-aibtc-adapter.test.ts", "test:stackflow-node:http": "STACKFLOW_NODE_HTTP_INTEGRATION=1 vitest run tests/stackflow-node-http.integration.test.ts", "test:report": "vitest run -- --coverage --costs", "test:watch": "chokidar \"tests/**/*.ts\" \"contracts/**/*.clar\" -c \"npm run test:report\"", @@ -35,6 +37,7 @@ "vitest-environment-clarinet": "^3.0.2" }, "devDependencies": { + "@noble/hashes": "^1.1.5", "@types/node": "^25.2.3", "esbuild": "^0.25.12" } diff --git a/packages/stackflow-agent/README.md b/packages/stackflow-agent/README.md index d7c22a9..54b8ea0 100644 --- a/packages/stackflow-agent/README.md +++ b/packages/stackflow-agent/README.md @@ -75,6 +75,56 @@ watcher.start(); 8. `disputeClosure(...)` 9. `watcher.runOnce()` or `watcher.start()` for hourly checks +## Quick workflow (setup pipe + send + receive) + +1. Track pipe locally: + +```js +const tracked = agent.trackPipe({ + contractId: "ST...stackflow-0-6-0", + pipeKey: { "principal-1": "SP...ME", "principal-2": "SP...THEM", token: null }, + localPrincipal: "SP...ME", + counterpartyPrincipal: "SP...THEM", +}); +``` + +2. Open/fund the pipe on-chain: + +```js +await agent.openPipe({ + contractId: tracked.contractId, + token: null, + amount: "1000", + counterpartyPrincipal: tracked.counterpartyPrincipal, + nonce: "0", +}); +``` + +3. Build an outgoing state update to send to counterparty: + +```js +const outgoing = agent.buildOutgoingTransfer({ + pipeId: tracked.pipeId, + amount: "25", + // actor defaults to tracked.localPrincipal +}); +``` + +4. Validate + accept incoming counterparty update: + +```js +const result = await agent.acceptIncomingTransfer({ + pipeId: tracked.pipeId, + payload: { + ...outgoing, + actor: tracked.counterpartyPrincipal, + theirSignature: "0x...", + }, +}); +``` + +5. Persisted local latest state is now available via `getPipeLatestState(...)`. + ## Notes 1. This scaffold intentionally avoids observer endpoints and local chain node. @@ -82,4 +132,12 @@ watcher.start(); 3. `HourlyClosureWatcher` supports two sources: - `getPipeState` (recommended): per-pipe read-only polling (`get-pipe`) - `listClosureEvents`: event scan mode -4. For production hardening, add alerting, signer balance checks, and idempotency audit logs. +4. Watcher retries are idempotent for already-disputed closures (same closure txid is skipped on later polls). +5. Read-only polling isolates per-pipe failures (`getPipeState` errors on one pipe do not stop others). +6. Event scan mode intentionally holds the cursor when any dispute submission errors occur, so failed disputes are retried on next run. +7. Event scan mode now reports `listErrors` (event source/indexer failures) and keeps the watcher cursor unchanged on those failures. +8. Invalid closure event payloads are skipped and counted in `invalidEvents` so one malformed record does not abort a full scan. +9. `buildOutgoingTransfer(...)` defaults `actor` to the tracked local principal and rejects mismatched actor values. +10. Incoming transfer validation enforces tracked contract/pipe/principals/token consistency; mismatched `pipeId`, `pipeKey`, `actor`, or token payloads are rejected. +11. Incoming transfer validation also enforces sequential nonces and balance invariants against the latest stored local state (same total balance, and counterparty-actor updates must not reduce local balance). +12. For production hardening, add alerting, signer balance checks, and idempotency audit logs. diff --git a/packages/stackflow-agent/src/agent-service.js b/packages/stackflow-agent/src/agent-service.js index f09f94d..707b48b 100644 --- a/packages/stackflow-agent/src/agent-service.js +++ b/packages/stackflow-agent/src/agent-service.js @@ -88,7 +88,7 @@ export class StackflowAgentService { buildOutgoingTransfer({ pipeId, amount, - actor, + actor = null, action = "1", secret = null, validAfter = null, @@ -134,6 +134,14 @@ export class StackflowAgentService { const nextTheir = currentTheir + transferAmount; const nextNonce = currentNonce + 1n; + const normalizedActor = + actor == null || String(actor).trim() === "" + ? tracked.localPrincipal + : assertNonEmptyString(actor, "actor"); + if (normalizedActor !== tracked.localPrincipal) { + throw new Error("actor must match tracked local principal"); + } + return { contractId: tracked.contractId, pipeKey: tracked.pipeKey, @@ -144,7 +152,7 @@ export class StackflowAgentService { theirBalance: nextTheir.toString(10), nonce: nextNonce.toString(10), action: toUnsignedString(action, "action"), - actor: assertNonEmptyString(actor, "actor"), + actor: normalizedActor, secret, validAfter, beneficialOnly: beneficialOnly === true, @@ -173,6 +181,41 @@ export class StackflowAgentService { reason: "contract-mismatch", }; } + + if (data.pipeId != null && String(data.pipeId).trim() !== tracked.pipeId) { + return { + valid: false, + reason: "pipe-id-mismatch", + }; + } + + if (data.pipeKey != null) { + if (!data.pipeKey || typeof data.pipeKey !== "object" || Array.isArray(data.pipeKey)) { + return { + valid: false, + reason: "pipe-key-invalid", + }; + } + let incomingPipeId; + try { + incomingPipeId = buildPipeId({ + contractId, + pipeKey: data.pipeKey, + }); + } catch { + return { + valid: false, + reason: "pipe-key-invalid", + }; + } + if (incomingPipeId !== tracked.pipeId) { + return { + valid: false, + reason: "pipe-key-mismatch", + }; + } + } + const forPrincipal = String(data.forPrincipal ?? "").trim(); if (forPrincipal !== tracked.localPrincipal) { return { @@ -187,6 +230,17 @@ export class StackflowAgentService { reason: "with-principal-mismatch", }; } + + const trackedToken = + tracked.token == null ? null : String(tracked.token).trim(); + const payloadToken = + data.token == null ? trackedToken : String(data.token).trim(); + if (payloadToken !== trackedToken) { + return { + valid: false, + reason: "token-mismatch", + }; + } const theirSignature = (() => { try { return normalizeHex(data.theirSignature, "theirSignature"); @@ -222,6 +276,12 @@ export class StackflowAgentService { reason: "actor-missing", }; } + if (actor !== tracked.counterpartyPrincipal) { + return { + valid: false, + reason: "actor-mismatch", + }; + } const latest = this.stateStore.getLatestSignatureState( tracked.pipeId, tracked.localPrincipal, @@ -236,6 +296,47 @@ export class StackflowAgentService { existingNonce: latest.nonce, }; } + if (incomingNonce !== existingNonce + 1n) { + return { + valid: false, + reason: "nonce-not-sequential", + existingNonce: latest.nonce, + }; + } + + const existingMyBalance = parseUnsignedBigInt( + latest.myBalance, + "existing myBalance", + ); + const existingTheirBalance = parseUnsignedBigInt( + latest.theirBalance, + "existing theirBalance", + ); + const incomingMyBalance = parseUnsignedBigInt(myBalance, "incoming myBalance"); + const incomingTheirBalance = parseUnsignedBigInt( + theirBalance, + "incoming theirBalance", + ); + + if ( + incomingMyBalance + incomingTheirBalance !== + existingMyBalance + existingTheirBalance + ) { + return { + valid: false, + reason: "balance-sum-mismatch", + }; + } + + if ( + incomingMyBalance < existingMyBalance || + incomingTheirBalance > existingTheirBalance + ) { + return { + valid: false, + reason: "balance-direction-invalid", + }; + } } let secret = null; @@ -256,7 +357,7 @@ export class StackflowAgentService { pipeKey: tracked.pipeKey, forPrincipal, withPrincipal, - token: data.token == null ? tracked.token : String(data.token).trim(), + token: trackedToken, myBalance, theirBalance, nonce, diff --git a/packages/stackflow-agent/src/aibtc-adapter.js b/packages/stackflow-agent/src/aibtc-adapter.js index 41fd3ba..8e2c1e6 100644 --- a/packages/stackflow-agent/src/aibtc-adapter.js +++ b/packages/stackflow-agent/src/aibtc-adapter.js @@ -139,6 +139,41 @@ function normalizeToolResult(result, toolName) { return result; } +function shouldRetrySip018WithDomain(error) { + const message = String(error instanceof Error ? error.message : error).toLowerCase(); + return ( + message.includes("domain") && + (message.includes("required") || message.includes("missing") || message.includes("must")) + ); +} + +function shouldRetrySip018Legacy(error) { + const message = String(error instanceof Error ? error.message : error).toLowerCase(); + return ( + message.includes("domain") || + message.includes("contract") || + message.includes("input validation") || + message.includes("invalid arguments") + ); +} + +function deriveSip018Domain(contract) { + const contractText = String(contract ?? "").trim(); + if (!contractText) { + return { name: "stackflow", version: "1.0.0" }; + } + + const [, rawName = contractText] = contractText.split("."); + const name = rawName || "stackflow"; + + const versionMatch = name.match(/(\d+)-(\d+)-(\d+)$/); + const version = versionMatch + ? `${versionMatch[1]}.${versionMatch[2]}.${versionMatch[3]}` + : "1.0.0"; + + return { name, version }; +} + export class AibtcWalletAdapter { constructor({ invokeTool, @@ -154,14 +189,33 @@ export class AibtcWalletAdapter { message, walletPassword = null, }) { - const result = normalizeToolResult( - await this.invokeTool("sip018_sign", { - contract, - message, - wallet_password: walletPassword ?? undefined, - }), - "sip018_sign", - ); + const domainArgs = { + message, + domain: deriveSip018Domain(contract), + wallet_password: walletPassword ?? undefined, + }; + + const legacyArgs = { + contract, + message, + wallet_password: walletPassword ?? undefined, + }; + + let result; + try { + result = normalizeToolResult( + await this.invokeTool("sip018_sign", domainArgs), + "sip018_sign", + ); + } catch (error) { + if (!shouldRetrySip018Legacy(error)) { + throw error; + } + result = normalizeToolResult( + await this.invokeTool("sip018_sign", legacyArgs), + "sip018_sign", + ); + } const signature = result.signature ?? result.data?.signature ?? null; if (typeof signature !== "string" || !signature.trim()) { diff --git a/packages/stackflow-agent/src/db.js b/packages/stackflow-agent/src/db.js index 1b58064..620f888 100644 --- a/packages/stackflow-agent/src/db.js +++ b/packages/stackflow-agent/src/db.js @@ -197,6 +197,9 @@ export class AgentStateStore { dispute_txid = ? WHERE txid = ? `); + this.getClosureStmt = this.db.prepare(` + SELECT * FROM closures WHERE txid = ? + `); this.getCursorStmt = this.db.prepare(` SELECT last_block_height FROM watcher_cursor WHERE id = 1 @@ -386,6 +389,29 @@ export class AgentStateStore { ); } + getClosure(txid) { + this.assertOpen(); + const row = this.getClosureStmt.get(assertNonEmptyString(txid, "txid")); + if (!row) { + return null; + } + return { + txid: row.txid, + contractId: row.contract_id, + pipeId: row.pipe_id, + pipeKey: JSON.parse(row.pipe_key_json), + eventName: row.event_name, + nonce: row.nonce, + closer: row.closer, + blockHeight: row.block_height, + expiresAt: row.expires_at, + closureMyBalance: row.closure_my_balance, + disputed: row.disputed === 1, + disputeTxid: row.dispute_txid, + createdAt: row.created_at, + }; + } + getWatcherCursor() { this.assertOpen(); const row = this.getCursorStmt.get(); diff --git a/packages/stackflow-agent/src/watcher.js b/packages/stackflow-agent/src/watcher.js index 9338c69..50d2d64 100644 --- a/packages/stackflow-agent/src/watcher.js +++ b/packages/stackflow-agent/src/watcher.js @@ -145,6 +145,24 @@ export class HourlyClosureWatcher { this.running = false; } + reportError(error, context = null) { + if (!error) { + return; + } + if (this.onError) { + if (context && error instanceof Error && !error.context) { + error.context = context; + } + this.onError(error); + return; + } + console.error( + `[stackflow-agent] watcher error${ + context ? ` (${context})` : "" + }: ${error instanceof Error ? error.message : String(error)}`, + ); + } + start() { if (this.timer) { return; @@ -200,22 +218,38 @@ export class HourlyClosureWatcher { pipesScanned: 0, closuresFound: 0, disputesSubmitted: 0, + skippedAlreadyDisputed: 0, + fetchErrors: 0, + disputeErrors: 0, }; } let closuresFound = 0; let disputesSubmitted = 0; + let skippedAlreadyDisputed = 0; + let fetchErrors = 0; + let disputeErrors = 0; let pipesScanned = 0; for (const trackedPipe of trackedPipes) { pipesScanned += 1; - const pipeState = await this.getPipeState({ - contractId: trackedPipe.contractId, - token: trackedPipe.token ?? null, - pipeKey: trackedPipe.pipeKey, - forPrincipal: trackedPipe.localPrincipal, - withPrincipal: trackedPipe.counterpartyPrincipal, - pipeId: trackedPipe.pipeId, - }); + let pipeState; + try { + pipeState = await this.getPipeState({ + contractId: trackedPipe.contractId, + token: trackedPipe.token ?? null, + pipeKey: trackedPipe.pipeKey, + forPrincipal: trackedPipe.localPrincipal, + withPrincipal: trackedPipe.counterpartyPrincipal, + pipeId: trackedPipe.pipeId, + }); + } catch (error) { + fetchErrors += 1; + this.reportError( + error, + `getPipeState:${trackedPipe.contractId}:${trackedPipe.pipeId}`, + ); + continue; + } const rawClosure = toClosureFromPipeState({ trackedPipe, pipeState, @@ -231,11 +265,24 @@ export class HourlyClosureWatcher { } closuresFound += 1; + const existingClosure = this.agentService.stateStore.getClosure(closure.txid); this.agentService.stateStore.recordClosure(closure); - const disputeResult = await this.agentService.disputeClosure({ - closureEvent: closure, - walletPassword: this.walletPassword, - }); + if (existingClosure?.disputed) { + skippedAlreadyDisputed += 1; + continue; + } + + let disputeResult; + try { + disputeResult = await this.agentService.disputeClosure({ + closureEvent: closure, + walletPassword: this.walletPassword, + }); + } catch (error) { + disputeErrors += 1; + this.reportError(error, `disputeClosure:${closure.txid}`); + continue; + } if (disputeResult.submitted) { disputesSubmitted += 1; } @@ -247,6 +294,9 @@ export class HourlyClosureWatcher { pipesScanned, closuresFound, disputesSubmitted, + skippedAlreadyDisputed, + fetchErrors, + disputeErrors, }; } finally { this.running = false; @@ -265,14 +315,34 @@ export class HourlyClosureWatcher { this.running = true; try { const fromBlockHeight = this.agentService.stateStore.getWatcherCursor(); - const events = await this.listClosureEvents({ - fromBlockHeight, - }); + let events; + try { + events = await this.listClosureEvents({ + fromBlockHeight, + }); + } catch (error) { + this.reportError(error, "listClosureEvents"); + return { + ok: false, + scanned: 0, + invalidEvents: 0, + disputesSubmitted: 0, + skippedAlreadyDisputed: 0, + disputeErrors: 0, + listErrors: 1, + fromBlockHeight, + toBlockHeight: fromBlockHeight, + }; + } if (!Array.isArray(events) || events.length === 0) { return { ok: true, scanned: 0, + invalidEvents: 0, disputesSubmitted: 0, + skippedAlreadyDisputed: 0, + disputeErrors: 0, + listErrors: 0, fromBlockHeight, toBlockHeight: fromBlockHeight, }; @@ -280,6 +350,10 @@ export class HourlyClosureWatcher { let highestBlock = parseUnsignedBigInt(fromBlockHeight, "fromBlockHeight"); let disputesSubmitted = 0; + let skippedAlreadyDisputed = 0; + let disputeErrors = 0; + let invalidEvents = 0; + let hasDisputeErrors = false; let scanned = 0; for (const rawEvent of events) { @@ -287,17 +361,29 @@ export class HourlyClosureWatcher { try { closure = normalizeClosureEvent(rawEvent); } catch { + invalidEvents += 1; continue; } scanned += 1; + const existingClosure = this.agentService.stateStore.getClosure(closure.txid); this.agentService.stateStore.recordClosure(closure); - - const disputeResult = await this.agentService.disputeClosure({ - closureEvent: closure, - walletPassword: this.walletPassword, - }); - if (disputeResult.submitted) { - disputesSubmitted += 1; + if (existingClosure?.disputed) { + skippedAlreadyDisputed += 1; + } else { + let disputeResult; + try { + disputeResult = await this.agentService.disputeClosure({ + closureEvent: closure, + walletPassword: this.walletPassword, + }); + } catch (error) { + disputeErrors += 1; + hasDisputeErrors = true; + this.reportError(error, `disputeClosure:${closure.txid}`); + } + if (disputeResult?.submitted) { + disputesSubmitted += 1; + } } const block = parseUnsignedBigInt(closure.blockHeight, "blockHeight"); @@ -306,13 +392,23 @@ export class HourlyClosureWatcher { } } - this.agentService.stateStore.setWatcherCursor(highestBlock.toString(10)); + const toBlockHeight = hasDisputeErrors + ? fromBlockHeight + : highestBlock.toString(10); + if (!hasDisputeErrors) { + this.agentService.stateStore.setWatcherCursor(toBlockHeight); + } + return { ok: true, scanned, + invalidEvents, disputesSubmitted, + skippedAlreadyDisputed, + disputeErrors, + listErrors: 0, fromBlockHeight, - toBlockHeight: highestBlock.toString(10), + toBlockHeight, }; } finally { this.running = false; diff --git a/server/src/config.ts b/server/src/config.ts index ff3118a..91dbee8 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -26,13 +26,37 @@ const DEFAULT_DB_FILE = path.resolve( 'server/data/stackflow-node-state.db', ); -function parseInteger(value: unknown, fallback: number): number { +function parseInteger(value: unknown, fallback: number, key: string): number { if (value === undefined || value === null || value === '') { return fallback; } - const parsed = Number.parseInt(String(value), 10); - return Number.isFinite(parsed) ? parsed : fallback; + const normalized = String(value).trim(); + if (!/^-?\d+$/.test(normalized)) { + throw new Error(`${key} must be an integer`); + } + + const parsed = Number.parseInt(normalized, 10); + if (!Number.isSafeInteger(parsed)) { + throw new Error(`${key} must be a safe integer`); + } + + return parsed; +} + +function parsePort(value: unknown): number { + const parsed = parseInteger(value, DEFAULT_PORT, 'STACKFLOW_NODE_PORT'); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535) { + throw new Error('STACKFLOW_NODE_PORT must be an integer between 1 and 65535'); + } + return parsed; +} + +function parseMaxRecentEvents(value: unknown): number { + return Math.max( + 1, + parseInteger(value, DEFAULT_MAX_RECENT_EVENTS, 'STACKFLOW_NODE_MAX_RECENT_EVENTS'), + ); } function parseCsv(value: unknown): string[] { @@ -60,7 +84,7 @@ function parsePrincipalCsv(value: unknown): string[] { return [...new Set(principals)]; } -function parseBoolean(value: unknown, fallback: boolean): boolean { +function parseBoolean(value: unknown, fallback: boolean, key: string): boolean { if (value === undefined || value === null || value === '') { return fallback; } @@ -73,7 +97,7 @@ function parseBoolean(value: unknown, fallback: boolean): boolean { return false; } - return fallback; + throw new Error(`${key} must be a boolean (true/false, 1/0, yes/no, on/off)`); } function normalizeBaseUrl(input: string): string { @@ -97,10 +121,16 @@ function normalizeBaseUrl(input: string): string { function parseNetwork(value: unknown): 'mainnet' | 'testnet' | 'devnet' | 'mocknet' { const normalized = String(value || 'devnet').trim().toLowerCase(); - if (normalized === 'mainnet' || normalized === 'testnet' || normalized === 'mocknet') { + if ( + normalized === 'mainnet' || + normalized === 'testnet' || + normalized === 'devnet' || + normalized === 'mocknet' + ) { return normalized; } - return 'devnet'; + + throw new Error('STACKS_NETWORK must be one of: mainnet, testnet, devnet, mocknet'); } function parseSignatureVerifierMode(value: unknown): SignatureVerifierMode { @@ -157,13 +187,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): StackflowNodeC return { host: env.STACKFLOW_NODE_HOST?.trim() || DEFAULT_HOST, - port: parseInteger(env.STACKFLOW_NODE_PORT, DEFAULT_PORT), + port: parsePort(env.STACKFLOW_NODE_PORT), dbFile, - maxRecentEvents: parseInteger( - env.STACKFLOW_NODE_MAX_RECENT_EVENTS, - DEFAULT_MAX_RECENT_EVENTS, + maxRecentEvents: parseMaxRecentEvents(env.STACKFLOW_NODE_MAX_RECENT_EVENTS), + logRawEvents: parseBoolean( + env.STACKFLOW_NODE_LOG_RAW_EVENTS, + false, + 'STACKFLOW_NODE_LOG_RAW_EVENTS', ), - logRawEvents: parseBoolean(env.STACKFLOW_NODE_LOG_RAW_EVENTS, false), watchedContracts: parseCsv(env.STACKFLOW_CONTRACTS), watchedPrincipals: parsePrincipalCsv(env.STACKFLOW_NODE_PRINCIPALS), stacksNetwork: parseNetwork(env.STACKS_NETWORK), @@ -198,47 +229,63 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): StackflowNodeC disputeOnlyBeneficial: parseBoolean( env.STACKFLOW_NODE_DISPUTE_ONLY_BENEFICIAL, false, + 'STACKFLOW_NODE_DISPUTE_ONLY_BENEFICIAL', ), peerWriteRateLimitPerMinute: Math.max( 0, parseInteger( env.STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE, DEFAULT_PEER_WRITE_RATE_LIMIT_PER_MINUTE, + 'STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE', ), ), trustProxy: parseBoolean( env.STACKFLOW_NODE_TRUST_PROXY, DEFAULT_TRUST_PROXY, + 'STACKFLOW_NODE_TRUST_PROXY', ), observerLocalhostOnly: parseBoolean( env.STACKFLOW_NODE_OBSERVER_LOCALHOST_ONLY, DEFAULT_OBSERVER_LOCALHOST_ONLY, + 'STACKFLOW_NODE_OBSERVER_LOCALHOST_ONLY', ), observerAllowedIps: parseCsv(env.STACKFLOW_NODE_OBSERVER_ALLOWED_IPS), adminReadToken: env.STACKFLOW_NODE_ADMIN_READ_TOKEN?.trim() || null, adminReadLocalhostOnly: parseBoolean( env.STACKFLOW_NODE_ADMIN_READ_LOCALHOST_ONLY, DEFAULT_ADMIN_READ_LOCALHOST_ONLY, + 'STACKFLOW_NODE_ADMIN_READ_LOCALHOST_ONLY', ), redactSensitiveReadData: parseBoolean( env.STACKFLOW_NODE_REDACT_SENSITIVE_READ_DATA, DEFAULT_REDACT_SENSITIVE_READ_DATA, + 'STACKFLOW_NODE_REDACT_SENSITIVE_READ_DATA', + ), + forwardingEnabled: parseBoolean( + env.STACKFLOW_NODE_FORWARDING_ENABLED, + false, + 'STACKFLOW_NODE_FORWARDING_ENABLED', ), - forwardingEnabled: parseBoolean(env.STACKFLOW_NODE_FORWARDING_ENABLED, false), forwardingMinFee: Math.max( 0, - parseInteger(env.STACKFLOW_NODE_FORWARDING_MIN_FEE, 0), + parseInteger( + env.STACKFLOW_NODE_FORWARDING_MIN_FEE, + 0, + 'STACKFLOW_NODE_FORWARDING_MIN_FEE', + ), ).toString(10), forwardingTimeoutMs: Math.max( 1_000, parseInteger( env.STACKFLOW_NODE_FORWARDING_TIMEOUT_MS, DEFAULT_FORWARDING_TIMEOUT_MS, + 'STACKFLOW_NODE_FORWARDING_TIMEOUT_MS', ), ), forwardingAllowPrivateDestinations: parseBoolean( env.STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONS, false, + 'STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONS', ), forwardingAllowedBaseUrls: parseCsv( env.STACKFLOW_NODE_FORWARDING_ALLOWED_BASE_URLS, @@ -248,6 +295,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): StackflowNodeC parseInteger( env.STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_INTERVAL_MS, DEFAULT_FORWARDING_REVEAL_RETRY_INTERVAL_MS, + 'STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_INTERVAL_MS', ), ), forwardingRevealRetryMaxAttempts: Math.max( @@ -255,6 +303,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): StackflowNodeC parseInteger( env.STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS, DEFAULT_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS, + 'STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS', ), ), }; diff --git a/server/src/index.ts b/server/src/index.ts index fcedf96..ddf443e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -666,9 +666,16 @@ function stringifyForLog(value: unknown): string { } } +function normalizeWatchedContracts(watchedContracts: string[]): string[] { + return watchedContracts + .map((contract) => contract.trim().toLowerCase()) + .filter((contract) => contract.length > 0); +} + function contractMatches(contractId: string, watchedContracts: string[]): boolean { if (watchedContracts.length > 0) { - return watchedContracts.includes(contractId); + const normalizedContractId = contractId.toLowerCase(); + return watchedContracts.some((candidate) => candidate === normalizedContractId); } return DEFAULT_STACKFLOW_CONTRACT_PATTERN.test(contractId); } @@ -677,6 +684,7 @@ function extractRawStackflowPrintEventSamples( payload: unknown, watchedContracts: string[], ): Record[] { + const normalizedWatchedContracts = normalizeWatchedContracts(watchedContracts); const queue: unknown[] = [payload]; const visited = new Set(); const samples: Record[] = []; @@ -738,7 +746,7 @@ function extractRawStackflowPrintEventSamples( if ( typeof contractId === 'string' && topic === 'print' && - contractMatches(contractId, watchedContracts) + contractMatches(contractId, normalizedWatchedContracts) ) { samples.push(candidate.envelope); } diff --git a/server/src/observer-parser.ts b/server/src/observer-parser.ts index d91d692..6da3731 100644 --- a/server/src/observer-parser.ts +++ b/server/src/observer-parser.ts @@ -239,6 +239,12 @@ function collectContractEventCandidates( return candidates; } +function normalizeWatchedContracts(watchedContracts: string[]): string[] { + return watchedContracts + .map((contract) => contract.trim()) + .filter((contract) => contract.length > 0); +} + function contractMatches( contractId: string | null, watchedContracts: string[], @@ -248,7 +254,7 @@ function contractMatches( } if (watchedContracts.length > 0) { - return watchedContracts.includes(contractId); + return watchedContracts.some((candidate) => candidate === contractId); } return DEFAULT_STACKFLOW_CONTRACT_PATTERN.test(contractId); @@ -386,7 +392,7 @@ export function extractStackflowPrintEvents( payload: unknown, options: ExtractOptions = {}, ): StackflowPrintEvent[] { - const watchedContracts = options.watchedContracts || []; + const watchedContracts = normalizeWatchedContracts(options.watchedContracts || []); const candidates = collectContractEventCandidates(payload); const normalized = candidates diff --git a/tests/forwarding-service.test.ts b/tests/forwarding-service.test.ts new file mode 100644 index 0000000..498cddf --- /dev/null +++ b/tests/forwarding-service.test.ts @@ -0,0 +1,240 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + ForwardingService, + ForwardingServiceError, +} from '../server/src/forwarding-service.ts'; + +const CONTRACT_ID = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0'; +const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; +const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; +const SIG_B = `0x${'22'.repeat(65)}`; +const HASHED_SECRET = + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb'; +const VALID_SECRET = + '0x8484848484848484848484848484848484848484848484848484848484848484'; + +function makeTransferPayload() { + return { + contractId: CONTRACT_ID, + forPrincipal: P1, + withPrincipal: P2, + token: null, + amount: '0', + myBalance: '910', + theirBalance: '90', + theirSignature: SIG_B, + nonce: '6', + action: '1', + actor: P2, + secret: null, + validAfter: null, + beneficialOnly: false, + }; +} + +describe('forwarding service', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('normalizes transfer payloads and signs incoming update after next-hop accepts', async () => { + const signTransfer = vi.fn().mockResolvedValue({ + request: makeTransferPayload(), + mySignature: `0x${'11'.repeat(65)}`, + upsert: { + stored: true, + replaced: false, + state: { + mySignature: `0x${'11'.repeat(65)}`, + theirSignature: SIG_B, + }, + }, + }); + + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response( + JSON.stringify({ + mySignature: `0x${'33'.repeat(65)}`, + theirSignature: SIG_B, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + + const service = new ForwardingService({ + counterpartyService: { + enabled: true, + counterpartyPrincipal: P1, + signTransfer, + } as any, + config: { + enabled: true, + minFee: '5', + timeoutMs: 1_000, + allowPrivateDestinations: true, + allowedBaseUrls: ['https://next-hop.example/'], + }, + }); + + const result = await service.processTransfer({ + paymentId: 'pay-2026-03-06-0001', + incomingAmount: '100', + outgoingAmount: '90', + hashedSecret: HASHED_SECRET, + incoming: makeTransferPayload(), + outgoing: { + baseUrl: 'https://next-hop.example', + payload: makeTransferPayload(), + }, + }); + + expect(result.feeAmount).toBe('10'); + expect(result.nextHopBaseUrl).toBe('https://next-hop.example'); + expect(result.hashedSecret).toBe(HASHED_SECRET); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://next-hop.example/counterparty/transfer'); + expect(init.method).toBe('POST'); + + const nextHopBody = JSON.parse(String(init.body)) as Record; + expect(nextHopBody.hashedSecret).toBe(HASHED_SECRET); + expect(nextHopBody.secret).toBe(HASHED_SECRET); + + expect(signTransfer).toHaveBeenCalledTimes(1); + const [incomingPayload] = signTransfer.mock.calls[0] as [Record]; + expect(incomingPayload.hashedSecret).toBe(HASHED_SECRET); + expect(incomingPayload.secret).toBe(HASHED_SECRET); + }); + + it('rejects private next-hop destinations by default', async () => { + const signTransfer = vi.fn(); + const fetchMock = vi.spyOn(globalThis, 'fetch'); + + const service = new ForwardingService({ + counterpartyService: { + enabled: true, + counterpartyPrincipal: P1, + signTransfer, + } as any, + config: { + enabled: true, + minFee: '1', + timeoutMs: 1_000, + allowPrivateDestinations: false, + allowedBaseUrls: [], + }, + }); + + await expect( + service.processTransfer({ + paymentId: 'pay-2026-03-06-0002', + incomingAmount: '100', + outgoingAmount: '99', + hashedSecret: HASHED_SECRET, + incoming: makeTransferPayload(), + outgoing: { + baseUrl: 'http://127.0.0.1:3999', + payload: makeTransferPayload(), + }, + }), + ).rejects.toMatchObject>({ + statusCode: 403, + details: { + reason: 'next-hop-private-destination', + }, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(signTransfer).not.toHaveBeenCalled(); + }); + + it('rejects negative forwarding fee before side effects', async () => { + const signTransfer = vi.fn(); + const fetchMock = vi.spyOn(globalThis, 'fetch'); + + const service = new ForwardingService({ + counterpartyService: { + enabled: true, + counterpartyPrincipal: P1, + signTransfer, + } as any, + config: { + enabled: true, + minFee: '1', + timeoutMs: 1_000, + allowPrivateDestinations: true, + allowedBaseUrls: ['https://next-hop.example'], + }, + }); + + await expect( + service.processTransfer({ + paymentId: 'pay-2026-03-06-0003', + incomingAmount: '90', + outgoingAmount: '100', + hashedSecret: HASHED_SECRET, + incoming: makeTransferPayload(), + outgoing: { + baseUrl: 'https://next-hop.example', + payload: makeTransferPayload(), + }, + }), + ).rejects.toMatchObject>({ + statusCode: 403, + details: { + reason: 'negative-forwarding-fee', + }, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(signTransfer).not.toHaveBeenCalled(); + }); + + it('verifies reveal preimage secrets', () => { + const service = new ForwardingService({ + counterpartyService: { + enabled: true, + counterpartyPrincipal: P1, + signTransfer: vi.fn(), + } as any, + config: { + enabled: true, + minFee: '1', + timeoutMs: 1_000, + allowPrivateDestinations: true, + allowedBaseUrls: [], + }, + }); + + expect( + service.verifyRevealSecret({ + hashedSecret: HASHED_SECRET, + secret: VALID_SECRET, + }), + ).toEqual({ + hashedSecret: HASHED_SECRET, + secret: VALID_SECRET, + }); + + expect(() => + service.verifyRevealSecret({ + hashedSecret: HASHED_SECRET, + secret: '0x1111111111111111111111111111111111111111111111111111111111111111', + }), + ).toThrowError(ForwardingServiceError); + + expect(() => + service.verifyRevealSecret({ + hashedSecret: HASHED_SECRET, + secret: '0x1111111111111111111111111111111111111111111111111111111111111111', + }), + ).toThrow(/secret does not match hashedSecret/i); + }); +}); diff --git a/tests/stackflow-agent.test.ts b/tests/stackflow-agent.test.ts index 202b728..2b1caca 100644 --- a/tests/stackflow-agent.test.ts +++ b/tests/stackflow-agent.test.ts @@ -208,6 +208,502 @@ describe("stackflow agent", () => { store.close(); }); + it("skips duplicate disputes for closures already marked disputed", async () => { + const dbFile = tempDbFile("agent-duplicate-dispute"); + const store = new AgentStateStore({ dbFile }); + + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + store.upsertSignatureState({ + contractId, + pipeKey, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "8", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + let disputeCalls = 0; + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + disputeCalls += 1; + return { txid: "0xdispute-dup" }; + }, + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + disputeOnlyBeneficial: true, + }); + + const watcher = new HourlyClosureWatcher({ + agentService: agent, + getPipeState: async () => ({ + "balance-1": "20", + "balance-2": "80", + "expires-at": "200", + nonce: "5", + closer: "ST1OTHER", + }), + }); + + const first = await watcher.runOnce(); + expect(first.disputesSubmitted).toBe(1); + expect(first.skippedAlreadyDisputed).toBe(0); + + const second = await watcher.runOnce(); + expect(second.disputesSubmitted).toBe(0); + expect(second.skippedAlreadyDisputed).toBe(1); + expect(disputeCalls).toBe(1); + + watcher.stop(); + store.close(); + }); + + it("continues readonly polling when one pipe state fetch fails", async () => { + const dbFile = tempDbFile("agent-readonly-fetch-error"); + const store = new AgentStateStore({ dbFile }); + + const contractId = "ST1TESTABC.contract"; + const pipeKeyA = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHERA", + token: null, + }; + const pipeKeyB = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHERB", + token: null, + }; + + const pipeIdA = buildPipeId({ contractId, pipeKey: pipeKeyA }); + const pipeIdB = buildPipeId({ contractId, pipeKey: pipeKeyB }); + + store.upsertTrackedPipe({ + pipeId: pipeIdA, + contractId, + pipeKey: pipeKeyA, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHERA", + token: null, + }); + store.upsertTrackedPipe({ + pipeId: pipeIdB, + contractId, + pipeKey: pipeKeyB, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHERB", + token: null, + }); + + store.upsertSignatureState({ + contractId, + pipeKey: pipeKeyB, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHERB", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "8", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + let disputeCalls = 0; + const errors: Error[] = []; + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + disputeCalls += 1; + return { txid: "0xdispute-fetch-error" }; + }, + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + disputeOnlyBeneficial: true, + }); + + let fetchCalls = 0; + const watcher = new HourlyClosureWatcher({ + agentService: agent, + getPipeState: async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + throw new Error("rpc unavailable"); + } + return { + "balance-1": "20", + "balance-2": "80", + "expires-at": "200", + nonce: "5", + closer: "ST1OTHERB", + }; + }, + onError: (error) => { + errors.push(error as Error); + }, + }); + + const result = await watcher.runOnce(); + expect(result.ok).toBe(true); + expect(result.mode).toBe("readonly-pipe"); + expect(result.pipesScanned).toBe(2); + expect(result.fetchErrors).toBe(1); + expect(result.disputeErrors).toBe(0); + expect(result.disputesSubmitted).toBe(1); + expect(disputeCalls).toBe(1); + expect(errors).toHaveLength(1); + + watcher.stop(); + store.close(); + }); + + it("holds event cursor when dispute submission errors", async () => { + const dbFile = tempDbFile("agent-event-dispute-error"); + const store = new AgentStateStore({ dbFile }); + + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + store.upsertSignatureState({ + contractId, + pipeKey, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "8", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + let submitCalls = 0; + const errors: Error[] = []; + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + submitCalls += 1; + if (submitCalls === 1) { + throw new Error("signer timeout"); + } + return { txid: "0xdispute-ok" }; + }, + async sip018Sign() { + return "0x" + "33".repeat(65); + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + disputeOnlyBeneficial: true, + }); + + const watcher = new HourlyClosureWatcher({ + agentService: agent, + listClosureEvents: async () => [ + { + contractId, + pipeKey, + eventName: "force-close", + nonce: "5", + closer: "ST1OTHER", + txid: "0xtx-err", + blockHeight: "123", + expiresAt: "200", + closureMyBalance: "20", + }, + { + contractId, + pipeKey, + eventName: "force-close", + nonce: "6", + closer: "ST1OTHER", + txid: "0xtx-ok", + blockHeight: "124", + expiresAt: "201", + closureMyBalance: "30", + }, + ], + onError: (error) => { + errors.push(error as Error); + }, + }); + + const first = await watcher.runOnce(); + expect(first.ok).toBe(true); + expect(first.scanned).toBe(2); + expect(first.disputeErrors).toBe(1); + expect(first.disputesSubmitted).toBe(1); + expect(first.toBlockHeight).toBe("0"); + expect(store.getWatcherCursor()).toBe("0"); + expect(errors).toHaveLength(1); + + const second = await watcher.runOnce(); + expect(second.disputeErrors).toBe(0); + expect(second.disputesSubmitted).toBe(1); + expect(second.toBlockHeight).toBe("124"); + expect(store.getWatcherCursor()).toBe("124"); + + watcher.stop(); + store.close(); + }); + + it("keeps event cursor and reports list source failures", async () => { + const dbFile = tempDbFile("agent-event-source-error"); + const store = new AgentStateStore({ dbFile }); + + const errors: Error[] = []; + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + }); + + const watcher = new HourlyClosureWatcher({ + agentService: agent, + listClosureEvents: async () => { + throw new Error("indexer timeout"); + }, + onError: (error) => { + errors.push(error as Error); + }, + }); + + const result = await watcher.runOnce(); + expect(result.ok).toBe(false); + expect(result.listErrors).toBe(1); + expect(result.scanned).toBe(0); + expect(result.toBlockHeight).toBe("0"); + expect(store.getWatcherCursor()).toBe("0"); + expect(errors).toHaveLength(1); + + watcher.stop(); + store.close(); + }); + + it("counts invalid closure events without aborting scan", async () => { + const dbFile = tempDbFile("agent-event-invalid-event"); + const store = new AgentStateStore({ dbFile }); + + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + store.upsertSignatureState({ + contractId, + pipeKey, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "8", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + let disputeCalls = 0; + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + disputeCalls += 1; + return { txid: "0xdispute-ok" }; + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + disputeOnlyBeneficial: true, + }); + + const watcher = new HourlyClosureWatcher({ + agentService: agent, + listClosureEvents: async () => [ + { + eventName: "invalid", + }, + { + contractId, + pipeKey, + eventName: "force-close", + nonce: "5", + closer: "ST1OTHER", + txid: "0xtx-valid", + blockHeight: "123", + expiresAt: "200", + closureMyBalance: "20", + }, + ], + }); + + const result = await watcher.runOnce(); + expect(result.ok).toBe(true); + expect(result.invalidEvents).toBe(1); + expect(result.scanned).toBe(1); + expect(result.disputesSubmitted).toBe(1); + expect(result.toBlockHeight).toBe("123"); + expect(store.getWatcherCursor()).toBe("123"); + expect(disputeCalls).toBe(1); + + watcher.stop(); + store.close(); + }); + + it("skips overlapping readonly watcher runs", async () => { + const dbFile = tempDbFile("agent-readonly-overlap"); + const store = new AgentStateStore({ dbFile }); + + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + + let resolveFetch: ((value: unknown) => void) | null = null; + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + return { txid: "0xnoop" }; + }, + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + }); + + const watcher = new HourlyClosureWatcher({ + agentService: agent, + getPipeState: () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + }); + + const firstRunPromise = watcher.runOnce(); + const overlapping = await watcher.runOnce(); + + expect(overlapping.ok).toBe(true); + expect(overlapping.skipped).toBe(true); + expect(overlapping.reason).toBe("already-running"); + + resolveFetch?.({ + "balance-1": "20", + "balance-2": "80", + "expires-at": "200", + nonce: "5", + closer: "ST1OTHER", + }); + + await firstRunPromise; + watcher.stop(); + store.close(); + }); + it("validates and signs incoming transfer requests", async () => { const dbFile = tempDbFile("agent-sign"); const store = new AgentStateStore({ dbFile }); @@ -243,10 +739,467 @@ describe("stackflow agent", () => { network: "devnet", }); - const result = await agent.acceptIncomingTransfer({ + const result = await agent.acceptIncomingTransfer({ + pipeId, + payload: { + contractId, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "1", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(result.accepted).toBe(true); + expect(result.mySignature).toMatch(/^0x[0-9a-f]+$/); + const latest = store.getLatestSignatureState(pipeId, "ST1LOCAL"); + expect(latest?.nonce).toBe("1"); + store.close(); + }); + + it("rejects incoming transfer requests with token mismatch", () => { + const dbFile = tempDbFile("agent-sign-token-mismatch"); + const store = new AgentStateStore({ dbFile }); + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: "ST1TOKEN.token-1", + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: "ST1TOKEN.token-1", + }); + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + }); + + const validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: "ST1TOKEN.token-2", + myBalance: "90", + theirBalance: "10", + nonce: "1", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("token-mismatch"); + store.close(); + }); + + it("rejects incoming transfer requests with actor mismatch", () => { + const dbFile = tempDbFile("agent-sign-actor-mismatch"); + const store = new AgentStateStore({ dbFile }); + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + }); + + const validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "1", + action: "1", + actor: "ST1THIRD", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("actor-mismatch"); + store.close(); + }); + + it("rejects incoming transfer requests with pipe id mismatch", () => { + const dbFile = tempDbFile("agent-sign-pipeid-mismatch"); + const store = new AgentStateStore({ dbFile }); + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + }); + + const validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + pipeId: "wrong-pipe-id", + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "1", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("pipe-id-mismatch"); + store.close(); + }); + + it("rejects incoming transfer requests with non-sequential nonce", () => { + const dbFile = tempDbFile("agent-sign-nonce-gap"); + const store = new AgentStateStore({ dbFile }); + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + store.upsertSignatureState({ + contractId, + pipeKey, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "1", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + }); + + const validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "92", + theirBalance: "8", + nonce: "3", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("nonce-not-sequential"); + store.close(); + }); + + it("rejects incoming transfer requests that change total pipe balance", () => { + const dbFile = tempDbFile("agent-sign-balance-sum"); + const store = new AgentStateStore({ dbFile }); + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + store.upsertSignatureState({ + contractId, + pipeKey, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "1", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + }); + + const validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "95", + theirBalance: "10", + nonce: "2", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("balance-sum-mismatch"); + store.close(); + }); + + it("rejects incoming transfer requests with invalid counterparty balance direction", () => { + const dbFile = tempDbFile("agent-sign-balance-direction"); + const store = new AgentStateStore({ dbFile }); + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + store.upsertSignatureState({ + contractId, + pipeKey, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "1", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + }); + + const validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "85", + theirBalance: "15", + nonce: "2", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("balance-direction-invalid"); + store.close(); + }); + + it("rejects incoming transfer requests with pipe key mismatch", () => { + const dbFile = tempDbFile("agent-sign-pipekey-mismatch"); + const store = new AgentStateStore({ dbFile }); + const contractId = "ST1TESTABC.contract"; + const trackedPipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey: trackedPipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey: trackedPipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + }); + + const validation = agent.validateIncomingTransfer({ pipeId, payload: { contractId, + pipeKey: { + "principal-1": "ST1LOCAL", + "principal-2": "ST1THIRD", + token: null, + }, forPrincipal: "ST1LOCAL", withPrincipal: "ST1OTHER", token: null, @@ -259,10 +1212,205 @@ describe("stackflow agent", () => { }, }); - expect(result.accepted).toBe(true); - expect(result.mySignature).toMatch(/^0x[0-9a-f]+$/); + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("pipe-key-mismatch"); + store.close(); + }); + + it("opens a pipe via signer adapter with expected contract call", async () => { + const dbFile = tempDbFile("agent-open"); + const store = new AgentStateStore({ dbFile }); + + const calls: Array<{ contractId: string; functionName: string; functionArgs: unknown[]; network?: string }> = []; + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract(input) { + calls.push(input as { contractId: string; functionName: string; functionArgs: unknown[]; network?: string }); + return { ok: true, txid: "0xopen" }; + }, + }, + network: "devnet", + }); + + const result = await agent.openPipe({ + contractId: "ST1STACKFLOW.stackflow-0-6-0", + token: null, + amount: "1000", + counterpartyPrincipal: "ST1COUNTERPARTY", + nonce: "0", + }); + + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + contractId: "ST1STACKFLOW.stackflow-0-6-0", + functionName: "fund-pipe", + functionArgs: [null, "1000", "ST1COUNTERPARTY", "0"], + network: "devnet", + }); + + store.close(); + }); + + it("builds outgoing transfer from tracked state and accepts signed incoming update", async () => { + const dbFile = tempDbFile("agent-send-receive"); + const store = new AgentStateStore({ dbFile }); + + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + + store.upsertSignatureState({ + contractId, + pipeKey, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "50", + theirBalance: "50", + nonce: "0", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + }); + + const outgoing = agent.buildOutgoingTransfer({ + pipeId, + amount: "25", + }); + + expect(outgoing.actor).toBe("ST1LOCAL"); + expect(outgoing.myBalance).toBe("25"); + expect(outgoing.theirBalance).toBe("75"); + expect(outgoing.nonce).toBe("1"); + + const accepted = await agent.acceptIncomingTransfer({ + pipeId, + payload: { + contractId, + pipeKey, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "75", + theirBalance: "25", + nonce: "1", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "33".repeat(65), + }, + }); + + expect(accepted.accepted).toBe(true); + expect(accepted.mySignature).toMatch(/^0x[0-9a-f]+$/); + const latest = store.getLatestSignatureState(pipeId, "ST1LOCAL"); expect(latest?.nonce).toBe("1"); + expect(latest?.myBalance).toBe("75"); + expect(latest?.theirBalance).toBe("25"); + + store.close(); + }); + + it("rejects outgoing transfer requests with actor mismatch", () => { + const dbFile = tempDbFile("agent-send-actor-mismatch"); + const store = new AgentStateStore({ dbFile }); + + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + + store.upsertSignatureState({ + contractId, + pipeKey, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "100", + theirBalance: "0", + nonce: "0", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + }); + + expect(() => + agent.buildOutgoingTransfer({ + pipeId, + amount: "25", + actor: "ST1THIRD", + }), + ).toThrow("actor must match tracked local principal"); + store.close(); }); diff --git a/tests/stackflow-aibtc-adapter.test.ts b/tests/stackflow-aibtc-adapter.test.ts new file mode 100644 index 0000000..8c4efc4 --- /dev/null +++ b/tests/stackflow-aibtc-adapter.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { AibtcWalletAdapter } from "../packages/stackflow-agent/src/aibtc-adapter.js"; + +describe("AibtcWalletAdapter.sip018Sign", () => { + it("uses domain shape by default for modern MCP providers", async () => { + const calls: Array<{ name: string; args: any }> = []; + const adapter = new AibtcWalletAdapter({ + invokeTool: async (name, args) => { + calls.push({ name, args }); + return { signature: "abcd" }; + }, + }); + + const sig = await adapter.sip018Sign({ + contract: "SP1ABC.stackflow-sbtc-0-6-0", + message: { hello: "world" }, + }); + + expect(sig).toBe("abcd"); + expect(calls).toHaveLength(1); + expect(calls[0].name).toBe("sip018_sign"); + expect(calls[0].args.domain).toEqual({ + name: "stackflow-sbtc-0-6-0", + version: "0.6.0", + }); + expect(calls[0].args.contract).toBeUndefined(); + }); + + it("falls back to legacy contract shape when domain shape is rejected", async () => { + const calls: Array<{ name: string; args: any }> = []; + const adapter = new AibtcWalletAdapter({ + invokeTool: async (name, args) => { + calls.push({ name, args }); + if (calls.length === 1) { + throw new Error("Invalid arguments: unknown field domain"); + } + return { signature: "ef01" }; + }, + }); + + const sig = await adapter.sip018Sign({ + contract: "SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-sbtc-0-6-0", + message: { hello: "world" }, + }); + + expect(sig).toBe("ef01"); + expect(calls).toHaveLength(2); + expect(calls[0].args.domain).toEqual({ + name: "stackflow-sbtc-0-6-0", + version: "0.6.0", + }); + expect(calls[1].args.contract).toBe( + "SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-sbtc-0-6-0", + ); + }); +}); diff --git a/tests/stackflow-node-config.test.ts b/tests/stackflow-node-config.test.ts new file mode 100644 index 0000000..689c08a --- /dev/null +++ b/tests/stackflow-node-config.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { loadConfig } from '../server/src/config.ts'; + +const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; +const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; + +describe('stackflow-node config parsing', () => { + it('loads sane defaults when env is empty', () => { + const config = loadConfig({}); + + expect(config.host).toBe('127.0.0.1'); + expect(config.port).toBe(8787); + expect(config.maxRecentEvents).toBe(500); + expect(config.stacksNetwork).toBe('devnet'); + expect(config.signatureVerifierMode).toBe('readonly'); + expect(config.disputeExecutorMode).toBe('auto'); + expect(config.forwardingTimeoutMs).toBe(10_000); + expect(config.forwardingRevealRetryIntervalMs).toBe(15_000); + expect(config.forwardingRevealRetryMaxAttempts).toBe(20); + expect(config.dbFile).toContain('server/data/stackflow-node-state.db'); + }); + + it('normalizes and de-duplicates watched principals', () => { + const config = loadConfig({ + STACKFLOW_NODE_PRINCIPALS: ` ${P1},${P2},${P1} `, + }); + + expect(config.watchedPrincipals).toEqual([P1, P2]); + }); + + it('rejects invalid watched principal values', () => { + expect(() => + loadConfig({ + STACKFLOW_NODE_PRINCIPALS: 'not-a-principal', + }), + ).toThrow(); + }); + + it('rejects watched principal lists above max size', () => { + const manyPrincipals = Array.from({ length: 101 }, (_, index) => + index % 2 === 0 ? P1 : P2, + ).join(','); + + expect(() => + loadConfig({ + STACKFLOW_NODE_PRINCIPALS: manyPrincipals, + }), + ).toThrow(/exceeds max of 100/); + }); + + it('clamps and coerces numeric safety bounds', () => { + const config = loadConfig({ + STACKFLOW_NODE_MAX_RECENT_EVENTS: '-10', + STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE: '-1', + STACKFLOW_NODE_FORWARDING_MIN_FEE: '-99', + STACKFLOW_NODE_FORWARDING_TIMEOUT_MS: '25', + STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_INTERVAL_MS: '10', + STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS: '0', + }); + + expect(config.maxRecentEvents).toBe(1); + expect(config.peerWriteRateLimitPerMinute).toBe(0); + expect(config.forwardingMinFee).toBe('0'); + expect(config.forwardingTimeoutMs).toBe(1_000); + expect(config.forwardingRevealRetryIntervalMs).toBe(1_000); + expect(config.forwardingRevealRetryMaxAttempts).toBe(1); + }); + + it('parses boolean aliases and rejects invalid boolean text', () => { + const config = loadConfig({ + STACKFLOW_NODE_FORWARDING_ENABLED: 'YeS', + STACKFLOW_NODE_TRUST_PROXY: '0', + }); + + expect(config.forwardingEnabled).toBe(true); + expect(config.trustProxy).toBe(false); + + expect(() => + loadConfig({ + STACKFLOW_NODE_FORWARDING_ENABLED: 'maybe', + }), + ).toThrow(/STACKFLOW_NODE_FORWARDING_ENABLED must be a boolean/); + }); + + it('fails fast when integer env vars contain non-integer text', () => { + expect(() => + loadConfig({ + STACKFLOW_NODE_FORWARDING_TIMEOUT_MS: '30s', + }), + ).toThrow(/STACKFLOW_NODE_FORWARDING_TIMEOUT_MS must be an integer/); + + expect(() => + loadConfig({ + STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE: '12.5', + }), + ).toThrow(/STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE must be an integer/); + }); + + it('fails fast when integer env vars exceed safe integer bounds', () => { + expect(() => + loadConfig({ + STACKFLOW_NODE_MAX_RECENT_EVENTS: '9007199254740992', + }), + ).toThrow(/STACKFLOW_NODE_MAX_RECENT_EVENTS must be a safe integer/); + }); + + it('rejects stackflow-node ports outside the TCP range', () => { + expect(() => + loadConfig({ + STACKFLOW_NODE_PORT: '0', + }), + ).toThrow(/PORT must be an integer between 1 and 65535/); + + expect(() => + loadConfig({ + STACKFLOW_NODE_PORT: '70000', + }), + ).toThrow(/PORT must be an integer between 1 and 65535/); + }); + + it('normalizes forwarding base-url allowlists', () => { + const config = loadConfig({ + STACKFLOW_NODE_FORWARDING_ALLOWED_BASE_URLS: + ' https://node-b.example.com/path?x=1 , http://127.0.0.1:9797/ ', + }); + + expect(config.forwardingAllowedBaseUrls).toEqual([ + 'https://node-b.example.com', + 'http://127.0.0.1:9797', + ]); + }); + + it('rejects forwarding base-url allowlist entries that are not http/https', () => { + expect(() => + loadConfig({ + STACKFLOW_NODE_FORWARDING_ALLOWED_BASE_URLS: + 'https://node-b.example.com,ftp://bad.example.com', + }), + ).toThrow(/must use http\/https/); + }); + + it('supports strict mode validation for enum and message version fields', () => { + expect(() => + loadConfig({ + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'bad-mode', + }), + ).toThrow(/SIGNATURE_VERIFIER_MODE/); + + expect(() => + loadConfig({ + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'bad-mode', + }), + ).toThrow(/DISPUTE_EXECUTOR_MODE/); + + expect(() => + loadConfig({ + STACKFLOW_NODE_COUNTERPARTY_SIGNER_MODE: 'bad-mode', + }), + ).toThrow(/COUNTERPARTY_SIGNER_MODE/); + + expect(() => + loadConfig({ + STACKS_NETWORK: 'dev', + }), + ).toThrow(/STACKS_NETWORK must be one of/); + + expect(() => + loadConfig({ + STACKFLOW_NODE_STACKFLOW_MESSAGE_VERSION: '版本', + }), + ).toThrow(/must be ASCII/); + }); +}); diff --git a/tests/stackflow-node-observer.test.ts b/tests/stackflow-node-observer.test.ts index 3e7f830..9c6b798 100644 --- a/tests/stackflow-node-observer.test.ts +++ b/tests/stackflow-node-observer.test.ts @@ -228,7 +228,7 @@ describe('watchtower event parser', () => { }; const events = extractStackflowPrintEvents(payload, { - watchedContracts: [`${CLOSER}.custom-flow`], + watchedContracts: [` ${CLOSER}.custom-flow `], }); expect(events).toHaveLength(1); diff --git a/tests/utils.ts b/tests/utils.ts index 3c993d7..0288032 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -5,7 +5,7 @@ import { serializeCVBytes, signWithKey, } from "@stacks/transactions"; -import { createHash } from "crypto"; +import { sha256 as nobleSha256 } from "@noble/hashes/sha256"; export const accounts = simnet.getAccounts(); export const deployer = accounts.get("deployer")!; @@ -90,7 +90,7 @@ const chainIds = { }; export function sha256(data: Buffer): Buffer { - return createHash("sha256").update(data).digest(); + return Buffer.from(nobleSha256(data)); } function structuredDataHash(structuredData: ClarityValue): Buffer {