feat(bounty): native bounty system — replaces bounty.drx4.xyz proxy#843
Conversation
Replaces the bounty.drx4.xyz proxy with first-party endpoints. Genesis-level
(L2+) agents post bounties; any Registered (L1+) agent submits; the poster
accepts a winner and proves payment with a confirmed on-chain sBTC txid that
the platform verifies on Hiro (sender/recipient/amount/memo) with a memo
binding "BNTY:{bountyId}" so the same transfer cannot pay another bounty.
Status is **derived from timestamps**, not stored — no status column, no cron,
no lazy-resolve-and-persist. bountyStatus(record, now) maps the timestamps
(createdAt / expiresAt / acceptedAt / paidAt / cancelledAt) to one of six
observable states: open, judging, winner-announced, paid, abandoned,
cancelled. Same function runs in TS, in API responses, and as SQL predicates
via statusToSql() so filtered list queries stay precise.
Scope (phase 1 — backend only):
- migrations/012_bounties.sql — two tables, no status column, indexes tuned
for the derived-status SQL predicates
- lib/bounty/* — types (+ bountyStatus), constants, signatures, validation,
d1-helpers, kv-helpers (txid uniqueness only — no record mirror), id,
txid-verify (Hiro-backed, returns canonical tx_id for storage)
- 9 routes under /api/bounties — GET list (with self-doc envelope), POST
create, GET detail (with denormalized winner + payment blocks), GET
submissions (paginated) + GET single submission permalink, POST submit,
POST accept, POST paid, POST cancel
- 39 unit tests covering message construction, status derivation across all
six states + grace transitions, and every txid-verify failure code with
mocked Hiro responses
Conventions mirrored: signed POST + level gating from app/api/vouch/route.ts;
KV+D1 dual-write pattern from PRs #720/#722/#732 (D1 sole source of truth per
Phase 2.5 / PR #745 — no record mirror in KV); txid recovery + memo binding
from lib/inbox/x402-verify.ts; stacksApiFetch() from lib/stacks-api-fetch.ts
for Hiro calls.
Out of scope for this phase: discovery doc updates (Phase 2), UX pages
(Phase 3), inbox notifications + reputation counters (Phase 4).
…ies in discovery docs (phase 2)
Updates every discovery surface that previously pointed agents at the external
bounty.drx4.xyz proxy. Agents browsing /llms.txt, /.well-known/agent.json,
/api/heartbeat, /skill.md, /docs, or /api/openapi.json now land on the
first-party bounty system shipped in phase 1.
Specifically:
- app/.well-known/agent.json — ecosystem entry now points at /bounty; new
"bounty-system" A2A skill describes the full post/submit/accept/paid/cancel
surface; the "ecosystem" skill text updated.
- app/llms.txt + app/llms-full.txt — bounty pointers swapped from bounty.drx4.xyz
to aibtc.com/bounty with /api/bounties reference; the L2+ ecosystem orientation
paragraph updated; the "Bounty Board" external-services section rewritten to
describe the native flow (signed POSTs, status derived from timestamps, payment
proven by on-chain sBTC txid with BNTY:{bountyId} memo).
- app/api/heartbeat/route.ts — nextAction for L2+ "Explore Ecosystem" branch
now points at the native UI.
- app/skill.md — the actions table now lists the three bounty endpoints
(find/post/submit) instead of just an external link.
- app/docs/[topic]/route.ts + app/docs/route.ts — new "bounties" topic doc
covers the workflow end-to-end (signed-message formats, state machine,
memo binding, examples). Indexed in /docs.
- app/api/openapi.json — 9 bounty endpoints (list/create/detail/submissions x2/
submit/accept/paid/cancel) plus a complete schema set (BountyRecord,
BountySubmission, BountyWinner, BountyPaymentHint, BountyCreateRequest,
BountySubmitRequest, BountyAcceptRequest, BountyPaidRequest,
BountyCancelRequest, BountyListResponse, BountyDetailResponse,
BountySubmissionsPageResponse, BountyStatus).
- CLAUDE.md — adds a full Bounty System section (state machine, endpoints,
paid-txid verification chain, storage) and a new KV pattern row
(bounty:paid-txid). Heartbeat orientation note updated.
No code logic changed; this commit is documentation surfaces only. The phase 1
backend is the source of truth for behavior.
Rewires the /bounty pages from the external bounty.drx4.xyz proxy to the
first-party /api/bounties surface shipped in phase 1. Visual design preserved
where it still fits; the data model, status set, and timeline are rebuilt
around the new derived-status semantics.
- app/bounty/types.ts — re-exports BountyRecord / BountySubmission /
BountyStatus / BountyWinner / BountyPaymentHint from @/lib/bounty; defines
BountyWithStatus and BountyDetailData shapes the pages consume.
- app/bounty/utils.ts — STATUS_STYLES, STATUS_LABELS, and submissionWindowLabel
for the six derived states (open / judging / winner-announced / paid /
abandoned / cancelled). Drops the old open/claimed/submitted/approved set.
- app/bounty/page.tsx — server component now calls @/lib/bounty:listBounties
directly via getCloudflareContext (D1-first, no fetch round-trip). Computes
derived status server-side via bountyStatus(record, now).
- app/bounty/BountyDirectory.tsx — filter chips updated to the new state set;
search now matches title + tags + description; stats counters compute from
the page (open / paid / total-paid / total); "Post a bounty" CTA links to
/bounty/new.
- app/bounty/[id]/page.tsx — server-side fetch via @/lib/bounty helpers builds
the BountyDetailData (bounty + status + first page of submissions + optional
winner block + optional payment hint for posters in winner-announced).
- app/bounty/[id]/BountyDetail.tsx — new four-step timeline (open → judging
→ winner-announced → paid) with terminal-fail branch for cancelled /
abandoned; renders the denormalized Winner card; renders the Payment Hint
with the exact BNTY:{bountyId} memo and recipient + amount; renders the
Hiro explorer link for paid bounties; marks the winning submission in the
submissions list.
- app/bounty/new/page.tsx — new instructions page explaining the three steps
to post a bounty via signed POST (canonical body hash → sign → POST), with
a copyable curl example and the lifecycle summary.
External imports of bounty.drx4.xyz are gone from /bounty. The pages now
fail gracefully when D1 is unavailable (the same fail-open posture as the
inbox standalone page).
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
landing-page | 1658ab3 | May 16 2026, 12:15 PM |
Next.js Route files only allow specific named exports (GET, POST, etc.).
A leftover `export const _internal = { TERMINAL_STATUSES }` was tripping
the typed-routes check in production build:
Type error: Route "app/api/bounties/route.ts" does not match the
required types of a Next.js Route.
"_internal" is not a valid Route export field.
Remove the export and the unused TERMINAL_STATUSES constant.
The previous tag filter built a LIKE pattern over the JSON-encoded tags column and escaped only double quotes, not backslashes. CodeQL flagged this as "Incomplete string escaping or encoding" because a tag containing \\ would produce a pattern that doesn't match the JSON-encoded form. Not a security issue — filters.tag is bound as a SQL parameter, so SQL injection was never possible. The flaw was filter accuracy: tags with backslashes (or any character JSON encodes specially) would silently miss. Switch to SQLite JSON1's json_each so the predicate iterates the actual JSON array and compares values directly: EXISTS (SELECT 1 FROM json_each(b.tags) WHERE value = ?) This makes the filter semantically correct regardless of tag content, and removes the entire class of LIKE-on-JSON escaping concerns.
whoabuddy
left a comment
There was a problem hiding this comment.
Senior-engineer review — focused on correctness and Cloudflare cost. The system is structurally sound (status-as-derived-function, D1-as-truth, body-hash binding, canonical txid storage, defense-in-depth on txid uniqueness, cf-ray-correlated logging — all good). Inline comments below cover the trust-critical memo path, a TOCTOU gap on grace-window state transitions, a txid-normalization edge case, the COUNT(*) hot path, a status-boundary mismatch between TS and SQL, and a couple of housekeeping items.
Intentionally not commented:
- Rate limiting: separate Cloudflare bindings PR in flight.
- Server-component cache bypass: by design.
Please bump migration 012 → 013 (next free slot) before merge.
|
I want to work on this |
arc0btc
left a comment
There was a problem hiding this comment.
Replaces the external bounty.drx4.xyz proxy with a first-party system backed by D1, KV, and on-chain sBTC verification — a significant step forward for platform trust and agent self-sufficiency.
What works well:
- Status-as-pure-function over timestamps is the right architecture. No stale state, no sync bugs, and
statusToSqlkeeping the SQL predicates and TS logic in sync is a strong design invariant. - The 9-step txid verification chain (
txid-verify.ts) is thorough. Memo binding (BNTY:{bountyId}) prevents cross-bounty replay; KV pre-check + D1 unique-partial-index gives two independent enforcement layers; canonicaltx_idstorage delegates normalization to Hiro — all correct decisions. - Body hash binding on signed POSTs (sha256 of canonical JSON) prevents payload substitution after signing. The sorted-key canonicalization is deterministic and the implementation is clean.
- Auth before data: every POST validates the signature and level gate before any D1 read or write. Compliant with
server-auth-actions. - Edge cache headers correctly private on submitter-filtered GETs, public with short TTL on list/detail — no risk of cross-user data leakage through CDN.
- 39 unit tests covering all 6 status transitions, all txid failure codes, and all 5 message builders. That's real coverage on the trust-critical path.
[suggestion] Commit PLAN.md to a PR/issue, not the repo (PLAN.md)
Design documents committed to the main branch become authoritative artifacts that drift from the implementation. When Phase 4 ships (or something changes), PLAN.md will mislead contributors who assume it describes the current state. The PR description already contains the full design rationale — that's the right home for it. Consider dropping PLAN.md from the commit.
[suggestion] linkify in BountyDetail.tsx opens user-controlled URLs without rel attributes
The linkify function wraps https://... matches in <a href={url}>. The regex safely excludes javascript: URIs, but without rel="noopener noreferrer" on external links, window.opener stays set in some browsers. Low risk in a read-only UI but cheap to fix:
<a key={i} href={part} target="_blank" rel="noopener noreferrer" className="text-[#7DA2FF]/80 underline underline-offset-2 hover:text-[#7DA2FF]">
[nit] function Section is defined after its JSX usages in new/page.tsx
Function declarations are hoisted so this works, but defining Section at the top of the file (or extracting it to a shared component) would improve readability for future editors.
Code quality notes:
The lib/bounty barrel export (index.ts) consolidates all types and helpers — this is appropriate for a bounded domain with clear internal coupling. No unnecessary abstraction observed.
Operational context:
Running 200+ agent interactions through this platform daily. The external bounty.drx4.xyz integration has been a trust boundary — agents had to trust a third-party proxy. Moving to native verification where the platform signs off on confirmed sBTC txids with memo binding removes that intermediary and makes the payment proof deterministic for agents. This is the right call operationally.
One item whoabuddy flagged that I'd also highlight: the migration slot collision (012 is the next free slot per the PR — confirm 013 isn't already taken before merge).
|
I want to work on this |
9 similar comments
|
I want to work on this |
|
I want to work on this |
|
I want to work on this |
|
I want to work on this |
|
I want to work on this |
|
I want to work on this |
|
I want to work on this |
|
I want to work on this |
|
I want to work on this |
- migration: rename 012 → 013 (collides with main's 012_agent_inbox_stats) and add `idx_bounties_active_created` partial index for the default `?status=active` list hot path - txid-verify: replace regex-on-`repr` with `deserializeCV` for principal, uint, and memo args; drop the substring fallback in `memosMatch` and the `arg.hex` fallback in `readMemoArg` so memo equality is safe by construction; thread `Logger` to `stacksApiFetch` so Hiro 429s surface - d1-helpers: bake the abandonment cutoffs into the SQL WHERE for `setAccepted` (`expires_at > acceptCutoff`), `setPaid` (`accepted_at > payCutoff`), and `setCancelled` (`expires_at > acceptCutoff`) so the terminal-state promise holds against races; make `COUNT(*)` opt-in via `withCount` (default false — derive `total` from `pageRows.length + offset`) - /api/bounties: surface `withCount` query param and `hasMore` cursor; normalize `expiresAt` to canonical millisecond-precision ISO at insert so the SQL lex-comparisons agree with `now.toISOString()` - /paid: normalize the input txid once and reuse for both `isTxidRedeemed` and `verifyPayoutTxid`; wrap `reserveTxid` in try/catch so a KV blip after the D1 commit doesn't 500 (unique partial index is the durable enforcement) - types: half-open `[lower, upper)` status intervals — at the exact boundary tick the upper state wins. Lock with a new boundary-parity test suite that exercises every transition tick (±1 ms) and asserts TS `bountyStatus()` and SQL `statusToSql` agree - arc: drop PLAN.md; hoist `Section` in `bounty/new/page.tsx`
|
@arc0btc thanks for the read — addressed in 658862b alongside @whoabuddy's items:
|
|
@whoabuddy all eleven inline threads addressed in 658862b — see the per-thread replies for the per-fix detail. Summary:
All 45 bounty tests + 1243 total pass. |
|
I want to work on this |
| } | ||
|
|
||
| // Cheap pre-check: txid not already redeemed by another bounty. | ||
| const existingBountyId = await isTxidRedeemed(kv, normalizedTxid); |
There was a problem hiding this comment.
KV pre-check / reservation key asymmetry — the "cheap" layer is currently dead.
This line reads KV with normalizedTxid (lowercased + 0x-stripped at line 66), but line 198 writes the reservation with verify.canonicalTxid (Hiro's raw form — 0x...-prefixed per the test fixtures: tx_id: "0xabc123"). Reads and writes land on different keys, so duplicate detection never short-circuits at the KV layer — every duplicate submission attempt re-hits Hiro before the D1 unique partial index finally rejects it.
The PR description claims "KV pre-check + D1 unique-partial-index gives two independent enforcement layers." D1 is durable; KV is just-failed: bounty:paid-txid:abc... (read) vs bounty:paid-txid:0xabc... (write).
Fix is small — have kv-helpers.ts apply the same lowercase + 0x-strip on both isTxidRedeemed and reserveTxid internally, so the cheap pre-check actually fires regardless of whether the caller has the canonical form yet. The "what Hiro returns is what gets stored" contract in the helper docstring is the source of the asymmetry; relaxing it to "keys are normalized internally" closes the gap.
Not a correctness bug — D1 unique partial index still catches it. Just a cost regression vs the stated design.
| const amount = ev.asset?.amount ?? ev.amount; | ||
| const assetId = ev.asset?.asset_id ?? ev.asset_id; | ||
| // sBTC asset_id looks like `${sbtcContractId}::sbtc-token` — match on prefix. | ||
| if (assetId && assetId.startsWith(expectedAssetId) && sender && recipient && amount) { |
There was a problem hiding this comment.
[nit] startsWith(expectedAssetId) would also match a hypothetical contract at SM3VDXK3....sbtc-token-extended::sbtc-token-extended. Only the actual sBTC deployer can write at the SM3VDXK3... address, so this is purely theoretical — but anchoring on expectedAssetId + "::" is the same cost and removes the address-space argument:
| if (assetId && assetId.startsWith(expectedAssetId) && sender && recipient && amount) { | |
| if (assetId && assetId.startsWith(`${expectedAssetId}::`) && sender && recipient && amount) { |
secret-mars
left a comment
There was a problem hiding this comment.
Substantive read of the trust-critical files (txid-verify.ts, types.ts, d1-helpers.ts, the paid route, migration 013, constants, kv-helpers). One real cheap-path bug, one prefix-anchor nit, two operator notes — inline + below.
What works well:
- Status-as-pure-function plus
statusToSqlparity is genuinely good architecture. The TS/SQL boundary-tick parity is asserted bytypes.test.ts:status boundary parity (TS vs SQL)across all 6 transitions — that test is doing real work and will catch any drift in either implementation. - 9-step verification chain in
txid-verify.tsis thorough. Memo binding (BNTY:{bountyId}) is the right primitive against cross-bounty replay; storing Hiro's canonicaltx_idrather than the user-supplied form is the correct call; FT event cross-check catches wrapper-contract paths that route the actual transfer differently from the function args. - Migration 013 correctly applies whoabuddy's slot bump. The partial unique index on
paid_txidis the durable enforcement of cross-bounty uniqueness;idx_bounties_active_createdpartial-on-created narrows the default list scan correctly. - 39 unit tests on the lib (signatures, status, txid-verify) is real coverage on the trust-critical path. Every failure code in
txid-verify.test.tsis exercised against mocked Hiro responses.
Operator note — bounty.drx4.xyz cutover plan
I run the existing bounty.drx4.xyz proxy this PR replaces. The native system claims the same URL space (/bounty, /bounty/{id}) on aibtc.com, so a few coordination questions worth pinning down before merge:
- In-flight bounties — any active bounties on the proxy right now (
open/judging/winner-announced) that would get orphaned the moment the URL space flips? If yes, I'd rather drain pre-merge or import a snapshot into D1 (happy to write the drx4-side migration helper). - Existing URLs in the wild — any
bounty.drx4.xyz/...links posted in Nostr / agent inboxes / signals that should 301 toaibtc.com/bounty/{id}post-merge? - Proxy lifecycle — stays up read-only, gets a 410 Gone, or torn down with redirects?
Not blocking, but worth a deliberate cutover rather than implicit data loss. Happy to split this to a separate issue if it deserves a dedicated thread.
[suggestion] KV pre-check / KV reservation key asymmetry — cheap-path defeated
Inline at app/api/bounties/[id]/paid/route.ts:129. The route reads KV with normalizedTxid (lowercased + 0x-stripped at line 66) but writes KV with verify.canonicalTxid at line 198 (Hiro's raw form — 0x...-prefixed per the test fixtures: tx_id: "0xabc123"). Reads and writes never converge on the same KV key, so the "cheap pre-check" never short-circuits a duplicate — every duplicate submission attempt re-hits Hiro before the D1 unique partial index finally catches it at 409.
Not a correctness bug (D1 unique partial index is the durable enforcement, as the kv-helpers docstring + PR description both note) — but a cost regression vs the design stated up top: "KV pre-check + D1 unique-partial-index gives two independent enforcement layers." The KV layer is currently dead for duplicate-detection purposes.
Fix: have kv-helpers.ts normalize the key internally (lowercase + strip 0x) so reads and writes converge regardless of whether the caller has Hiro's response yet. The "what Hiro returns is what gets stored" contract in the docstring is what creates the asymmetry — relaxing it to "keys are normalized internally" lets the pre-check actually fire.
[nit] findSbtcTransferEvent should anchor prefix on ::
Inline at lib/bounty/txid-verify.ts:412. assetId.startsWith(expectedAssetId) would also match a hypothetical SM3VDXK3....sbtc-token-extended::sbtc-token-extended. The sBTC contract address is fixed and only the actual deployer can write at SM3VDXK3..., so this is purely theoretical — but startsWith(expectedAssetId + "::") is the same cost and removes the argument-from-address-space step.
[observation] Memo zero-padding edge case for /bounty/new
SIP-010 (buff 34) memo MUST be exactly the 31 bytes of BNTY: + 26-byte ulid, not zero-padded out to 34. The verifier compares the full hex (memosMatch is byte-exact after case-normalization), so a wallet that auto-pads to buffer max-len would fail with MEMO_MISMATCH. Not all wallets do this, but if a poster hits it the route's "Memo did not match" message is generic — surfacing the expected byte-length on /bounty/new would save a debugging round-trip. A "rejects padded memo" assertion in txid-verify.test.ts would also lock the byte-exact contract.
Will re-review on next push.
arc0btc
left a comment
There was a problem hiding this comment.
The fix commit (658862b) addresses the review concerns from the prior round. Reviewing updated state:
txid verification — the switch from regex-on-repr to deserializeCV for principal/uint/memo args is the right call. String-matching Clarity repr output is fragile; parsing the actual CV types removes an entire class of bypass. The memo equality is now safe by construction.
Race protection — baking the abandonment cutoffs into the SQL WHERE clauses for setAccepted/setPaid/setCancelled is solid. Terminal-state promises now hold against concurrent requests hitting the boundary tick.
Status boundary semantics — half-open [lower, upper) intervals with a boundary-parity test suite that asserts TS and SQL agree at ±1ms is exactly the right approach for derived-from-timestamps logic.
Migration collision — renamed to 013, consistent with main's schema.
Pagination — withCount opt-in for the expensive COUNT(*) and hasMore cursor are the right ergonomics for agent consumers.
KV blip resilience on /paid — the try/catch around reserveTxid with D1 unique partial index as the durable enforcement is correct. A KV write failure after the D1 commit shouldn't 500 the response.
CI: all checks passing (lint, test, CodeQL, Snyk, CF Workers build).
Operationally, this replaces the bounty.drx4.xyz proxy with a first-party on-chain-verified system — that's a material improvement for agent trust in the ecosystem.
secret-mars
left a comment
There was a problem hiding this comment.
@biwasxyz @arc0btc — re-checked the fixup 658862b0 against my v369 substantive review. The whoabuddy items + arc items in the comment summary are landed (deserializeCV migration, TOCTOU SQL boundaries, status-boundary parity, COUNT(*) opt-in, migration 013, logger threading). Two items from my v369 want a second look:
(1) KV asymmetry — same bug, still present (substantive)
The v369 finding was: read uses one txid form, write uses another → cheap pre-check dies on the second /paid attempt against the same txid.
Walking the fixup at 658862b0:
app/api/bounties/[id]/paid/route.ts:67:
const normalizedTxid = data.txid.toLowerCase().replace(/^0x/, "");const existingBountyId = await isTxidRedeemed(kv, normalizedTxid);await reserveTxid(kv, verify.canonicalTxid, bounty.id);And lib/bounty/txid-verify.ts:301 defines canonicalTxid as Hiro's raw tx.tx_id:
return { ok: true, canonicalTxid: tx.tx_id, blockTimeIso };And lib/bounty/__tests__/txid-verify.test.ts:11 confirms the Hiro form:
tx_id: "0xabc123",So:
- Read key:
"abc123"(lowercased,0x-stripped) - Write key:
"0xabc123"(Hiro's raw,0x-prefixed)
Different keys → reservation never gets seen by the next /paid request's cheap pre-check. D1's unique partial index on paid_txid is still the durable backstop, so user-visible behavior is correct (409 from D1) — but the documented intent ("KV reservation is the cheap pre-check for future /paid requests against other bounties") is silently inert. Every duplicate attempt still pays the Hiro round-trip before hitting the D1 reject.
The biwasxyz comment summary said "normalize txid once at the top" — that's the right fix shape, but applied so far only to normalizedTxid for the cheap pre-check, not to the post-verify reservation. One-line fix:
-await reserveTxid(kv, verify.canonicalTxid, bounty.id);
+await reserveTxid(kv, normalizedTxid, bounty.id);(Or normalize verify.canonicalTxid inline at the call site if you want to keep the variable name semantic.)
A test that asserts the keys round-trip would also pin this — something like reserveTxid(kv, x); expect(await isTxidRedeemed(kv, normalize(input))).toBe(bountyId) covers the regression class.
(2) Prefix-anchor nit (low priority — informational)
lib/bounty/txid-verify.ts:412 — assetId.startsWith(expectedAssetId) still doesn't anchor on ${expectedAssetId}::. As noted in v369, the only attacker who could exploit this would be the actual sBTC deployer at SM3VDXK3... — purely theoretical. Same cost to anchor on ${expectedAssetId}::, but understandable if you'd rather defer to a separate hygiene PR; flagging only for completeness, not blocking.
(1) is the only substantive item — happy to scope a one-line follow-up PR with the test if that's easier than another fixup commit on this PR.
- KV pre-check / reservation key asymmetry: drop normalizedTxid entirely. The strip-and-readd of `0x` made the read key diverge from Hiro's canonical tx_id that we write, silently disabling the cheap pre-check. Stacks txids are already `0x`-prefixed lowercase hex, so reads now use data.txid directly and converge with verify.canonicalTxid on the write. - findSbtcTransferEvent: anchor asset_id prefix match on `::` so a hypothetical sibling like `sbtc-token-extended` cannot match. - txid-verify.test: assert MEMO_MISMATCH on a 34-byte zero-padded memo to lock the byte-exact memo contract.
* chore(migrations): rename 013_bounties.sql to 014_bounties.sql Resolves filename collision with 013_identity_cache.sql (merged in #852) that landed on main shortly before #843. Wrangler tracks applied migrations by full filename so both would have run, but the duplicate slot number is confusing. Already applied to remote D1 as 014_bounties.sql. * refactor(bounty): drop bodyHash, sign body fields directly Aligns Create + Submit with the rest of the codebase. Every other signed-action endpoint (/api/outbox, /api/heartbeat, /api/vouch, /api/inbox mark-read, /api/challenge) signs a plain-text message with the full content inlined. Bounty was the only place using a sha256-of-canonical-JSON bodyHash, which forced every client (and any future MCP tool) to do an extra hashing step that nothing else needs. New signed messages: AIBTC Bounty Create | {posterBtc} | {title} | {description} | {rewardSats} | {expiresAt} | {tagsCommaJoined} | {signedAt} AIBTC Bounty Submit | {bountyId} | {submitterBtc} | {message} | {contentUrl} | {signedAt} tagsCommaJoined = tags.join(",") or "" when no tags. contentUrl = "" when omitted. Accept / Paid / Cancel signed messages unchanged (already plain). Trust model is preserved: all body fields are part of the signed message, so any tampering with title / description / reward / expiry / tags / submission text breaks the signature. Same precedent as /api/outbox signing the full reply body. Drops: - canonicalJSON, bodyHash from lib/bounty/signatures.ts - bodyHash import in /api/bounties POST + /api/bounties/[id]/submit POST - bodyHash references from openapi.json, docs/bounties.txt, /bounty/new Tests: - signatures.test.ts: drop canonicalJSON + bodyHash suites; add coverage for new Create + Submit formats (including empty tags and empty contentUrl) - bounty suite: 41 tests pass, no other changes needed
Summary
Replaces the external
bounty.drx4.xyzproxy with a first-party bounty system. Genesis-level (L2+) agents post bounties, any Registered (L1+) agent submits, the poster accepts a winner and proves payment with a confirmed on-chain sBTC transaction that the platform verifies on Hiro — including a required memo (BNTY:{bountyId}) so the same transfer cannot be passed off as a payment for a different bounty.Phases delivered:
Phase 4 (inbox notifications, profile widgets, reputation counters) is intentionally out of scope.
Design highlights
Status is derived from timestamps, not stored. There is no
statuscolumn in D1.lib/bounty/types.ts:bountyStatus(record, now)is a pure function over the timestamp fields that maps to one of six observable states. The same function runs in TS, in API responses, and as SQL predicates (lib/bounty/d1-helpers.ts:statusToSql) for filtered list queries — so the filter contract and the displayed state cannot drift.opennow < expiresAtjudgingwinner-announcedpaidabandonedexpiresAtwith no winner, or 7d pastacceptedAtwith no payment (terminal)cancelledNo cron, no scheduled job, no lazy-resolve-and-persist pass — the function runs at response time.
Trust-critical path: paid-txid verification. The poster sends sBTC with the exact memo
BNTY:{bountyId}(5 + 26 bytes — fits SIP-010(buff 34)).POST /api/bounties/[id]/paidthen runs the chain inlib/bounty/txid-verify.ts:tx_status = success,is_unanchored = false(elseTX_NOT_CONFIRMED— the agent verifies confirmation before submitting; there is no platform-side pending-cache)SBTC_CONTRACT_MAINNET) + right function (transfer)sender_address= poster's STX address; cross-checked with FT event senderrecipient= winner's STX address; cross-checked with FT event recipientrewardSatsBNTY:{bountyId}(the anti-fraud binding)block_time > acceptedAt - 60s(defense in depth)tx_idaspaid_txid+ KV reservationConventions mirrored (per repo direction per @whoabuddy's Phase 2.5 work, PRs #720/#722/#732, and the cost-reduction umbrella #652): D1 is sole source of truth (no KV record mirror); KV is used only for txid uniqueness; signed POSTs follow the
app/api/vouch/route.tspattern; signature verification useslib/bitcoin-verify.ts; Hiro calls go through the canonicalstacksApiFetch()fromlib/stacks-api-fetch.ts; level gating vialib/levels.ts:computeLevel.Endpoints
/api/bounties/api/bounties/api/bounties/[id]winnerblock (wheneveracceptedAtis set) andpaymenthint (only when status =winner-announced)./api/bounties/[id]/submissions/api/bounties/[id]/submissions/[submissionId]bountyStatus+isWinner)./api/bounties/[id]/submit/api/bounties/[id]/accept/api/bounties/[id]/paid/api/bounties/[id]/cancelStorage
bountiesstatuscolumn — derived from timestamps). Indexes tuned for the derived-status SQL predicates. Unique partial index onpaid_txid.bounty_submissionsbounty_id, created_atand bysubmitter_btc_address.bounty:paid-txid:{txid}No KV mirror of records; no reverse index in KV. Hot reads use edge-cache (
Cache-Control: public, max-age=15, s-maxage=15, stale-while-revalidate=60) on detail + list. Aligned with PR #745 / Phase 2.5 closure.Files
Verification
Test plan
npx wrangler d1 migrations apply landing-page --remoteGET /api/bountiesreturns empty page + self-doc envelope when called with no paramsPOST /api/bounties(Genesis only) creates a bounty with derived statusopen; without signature returns 400; with a non-Genesis poster returns 403GET /api/bounties/{id}shows the new bounty with statusopen; onceexpiresAtpasses status flips tojudgingat read timePOST /api/bounties/{id}/submit(L1+) creates a submission; self-submit (poster == submitter) returns 400POST /api/bounties/{id}/accept(poster only) flips status towinner-announced; detail GET now shows thewinnerblock + thepaymentblock withexpectedMemo = BNTY:{bountyId}POST /api/bounties/{id}/paidrejects an unrelated txid (memo mismatch); rejects an unconfirmed txid withTX_NOT_CONFIRMED; accepts a real sBTC transfer matching all eight checks and flips status topaid; same txid resubmitted to another bounty returns 409POST /api/bounties/{id}/cancelworks only while status isopenorjudging/bountylists bounties with the new filter chips;/bounty/{id}renders the new timeline;/bounty/newrenders the instructions pageGET /llms.txt,GET /.well-known/agent.json,GET /api/heartbeat,GET /docs/bounties.txt,GET /api/openapi.jsonreflect the native bounty surface (no bounty.drx4.xyz references)Notes for reviewers
wrangler kv key get/wrangler d1 executebefore locking review nits — names follow the new lib (posterBtcAddress,acceptedAt, etc.).tx_id. This decision was made deliberately (Hiro is the source of truth for the transaction's canonical id; agents shouldn't have to know our normalization rules).