Skip to content

Commit 2f2db7d

Browse files
committed
Validate incoming pipe identity for transfer acceptance
1 parent bcc72b7 commit 2f2db7d

File tree

3 files changed

+156
-1
lines changed

3 files changed

+156
-1
lines changed

packages/stackflow-agent/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,5 +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. Incoming transfer validation enforces tracked contract/principals/token consistency; mismatched token payloads are rejected.
138+
7. Incoming transfer validation enforces tracked contract/pipe/principals/token consistency; mismatched `pipeId`, `pipeKey`, or token payloads are rejected.
139139
8. For production hardening, add alerting, signer balance checks, and idempotency audit logs.

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,41 @@ export class StackflowAgentService {
173173
reason: "contract-mismatch",
174174
};
175175
}
176+
177+
if (data.pipeId != null && String(data.pipeId).trim() !== tracked.pipeId) {
178+
return {
179+
valid: false,
180+
reason: "pipe-id-mismatch",
181+
};
182+
}
183+
184+
if (data.pipeKey != null) {
185+
if (!data.pipeKey || typeof data.pipeKey !== "object" || Array.isArray(data.pipeKey)) {
186+
return {
187+
valid: false,
188+
reason: "pipe-key-invalid",
189+
};
190+
}
191+
let incomingPipeId;
192+
try {
193+
incomingPipeId = buildPipeId({
194+
contractId,
195+
pipeKey: data.pipeKey,
196+
});
197+
} catch {
198+
return {
199+
valid: false,
200+
reason: "pipe-key-invalid",
201+
};
202+
}
203+
if (incomingPipeId !== tracked.pipeId) {
204+
return {
205+
valid: false,
206+
reason: "pipe-key-mismatch",
207+
};
208+
}
209+
}
210+
176211
const forPrincipal = String(data.forPrincipal ?? "").trim();
177212
if (forPrincipal !== tracked.localPrincipal) {
178213
return {

tests/stackflow-agent.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,126 @@ describe("stackflow agent", () => {
691691
store.close();
692692
});
693693

694+
it("rejects incoming transfer requests with pipe id mismatch", () => {
695+
const dbFile = tempDbFile("agent-sign-pipeid-mismatch");
696+
const store = new AgentStateStore({ dbFile });
697+
const contractId = "ST1TESTABC.contract";
698+
const pipeKey = {
699+
"principal-1": "ST1LOCAL",
700+
"principal-2": "ST1OTHER",
701+
token: null,
702+
};
703+
const pipeId = buildPipeId({ contractId, pipeKey });
704+
705+
store.upsertTrackedPipe({
706+
pipeId,
707+
contractId,
708+
pipeKey,
709+
localPrincipal: "ST1LOCAL",
710+
counterpartyPrincipal: "ST1OTHER",
711+
token: null,
712+
});
713+
714+
const agent = new StackflowAgentService({
715+
stateStore: store,
716+
signer: {
717+
async sip018Sign() {
718+
return "0x" + "44".repeat(65);
719+
},
720+
async submitDispute() {
721+
return { txid: "0x1" };
722+
},
723+
async callContract() {
724+
return { ok: true };
725+
},
726+
},
727+
network: "devnet",
728+
});
729+
730+
const validation = agent.validateIncomingTransfer({
731+
pipeId,
732+
payload: {
733+
contractId,
734+
pipeId: "wrong-pipe-id",
735+
forPrincipal: "ST1LOCAL",
736+
withPrincipal: "ST1OTHER",
737+
token: null,
738+
myBalance: "90",
739+
theirBalance: "10",
740+
nonce: "1",
741+
action: "1",
742+
actor: "ST1OTHER",
743+
theirSignature: "0x" + "22".repeat(65),
744+
},
745+
});
746+
747+
expect(validation.valid).toBe(false);
748+
expect(validation.reason).toBe("pipe-id-mismatch");
749+
store.close();
750+
});
751+
752+
it("rejects incoming transfer requests with pipe key mismatch", () => {
753+
const dbFile = tempDbFile("agent-sign-pipekey-mismatch");
754+
const store = new AgentStateStore({ dbFile });
755+
const contractId = "ST1TESTABC.contract";
756+
const trackedPipeKey = {
757+
"principal-1": "ST1LOCAL",
758+
"principal-2": "ST1OTHER",
759+
token: null,
760+
};
761+
const pipeId = buildPipeId({ contractId, pipeKey: trackedPipeKey });
762+
763+
store.upsertTrackedPipe({
764+
pipeId,
765+
contractId,
766+
pipeKey: trackedPipeKey,
767+
localPrincipal: "ST1LOCAL",
768+
counterpartyPrincipal: "ST1OTHER",
769+
token: null,
770+
});
771+
772+
const agent = new StackflowAgentService({
773+
stateStore: store,
774+
signer: {
775+
async sip018Sign() {
776+
return "0x" + "44".repeat(65);
777+
},
778+
async submitDispute() {
779+
return { txid: "0x1" };
780+
},
781+
async callContract() {
782+
return { ok: true };
783+
},
784+
},
785+
network: "devnet",
786+
});
787+
788+
const validation = agent.validateIncomingTransfer({
789+
pipeId,
790+
payload: {
791+
contractId,
792+
pipeKey: {
793+
"principal-1": "ST1LOCAL",
794+
"principal-2": "ST1THIRD",
795+
token: null,
796+
},
797+
forPrincipal: "ST1LOCAL",
798+
withPrincipal: "ST1OTHER",
799+
token: null,
800+
myBalance: "90",
801+
theirBalance: "10",
802+
nonce: "1",
803+
action: "1",
804+
actor: "ST1OTHER",
805+
theirSignature: "0x" + "22".repeat(65),
806+
},
807+
});
808+
809+
expect(validation.valid).toBe(false);
810+
expect(validation.reason).toBe("pipe-key-mismatch");
811+
store.close();
812+
});
813+
694814
it("opens a pipe via signer adapter with expected contract call", async () => {
695815
const dbFile = tempDbFile("agent-open");
696816
const store = new AgentStateStore({ dbFile });

0 commit comments

Comments
 (0)