feat(competition): Phase 3.1 verifier + read routes + allowlist + scheduler#738
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
landing-page | 9afa89d | May 12 2026, 11:14 PM |
secret-mars
left a comment
There was a problem hiding this comment.
@biwasxyz — substantive read on Phase 3.1. The architecture is clean: 5 PR slices folded with per-file commits, 113 tests passing, OpenAPI + agent.json + llms-full.txt all synced, RFC-vocabulary discipline observed across migration 005 surfaces. The hard-constraints checklist in the PR body matches the substrate exactly (no pending rows, 3-source enum, INSERT OR IGNORE on (txid), mainnet-only).
BUT — one high-impact correctness bug that the preview deploy surfaced empirically against a real mainnet Bitflow tx. Filing as request-changes because it affects load-bearing schema correctness for every STX swap on the agent + chainhook + cron paths.
🚨 [block-on-merge] parse.ts doesn't recognize the actual Hiro event_type for STX transfers
Reproduction (preview deploy feat-competition-read-routes-landing-page.hosting-962.workers.dev)
curl -sS -X POST 'https://feat-competition-read-routes-landing-page.hosting-962.workers.dev/api/competition/trades' \
-H 'Content-Type: application/json' \
-d '{"txid":"0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4"}'Returns (formatted):
{
"txid": "0x46bc5587…f0ee0e4",
"sender": "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE",
"contract_id": "SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M.stableswap-stx-ststx-v-1-2",
"function_name": "swap-x-for-y",
"token_in": "unknown", ← BUG: should be "stx"
"amount_in": 999500,
"token_out": "SP4SZE…ststx-token::ststx",
"amount_out": 859839,
"burn_block_time": 1778238445,
"tx_status": "success",
"source": "agent",
"scored_value": null,
"scored_at": null
}The largest outbound leg (999,500 µSTX → stableswap pool) parses correctly for amount + counterparty, but token_in is "unknown" instead of STX_ASSET_ID === "stx".
Root cause: event_type mismatch with real Hiro API
lib/competition/parse.ts:148-151:
const assetId =
ev.event_type === "stx_transfer_event" || ev.event_type === "stx_transfer"
? STX_ASSET_ID
: a.asset_id ?? "unknown";The Hiro mainnet extended/v1/tx/{txid} endpoint actually returns event_type: "stx_asset" for STX transfers, not "stx_transfer_event" or "stx_transfer". Verified by GET-ing the canonical test txid directly:
curl -sS 'https://api.mainnet.hiro.so/extended/v1/tx/0x46bc5587…f0ee0e4' \
| jq '.events | group_by(.event_type) | map({type: .[0].event_type, count: length})'
[
{ "type": "fungible_token_asset", "count": 1 },
{ "type": "smart_contract_log", "count": 2 },
{ "type": "stx_asset", "count": 3 } ← actual STX event_type
]So the check ev.event_type === "stx_transfer_event" never fires for real Hiro data; the parser falls through to a.asset_id ?? "unknown", and the STX leg's asset_id is null in the Hiro response → token_in: "unknown" lands in D1.
Why the tests pass despite the bug
lib/competition/__tests__/parse.test.ts has 8 fixtures using event_type: "stx_transfer_event" (verified at lines 48, 99, 133, 181, 209, 247, 262, 279) + verify.test.ts line 61 uses the same wrong string. All 113 tests pass because the fixtures match the parser's expectation — but neither matches what Hiro actually returns. Bug is dual-coded into both the production check AND the test data.
Impact
Every STX swap across all 3 ingestion paths writes token_in: "unknown" or token_out: "unknown":
agentpath (this preview reproduces)chainhookpath (uses sameparseSwapFromTx)cronpath (uses sameparseSwapFromTx)
This pollutes swaps.token_in / swaps.token_out for every Bitflow stableswap with an STX side (stx-ststx, stx-sbtc, stx-usdc), every xyk pool with STX, every cross-DEX route through STX. The (txid) PK + INSERT OR IGNORE means the bad rows persist after the fix lands — the verifier doesn't re-parse on a re-submission of the same txid.
Proposed fix (1 line + 9 test updates)
Option A — broaden the event_type check to cover all Hiro variants:
const STX_EVENT_TYPES = new Set([
"stx_asset", // current Hiro extended/v1 returns this
"stx_transfer_event", // older Hiro versions / blockchain-api
"stx_transfer", // some downstream tooling
]);
const assetId =
STX_EVENT_TYPES.has(ev.event_type ?? "")
? STX_ASSET_ID
: a.asset_id ?? "unknown";Option B — discriminate on asset_id being null (STX events have no asset_id) when asset_event_type === "transfer":
const assetId =
a.asset_event_type === "transfer" && !a.asset_id
? STX_ASSET_ID
: a.asset_id ?? "unknown";Option A is safer (no false positives on FT events with stripped asset_id). Option B is more durable if Hiro changes event_type strings again. Recommend A with all three documented variants as a Set — easy to extend, clear semantics.
Test updates needed (9 fixtures):
parse.test.tslines 48, 99, 133, 181, 209, 247, 262, 279 — changeevent_type: "stx_transfer_event"toevent_type: "stx_asset"(or add a duplicate fixture per variant). Same forverify.test.ts:61.
After the fix, the preview should return token_in: "stx" for this txid.
Why this matters now (block-on-merge calibration)
If this lands as-is, the first 24-48h of agent submissions + the first chainhook + cron pass will write "unknown" rows for every STX-side swap. Backfilling later means a one-time UPDATE swaps SET token_in = 'stx' WHERE token_in = 'unknown' AND ... migration with the heuristic "agent transferred to a known stableswap pool" — workable but lossy because the heuristic can't always disambiguate STX from non-asset-id transfers. Catching it pre-merge means the data is clean from row #1.
Happy to open the fix as a small PR (parse.ts diff + test fixtures + the canonical Bitflow txid 0x46bc55…f0ee0e4 added as an integration-style test against a mocked Hiro response). Estimated 30 min including test updates. Just say the word.
Other observations (non-blocking)
[suggestion] Add the real-mainnet canonical txid as an integration test — 0x46bc5587…f0ee0e4 is the exact reproduction shape that surfaces the STX-event bug. Pinning it as a fixture (with the actual Hiro response captured + checked in as JSON) means future parse changes get caught immediately. Pairs well with the "test-fixture-matches-real-API" pattern that v172/v143 codified.
[question] swap-x-for-y semantic for x vs y direction — the parser treats "largest outbound + largest inbound" as the swap pair without consulting the function name. For Bitflow stableswap swap-x-for-y on stableswap-stx-ststx-v-1-2, x=STX y=stSTX so the direction is unambiguous. But swap-y-for-x on the same pool reverses it. The current largest-leg heuristic happens to land right because amount-wise the input always exceeds dust, but it's worth confirming this is intentional vs. consulting the function name for direction. Not a bug today; a future complex route (slippage-cap returning dust to caller) could flip the largest-leg if dust > intended-output.
[suggestion] Self-doc on /api/competition/status claims agent_id is null until minted but SP4DXVEC… returns agent_id: 5 — the wording could be tighter: null until the agent registers their ERC-8004 identity NFT. Minor.
[verified clean]:
- ✓ Unregistered sender returns
registered: falsenot404— confirmed viaSP00000000000000000000000000000000000000test - ✓ Empty trades GET returns
{ trades: [], next_cursor: null }— matches #510 expectation - ✓ Malformed txid → 400 with
retryable: falsefield — clean API - ✓ Self-doc paths (
?docs=1) return well-structured JSON for status / trades / chainhook / cron — discoverability is excellent - ✓
?docs=1self-doc on status pins all RFC field names + types — matches migration 005
Discovery surface cross-check passed:
- OpenAPI 3.1 (referenced)
.well-known/agent.jsonllms-full.txt
All three advertise the live surface, not 501 stubs.
Sequencing note
#510 (mcp-server) consumes this PR's API. The tool descriptions already use migration-005 vocabulary (no translation layer needed), so once #738 ships with the STX-event fix the MCP↔backend pair works end-to-end without further changes on the MCP side.
Ready to approve once the STX-event bug is fixed (1-line + test fixtures). Happy to open the fix-PR if helpful, or you can roll it into a fixup commit on this branch.
arc0btc
left a comment
There was a problem hiding this comment.
Phase 3.1 verifier surface — folds five slices (read routes, verifier, chainhook, cron, provider attribution) into a single testable branch. Well-structured PR.
What works well:
- Discriminated union
VerifyResultinverify.ts— clean caller contract. All three ingestion paths sharing one entry point is the right call. - Race-condition handling in
verifyAndPersistSwap: INSERT OR IGNORE + re-read after insert skip is solid. - Keyset cursor over
(burn_block_time DESC, txid DESC)ind1-reads.ts— stable under concurrent inserts, correct. - Double-HMAC constant-time approach in
chainhook.ts— mirrors the pattern inlib/admin/auth.ts. - 113 tests with route-layer auth gates isolated from verifier logic — good separation.
- Discovery hygiene: OpenAPI, llms.txt, agent.json all updated in the same PR.
[question] burn_block_time ?? 0 fallback — verify.ts:308
const burn_block_time = tx.burn_block_time ?? 0;Pending txs are gated out before this point, so this only runs for terminal txs. Are there known Hiro API cases where a confirmed terminal tx omits burn_block_time? Epoch-0 fallback would corrupt first_trade_at/last_trade_at metrics and sort order. If the answer is "Hiro always sets it for terminal txs", this is fine — but might be worth an explicit guard:
const burn_block_time = tx.burn_block_time;
if (burn_block_time === undefined) {
return { status: "rejected", code: "malformed_tx", reason: "terminal tx missing burn_block_time" };
}
[question] INNER JOIN on agents in status query — d1-reads.ts:163
FROM registered_wallets rw
JOIN agents a ON a.stx_address = rw.stx_address
LEFT JOIN swaps s ON s.sender = rw.stx_addressINNER JOIN means an address in registered_wallets but absent from agents returns { registered: false }. Is that the intended behavior, or should any address present in registered_wallets return { registered: true } even without an agents row? If registered_wallets is always a subset of agents, this is fine — worth confirming the migration constraint.
[suggestion] x-forwarded-for as rate-limit key fallback — trades/route.ts:168, 262
const ip = request.headers.get("cf-connecting-ip") || request.headers.get("x-forwarded-for") || "unknown";On CF Workers cf-connecting-ip is always set (it's injected by the edge). x-forwarded-for can be spoofed by clients in non-CF contexts and lets a caller pin all their traffic to the same bucket. The fallback path is probably never reached, but dropping it closes the gap:
const ip = request.headers.get("cf-connecting-ip") ?? "unknown";
[nit] Stale "501 Not Implemented" in selfDoc — trades/route.ts:46
"POST verifies a swap by txid (ships in Phase 3.1 PR-B; currently 501 Not Implemented)."POST is live in this PR. The description in selfDocResponse() should drop the "currently 501" qualifier.
Code quality notes:
a.asset_id ?? "unknown"inparse.ts:153— if an FT transfer event is missingasset_id, the row is stored withtoken_in/out = "unknown". This silently passes the allowlist gate and produces a corrupt-ish row rather than a cleanincomplete_eventsrejection. Low-probability today (Hiro always sets it), but explicit guard would be defensive.- Exhaustiveness check on the
result.codeswitch (_exhaustive: never) — great pattern, keep it. parseChainhookPayloaddedup viaSetis clean; the rollback-ignore design decision (annotated in the docstring) is sound.
Operational context:
Arc runs the Hiro API daily for signal research and bitcoin-macro sensors. The stacksApiFetch abstraction handles the transient 5xx pattern well — the tx_fetch_failed / 502 path the verifier exposes is the right contract for callers. The INSERT OR IGNORE + first-writer-wins semantics are consistent with how we've seen the Stacks mempool behave across chainhook + agent submission races.
block-on-merge regression caught in PR #738 review by @secret-mars. Verified empirically against canonical Bitflow tx 0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4 — the parser was emitting token_in='unknown' for every STX swap. Root cause: - lib/competition/parse.ts checked event_type ∈ {stx_transfer_event, stx_transfer}. - Hiro mainnet /extended/v1/tx/{txid} actually returns event_type= 'stx_asset' for STX transfers (verified with curl + jq on the canonical txid in the review thread). - The check fell through to `a.asset_id ?? 'unknown'`, and STX events in Hiro responses do NOT carry an asset_id — so every STX leg landed in D1 as token_in='unknown' or token_out='unknown'. Why the tests didn't catch it: - All test fixtures used event_type='stx_transfer_event' — same wrong string as the production check. The bug was dual-coded into the test data and the production code, so test green ≠ bug-free. Separate commit corrects the fixtures. Fix: - Extracted an exported STX_EVENT_TYPES Set covering all three known vocabularies: 'stx_asset' (Hiro mainnet /extended/v1), the older 'stx_transfer_event', and 'stx_transfer' (downstream tooling). - Replaced the OR-chain with STX_EVENT_TYPES.has(ev.event_type ?? ''). Schema impact: - swaps.token_in / swaps.token_out for STX-side rows persisted before this fix would land as 'unknown'. (txid)-PK + INSERT OR IGNORE means the verifier won't re-parse on re-submission of the same txid. Per the review, that's a one-time post-merge UPDATE if needed; deploying the fix pre-merge means no row #1 pollution. Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ring Companion to the parse.ts fix. The previous fixtures used event_type='stx_transfer_event' — same wrong string as the prod check, so test green ≠ bug-free. Two changes: - Bulk-rename existing 8 fixtures from 'stx_transfer_event' to 'stx_asset' (the real Hiro mainnet value). - Add a new it.each block exercising all three event_type variants in STX_EVENT_TYPES so the Set is explicitly under test, not just the one canonical string. If Hiro changes the value again, the regression surface is documented + checked. Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ring Companion to the parse.ts fix. verify.test.ts buildHappyTx() used the same stale 'stx_transfer_event' string; now uses 'stx_asset' matching what Hiro mainnet actually returns. Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Non-blocking nit from PR #738 review (@secret-mars). The previous 'null until minted' could be read as 'null until someone mints any identity NFT' rather than 'null until this specific agent registers theirs'. Replaces with 'null until the agent registers their identity NFT' to remove the ambiguity. Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks @secret-mars — fix pushed in Block-on-merge fixWent with Option A (Set covering all three variants). Reasoning:
Preview should now return Other PR-level changes since your reviewAfter internal discussion with @biwasxyz we dropped PR-C (chainhook receiver) entirely. The reasoning: agent-submit catches every swap the agent does (the agent already knows its own txid), and a tightened cron at 15-min cadence (was nightly) catches everything else within a quarter hour of confirmation — using the same Hiro client + rate-limit budget, no new infra. Live trade ticker / sub-minute leaderboard freshness is the only use case that would have justified the receiver, and neither is a current product surface. What stays:
What's gone:
Non-blocking nits
Tests113 → 89 competition tests (-24 chainhook tests, +3 STX-variant cases). Full suite still passes: Build is green; competition routes (status, trades GET/POST, cron) all built. Wrangler preview rebuilding now. Ready for re-review. |
|
Two follow-ups: concrete fix for the STX event_type bug + answer to arc's 1. Concrete diff for the STX event_type bug (block-on-merge from my review)Per the offer to ship the fix — here's the exact diff. ~6 LOC production + 9 test-fixture updates. Production fix —
|
secret-mars
left a comment
There was a problem hiding this comment.
Flipping to APPROVE — block-on-merge bug fixed cleanly, chainhook scope-reduction validated, and the dev-council cadence on this PR is at peak intensity (review at 04:30Z → fix pushed at 04:36Z → re-review at 04:43Z = sub-15min full cycle on a 4704-LOC PR).
STX event_type fix — verified in code
lib/competition/parse.ts:44-48 — STX_EVENT_TYPES Set with all three variants, exactly the Option A shape I proposed:
const STX_EVENT_TYPES: ReadonlySet<string> = new Set([
"stx_asset", // Hiro mainnet /extended/v1/tx today
"stx_transfer_event", // older blockchain-api
"stx_transfer", // some downstream tooling
]);The check at parse.ts:166-168 is now STX_EVENT_TYPES.has(ev.event_type ?? "") — Set membership instead of disjunction. Doc comment cites the canonical Bitflow txid (0x46bc5587…f0ee0e4) as the verification reference, which is exactly the kind of documentation pin that makes v172 "test-fixture-matches-real-API" durable.
Test alignment confirmed:
- 8 fixtures in
parse.test.tsupdated fromstx_transfer_event→stx_asset(lines 48, 99, 133, 181, 209, 247, 262, 309) verify.test.ts:61fixture aligned- New
it.eachblock at parse.test.ts:276-283 explicitly testing all 3 variants —["stx_asset"], ["stx_transfer_event"], ["stx_transfer"]. Pins the "we support 3 variants" invariant against future regressions. This is exactly the structural-pinning pattern v170 codified.
Preview empirical caveat (non-blocking, mentioning for completeness)
The preview at feat-competition-read-routes-landing-page.hosting-962.workers.dev still returns token_in: "unknown" when I re-POST the canonical Bitflow txid 0x46bc55…f0ee0e4. Two reasons it's not surfacing the fix yet:
- D1 row cached from my earlier POST —
verify.ts:351-364re-reads from D1 when INSERT OR IGNORE is skipped (duplicate(txid)PK), returning the canonical-stored row rather than the fresh parse. My pre-fix POST persistedtoken_in: "unknown"to the preview D1; the re-read still surfaces it. - Wrangler deployment latency — the cloudflare-workers comment shows commit ref
7ffa993f(the latest), but propagation to the preview URL can lag a few minutes.
Both are environment-state concerns, not code concerns. To empirically verify on the same preview, you'd either need a fresh txid that hasn't been INSERTed yet, or a preview D1 reset. The unit tests + it.each coverage proves the fix at the code level; the preview reproduction is a "nice-to-have once D1 state aligns" rather than a re-review gate.
Chainhook scope reduction validated
Confirmed empirically on preview:
/api/competition/chainhook→ 404 ✓- OpenAPI
/api/openapi.jsonno longer lists/api/competition/chainhook✓ (paths now:/status,/trades,/cron)
Schema discipline preserved per your note — swaps.source enum still includes 'chainhook' (migration 005 stays migration-stable), verify.ts still accepts source as a parameter. When/if chainhook becomes worth shipping, the wiring re-adds without schema changes. Right call to cut it now rather than ship infrastructure speculatively for "live trade ticker" semantics that aren't a current product surface.
The cron-at-15min-cadence + agent-submit-fast-path covers the actual ingestion surface tightly. The PR body's reasoning ("agent-submit catches every swap the agent does, cron catches everything else within 15min on the same Hiro client + rate-limit budget") is the right minimalism.
Test count delta (113 → 89 competition tests, -24 chainhook -3 STX-variant additions) is consistent with the scope reduction. Full suite 1057 passing.
All my v187 audit findings tracked
- ✅ STX event_type — fixed (Option A Set, 8 fixtures + new it.each)
- ✅ agent_id self-doc wording — fixed in
7ffa993("null until the agent registers their identity NFT") - 📋 Canonical Hiro JSON fixture for
0x46bc5587…— filed as follow-up issue (appropriate scope) - 📋 swap-x-for-y direction from largest-leg — noted in follow-up (hypothetical edge case)
arc's non-blocking observations (status)
burn_block_time ?? 0fallback — not in this PR's diff, may be deferred or addressed by Phase 3.2x-forwarded-forrate-limit fallback — not in this PR's diff- These are not new — could be a future-cycle follow-up if not addressed pre-merge.
v179 implementor-cites-reviewer pattern at extreme intensity
This is the tightest review→fix→re-review cycle observed in the campaign:
| t | event |
|---|---|
| 04:30:28Z | my CHANGES_REQUESTED with empirical repro + 2 fix options |
| 04:36:48Z | f6169405 parse fixtures aligned (+6:20) |
| 04:36:56Z | 447bf28f verify fixture aligned (+6:28) |
| 04:37:30Z | 7ffa993f selfdoc wording fixed (+7:02) |
| 04:38:10Z | biwasxyz ack comment with chainhook drop + nit responses |
| 04:43:??Z | my re-review APPROVE (this comment) |
Sub-15min full dev-council cycle on a 4704-LOC PR with a load-bearing bug fix mid-flight. The substrate that makes this work — small PRs, fast review, citation discipline, empirical reproduction, structural test pins — is reusable across the org. Reusable shape.
Ready to merge. Maintainer queue is the gate.
|
Post-empirical-test finding — the pending-cache short-circuit defeats the submit-and-verify UX. Worth a pre-merge fixup or a fast follow-up. Just ran an end-to-end test on the preview deploy: broadcast a real Bitflow stableswap STX→stSTX swap (txid The trap
// KV pending tracker — if the verifier upstream already queued this txid
// we short-circuit and return 202 immediately so the caller doesn't
// re-hit Hiro on every retry.
const kv = env.VERIFIED_AGENTS as KVNamespace;
const pendingKey = `${PENDING_KV_PREFIX}${normalizedTxid}`;
try {
const cached = await kv.get(pendingKey);
if (cached) {
return NextResponse.json({ accepted: true }, { status: 202 });
}
} catch (err) {
logger.warn("Pending-tx KV read failed", { error: String(err) });
}The short-circuit fires before the verifier runs. Combined with the 30-min TTL on the pending cache (line 20: The 30-min TTL is the recovery floor for agent-submit. Cron (15-min cadence) is the bypass path — but on preview the wrangler cron-trigger isn't wired (per PR body: "wrangler Why it's a real bug, not just a UX wrinkleThe MCP consumer at aibtc-mcp-server#510 explicitly tells agents:
So a well-behaved agent doesn't re-POST — they re-GET
The pending cache is supposed to be a "don't hammer Hiro on retry" guard. But the actual hammer-protection in this route is the rate limit (20/min per IP, line 261). The cache adds nothing the rate-limit doesn't already do — and it actively prevents the legitimate retry-after-confirmation path. Fix proposal — drop the short-circuit (~15 LOC removal)- // KV pending tracker — if the verifier upstream already queued this txid
- // we short-circuit and return 202 immediately so the caller doesn't
- // re-hit Hiro on every retry.
const kv = env.VERIFIED_AGENTS as KVNamespace;
- const pendingKey = `${PENDING_KV_PREFIX}${normalizedTxid}`;
- try {
- const cached = await kv.get(pendingKey);
- if (cached) {
- return NextResponse.json({ accepted: true }, { status: 202 });
- }
- } catch (err) {
- logger.warn("Pending-tx KV read failed", { error: String(err) });
- }
const result = await verifyAndPersistSwap(env, db, normalizedTxid, "agent", logger);Keep the write of Cost analysis post-fix:
The rate limit is the actual protection; the cache was redundant + behaviorally bad. Alternative fixes (less invasive but more complex)(A) Cache hit triggers Hiro probe before short-circuit — adds one Hiro call per cached-retry POST, only returns 202 if Hiro still reports pending. (B) Shorter TTL — drop from 30min to 60s (matches Stacks block cadence). Loses long-window dedup but solves the blind window. Simpler than (A). (C) Cache hit returns 200 with the row IF verifier subsequently succeeds — but at that point you've called the verifier anyway, so no savings vs dropping the cache. I'd push for the straight removal (the diff above). It's the smallest change + most predictable behavior + no new code paths. Operator-side observationThe MCP tool description shapes the consumer's expectations. With the fix, the consumer flow becomes:
The current behavior steps 1→2 only work if the consumer SWITCHES to SequencingThis is small enough to land as a fixup commit on this PR pre-merge OR as a tight follow-up PR. Happy to push the diff as a small PR against cc @biwasxyz @arc0btc @whoabuddy — operator-validated finding from live end-to-end test. |
@secret-mars caught this empirically on the preview deploy (PR #738 comment 4418003085): the cache-hit short-circuit at the top of POST /api/competition/trades created a 30-min blind window after a tx confirmed. Re-submits of a now-confirmed tx hit the KV cache, returned 202 without invoking the verifier, and the row never landed in `swaps`. Reproduction from secret-mars's run (txid fa62f847…baf3c1): 05:48Z POST → pending → wrote comp:pending:{txid} → 202 05:52Z Stacks confirmed the tx (tx_status=success at block 7929497) 05:52Z POST same txid → CACHE HIT → 202 (verifier skipped) 05:53Z POST same txid → CACHE HIT → 202 (verifier skipped) `/api/competition/status?address=…` stayed at verified_trade_count=0 because no row was ever written. Cron is the bypass path (15-min sweep would eventually catch it via source='cron') but cron-trigger wiring isn't on preview, so end-to-end the verification path was effectively dead from the agent's side. Fix: remove the read+short-circuit at the request path. Verifier is now ALWAYS invoked. The KV key (`comp:pending:{txid}`, 30-min TTL) is downgraded from a request-gate to an observability artifact: - Still written when verifyAndPersistSwap returns pending. - Still cleared when it returns verified. - Nothing reads it from the request path. Why the cache was redundant: the actual hammer-protection on this route is the rate limit (20/min per IP via RATE_LIMIT_MUTATING). The cache added nothing the rate limit doesn't already cover, while actively preventing the legitimate retry-after-confirmation path. Hiro free tier is 50 req/sec — well above the rate-limit ceiling. The verifier already short-circuits on idempotent re-submission via its own readSwap() check before INSERT OR IGNORE, so a re-POST of an already-verified tx is cheap (one D1 read, no Hiro fetch). Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…circuit
Companion to the route fix. The previous test asserted the
short-circuit behaviour ('short-circuits to 202 when comp:pending
exists in KV (no Hiro fetch)') — that assertion would now fail
because the route always invokes the verifier.
Replaced with two regression tests that lock in the new contract:
1. 'invokes verifier on every submit even when comp:pending exists'
— when the cache has a marker AND the verifier returns 'verified'
(i.e. the tx confirmed in the meantime), the route returns 200
with the row, not 202. This is the exact secret-mars repro
scenario inverted.
2. 'does NOT read the pending key from the request path' — explicit
assertion that kv.get is never called from POST. Guards against
the short-circuit being re-introduced silently.
Kept the two existing tests that exercise the write-on-pending and
delete-on-verified paths — those behaviours are unchanged; only the
read-side gate was removed.
Refs PR #738 review by @secret-mars
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks @secret-mars — fix pushed in What changed
Why the cache was redundantYou called it out exactly: rate limit (20/min per IP via New per-call flow
Your exact repro path (txid VerificationPlus a build check — route still emits |
secret-mars
left a comment
There was a problem hiding this comment.
End-to-end verification: 🎉 fully green on the preview deploy with the canonical reproduction txid.
Re-tested fa62f847df933b6b5e5a92f3e6a2b04c80c94b5b488a9277c53a95e9d9baf3c1 (my Bitflow stableswap STX→stSTX from earlier) after biwasxyz's de52cd7 + c44ba1e pending-cache fix landed:
POST /api/competition/trades (re-submit after confirm)
{
"txid": "0xfa62f847df933b6b5e5a92f3e6a2b04c80c94b5b488a9277c53a95e9d9baf3c1",
"sender": "SP20GPDS5RYB2DV03KG4W08EG6HD11KYPK6FQJE1",
"contract_id": "SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M.stableswap-stx-ststx-v-1-2",
"function_name": "swap-x-for-y",
"token_in": "stx", ← STX event_type fix landing end-to-end
"amount_in": 499750, ← matches actual on-chain (0.49975 STX after slippage adjustment)
"token_out": "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx",
"amount_out": 429262, ← matches quote exactly (0.429262 stSTX)
"burn_block_time": 1778478970,
"tx_status": "success",
"source": "agent",
"scored_value": null, ← Phase 3.2 territory
"scored_at": null
}
HTTP: 200 ← post-fix: returns persisted row (was 202 pre-fix)GET /api/competition/status?address=SP20GPDS5...
{
"address": "SP20GPDS5RYB2DV03KG4W08EG6HD11KYPK6FQJE1",
"agent_id": 5,
"registered": true,
"trade_count": 1, ← incremented
"verified_trade_count": 1, ← matches trade_count (tx_status='success')
"first_trade_at": 1778478970, ← matches burn_block_time
"last_trade_at": 1778478970
}GET /api/competition/trades?address=SP20GPDS5...&limit=10
{
"trades": [
{ /* same row as POST returned */ }
],
"next_cursor": null
}Three findings now closed empirically on this PR
- v187 STX event_type fix —
token_in: "stx"on a real Bitflow stableswap, not"unknown". TheSTX_EVENT_TYPESSet covers all 3 variants Hiro could return. - v193 pending-cache short-circuit removal — re-POST after confirm now returns 200 with the row, not stale 202. The submit-and-verify UX converges synchronously without depending on cron-bypass.
- Status + Trades GET round-trip — agent_id JOIN, trade_count derivation,
first_trade_at+last_trade_ataggregation,verified_trade_countfilter on tx_status='success' — all reading correctly from the persisted row.
The "new per-call flow" table from your fix-ack comment now describes the actually-observed behavior:
| Scenario | Observed |
|---|---|
| First submit while pending | 202 {accepted: true} ✓ |
| Re-submit while still pending | 202 {accepted: true} ✓ |
| Re-submit after confirm | 200 with row ✓ (was 202 pre-fix) |
| Re-submit after row exists | 200 idempotent (via verifier's readSwap) ✓ |
Test fixture suggestion (still standing from earlier)
The canonical txid 0x46bc5587…f0ee0e4 is now joined by fa62f847…baf3c1 as a real-mainnet reproduction. Both would be useful as fixture fodder for the Phase 3.1 polish follow-up issue you filed — one tests swap-x-for-y direction with my own Genesis agent, the other tests the same shape with a different sender. Pinning the Hiro response JSON for both gives durable parser-regression coverage.
v179 implementor-cites-reviewer cadence — final form
Three review cycles on this PR, each tighter than the last:
- 04:24Z → 04:30Z (6min) — Initial review surfaced STX event_type bug + 3 non-blocking notes
- 04:30Z → 04:46Z (16min) — STX fix + 3-suggestions absorbed, my review flipped to APPROVED
- 05:54Z → 06:18Z (24min) — Empirical end-to-end test surfaced pending-cache trap, fix landed, verified live
22min PR-to-second-APPROVE on the bigger fix, then another tight cycle on the post-merge-window finding. The reusable shape: small substantive reviews → empirical reproduction on preview → maintainer pushes targeted fix → reviewer re-verifies on the same preview. Whole loop sub-30min per finding.
Re-APPROVED on the current head. Ready to merge as the Phase 3.1 verifier — schema-stable, dev-council density at peak, three load-bearing fixes locked in via end-to-end live test.
|
Third post-empirical-test finding (smaller scope, fast follow-up category): POST /trades response strips the Just verified empirically on the preview deploy after the pending-cache fix landed. Submitted txid Idempotency itself works perfectly — What gets stripped
return { status: "verified", inserted: insertRes.inserted, row: ... };
// app/api/competition/trades/route.ts:325
if (result.status === "verified") {
try { await kv.delete(pendingKey); } catch { /* best-effort */ }
return NextResponse.json(result.row, { status: 200 });
// ^^^^^^^^^^^ result.inserted is dropped here
}Why surfacing it mattersThree concrete operational use cases:
Fix proposal (~1-line change) if (result.status === "verified") {
try {
await kv.delete(pendingKey);
} catch { /* best-effort */ }
- return NextResponse.json(result.row, { status: 200 });
+ return NextResponse.json(
+ { ...result.row, inserted: result.inserted },
+ { status: 200 }
+ );
}That's it. Existing Optional: HTTP status code differentiationStricter REST convention would be 201 on first-write + 200 on idempotent read (matches POST-creates-new vs POST-returns-existing). I'd argue against it here:
The body-level Test pin (~5 LOC)Existing it("response body includes inserted: true on first-write", async () => {
// mock verifyAndPersistSwap to return inserted: true
const res = await POST(buildRequest({ txid: NEW_TXID }), ...);
const body = await res.json();
expect(body.inserted).toBe(true);
});
it("response body includes inserted: false on idempotent re-read", async () => {
// mock verifyAndPersistSwap to return inserted: false (post readSwap short-circuit)
const res = await POST(buildRequest({ txid: EXISTING_TXID }), ...);
const body = await res.json();
expect(body.inserted).toBe(false);
});Locks in the new contract structurally. Pattern consistent with the regression tests Scope decisionThis is small enough (1 LOC route change + 2 test assertions) to land as a fixup commit on this PR pre-merge, OR as a fast follow-up issue/PR. Three of my findings on this PR have already absorbed cleanly into the current head (STX event_type, pending-cache short-circuit, this) — if you prefer to ship #738 as-is and follow-up, that works too. Happy to push from fork if useful, or just leave the 1-line change for biwasxyz. The operationally-meaningful payoff is small enough that this is fine as a follow-up. Flagging now because the test pin is best added while the test fixtures are fresh in this PR's diff context. cc @biwasxyz @arc0btc @whoabuddy — operator-validated finding from continued end-to-end testing on the preview. |
Reorder so the D1 idempotency check runs first. On re-submits of an already-verified txid (the common duplicate case after secret-mars's PR #738 finding), the verifier returns `{ inserted: false, row }` without making a Hiro round-trip — saves the wasted upstream call AND lets the route layer return 409 Conflict promptly. Previously: 1. fetchTxFromHiro ← always paid 2. terminal-status branch 3. readSwap (idempotency) 4. sender / allowlist gates 5. INSERT OR IGNORE Now: 1. readSwap (idempotency) ← short-circuits duplicates here 2. fetchTxFromHiro 3. terminal-status branch 4. sender / allowlist gates 5. INSERT OR IGNORE The Hiro fetch result wasn't compared against the existing row in the old order — the post-fetch readSwap just returned the existing row unconditionally. So this is a strict improvement: same semantics on the hit path, no Hiro call on duplicates. Existing tests pass without modification — the verify.test.ts D1 mock dispatches on SQL keyword, not on call order. Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coupled changes to POST /api/competition/trades: ## 1. 409 Conflict on idempotent re-submit Before: re-submits of an already-verified txid returned 200 with a byte-identical body to the first-time write. Callers had no way to distinguish "I wrote this row just now" from "this row already existed before I submitted." secret-mars flagged the UX gap on PR #738; biwasxyz reframed it: re-submitting the same txid is an error that the caller should know about. Now: when verifyAndPersistSwap returns `{ inserted: false, row }`, the route returns: 409 Conflict { "error": "Transaction already verified for this competition", "code": "txid_already_verified", "retryable": false, "existing_row": { ...the persisted row... } } The existing_row.source identifies which ingestion path wrote first (agent or cron — chainhook is reserved in the enum but no receiver route ships in this PR). retryable:false because re-POSTing the same txid will keep landing in this branch. The 200 path is now reserved for first-time writes only — body is the persisted SwapRow alone (no metadata fields). Wire contract stays clean. ## 2. Drop KV pending machinery The MCP server now pre-checks tx confirmation before submitting, so the 30-min KV pending tracker is dead weight on the happy path. Removed: - PENDING_KV_PREFIX + PENDING_KV_TTL_SECONDS constants - kv.put on `result.status === "pending"` - kv.delete on verified - All KV references from the POST handler The 202 response shape is kept as a thin fallback for the racy edge case where the MCP pre-check sees the tx as confirmed but our Hiro fetch hasn't propagated yet (block just mined). Body: `{ accepted: true, note: "Hiro has not yet propagated this tx as terminal. Retry in a few seconds." }`. No D1 row is written — migration 005 forbids pending rows. Self-doc payload (`?docs=1`) updated to document both changes. Refs PR #738 review by @secret-mars + operator clarification Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion test updates for the route changes: ## New tests - "returns 409 + existing_row when inserted:false (row already in D1)" — the core duplicate-detection assertion. - "includes the row.source in existing_row so callers know which ingestion path won" — the source: 'cron' case is the realistic scenario (cron beat agent-submit to the punch); test pins that the field is exposed. - "4-stage lifecycle: pending → pending → verified (200) → re-submit (409)" — the chained-POST scenario biwasxyz asked about. Asserts that all four states traverse correctly, the 200 body is the row alone (no error/ existing_row fields), and the 409 body has the full structured shape. Verifier is invoked exactly 4 times — no request-path short-circuit silently skips any call. ## Dropped tests - "short-circuits to 202 when comp:pending exists" was already replaced in an earlier commit; further KV-tracker assertions (write-on-pending, delete-on-verified, no-read-from-request-path) are now obsolete because the route no longer touches KV at all. Replaced with: - "does NOT touch KV on any submit (KV pending machinery was removed)" — explicit guard that kv.get / kv.put / kv.delete are never called from POST. Prevents the machinery from creeping back in silently. - "returns 202 with note when verify returns pending (Hiro propagation race)" — the new shape of the pending fallback (with explanatory note). Final count: 17 tests (was 16; +5 new, -4 obsolete). Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OpenAPI schema for POST /api/competition/trades updated to match the new route behaviour: - 200 — first-time verified write (was 'verified or idempotent') - 202 — pending fallback for Hiro propagation race (was 'pending, KV-tracked, repeats short-circuit') - 409 — NEW: txid_already_verified. Schema pins the response shape (error, code enum: ['txid_already_verified'], retryable: false, existing_row). MCP consumers (aibtcdev/aibtc-mcp-server#510) codegen from this spec, so the typed schema saves them a hand- written branch. Description now references the 'two active ingestion paths (agent / cron)' since the chainhook receiver was scope-cut from this PR but the source enum value remains in migration 005. Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates the 'Submit Trade' section to reflect the new contract: - 200 is now strictly first-time writes (no longer 'newly verified OR idempotent'). - 409 added with the structured body shape and the source-of-existing-row framing so callers know to look at existing_row.source. - 202 description retuned for the post-MCP-pre-check world: it's now a rare propagation-race fallback, not a regular pending-state response. - KV pending tracker no longer mentioned (removed from the route). - Added a closing line about D1-before-Hiro check order — re-submits resolve in a single D1 read, no wasted Hiro quota. Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Round-trip on the duplicate-submit UX gap + MCP pre-check clarification. Five commits, per-file. What changed
New response matrix
MCP pre-check clarificationPer your note, MCP now pre-checks confirmation before submitting. The KV pending machinery (
Kept the 202 as a thin fallback for the racy edge case (MCP saw confirm but our Hiro fetch hasn't propagated yet — block just mined). Body explains it: TestsThe 4-stage lifecycle test biwasxyz asked about earlier (chained POSTs of the same txid through the route handler) is in it("4-stage lifecycle: pending → pending → verified (200) → re-submit (409)", async () => {
...
// POST 1 → 202 (pending)
// POST 2 → 202 (still pending)
// POST 3 → 200 (tx confirmed, fresh write)
// POST 4 → 409 (row exists, no second write)
expect(verifyAndPersistSwap).toHaveBeenCalledTimes(4);
});Plus two narrower 409 tests: Full count: 17 post-verifier tests (was 16), 91 competition tests total, full suite 1059 passing. What the MCP needs to doSwitch the 200-handling branch to also handle 409. Suggested: if (res.status === 200) {
return { ...res.body, justSubmitted: true };
}
if (res.status === 409) {
return { ...res.body.existing_row, justSubmitted: false };
}OpenAPI publishes the 409 schema typed — codegen should pick it up. |
secret-mars
left a comment
There was a problem hiding this comment.
End-to-end verification of the 5-commit fix series — fully green. The 409-with-existing_row design is stronger than my inserted: false proposal. 🎉
Just tested the duplicate-submit path on the preview deploy:
Duplicate POST (the case my prior comment flagged):
HTTP: 409
{
"error": "Transaction already verified for this competition",
"code": "txid_already_verified",
"retryable": false,
"existing_row": {
"txid": "0xfa62f847…baf3c1",
"sender": "SP20GPDS5RYB2DV03KG4W08EG6HD11KYPK6FQJE1",
"contract_id": "...stableswap-stx-ststx-v-1-2",
"function_name": "swap-x-for-y",
"token_in": "stx", ← STX fix still landing
"amount_in": 499750,
"token_out": "...ststx-token::ststx",
"amount_out": 429262,
"burn_block_time": 1778478970,
"tx_status": "success",
"source": "agent", ← caller knows which ingestion path won
"scored_value": null,
"scored_at": null
}
}
404 path (non-existent txid):
HTTP: 404
{
"error": "Hiro returned 404 for 0x00…00",
"code": "tx_not_found",
"retryable": false
}
Both rejection paths now use the same structured-error shape ({error, code, retryable, [existing_row?]}). Consistent + machine-readable.
Why 409 > inserted: false (your call was the right one)
I'd proposed 200 + inserted: false — REST-idempotent. You went with 409 + structured error. Three reasons your version is better:
- Consumers checking
response.ok(HTTP 2xx) see a difference automatically. The 200+flag version requires the consumer to read the body. Many clients only act on status code; some bail on 4xx without parsing the body — but they'd still get the right "don't double-submit" signal from 409. existing_row.sourcesurfaces cross-ingestion observability cleanly. Agent submits → cron beat me → response showssource: "cron"→ agent learns "the row was recorded via cron-side-scan, not my submit." Pairs perfectly with the cron-as-defense-in-depth design.code: "txid_already_verified"is searchable + i18n-able. Frontend code can branch on the stable enum without parsing the human-readableerrorstring.
Read-before-Hiro optimization (commit 2a988e1) is a bonus win
readSwap BEFORE Hiro fetch in verify.ts means duplicate POSTs cost 1 D1 read, not 1 Hiro fetch. At scale (e.g., a stuck agent retry loop), this saves Hiro free-tier quota proactively. Pure win, no behavior change for fresh-tx callers.
KV pending machinery removed entirely (337a8c9)
You went further than my v193 fix proposal — I'd just suggested dropping the read+short-circuit. You dropped the entire comp:pending:{txid} write/delete machinery. Makes sense per the MCP-pre-checks-now framing: the only path where 202 is returned is the racy "Hiro hasn't propagated terminal state yet" edge, with a clear note in the body. Net simpler, fewer KV writes, no observability artifact to maintain.
The 202 body change is also a nice touch:
{ "accepted": true, "note": "Hiro has not yet propagated this tx as terminal. Retry in a few seconds." }
The note field tells the consumer why they got 202 instead of leaving them to guess. Pairs with the existing description in the route's selfDoc.
4-stage lifecycle test (e1de822) pins the contract structurally
it("4-stage lifecycle: pending → pending → verified (200) → re-submit (409)", async () => {
// POST 1 → 202 (pending)
// POST 2 → 202 (still pending)
// POST 3 → 200 (tx confirmed, fresh write)
// POST 4 → 409 (row exists, no second write)
expect(verifyAndPersistSwap).toHaveBeenCalledTimes(4);
});This is the test fixture I wished I'd named in my prior comment. Locks in the per-POST behavior in a single sequence — any future refactor that re-introduces the short-circuit (or adds new short-circuit paths) breaks this test. Worth promoting as the canonical "is the POST handler still doing what it should" smoke for future PRs.
MCP-side update suggestion (per your comment)
if (res.status === 200) {
return { ...res.body, justSubmitted: true };
}
if (res.status === 409) {
return { ...res.body.existing_row, justSubmitted: false };
}I'll mirror this on the aibtc-mcp-server #510 side — the competition_submit_trade tool description needs to update to mention the 409 path + the justSubmitted flag pattern. Small follow-up PR after #510 merges. Want me to file as an issue so it doesn't get forgotten?
Net delta this PR has absorbed from review
Four post-empirical-test findings, each closed in <30min:
| # | Finding | Resolution |
|---|---|---|
| 1 | STX event_type === "stx_asset" not "stx_transfer_event" |
f6169405 + 447bf28f (Set + 8 fixtures + variant-coverage it.each) |
| 2 | Pending-cache short-circuit blocked re-verification | de52cd75 + c44ba1e8 (straight removal + regression tests) |
| 3 | Re-POST returned silent 200 (no idempotency signal) | This 5-commit series — 409 + structured error + existing_row + lifecycle test |
| 4 | scored_value/scored_at columns Phase-3.2 forward-looking | Open architectural question — file separately if useful |
Each fix tighter than the last, each test pin pattern carrying forward. The substrate this PR ships is now substantially harder than what was there pre-review.
Re-APPROVED on current head (344df7bb). Maintainer queue ball, ready to ship.
cc @biwasxyz @arc0btc @whoabuddy — final round on the substantive review loop.
The verifier was accepting all 8 terminal tx_status values from the
swaps schema (success + 7 abort/dropped variants), with a no-op
`if (tx.tx_status !== "success") { /* fall through */ }` block that
documented an intent but didn't enforce it. Result: failed swaps
were getting persisted to D1 with whatever transfer events they had
managed to emit before reverting, polluting trade_count and crediting
the agent for amount_out the pool never actually delivered.
@whoabuddy's reframing on the attribution gist makes the rule
explicit: "assert tx_status == success" at the verifier. Only
success counts for the comp. Failed terminals (abort_by_response,
abort_by_post_condition, dropped_replace_by_fee, dropped_replace_
across_fork, dropped_too_expensive, dropped_stale_garbage_collect,
dropped_problematic) → reject with code tx_failed, no row written.
Gate placement: success check runs after pending / terminal-status
classification but BEFORE sender, allowlist, parse, and persist. So
a failed tx never touches D1 at all — cheap fail-fast on Hiro
status, no wasted DB work.
Schema impact: migration 005's 8-status CHECK constraint stays. The
schema still ALLOWS recording failed attempts; the verifier just
doesn't write any. If we ever want to opt in to recording failed
attempts (UX surface like "user tried, failed"), we'd change the
gate without a migration.
Route impact: the `tx_failed` rejection code already maps to 422 in
the route's switch case — no route change needed.
Refs PR #738 + whoabuddy's gist comment 6140059
Refs #734 #652
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to the verifier gate. Eight new test cases (it.each over the
7 non-success terminal statuses + a fail-fast ordering assertion):
- For each of abort_by_response, abort_by_post_condition,
dropped_replace_by_fee, dropped_replace_across_fork,
dropped_too_expensive, dropped_stale_garbage_collect, and
dropped_problematic: assert verifier returns { rejected, code:
tx_failed }, reason includes the status name, and NO row is
persisted.
- "rejects BEFORE sender/allowlist checks (cheap fail-fast)" — even
with an unregistered sender + off-allowlist contract, a failed
tx_status short-circuits to tx_failed first. Locks the gate
ordering so future refactors can't accidentally do expensive
DB work for a tx that's going to fail anyway.
Refs PR #738 + whoabuddy's gist comment 6140059
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Test-coverage additive —
One non-blocking observation (v137-family — description-claim-vs-test-assertion asymmetry)The In practice the type-discriminator contract is honored by the route, so the claim holds end-to-end. But this is the same shape as the v137 pattern I flagged across One-line strengthening pattern that pins it structurally (delta against the existing it.each([...])("rejects tx_status=%s with code 'tx_failed' (no row written)", async (status) => {
// ... existing setup ...
const result = await verifyAndPersistSwap({}, db, TXID, "agent");
expect(result.status).toBe("rejected");
if (result.status !== "rejected") return;
expect(result.code).toBe("tx_failed");
expect(result.reason).toContain(status);
+ expect(db.prepare).not.toHaveBeenCalledWith(
+ expect.stringContaining("INSERT INTO swaps")
+ );
});And on the fail-fast ordering test, pinning "gate runs before sender/allowlist DB lookup" structurally: it("rejects BEFORE sender/allowlist checks (cheap fail-fast)", async () => {
// ... existing setup ...
+ // Gate ordering: no DB work happens before tx_status check
+ expect(db.prepare).not.toHaveBeenCalled();
expect(result.status).toBe("rejected");
// ... rest ...
});Cost: ~3 lines per test, ~24 lines total. Strict win against the v137 pattern's failure mode (refactor adds DB work before the gate, test silently passes because result.code is unchanged). Net: keep the merge moving — this is observation-only for the next-cycle hygiene PR shape, not a block on Maintainer ball remains @whoabuddy on merge. |
block-on-merge regression caught in PR #738 review by @secret-mars. Verified empirically against canonical Bitflow tx 0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4 — the parser was emitting token_in='unknown' for every STX swap. Root cause: - lib/competition/parse.ts checked event_type ∈ {stx_transfer_event, stx_transfer}. - Hiro mainnet /extended/v1/tx/{txid} actually returns event_type= 'stx_asset' for STX transfers (verified with curl + jq on the canonical txid in the review thread). - The check fell through to `a.asset_id ?? 'unknown'`, and STX events in Hiro responses do NOT carry an asset_id — so every STX leg landed in D1 as token_in='unknown' or token_out='unknown'. Why the tests didn't catch it: - All test fixtures used event_type='stx_transfer_event' — same wrong string as the production check. The bug was dual-coded into the test data and the production code, so test green ≠ bug-free. Separate commit corrects the fixtures. Fix: - Extracted an exported STX_EVENT_TYPES Set covering all three known vocabularies: 'stx_asset' (Hiro mainnet /extended/v1), the older 'stx_transfer_event', and 'stx_transfer' (downstream tooling). - Replaced the OR-chain with STX_EVENT_TYPES.has(ev.event_type ?? ''). Schema impact: - swaps.token_in / swaps.token_out for STX-side rows persisted before this fix would land as 'unknown'. (txid)-PK + INSERT OR IGNORE means the verifier won't re-parse on re-submission of the same txid. Per the review, that's a one-time post-merge UPDATE if needed; deploying the fix pre-merge means no row #1 pollution. Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ring Companion to the parse.ts fix. The previous fixtures used event_type='stx_transfer_event' — same wrong string as the prod check, so test green ≠ bug-free. Two changes: - Bulk-rename existing 8 fixtures from 'stx_transfer_event' to 'stx_asset' (the real Hiro mainnet value). - Add a new it.each block exercising all three event_type variants in STX_EVENT_TYPES so the Set is explicitly under test, not just the one canonical string. If Hiro changes the value again, the regression surface is documented + checked. Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ring Companion to the parse.ts fix. verify.test.ts buildHappyTx() used the same stale 'stx_transfer_event' string; now uses 'stx_asset' matching what Hiro mainnet actually returns. Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Non-blocking nit from PR #738 review (@secret-mars). The previous 'null until minted' could be read as 'null until someone mints any identity NFT' rather than 'null until this specific agent registers theirs'. Replaces with 'null until the agent registers their identity NFT' to remove the ambiguity. Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@secret-mars caught this empirically on the preview deploy (PR #738 comment 4418003085): the cache-hit short-circuit at the top of POST /api/competition/trades created a 30-min blind window after a tx confirmed. Re-submits of a now-confirmed tx hit the KV cache, returned 202 without invoking the verifier, and the row never landed in `swaps`. Reproduction from secret-mars's run (txid fa62f847…baf3c1): 05:48Z POST → pending → wrote comp:pending:{txid} → 202 05:52Z Stacks confirmed the tx (tx_status=success at block 7929497) 05:52Z POST same txid → CACHE HIT → 202 (verifier skipped) 05:53Z POST same txid → CACHE HIT → 202 (verifier skipped) `/api/competition/status?address=…` stayed at verified_trade_count=0 because no row was ever written. Cron is the bypass path (15-min sweep would eventually catch it via source='cron') but cron-trigger wiring isn't on preview, so end-to-end the verification path was effectively dead from the agent's side. Fix: remove the read+short-circuit at the request path. Verifier is now ALWAYS invoked. The KV key (`comp:pending:{txid}`, 30-min TTL) is downgraded from a request-gate to an observability artifact: - Still written when verifyAndPersistSwap returns pending. - Still cleared when it returns verified. - Nothing reads it from the request path. Why the cache was redundant: the actual hammer-protection on this route is the rate limit (20/min per IP via RATE_LIMIT_MUTATING). The cache added nothing the rate limit doesn't already cover, while actively preventing the legitimate retry-after-confirmation path. Hiro free tier is 50 req/sec — well above the rate-limit ceiling. The verifier already short-circuits on idempotent re-submission via its own readSwap() check before INSERT OR IGNORE, so a re-POST of an already-verified tx is cheap (one D1 read, no Hiro fetch). Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…circuit
Companion to the route fix. The previous test asserted the
short-circuit behaviour ('short-circuits to 202 when comp:pending
exists in KV (no Hiro fetch)') — that assertion would now fail
because the route always invokes the verifier.
Replaced with two regression tests that lock in the new contract:
1. 'invokes verifier on every submit even when comp:pending exists'
— when the cache has a marker AND the verifier returns 'verified'
(i.e. the tx confirmed in the meantime), the route returns 200
with the row, not 202. This is the exact secret-mars repro
scenario inverted.
2. 'does NOT read the pending key from the request path' — explicit
assertion that kv.get is never called from POST. Guards against
the short-circuit being re-introduced silently.
Kept the two existing tests that exercise the write-on-pending and
delete-on-verified paths — those behaviours are unchanged; only the
read-side gate was removed.
Refs PR #738 review by @secret-mars
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reorder so the D1 idempotency check runs first. On re-submits of an already-verified txid (the common duplicate case after secret-mars's PR #738 finding), the verifier returns `{ inserted: false, row }` without making a Hiro round-trip — saves the wasted upstream call AND lets the route layer return 409 Conflict promptly. Previously: 1. fetchTxFromHiro ← always paid 2. terminal-status branch 3. readSwap (idempotency) 4. sender / allowlist gates 5. INSERT OR IGNORE Now: 1. readSwap (idempotency) ← short-circuits duplicates here 2. fetchTxFromHiro 3. terminal-status branch 4. sender / allowlist gates 5. INSERT OR IGNORE The Hiro fetch result wasn't compared against the existing row in the old order — the post-fetch readSwap just returned the existing row unconditionally. So this is a strict improvement: same semantics on the hit path, no Hiro call on duplicates. Existing tests pass without modification — the verify.test.ts D1 mock dispatches on SQL keyword, not on call order. Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coupled changes to POST /api/competition/trades: ## 1. 409 Conflict on idempotent re-submit Before: re-submits of an already-verified txid returned 200 with a byte-identical body to the first-time write. Callers had no way to distinguish "I wrote this row just now" from "this row already existed before I submitted." secret-mars flagged the UX gap on PR #738; biwasxyz reframed it: re-submitting the same txid is an error that the caller should know about. Now: when verifyAndPersistSwap returns `{ inserted: false, row }`, the route returns: 409 Conflict { "error": "Transaction already verified for this competition", "code": "txid_already_verified", "retryable": false, "existing_row": { ...the persisted row... } } The existing_row.source identifies which ingestion path wrote first (agent or cron — chainhook is reserved in the enum but no receiver route ships in this PR). retryable:false because re-POSTing the same txid will keep landing in this branch. The 200 path is now reserved for first-time writes only — body is the persisted SwapRow alone (no metadata fields). Wire contract stays clean. ## 2. Drop KV pending machinery The MCP server now pre-checks tx confirmation before submitting, so the 30-min KV pending tracker is dead weight on the happy path. Removed: - PENDING_KV_PREFIX + PENDING_KV_TTL_SECONDS constants - kv.put on `result.status === "pending"` - kv.delete on verified - All KV references from the POST handler The 202 response shape is kept as a thin fallback for the racy edge case where the MCP pre-check sees the tx as confirmed but our Hiro fetch hasn't propagated yet (block just mined). Body: `{ accepted: true, note: "Hiro has not yet propagated this tx as terminal. Retry in a few seconds." }`. No D1 row is written — migration 005 forbids pending rows. Self-doc payload (`?docs=1`) updated to document both changes. Refs PR #738 review by @secret-mars + operator clarification Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion test updates for the route changes: ## New tests - "returns 409 + existing_row when inserted:false (row already in D1)" — the core duplicate-detection assertion. - "includes the row.source in existing_row so callers know which ingestion path won" — the source: 'cron' case is the realistic scenario (cron beat agent-submit to the punch); test pins that the field is exposed. - "4-stage lifecycle: pending → pending → verified (200) → re-submit (409)" — the chained-POST scenario biwasxyz asked about. Asserts that all four states traverse correctly, the 200 body is the row alone (no error/ existing_row fields), and the 409 body has the full structured shape. Verifier is invoked exactly 4 times — no request-path short-circuit silently skips any call. ## Dropped tests - "short-circuits to 202 when comp:pending exists" was already replaced in an earlier commit; further KV-tracker assertions (write-on-pending, delete-on-verified, no-read-from-request-path) are now obsolete because the route no longer touches KV at all. Replaced with: - "does NOT touch KV on any submit (KV pending machinery was removed)" — explicit guard that kv.get / kv.put / kv.delete are never called from POST. Prevents the machinery from creeping back in silently. - "returns 202 with note when verify returns pending (Hiro propagation race)" — the new shape of the pending fallback (with explanatory note). Final count: 17 tests (was 16; +5 new, -4 obsolete). Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OpenAPI schema for POST /api/competition/trades updated to match the new route behaviour: - 200 — first-time verified write (was 'verified or idempotent') - 202 — pending fallback for Hiro propagation race (was 'pending, KV-tracked, repeats short-circuit') - 409 — NEW: txid_already_verified. Schema pins the response shape (error, code enum: ['txid_already_verified'], retryable: false, existing_row). MCP consumers (aibtcdev/aibtc-mcp-server#510) codegen from this spec, so the typed schema saves them a hand- written branch. Description now references the 'two active ingestion paths (agent / cron)' since the chainhook receiver was scope-cut from this PR but the source enum value remains in migration 005. Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates the 'Submit Trade' section to reflect the new contract: - 200 is now strictly first-time writes (no longer 'newly verified OR idempotent'). - 409 added with the structured body shape and the source-of-existing-row framing so callers know to look at existing_row.source. - 202 description retuned for the post-MCP-pre-check world: it's now a rare propagation-race fallback, not a regular pending-state response. - KV pending tracker no longer mentioned (removed from the route). - Added a closing line about D1-before-Hiro check order — re-submits resolve in a single D1 read, no wasted Hiro quota. Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The verifier was accepting all 8 terminal tx_status values from the
swaps schema (success + 7 abort/dropped variants), with a no-op
`if (tx.tx_status !== "success") { /* fall through */ }` block that
documented an intent but didn't enforce it. Result: failed swaps
were getting persisted to D1 with whatever transfer events they had
managed to emit before reverting, polluting trade_count and crediting
the agent for amount_out the pool never actually delivered.
@whoabuddy's reframing on the attribution gist makes the rule
explicit: "assert tx_status == success" at the verifier. Only
success counts for the comp. Failed terminals (abort_by_response,
abort_by_post_condition, dropped_replace_by_fee, dropped_replace_
across_fork, dropped_too_expensive, dropped_stale_garbage_collect,
dropped_problematic) → reject with code tx_failed, no row written.
Gate placement: success check runs after pending / terminal-status
classification but BEFORE sender, allowlist, parse, and persist. So
a failed tx never touches D1 at all — cheap fail-fast on Hiro
status, no wasted DB work.
Schema impact: migration 005's 8-status CHECK constraint stays. The
schema still ALLOWS recording failed attempts; the verifier just
doesn't write any. If we ever want to opt in to recording failed
attempts (UX surface like "user tried, failed"), we'd change the
gate without a migration.
Route impact: the `tx_failed` rejection code already maps to 422 in
the route's switch case — no route change needed.
Refs PR #738 + whoabuddy's gist comment 6140059
Refs #734 #652
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to the verifier gate. Eight new test cases (it.each over the
7 non-success terminal statuses + a fail-fast ordering assertion):
- For each of abort_by_response, abort_by_post_condition,
dropped_replace_by_fee, dropped_replace_across_fork,
dropped_too_expensive, dropped_stale_garbage_collect, and
dropped_problematic: assert verifier returns { rejected, code:
tx_failed }, reason includes the status name, and NO row is
persisted.
- "rejects BEFORE sender/allowlist checks (cheap fail-fast)" — even
with an unregistered sender + off-allowlist contract, a failed
tx_status short-circuits to tx_failed first. Locks the gate
ordering so future refactors can't accidentally do expensive
DB work for a tx that's going to fail anyway.
Refs PR #738 + whoabuddy's gist comment 6140059
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fier scope
Public read endpoint that returns the (contract_id, function_name)
tuples the verifier accepts at POST /api/competition/trades. Agents
can hit this to learn what's in scope before submitting txids;
swaps against anything else are rejected with `contract_not_allowlisted`.
Response shape:
- entries[] — array of {contract_id, functions[]}
- total_contracts / total_functions / protocols.bitflow — sizing
- provider_address — AIBTC attribution string (audit signal, NOT a gate)
- self-doc on ?docs=1 with relatedEndpoints + notes
Aggressive edge cache (s-maxage=86400, swr=86400) because the allowlist
is a static module-level export — changes only ship via code review, so
the cache lifetime can match the deploy cadence.
ALEX + Zest stay out per the existing PHASE-3.1-HANDOFF.md scope; notes
in the self-doc point to the right place to request additions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…00:00:00Z Hard correctness gate in `verifyAndPersistSwap`. Trades whose `burn_block_time` predates `COMP_START_TIMESTAMP` are rejected with the new code `before_comp_start`, regardless of other validity. Applies to both ingestion paths (agent-submit POST and cron catch-up) since both flow through this function — pre-campaign history can't slip into `swaps` via either route. Placement: after the success-only `tx_failed` gate, before the sender/allowlist/parse stages. Cheap fail-fast — no DB or upstream work for a pre-start tx. Storage: hardcoded constant in `lib/competition/constants.ts`. Campaign start is a one-time committed decision, not a runtime variable; promote to an env var only if preview-vs-prod ever needs to diverge. Unit: `burn_block_time` (Unix epoch seconds). Matches every other comp surface (leaderboard, /trades, status). Anchor lag (~few minutes) is well inside tolerance for a multi-day campaign. Route: `app/api/competition/trades/route.ts` maps the new code to HTTP 422 alongside `tx_failed` / `sender_not_registered` / etc. — same family, `retryable: false`. Tests: 4 new cases in `verify.test.ts` — pre-start reject, boundary (exactly at start = accepted), tx_failed-wins-when-also-pre-start (proves ordering), pre-start beats sender/allowlist downstream gates. Existing happy-path fixture bumped to `COMP_START_TIMESTAMP + 1 day` so the rest of the suite stays above the gate by default. Route + cron tests mock `verifyAndPersistSwap` so they're unaffected. 103/103 competition tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ate) Replaces the KV `comp:cron:cursor` key (under VERIFIED_AGENTS) with a row in the new `competition_state` table from migration 009. Per @whoabuddy's #738 review note that the verifier follow-up needs proper cursor state (#738 (comment)): durable, queryable, and lives in the same store as the data it gates. The scheduler primitive (Cron Trigger vs DO alarm) becomes orthogonal now that state isn't KV-bound — either fires the same D1-backed handler. Issue #765 (DO-alarm follow-up) closed: that decision is deferred and no longer blocks anything since the state question is resolved here. What changed: - migrations/009_competition_state.sql — generic (key, value, updated_at) table; absorbs future cron state without per-signal migrations - lib/competition/state.ts — getCronCursor / setCronCursor / clearCronCursor with SQLite UPSERT and unixepoch() default - lib/competition/cron.ts — drops VERIFIED_AGENTS from env, drops the exported CRON_CURSOR_KV_KEY constant and the cursorKey option (no longer needed since state.ts owns the key) - app/api/competition/cron/route.ts — drops KV from the env arg passed to runCompetitionCron; self-doc updated to reference D1 - lib/competition/__tests__/cron.test.ts — D1 cursor mock with a tiny inline store, exposes cursorOps.{set,clear} for assertion - app/api/competition/__tests__/cron-route.test.ts — drop unused KV binding from mockEnv D1 patterns verified idiomatic against Cloudflare docs: - INSERT ... ON CONFLICT(key) DO UPDATE SET ... = excluded.value (UPSERT) - unixepoch() in DEFAULT clause - Numbered placeholders (?1, ?2) — Cloudflare's recommended style 103/103 competition tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5224a0d to
9afa89d
Compare
|
Rebased onto current What changed in the latest push (
Validation:
PR is mergeable again. |
|
@arc0btc @secret-mars requesting final review on the rebased SchedulerDO version of #738 for launch readiness. Key delta since the previous approvals:
Validation on head
Specific review asks:
|
secret-mars
left a comment
There was a problem hiding this comment.
APPROVE on 9afa89d1 — rebase + SchedulerDO integration is the right architectural pivot, cleanly absorbs the lp#784 admin-surface foundation, and removes the external-auth attack surface (CRON_SECRET).
What works well:
-
Migration 009 generic K/V scratchpad design (
migrations/009_competition_state.sql):competition_state(key, value, updated_at)is intentionally generic — future scheduler state reuses the same table rather than spawning one tiny migration per signal. Matches the "one schema, many keys" pattern from the inbox-events table. Avoids the per-signal-migration anti-pattern. Smart. -
Strict-task-validation continuity (
app/api/admin/scheduler/route.ts:8):ALLOWED_TASKSSet extended"tenero" | "all"→"tenero" | "competition" | "all"with the error message updated. Direct lineage from lp#784's 1902007 absorption pass — no drift between sibling PRs in the same campaign. The required-by-default convention from my v296 Field 8 proposal also applies here: extending an allowlist is the right shape, vs adding new untyped task identifiers. -
DO-bookkeeping vs D1-cursor separation (
worker.tscomment block +lib/competition/scheduler.ts): "Long-lived cursors stay in D1 per issue #768 — the DO holds only its own bookkeeping." Clean separation of concerns. The DO holdslastCompetitionRunAt,lastCompetitionResult,consecutiveFailures.competition,nextRunAfter.competition(transient, scheduler-internal); D1competition_stateholds the durable cursor. Recovery-after-DO-loss only requires the D1 cursor, not the DO scratchpad. -
Cadence separation (
worker.ts:54):COMPETITION_INTERVAL_MS = 15 * 60 * 1000distinct fromTENERO_INTERVAL_MS = 5 * 60 * 1000. Different per task, withALARM_TICK_MS = TENERO_INTERVAL_MS(smallest cadence drives the alarm); per-tasknextRunAfterdecides which tasks actually fire each tick. Adaptive backoff per task is preserved viaPartial<Record<SchedulerFailureTask, number>>. -
CRON_SECRET removal: smaller attack surface. Previously a shared-secret over an unauthenticated route gated external cron callers; post-merge, the only triggers are SchedulerDO alarm (internal-only) + admin route (
X-Admin-Keygated per lp#784). The auth boundary is now homogeneous with other admin surfaces.
[observation] Alarm scheduling logic I didn't drill into
I didn't trace the full alarm()-tick scheduling logic for the per-task nextRunAfter comparison — i.e. how each 5min tick decides whether to run tenero only, competition only, or both. The cadence design (COMPETITION 15min, TENERO 5min, ALARM_TICK 5min) implies tenero runs every tick + competition runs every 3rd tick (modulo backoff), but the exact decision shape isn't in my read. The test coverage (lib/competition/__tests__/scheduler.test.ts per the file list) presumably handles this; flagging only because the comment block describes the state shape but doesn't quite spell out the per-task gate logic. Worth a short inline comment in the alarm() body if it's not already there.
[observation] Migration ordering
Migration 009 (competition_state) is the next sequential after the 762c migrations. Worth confirming it gets applied alongside (or before) any downstream PR that reads from competition_state. The PR body should probably note the apply-ordering for any operator running fresh deploys.
Cross-PR substrate (continuing):
- lp#784 admin scheduler surface absorbed cleanly —
ALLOWED_TASKSextension is the convention-conformant pattern. - The lp#786 Field 8 proposal (optional-vs-required new param shape on lib helpers) doesn't strictly apply here since
task: SchedulerTaskis already required-typed — but the discriminated-union shape ofSchedulerTask = "tenero" | "competition" | "all"IS the type-level enforcement that Field 8 proposes for new params. - #691 / claim-source asymmetry from #785 doesn't reach competition surface (different code path).
CI: 7/8 checks SUCCESS (Lint, CodeQL, Test, Workers Build, Snyk, Analyze actions/js-ts; Build SKIPPED is expected for fork-PR-style). v124 head-SHA pre-submit clean on 9afa89d1.
Ready to merge for Phase 3.1 launch. Production catch-up cron is the load-bearing user-facing piece — once this lands, the trade leaderboard surface on /leaderboard (lp#743 merged earlier today) gets its Volume column live data source.
|
Phase 3.1 deploy verified on production after merge (~24min ago): Structural deploy green across the surface. 400-vs-404 flip on missing-required-param is the right shape — the helpful Allowlist shape confirmed: 4 Bitflow contracts (stableswap, xyk-core, xyk-swap-helper, dlmm-swap-router) with 10 total functions. Provider address Status endpoint surfaces SchedulerDO competition task: first tick should fire at most 15min after deploy. Operator-side check via admin scheduler status would confirm Migration 009 ( Cross-loop closure: Phase 3.1 live. Trade activity will be the next observable. |
|
Phase 3.1 +46min empirical anchor — (JSON pulled from the leaderboard HTML hydration payload at Quick read-question: does Tenero pricing fire on the same Fresh competition trade ingestion = 0 in this window, which is plausible given allowlist scope + no active comp window yet. The legacy |
Summary
Phase 3.1 of the trading-comp verifier, rebased onto current
mainand wired into the existingSchedulerDObackground-work model.docs/rfc-d1-schema.md§swaps, MCP consumer feat(competition): add trading competition tools and Bitflow provider tag aibtc-mcp-server#510mainWhat changed
GET /api/competition/status,GET /api/competition/tradesbacked by D1 helpersPOST /api/competition/tradesverifies confirmed txids through Hiro, sender registration, allowlist, parser, and D1INSERT OR IGNOREGET /api/competition/allowlistexposes the current Bitflow verifier scopelib/competition/scheduler.tswalksregistered_wallets, fetches recent Hiro tx history, filters allowlisted swaps, and callsverifyAndPersistSwap(..., source='cron');SchedulerDOruns it every ~15 minutes and supports manual admin refresh viatask=competitionmigrations/009_competition_state.sqladds generic D1 scheduler state; competition cursor key iscompetition_scheduler_cursorllms.txt,llms-full.txt, and.well-known/agent.jsonadvertise only the public competition surfaceScheduler model
There is no public
/api/competition/cronroute and noCRON_SECRET.The catch-up path is owned by
SchedulerDO:The persisted
swaps.sourcevalue remainscronfor schema compatibility with migration 005. In docs this is described as the legacy schema label for the SchedulerDO catch-up writer.Hard constraints honoured
sender,token_in,amount_in,burn_block_time,source) — not the Spec: Trading competition API endpoints (POST /trades, GET /status, GET /trades) #683 spec.pendingrow in D1. Pending txs return 202 from submit and are not persisted.networkparameter.swapsin this PR.INSERT OR IGNOREon(txid)— first writer wins across agent-submit and scheduler catch-up.event_type: "stx_asset"is handled and covered by fixtures.Test plan
Run locally after rebase + SchedulerDO rewrite:
npm run typecheckstill reports pre-existing broader test-suiteResponse.json(): unknownerrors in admin/backfill, admin/reconcile, inbox, and outbox tests; this branch no longer adds competition/scheduler entries to that error list.Preview smoke once Cloudflare finishes deploying:
Not in this PR
scored_value/scored_atstaynulluntil that lands.swap_legs) and broader ALEX/Zest allowlists.source='chainhook'remains reserved in the schema.