Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8f3c490
Improve stackflow-agent workflow coverage and quickstart docs
warmidris Mar 5, 2026
c0ac72c
Improve watcher idempotency for duplicate closure polls
warmidris Mar 5, 2026
5f6f3a7
Harden watcher error isolation and retry cursor behavior
warmidris Mar 5, 2026
6cf37d5
Harden incoming token validation and overlap watcher coverage
warmidris Mar 5, 2026
0432772
Validate incoming pipe identity for transfer acceptance
warmidris Mar 5, 2026
0464d97
Validate incoming transfer actor against tracked counterparty
warmidris Mar 5, 2026
42b71ac
Guard outgoing transfer actor and default to tracked principal
warmidris Mar 5, 2026
acda028
Enforce sequential incoming states and balance invariants
warmidris Mar 5, 2026
d850e28
Harden watcher event-source failure handling and scan metrics
warmidris Mar 5, 2026
69dd677
Use real SIP-018 signing domain fallback in AIBTC adapter
warmidris Mar 5, 2026
943039a
Fix clarity test hashing in vite runtime and document test commands
warmidris Mar 5, 2026
09f6d87
Normalize watched contract matching in observer paths
warmidris Mar 6, 2026
30d4ab4
Add stackflow-node config parser coverage
warmidris Mar 6, 2026
7d79d30
Harden stackflow-node config bounds for port and event retention
warmidris Mar 6, 2026
592d886
Fail fast on invalid stackflow-node boolean env config
warmidris Mar 6, 2026
ef0b2e5
test(node): add forwarding-service unit coverage
warmidris Mar 6, 2026
b3afb29
Fail fast on malformed numeric stackflow-node env values
warmidris Mar 6, 2026
b8aeb10
Fail fast on invalid STACKS_NETWORK values
warmidris Mar 6, 2026
98442ed
fix: preserve case in watched contract matching
warmidris Mar 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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):

Expand Down
24 changes: 24 additions & 0 deletions docs/AGENT-PIPE-TEST-LOG.md
Original file line number Diff line number Diff line change
@@ -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

1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand All @@ -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"
}
Expand Down
60 changes: 59 additions & 1 deletion packages/stackflow-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,69 @@ 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.
2. The watcher interval defaults to one hour; dispute window is still 144 BTC blocks.
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.
107 changes: 104 additions & 3 deletions packages/stackflow-agent/src/agent-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export class StackflowAgentService {
buildOutgoingTransfer({
pipeId,
amount,
actor,
actor = null,
action = "1",
secret = null,
validAfter = null,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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");
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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,
Expand Down
Loading
Loading