Skip to content

Commit 2dcc755

Browse files
whoabuddyclaude
andauthored
fix: token contract handling and test validation (#33)
* feat(tests): add randomization for cron test variance Add --sample, --random-lifecycle, and --random-token flags to support randomized test runs. This provides variance across cron executions while ensuring full endpoint coverage over the course of a day. - Add shuffle(), sampleArray(), pickRandom() helpers - Update test runner to support sampling flags - Update cron script to use randomization (3 stateless + 2 lifecycle) - For mainnet: randomly select STX/sBTC/USDCx each run - Document new flags in CLAUDE.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: correct testnet USDCx contract address - Testnet USDCx: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.usdcx Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(tests): validate asset against expected contract ID not token symbol The test runner was comparing requirements.asset (full contract ID like SM3VDXK3...sbtc-token) against tokenType (symbol like "sBTC"). This worked for STX but failed for sBTC and USDCx. - Add EXPECTED_ASSETS mapping matching TOKEN_CONTRACTS in middleware - Add getExpectedAsset() helper to resolve token type to contract ID - Update validation to compare against expected asset string Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(tests): add input validation to sampling helpers Address Copilot review feedback: - sampleArray: validate n for NaN/negative/non-integer values - pickRandom: throw on empty array instead of returning undefined Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(tests): validate CLI args and fix random token display Address additional Copilot review feedback: - Validate --sample and --random-lifecycle args for NaN/invalid values - Clear randomToken flag when tokens are explicitly specified to avoid misleading "(random)" suffix in output Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 16c99c6 commit 2dcc755

6 files changed

Lines changed: 160 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ npm run test:kv # Just KV lifecycle test
3434
# Filter tests
3535
bun run tests/_run_all_tests.ts --category=hashing
3636
bun run tests/_run_all_tests.ts --filter=sha256 --all-tokens
37+
38+
# Randomized tests (for cron variance)
39+
bun run tests/_run_all_tests.ts --sample=5 # 5 random stateless
40+
bun run tests/_run_all_tests.ts --random-lifecycle=2 # 2 random lifecycle
41+
bun run tests/_run_all_tests.ts --random-token # Random STX/sBTC/USDCx
42+
bun run tests/_run_all_tests.ts --mode=full --sample=3 --random-lifecycle=2 --random-token
3743
```
3844

3945
## Domains

scripts/run-tests-cron.sh

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
#!/bin/bash
2-
# Cron script to run full test suite - only logs failures
2+
# Cron script to run randomized test samples - ensures full coverage over time
33
# Usage: ./scripts/run-tests-cron.sh [--network=testnet|mainnet]
4-
# Cron: 0 4,12,20 * * * /path/to/run-tests-cron.sh
5-
# 0 10 * * * /path/to/run-tests-cron.sh --network=mainnet
4+
#
5+
# Recommended cron schedule (every 2 hours for testnet, every 4 hours for mainnet):
6+
# 0 */2 * * * /path/to/run-tests-cron.sh # testnet (12x/day)
7+
# 0 2,6,10,14,18,22 * * * /path/to/run-tests-cron.sh --network=mainnet # mainnet (6x/day)
8+
#
9+
# Coverage calculation:
10+
# - 14 stateless endpoints, 7 lifecycle categories
11+
# - Each run: 3 stateless + 2 lifecycle = good variance
12+
# - Testnet (12x/day): 36 stateless + 24 lifecycle = ~2.5x coverage
13+
# - Mainnet (6x/day): 18 stateless + 12 lifecycle = ~1.3x coverage, random token each run
614

715
# Set up PATH for cron environment (bun, node, npm, etc.)
816
NODE_VERSIONS_DIR="$HOME/.nvm/versions/node"
@@ -73,8 +81,17 @@ echo "Network: ${X402_NETWORK}" >> "$TEMP_LOG"
7381
echo "Server: (derived from network)" >> "$TEMP_LOG"
7482
echo "" >> "$TEMP_LOG"
7583

76-
# Run the full test suite
77-
bun run tests/_run_all_tests.ts --mode=full >> "$TEMP_LOG" 2>&1
84+
# Build test command based on network
85+
# - Both: sample 3 stateless endpoints + 2 random lifecycle categories
86+
# - Mainnet: also randomize token (STX/sBTC/USDCx)
87+
if [ "$X402_NETWORK" = "mainnet" ]; then
88+
TEST_ARGS="--mode=full --sample=3 --random-lifecycle=2 --random-token"
89+
else
90+
TEST_ARGS="--mode=full --sample=3 --random-lifecycle=2"
91+
fi
92+
93+
# Run the randomized test suite
94+
bun run tests/_run_all_tests.ts $TEST_ARGS >> "$TEMP_LOG" 2>&1
7895
EXIT_CODE=$?
7996

8097
echo "" >> "$TEMP_LOG"

src/middleware/x402.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ const TOKEN_CONTRACTS: Record<"mainnet" | "testnet", Record<"sBTC" | "USDCx", To
6060
},
6161
testnet: {
6262
sBTC: { address: "ST1F7QA2MDF17S807EPA36TSS8AMEFY4KA9TVGWXT", name: "sbtc-token" },
63-
USDCx: { address: "ST1NXBK3K5YYMD6FD41MVNP3JS1GABZ8TRVX023PT", name: "token-susdc" },
63+
USDCx: { address: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM", name: "usdcx" },
6464
},
6565
};
6666

tests/_run_all_tests.ts

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
* bun run tests/_run_all_tests.ts --delay=1000 # 1s delay between tests
1919
* bun run tests/_run_all_tests.ts --retries=3 # 3 retries for rate limits
2020
*
21+
* Randomization (for cron variance):
22+
* bun run tests/_run_all_tests.ts --sample=5 # Run 5 random stateless endpoints
23+
* bun run tests/_run_all_tests.ts --random-lifecycle=2 # Run 2 random lifecycle categories
24+
* bun run tests/_run_all_tests.ts --random-token # Pick one random token (STX/sBTC/USDCx)
25+
* bun run tests/_run_all_tests.ts --mode=full --sample=5 --random-lifecycle=2 --random-token
26+
*
2127
* Environment:
2228
* X402_CLIENT_PK - Mnemonic for payments (required)
2329
* X402_NETWORK - Network (default: testnet)
@@ -43,6 +49,7 @@ import {
4349
X402_CLIENT_PK,
4450
X402_NETWORK,
4551
X402_WORKER_URL,
52+
TEST_TOKENS,
4653
createTestLogger,
4754
DEFAULT_TEST_DELAY_MS,
4855
POST_LIFECYCLE_DELAY_MS,
@@ -51,6 +58,8 @@ import {
5158
calculateBackoff,
5259
sleep,
5360
NONCE_CONFLICT_DELAY_MS,
61+
sampleArray,
62+
pickRandom,
5463
} from "./_shared_utils";
5564

5665
// Import lifecycle test runners
@@ -79,6 +88,35 @@ const LIFECYCLE_RUNNERS: Record<
7988
inference: runInferenceLifecycle,
8089
};
8190

91+
// =============================================================================
92+
// Expected Assets (must match TOKEN_CONTRACTS in src/middleware/x402.ts)
93+
// =============================================================================
94+
95+
/**
96+
* Expected asset strings for each token type per network.
97+
* STX is always "STX", SIP-010 tokens use "address.contract-name" format.
98+
*/
99+
const EXPECTED_ASSETS: Record<"mainnet" | "testnet", Record<TokenType, string>> = {
100+
mainnet: {
101+
STX: "STX",
102+
sBTC: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token",
103+
USDCx: "SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx",
104+
},
105+
testnet: {
106+
STX: "STX",
107+
sBTC: "ST1F7QA2MDF17S807EPA36TSS8AMEFY4KA9TVGWXT.sbtc-token",
108+
USDCx: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.usdcx",
109+
},
110+
};
111+
112+
/**
113+
* Get expected asset string for a token type on current network
114+
*/
115+
function getExpectedAsset(tokenType: TokenType): string {
116+
const network = X402_NETWORK as "mainnet" | "testnet";
117+
return EXPECTED_ASSETS[network][tokenType];
118+
}
119+
82120
// =============================================================================
83121
// Error Types
84122
// =============================================================================
@@ -162,6 +200,10 @@ interface RunConfig {
162200
verbose: boolean;
163201
delayMs: number;
164202
maxRetries: number;
203+
// Randomization options
204+
sampleSize: number | null; // --sample=N: run N random stateless endpoints
205+
randomLifecycleCount: number | null; // --random-lifecycle=N: run N random lifecycle categories
206+
randomToken: boolean; // --random-token: pick one random token
165207
}
166208

167209
function parseArgs(): RunConfig {
@@ -175,6 +217,10 @@ function parseArgs(): RunConfig {
175217
verbose: process.env.VERBOSE === "1",
176218
delayMs: parseInt(process.env.TEST_DELAY_MS || String(DEFAULT_TEST_DELAY_MS), 10),
177219
maxRetries: parseInt(process.env.TEST_MAX_RETRIES || "3", 10),
220+
// Randomization defaults
221+
sampleSize: null,
222+
randomLifecycleCount: null,
223+
randomToken: false,
178224
};
179225

180226
let tokenSpecified = false;
@@ -187,6 +233,9 @@ function parseArgs(): RunConfig {
187233
} else if (arg === "--all-tokens") {
188234
config.tokens = ["STX", "sBTC", "USDCx"];
189235
tokenSpecified = true;
236+
} else if (arg === "--random-token") {
237+
// Pick one random token - applied after parsing
238+
config.randomToken = true;
190239
} else if (arg.startsWith("--token=")) {
191240
const rawToken = arg.split("=")[1].toUpperCase();
192241
// Normalize token name (SBTC -> sBTC, USDCX -> USDCx)
@@ -204,6 +253,16 @@ function parseArgs(): RunConfig {
204253
config.tokens.push(token);
205254
}
206255
}
256+
} else if (arg.startsWith("--sample=")) {
257+
const sampleValue = parseInt(arg.split("=")[1], 10);
258+
if (!Number.isNaN(sampleValue) && sampleValue > 0) {
259+
config.sampleSize = sampleValue;
260+
}
261+
} else if (arg.startsWith("--random-lifecycle=")) {
262+
const lifecycleValue = parseInt(arg.split("=")[1], 10);
263+
if (!Number.isNaN(lifecycleValue) && lifecycleValue > 0) {
264+
config.randomLifecycleCount = lifecycleValue;
265+
}
207266
} else if (arg.startsWith("--category=")) {
208267
config.category = arg.split("=")[1].toLowerCase();
209268
} else if (arg.startsWith("--filter=")) {
@@ -219,6 +278,14 @@ function parseArgs(): RunConfig {
219278
}
220279
}
221280

281+
// Apply random token selection if requested (only if tokens weren't explicitly specified)
282+
if (config.randomToken && !tokenSpecified) {
283+
config.tokens = [pickRandom(TEST_TOKENS)];
284+
} else if (config.randomToken && tokenSpecified) {
285+
// Clear randomToken flag if tokens were explicitly set (so we don't print "(random)")
286+
config.randomToken = false;
287+
}
288+
222289
return config;
223290
}
224291

@@ -308,11 +375,12 @@ async function testEndpointWithToken(
308375
// Get the payment requirements from accepts array (inside loop to use fresh requirements after retry)
309376
const requirements = paymentReq.accepts[0];
310377

311-
// Validate that the accepted asset matches the requested token type
312-
if (requirements.asset !== tokenType) {
378+
// Validate that the accepted asset matches the expected asset for this token type
379+
const expectedAsset = getExpectedAsset(tokenType);
380+
if (requirements.asset !== expectedAsset) {
313381
return {
314382
passed: false,
315-
error: `Payment requirements asset ${requirements.asset} does not match requested token type ${tokenType}`,
383+
error: `Payment requirements asset ${requirements.asset} does not match expected ${expectedAsset} for token type ${tokenType}`,
316384
};
317385
}
318386

@@ -665,6 +733,16 @@ async function runTests(runConfig: RunConfig): Promise<RunStats> {
665733
);
666734
}
667735

736+
// Apply random sampling if specified
737+
if (runConfig.sampleSize !== null && endpointsToTest.length > 0) {
738+
endpointsToTest = sampleArray(endpointsToTest, runConfig.sampleSize);
739+
}
740+
741+
// Apply random lifecycle sampling if specified
742+
if (runConfig.randomLifecycleCount !== null && lifecycleCategories.length > 0) {
743+
lifecycleCategories = sampleArray(lifecycleCategories, runConfig.randomLifecycleCount);
744+
}
745+
668746
// Print header
669747
console.log(`\n${COLORS.bright}${"=".repeat(70)}${COLORS.reset}`);
670748
console.log(`${COLORS.bright} X402 API ENDPOINT TEST RUNNER${COLORS.reset}`);
@@ -676,12 +754,17 @@ async function runTests(runConfig: RunConfig): Promise<RunStats> {
676754
if (runConfig.category) {
677755
console.log(` Category: ${runConfig.category}`);
678756
}
679-
console.log(` Tokens: ${runConfig.tokens.join(", ")}`);
757+
console.log(` Tokens: ${runConfig.tokens.join(", ")}${runConfig.randomToken ? " (random)" : ""}`);
680758
if (endpointsToTest.length > 0) {
681-
console.log(` Endpoints: ${endpointsToTest.length} stateless`);
759+
const sampleNote = runConfig.sampleSize !== null ? ` (sampled from ${STATELESS_ENDPOINTS.length})` : "";
760+
console.log(` Endpoints: ${endpointsToTest.length} stateless${sampleNote}`);
761+
if (runConfig.sampleSize !== null) {
762+
console.log(` [${endpointsToTest.map((e) => e.name).join(", ")}]`);
763+
}
682764
}
683765
if (lifecycleCategories.length > 0) {
684-
console.log(` Lifecycle: ${lifecycleCategories.join(", ")}`);
766+
const lifecycleNote = runConfig.randomLifecycleCount !== null ? ` (sampled from ${STATEFUL_CATEGORIES.length})` : "";
767+
console.log(` Lifecycle: ${lifecycleCategories.join(", ")}${lifecycleNote}`);
685768
}
686769
console.log(` Delay: ${runConfig.delayMs}ms between tests`);
687770
console.log(` Retries: ${runConfig.maxRetries} for rate-limited requests`);

tests/_shared_utils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,47 @@ export const X402_WORKER_URL = getWorkerUrl();
4141

4242
export const TEST_TOKENS: TokenType[] = ["STX", "sBTC", "USDCx"];
4343

44+
// =============================================================================
45+
// Randomization Helpers
46+
// =============================================================================
47+
48+
/**
49+
* Fisher-Yates shuffle - returns a new shuffled array
50+
*/
51+
export function shuffle<T>(array: T[]): T[] {
52+
const result = [...array];
53+
for (let i = result.length - 1; i > 0; i--) {
54+
const j = Math.floor(Math.random() * (i + 1));
55+
[result[i], result[j]] = [result[j], result[i]];
56+
}
57+
return result;
58+
}
59+
60+
/**
61+
* Pick N random items from an array (without replacement)
62+
*/
63+
export function sampleArray<T>(array: T[], n: number): T[] {
64+
if (array.length === 0) return [];
65+
66+
// Normalize n to a safe, non-negative integer within array bounds
67+
let count = Math.floor(n);
68+
if (!Number.isFinite(count)) count = 0;
69+
if (count <= 0) return [];
70+
if (count >= array.length) return shuffle(array);
71+
72+
return shuffle(array).slice(0, count);
73+
}
74+
75+
/**
76+
* Pick a random item from an array
77+
*/
78+
export function pickRandom<T>(array: T[]): T {
79+
if (array.length === 0) {
80+
throw new Error("pickRandom: cannot pick from an empty array");
81+
}
82+
return array[Math.floor(Math.random() * array.length)];
83+
}
84+
4485
// =============================================================================
4586
// Timing Constants
4687
// =============================================================================

tests/check-setup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const MIN_BALANCES = {
3030
// Token contract identifiers (testnet)
3131
const TESTNET_TOKENS = {
3232
sBTC: "ST1F7QA2MDF17S807EPA36TSS8AMEFY4KA9TVGWXT.sbtc-token::sbtc-token",
33-
USDCx: "ST1F7QA2MDF17S807EPA36TSS8AMEFY4KA9TVGWXT.usdcx-token::usdcx-token",
33+
USDCx: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.usdcx::usdcx-token",
3434
};
3535

3636
// Token contract identifiers (mainnet)

0 commit comments

Comments
 (0)