Skip to content

Commit bcc72b7

Browse files
committed
Harden incoming token validation and overlap watcher coverage
1 parent c226c2a commit bcc72b7

3 files changed

Lines changed: 137 additions & 2 deletions

File tree

packages/stackflow-agent/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,5 @@ const result = await agent.acceptIncomingTransfer({
135135
4. Watcher retries are idempotent for already-disputed closures (same closure txid is skipped on later polls).
136136
5. Read-only polling isolates per-pipe failures (`getPipeState` errors on one pipe do not stop others).
137137
6. Event scan mode intentionally holds the cursor when any dispute submission errors occur, so failed disputes are retried on next run.
138-
7. For production hardening, add alerting, signer balance checks, and idempotency audit logs.
138+
7. Incoming transfer validation enforces tracked contract/principals/token consistency; mismatched token payloads are rejected.
139+
8. For production hardening, add alerting, signer balance checks, and idempotency audit logs.

packages/stackflow-agent/src/agent-service.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,17 @@ export class StackflowAgentService {
187187
reason: "with-principal-mismatch",
188188
};
189189
}
190+
191+
const trackedToken =
192+
tracked.token == null ? null : String(tracked.token).trim();
193+
const payloadToken =
194+
data.token == null ? trackedToken : String(data.token).trim();
195+
if (payloadToken !== trackedToken) {
196+
return {
197+
valid: false,
198+
reason: "token-mismatch",
199+
};
200+
}
190201
const theirSignature = (() => {
191202
try {
192203
return normalizeHex(data.theirSignature, "theirSignature");
@@ -256,7 +267,7 @@ export class StackflowAgentService {
256267
pipeKey: tracked.pipeKey,
257268
forPrincipal,
258269
withPrincipal,
259-
token: data.token == null ? tracked.token : String(data.token).trim(),
270+
token: trackedToken,
260271
myBalance,
261272
theirBalance,
262273
nonce,

tests/stackflow-agent.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,73 @@ describe("stackflow agent", () => {
510510
store.close();
511511
});
512512

513+
it("skips overlapping readonly watcher runs", async () => {
514+
const dbFile = tempDbFile("agent-readonly-overlap");
515+
const store = new AgentStateStore({ dbFile });
516+
517+
const contractId = "ST1TESTABC.contract";
518+
const pipeKey = {
519+
"principal-1": "ST1LOCAL",
520+
"principal-2": "ST1OTHER",
521+
token: null,
522+
};
523+
const pipeId = buildPipeId({ contractId, pipeKey });
524+
525+
store.upsertTrackedPipe({
526+
pipeId,
527+
contractId,
528+
pipeKey,
529+
localPrincipal: "ST1LOCAL",
530+
counterpartyPrincipal: "ST1OTHER",
531+
token: null,
532+
});
533+
534+
let resolveFetch: ((value: unknown) => void) | null = null;
535+
536+
const agent = new StackflowAgentService({
537+
stateStore: store,
538+
signer: {
539+
async submitDispute() {
540+
return { txid: "0xnoop" };
541+
},
542+
async sip018Sign() {
543+
return "0x" + "44".repeat(65);
544+
},
545+
async callContract() {
546+
return { ok: true };
547+
},
548+
},
549+
network: "devnet",
550+
});
551+
552+
const watcher = new HourlyClosureWatcher({
553+
agentService: agent,
554+
getPipeState: () =>
555+
new Promise((resolve) => {
556+
resolveFetch = resolve;
557+
}),
558+
});
559+
560+
const firstRunPromise = watcher.runOnce();
561+
const overlapping = await watcher.runOnce();
562+
563+
expect(overlapping.ok).toBe(true);
564+
expect(overlapping.skipped).toBe(true);
565+
expect(overlapping.reason).toBe("already-running");
566+
567+
resolveFetch?.({
568+
"balance-1": "20",
569+
"balance-2": "80",
570+
"expires-at": "200",
571+
nonce: "5",
572+
closer: "ST1OTHER",
573+
});
574+
575+
await firstRunPromise;
576+
watcher.stop();
577+
store.close();
578+
});
579+
513580
it("validates and signs incoming transfer requests", async () => {
514581
const dbFile = tempDbFile("agent-sign");
515582
const store = new AgentStateStore({ dbFile });
@@ -568,6 +635,62 @@ describe("stackflow agent", () => {
568635
store.close();
569636
});
570637

638+
it("rejects incoming transfer requests with token mismatch", () => {
639+
const dbFile = tempDbFile("agent-sign-token-mismatch");
640+
const store = new AgentStateStore({ dbFile });
641+
const contractId = "ST1TESTABC.contract";
642+
const pipeKey = {
643+
"principal-1": "ST1LOCAL",
644+
"principal-2": "ST1OTHER",
645+
token: "ST1TOKEN.token-1",
646+
};
647+
const pipeId = buildPipeId({ contractId, pipeKey });
648+
store.upsertTrackedPipe({
649+
pipeId,
650+
contractId,
651+
pipeKey,
652+
localPrincipal: "ST1LOCAL",
653+
counterpartyPrincipal: "ST1OTHER",
654+
token: "ST1TOKEN.token-1",
655+
});
656+
657+
const agent = new StackflowAgentService({
658+
stateStore: store,
659+
signer: {
660+
async sip018Sign() {
661+
return "0x" + "44".repeat(65);
662+
},
663+
async submitDispute() {
664+
return { txid: "0x1" };
665+
},
666+
async callContract() {
667+
return { ok: true };
668+
},
669+
},
670+
network: "devnet",
671+
});
672+
673+
const validation = agent.validateIncomingTransfer({
674+
pipeId,
675+
payload: {
676+
contractId,
677+
forPrincipal: "ST1LOCAL",
678+
withPrincipal: "ST1OTHER",
679+
token: "ST1TOKEN.token-2",
680+
myBalance: "90",
681+
theirBalance: "10",
682+
nonce: "1",
683+
action: "1",
684+
actor: "ST1OTHER",
685+
theirSignature: "0x" + "22".repeat(65),
686+
},
687+
});
688+
689+
expect(validation.valid).toBe(false);
690+
expect(validation.reason).toBe("token-mismatch");
691+
store.close();
692+
});
693+
571694
it("opens a pipe via signer adapter with expected contract call", async () => {
572695
const dbFile = tempDbFile("agent-open");
573696
const store = new AgentStateStore({ dbFile });

0 commit comments

Comments
 (0)