Skip to content

Commit ef0b2e5

Browse files
committed
test(node): add forwarding-service unit coverage
1 parent 592d886 commit ef0b2e5

2 files changed

Lines changed: 241 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"build:ui": "node scripts/build-ui.js",
1111
"test": "npm run test:clarity && npm run test:node",
1212
"test:clarity": "vitest run tests/stackflow.test.ts tests/reservoir.test.ts",
13-
"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/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",
13+
"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",
1414
"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",
1515
"test:stackflow-node:http": "STACKFLOW_NODE_HTTP_INTEGRATION=1 vitest run tests/stackflow-node-http.integration.test.ts",
1616
"test:report": "vitest run -- --coverage --costs",

tests/forwarding-service.test.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
3+
import {
4+
ForwardingService,
5+
ForwardingServiceError,
6+
} from '../server/src/forwarding-service.ts';
7+
8+
const CONTRACT_ID = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0';
9+
const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5';
10+
const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG';
11+
const SIG_B = `0x${'22'.repeat(65)}`;
12+
const HASHED_SECRET =
13+
'0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb';
14+
const VALID_SECRET =
15+
'0x8484848484848484848484848484848484848484848484848484848484848484';
16+
17+
function makeTransferPayload() {
18+
return {
19+
contractId: CONTRACT_ID,
20+
forPrincipal: P1,
21+
withPrincipal: P2,
22+
token: null,
23+
amount: '0',
24+
myBalance: '910',
25+
theirBalance: '90',
26+
theirSignature: SIG_B,
27+
nonce: '6',
28+
action: '1',
29+
actor: P2,
30+
secret: null,
31+
validAfter: null,
32+
beneficialOnly: false,
33+
};
34+
}
35+
36+
describe('forwarding service', () => {
37+
afterEach(() => {
38+
vi.restoreAllMocks();
39+
});
40+
41+
it('normalizes transfer payloads and signs incoming update after next-hop accepts', async () => {
42+
const signTransfer = vi.fn().mockResolvedValue({
43+
request: makeTransferPayload(),
44+
mySignature: `0x${'11'.repeat(65)}`,
45+
upsert: {
46+
stored: true,
47+
replaced: false,
48+
state: {
49+
mySignature: `0x${'11'.repeat(65)}`,
50+
theirSignature: SIG_B,
51+
},
52+
},
53+
});
54+
55+
const fetchMock = vi
56+
.spyOn(globalThis, 'fetch')
57+
.mockResolvedValue(
58+
new Response(
59+
JSON.stringify({
60+
mySignature: `0x${'33'.repeat(65)}`,
61+
theirSignature: SIG_B,
62+
}),
63+
{
64+
status: 200,
65+
headers: { 'content-type': 'application/json' },
66+
},
67+
),
68+
);
69+
70+
const service = new ForwardingService({
71+
counterpartyService: {
72+
enabled: true,
73+
counterpartyPrincipal: P1,
74+
signTransfer,
75+
} as any,
76+
config: {
77+
enabled: true,
78+
minFee: '5',
79+
timeoutMs: 1_000,
80+
allowPrivateDestinations: true,
81+
allowedBaseUrls: ['https://next-hop.example/'],
82+
},
83+
});
84+
85+
const result = await service.processTransfer({
86+
paymentId: 'pay-2026-03-06-0001',
87+
incomingAmount: '100',
88+
outgoingAmount: '90',
89+
hashedSecret: HASHED_SECRET,
90+
incoming: makeTransferPayload(),
91+
outgoing: {
92+
baseUrl: 'https://next-hop.example',
93+
payload: makeTransferPayload(),
94+
},
95+
});
96+
97+
expect(result.feeAmount).toBe('10');
98+
expect(result.nextHopBaseUrl).toBe('https://next-hop.example');
99+
expect(result.hashedSecret).toBe(HASHED_SECRET);
100+
101+
expect(fetchMock).toHaveBeenCalledTimes(1);
102+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
103+
expect(url).toBe('https://next-hop.example/counterparty/transfer');
104+
expect(init.method).toBe('POST');
105+
106+
const nextHopBody = JSON.parse(String(init.body)) as Record<string, unknown>;
107+
expect(nextHopBody.hashedSecret).toBe(HASHED_SECRET);
108+
expect(nextHopBody.secret).toBe(HASHED_SECRET);
109+
110+
expect(signTransfer).toHaveBeenCalledTimes(1);
111+
const [incomingPayload] = signTransfer.mock.calls[0] as [Record<string, unknown>];
112+
expect(incomingPayload.hashedSecret).toBe(HASHED_SECRET);
113+
expect(incomingPayload.secret).toBe(HASHED_SECRET);
114+
});
115+
116+
it('rejects private next-hop destinations by default', async () => {
117+
const signTransfer = vi.fn();
118+
const fetchMock = vi.spyOn(globalThis, 'fetch');
119+
120+
const service = new ForwardingService({
121+
counterpartyService: {
122+
enabled: true,
123+
counterpartyPrincipal: P1,
124+
signTransfer,
125+
} as any,
126+
config: {
127+
enabled: true,
128+
minFee: '1',
129+
timeoutMs: 1_000,
130+
allowPrivateDestinations: false,
131+
allowedBaseUrls: [],
132+
},
133+
});
134+
135+
await expect(
136+
service.processTransfer({
137+
paymentId: 'pay-2026-03-06-0002',
138+
incomingAmount: '100',
139+
outgoingAmount: '99',
140+
hashedSecret: HASHED_SECRET,
141+
incoming: makeTransferPayload(),
142+
outgoing: {
143+
baseUrl: 'http://127.0.0.1:3999',
144+
payload: makeTransferPayload(),
145+
},
146+
}),
147+
).rejects.toMatchObject<Partial<ForwardingServiceError>>({
148+
statusCode: 403,
149+
details: {
150+
reason: 'next-hop-private-destination',
151+
},
152+
});
153+
154+
expect(fetchMock).not.toHaveBeenCalled();
155+
expect(signTransfer).not.toHaveBeenCalled();
156+
});
157+
158+
it('rejects negative forwarding fee before side effects', async () => {
159+
const signTransfer = vi.fn();
160+
const fetchMock = vi.spyOn(globalThis, 'fetch');
161+
162+
const service = new ForwardingService({
163+
counterpartyService: {
164+
enabled: true,
165+
counterpartyPrincipal: P1,
166+
signTransfer,
167+
} as any,
168+
config: {
169+
enabled: true,
170+
minFee: '1',
171+
timeoutMs: 1_000,
172+
allowPrivateDestinations: true,
173+
allowedBaseUrls: ['https://next-hop.example'],
174+
},
175+
});
176+
177+
await expect(
178+
service.processTransfer({
179+
paymentId: 'pay-2026-03-06-0003',
180+
incomingAmount: '90',
181+
outgoingAmount: '100',
182+
hashedSecret: HASHED_SECRET,
183+
incoming: makeTransferPayload(),
184+
outgoing: {
185+
baseUrl: 'https://next-hop.example',
186+
payload: makeTransferPayload(),
187+
},
188+
}),
189+
).rejects.toMatchObject<Partial<ForwardingServiceError>>({
190+
statusCode: 403,
191+
details: {
192+
reason: 'negative-forwarding-fee',
193+
},
194+
});
195+
196+
expect(fetchMock).not.toHaveBeenCalled();
197+
expect(signTransfer).not.toHaveBeenCalled();
198+
});
199+
200+
it('verifies reveal preimage secrets', () => {
201+
const service = new ForwardingService({
202+
counterpartyService: {
203+
enabled: true,
204+
counterpartyPrincipal: P1,
205+
signTransfer: vi.fn(),
206+
} as any,
207+
config: {
208+
enabled: true,
209+
minFee: '1',
210+
timeoutMs: 1_000,
211+
allowPrivateDestinations: true,
212+
allowedBaseUrls: [],
213+
},
214+
});
215+
216+
expect(
217+
service.verifyRevealSecret({
218+
hashedSecret: HASHED_SECRET,
219+
secret: VALID_SECRET,
220+
}),
221+
).toEqual({
222+
hashedSecret: HASHED_SECRET,
223+
secret: VALID_SECRET,
224+
});
225+
226+
expect(() =>
227+
service.verifyRevealSecret({
228+
hashedSecret: HASHED_SECRET,
229+
secret: '0x1111111111111111111111111111111111111111111111111111111111111111',
230+
}),
231+
).toThrowError(ForwardingServiceError);
232+
233+
expect(() =>
234+
service.verifyRevealSecret({
235+
hashedSecret: HASHED_SECRET,
236+
secret: '0x1111111111111111111111111111111111111111111111111111111111111111',
237+
}),
238+
).toThrow(/secret does not match hashedSecret/i);
239+
});
240+
});

0 commit comments

Comments
 (0)