Skip to content

Commit 4cf53fe

Browse files
committed
Enforce sequential incoming states and balance invariants
1 parent 22be879 commit 4cf53fe

File tree

3 files changed

+278
-6
lines changed

3 files changed

+278
-6
lines changed

packages/stackflow-agent/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,5 @@ const result = await agent.acceptIncomingTransfer({
137137
6. Event scan mode intentionally holds the cursor when any dispute submission errors occur, so failed disputes are retried on next run.
138138
7. `buildOutgoingTransfer(...)` defaults `actor` to the tracked local principal and rejects mismatched actor values.
139139
8. Incoming transfer validation enforces tracked contract/pipe/principals/token consistency; mismatched `pipeId`, `pipeKey`, `actor`, or token payloads are rejected.
140-
9. For production hardening, add alerting, signer balance checks, and idempotency audit logs.
140+
9. 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).
141+
10. For production hardening, add alerting, signer balance checks, and idempotency audit logs.

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,47 @@ export class StackflowAgentService {
296296
existingNonce: latest.nonce,
297297
};
298298
}
299+
if (incomingNonce !== existingNonce + 1n) {
300+
return {
301+
valid: false,
302+
reason: "nonce-not-sequential",
303+
existingNonce: latest.nonce,
304+
};
305+
}
306+
307+
const existingMyBalance = parseUnsignedBigInt(
308+
latest.myBalance,
309+
"existing myBalance",
310+
);
311+
const existingTheirBalance = parseUnsignedBigInt(
312+
latest.theirBalance,
313+
"existing theirBalance",
314+
);
315+
const incomingMyBalance = parseUnsignedBigInt(myBalance, "incoming myBalance");
316+
const incomingTheirBalance = parseUnsignedBigInt(
317+
theirBalance,
318+
"incoming theirBalance",
319+
);
320+
321+
if (
322+
incomingMyBalance + incomingTheirBalance !==
323+
existingMyBalance + existingTheirBalance
324+
) {
325+
return {
326+
valid: false,
327+
reason: "balance-sum-mismatch",
328+
};
329+
}
330+
331+
if (
332+
incomingMyBalance < existingMyBalance ||
333+
incomingTheirBalance > existingTheirBalance
334+
) {
335+
return {
336+
valid: false,
337+
reason: "balance-direction-invalid",
338+
};
339+
}
299340
}
300341

301342
let secret = null;

tests/stackflow-agent.test.ts

Lines changed: 235 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,228 @@ describe("stackflow agent", () => {
806806
store.close();
807807
});
808808

809+
it("rejects incoming transfer requests with non-sequential nonce", () => {
810+
const dbFile = tempDbFile("agent-sign-nonce-gap");
811+
const store = new AgentStateStore({ dbFile });
812+
const contractId = "ST1TESTABC.contract";
813+
const pipeKey = {
814+
"principal-1": "ST1LOCAL",
815+
"principal-2": "ST1OTHER",
816+
token: null,
817+
};
818+
const pipeId = buildPipeId({ contractId, pipeKey });
819+
820+
store.upsertTrackedPipe({
821+
pipeId,
822+
contractId,
823+
pipeKey,
824+
localPrincipal: "ST1LOCAL",
825+
counterpartyPrincipal: "ST1OTHER",
826+
token: null,
827+
});
828+
store.upsertSignatureState({
829+
contractId,
830+
pipeKey,
831+
forPrincipal: "ST1LOCAL",
832+
withPrincipal: "ST1OTHER",
833+
token: null,
834+
myBalance: "90",
835+
theirBalance: "10",
836+
nonce: "1",
837+
action: "1",
838+
actor: "ST1LOCAL",
839+
mySignature: "0x" + "11".repeat(65),
840+
theirSignature: "0x" + "22".repeat(65),
841+
secret: null,
842+
validAfter: null,
843+
beneficialOnly: false,
844+
});
845+
846+
const agent = new StackflowAgentService({
847+
stateStore: store,
848+
signer: {
849+
async sip018Sign() {
850+
return "0x" + "44".repeat(65);
851+
},
852+
async submitDispute() {
853+
return { txid: "0x1" };
854+
},
855+
async callContract() {
856+
return { ok: true };
857+
},
858+
},
859+
network: "devnet",
860+
});
861+
862+
const validation = agent.validateIncomingTransfer({
863+
pipeId,
864+
payload: {
865+
contractId,
866+
forPrincipal: "ST1LOCAL",
867+
withPrincipal: "ST1OTHER",
868+
token: null,
869+
myBalance: "92",
870+
theirBalance: "8",
871+
nonce: "3",
872+
action: "1",
873+
actor: "ST1OTHER",
874+
theirSignature: "0x" + "22".repeat(65),
875+
},
876+
});
877+
878+
expect(validation.valid).toBe(false);
879+
expect(validation.reason).toBe("nonce-not-sequential");
880+
store.close();
881+
});
882+
883+
it("rejects incoming transfer requests that change total pipe balance", () => {
884+
const dbFile = tempDbFile("agent-sign-balance-sum");
885+
const store = new AgentStateStore({ dbFile });
886+
const contractId = "ST1TESTABC.contract";
887+
const pipeKey = {
888+
"principal-1": "ST1LOCAL",
889+
"principal-2": "ST1OTHER",
890+
token: null,
891+
};
892+
const pipeId = buildPipeId({ contractId, pipeKey });
893+
894+
store.upsertTrackedPipe({
895+
pipeId,
896+
contractId,
897+
pipeKey,
898+
localPrincipal: "ST1LOCAL",
899+
counterpartyPrincipal: "ST1OTHER",
900+
token: null,
901+
});
902+
store.upsertSignatureState({
903+
contractId,
904+
pipeKey,
905+
forPrincipal: "ST1LOCAL",
906+
withPrincipal: "ST1OTHER",
907+
token: null,
908+
myBalance: "90",
909+
theirBalance: "10",
910+
nonce: "1",
911+
action: "1",
912+
actor: "ST1LOCAL",
913+
mySignature: "0x" + "11".repeat(65),
914+
theirSignature: "0x" + "22".repeat(65),
915+
secret: null,
916+
validAfter: null,
917+
beneficialOnly: false,
918+
});
919+
920+
const agent = new StackflowAgentService({
921+
stateStore: store,
922+
signer: {
923+
async sip018Sign() {
924+
return "0x" + "44".repeat(65);
925+
},
926+
async submitDispute() {
927+
return { txid: "0x1" };
928+
},
929+
async callContract() {
930+
return { ok: true };
931+
},
932+
},
933+
network: "devnet",
934+
});
935+
936+
const validation = agent.validateIncomingTransfer({
937+
pipeId,
938+
payload: {
939+
contractId,
940+
forPrincipal: "ST1LOCAL",
941+
withPrincipal: "ST1OTHER",
942+
token: null,
943+
myBalance: "95",
944+
theirBalance: "10",
945+
nonce: "2",
946+
action: "1",
947+
actor: "ST1OTHER",
948+
theirSignature: "0x" + "22".repeat(65),
949+
},
950+
});
951+
952+
expect(validation.valid).toBe(false);
953+
expect(validation.reason).toBe("balance-sum-mismatch");
954+
store.close();
955+
});
956+
957+
it("rejects incoming transfer requests with invalid counterparty balance direction", () => {
958+
const dbFile = tempDbFile("agent-sign-balance-direction");
959+
const store = new AgentStateStore({ dbFile });
960+
const contractId = "ST1TESTABC.contract";
961+
const pipeKey = {
962+
"principal-1": "ST1LOCAL",
963+
"principal-2": "ST1OTHER",
964+
token: null,
965+
};
966+
const pipeId = buildPipeId({ contractId, pipeKey });
967+
968+
store.upsertTrackedPipe({
969+
pipeId,
970+
contractId,
971+
pipeKey,
972+
localPrincipal: "ST1LOCAL",
973+
counterpartyPrincipal: "ST1OTHER",
974+
token: null,
975+
});
976+
store.upsertSignatureState({
977+
contractId,
978+
pipeKey,
979+
forPrincipal: "ST1LOCAL",
980+
withPrincipal: "ST1OTHER",
981+
token: null,
982+
myBalance: "90",
983+
theirBalance: "10",
984+
nonce: "1",
985+
action: "1",
986+
actor: "ST1LOCAL",
987+
mySignature: "0x" + "11".repeat(65),
988+
theirSignature: "0x" + "22".repeat(65),
989+
secret: null,
990+
validAfter: null,
991+
beneficialOnly: false,
992+
});
993+
994+
const agent = new StackflowAgentService({
995+
stateStore: store,
996+
signer: {
997+
async sip018Sign() {
998+
return "0x" + "44".repeat(65);
999+
},
1000+
async submitDispute() {
1001+
return { txid: "0x1" };
1002+
},
1003+
async callContract() {
1004+
return { ok: true };
1005+
},
1006+
},
1007+
network: "devnet",
1008+
});
1009+
1010+
const validation = agent.validateIncomingTransfer({
1011+
pipeId,
1012+
payload: {
1013+
contractId,
1014+
forPrincipal: "ST1LOCAL",
1015+
withPrincipal: "ST1OTHER",
1016+
token: null,
1017+
myBalance: "85",
1018+
theirBalance: "15",
1019+
nonce: "2",
1020+
action: "1",
1021+
actor: "ST1OTHER",
1022+
theirSignature: "0x" + "22".repeat(65),
1023+
},
1024+
});
1025+
1026+
expect(validation.valid).toBe(false);
1027+
expect(validation.reason).toBe("balance-direction-invalid");
1028+
store.close();
1029+
});
1030+
8091031
it("rejects incoming transfer requests with pipe key mismatch", () => {
8101032
const dbFile = tempDbFile("agent-sign-pipekey-mismatch");
8111033
const store = new AgentStateStore({ dbFile });
@@ -935,8 +1157,8 @@ describe("stackflow agent", () => {
9351157
forPrincipal: "ST1LOCAL",
9361158
withPrincipal: "ST1OTHER",
9371159
token: null,
938-
myBalance: "100",
939-
theirBalance: "0",
1160+
myBalance: "50",
1161+
theirBalance: "50",
9401162
nonce: "0",
9411163
action: "1",
9421164
actor: "ST1LOCAL",
@@ -968,14 +1190,22 @@ describe("stackflow agent", () => {
9681190
});
9691191

9701192
expect(outgoing.actor).toBe("ST1LOCAL");
971-
expect(outgoing.myBalance).toBe("75");
972-
expect(outgoing.theirBalance).toBe("25");
1193+
expect(outgoing.myBalance).toBe("25");
1194+
expect(outgoing.theirBalance).toBe("75");
9731195
expect(outgoing.nonce).toBe("1");
9741196

9751197
const accepted = await agent.acceptIncomingTransfer({
9761198
pipeId,
9771199
payload: {
978-
...outgoing,
1200+
contractId,
1201+
pipeKey,
1202+
forPrincipal: "ST1LOCAL",
1203+
withPrincipal: "ST1OTHER",
1204+
token: null,
1205+
myBalance: "75",
1206+
theirBalance: "25",
1207+
nonce: "1",
1208+
action: "1",
9791209
actor: "ST1OTHER",
9801210
theirSignature: "0x" + "33".repeat(65),
9811211
},

0 commit comments

Comments
 (0)