Skip to content

feat(bounty): native bounty system — replaces bounty.drx4.xyz proxy#843

Merged
biwasxyz merged 7 commits into
mainfrom
feat/bounty-system-backend
May 16, 2026
Merged

feat(bounty): native bounty system — replaces bounty.drx4.xyz proxy#843
biwasxyz merged 7 commits into
mainfrom
feat/bounty-system-backend

Conversation

@biwasxyz
Copy link
Copy Markdown
Contributor

Summary

Replaces the external bounty.drx4.xyz proxy 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 1 — backend: D1 migration, lib, 9 API routes, 39 unit tests
  • Phase 2 — discovery: agent.json / llms.txt / llms-full.txt / heartbeat / skill.md / docs / openapi.json / CLAUDE.md
  • Phase 3 — UX: /bounty pages rewired from bounty.drx4.xyz to /api/bounties; new /bounty/new instructions page

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 status column 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.

Status Meaning
open Accepting submissions; now < expiresAt
judging Submission window closed; poster reviewing
winner-announced Poster accepted a submission; awaiting payment
paid Payment txid verified on-chain (terminal)
abandoned Poster ghosted past a grace window — 14d past expiresAt with no winner, or 7d past acceptedAt with no payment (terminal)
cancelled Poster killed it before any acceptance (terminal)

No 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]/paid then runs the chain in lib/bounty/txid-verify.ts:

  1. KV pre-check: txid not already redeemed by another bounty
  2. Hiro fetch — tx_status = success, is_unanchored = false (else TX_NOT_CONFIRMED — the agent verifies confirmation before submitting; there is no platform-side pending-cache)
  3. Right contract (SBTC_CONTRACT_MAINNET) + right function (transfer)
  4. sender_address = poster's STX address; cross-checked with FT event sender
  5. function arg recipient = winner's STX address; cross-checked with FT event recipient
  6. amount ≥ rewardSats
  7. memo equals BNTY:{bountyId} (the anti-fraud binding)
  8. block_time > acceptedAt - 60s (defense in depth)
  9. Store Hiro's canonical tx_id as paid_txid + KV reservation

Conventions 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.ts pattern; signature verification uses lib/bitcoin-verify.ts; Hiro calls go through the canonical stacksApiFetch() from lib/stacks-api-fetch.ts; level gating via lib/levels.ts:computeLevel.

Endpoints

Route Method Auth Notes
/api/bounties GET List + self-doc on no params. Filters: `?status=open
/api/bounties POST sig (L2+) Create bounty. Signed message: `AIBTC Bounty Create
/api/bounties/[id] GET Detail. Response includes denormalized winner block (whenever acceptedAt is set) and payment hint (only when status = winner-announced).
/api/bounties/[id]/submissions GET Paginated submissions for one bounty.
/api/bounties/[id]/submissions/[submissionId] GET Single submission permalink (returns bountyStatus + isWinner).
/api/bounties/[id]/submit POST sig (L1+) Submit work. Self-submit (poster == submitter) is rejected.
/api/bounties/[id]/accept POST sig (poster) Pick a winner.
/api/bounties/[id]/paid POST sig (poster) Prove payment with confirmed txid. Full on-chain verification.
/api/bounties/[id]/cancel POST sig (poster) Cancel before acceptance.

Storage

Where What
D1 bounties Bounty rows (no status column — derived from timestamps). Indexes tuned for the derived-status SQL predicates. Unique partial index on paid_txid.
D1 bounty_submissions One row per submission. Indexed by bounty_id, created_at and by submitter_btc_address.
KV bounty:paid-txid:{txid} Cross-bounty txid uniqueness (365-day TTL). D1 unique partial index is the durable enforcement; KV reservation is the cheap pre-check.

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

migrations/
  012_bounties.sql

lib/bounty/
  types.ts                    BountyRecord, BountySubmission, BountyStatus, bountyStatus()
  constants.ts                Limits, grace windows (14d accept / 7d pay), memo prefix, sBTC contracts
  signatures.ts               canonicalJSON, bodyHash, build*Message helpers, isWithinSignatureWindow
  validation.ts               validateCreateBounty / Submit / Accept / Paid / Cancel
  d1-helpers.ts               insertBounty, getBounty, listBounties (statusToSql), setAccepted/Paid/Cancelled, submission helpers
  kv-helpers.ts               isTxidRedeemed, reserveTxid (txid uniqueness only)
  txid-verify.ts              buildExpectedMemo, verifyPayoutTxid (Hiro-backed via stacksApiFetch; returns canonical tx_id)
  id.ts                       generateBountyId, generateSubmissionId
  index.ts                    barrel export
  __tests__/
    signatures.test.ts        15 tests — canonicalJSON, bodyHash, all 5 message builders, replay window
    types.test.ts             9 tests — bountyStatus() across all 6 states + grace transitions + boundary
    txid-verify.test.ts       15 tests — buildExpectedMemo + every TxidVerifyFailureCode with mocked Hiro

app/api/bounties/
  route.ts                              GET (list + self-doc) + POST (create)
  [id]/route.ts                          GET (detail with winner + payment blocks)
  [id]/submissions/route.ts              GET (paginated)
  [id]/submissions/[submissionId]/route.ts  GET (single permalink)
  [id]/submit/route.ts                   POST
  [id]/accept/route.ts                   POST
  [id]/paid/route.ts                     POST
  [id]/cancel/route.ts                   POST

app/bounty/                  (replaces external proxy — same URL space)
  types.ts                   Re-exports lib/bounty types
  utils.ts                   STATUS_STYLES, STATUS_LABELS, submissionWindowLabel for the 6 new states
  page.tsx                   Server component — calls lib/bounty:listBounties directly via getCloudflareContext
  BountyDirectory.tsx        List + filter chips (new state set) + "Post a bounty" CTA
  [id]/page.tsx              Server component — fetches detail via @/lib/bounty
  [id]/BountyDetail.tsx      Detail UI with new 4-step timeline + denormalized Winner card + Payment Hint card
  new/page.tsx               Instructions page for posters (canonical body hash → sign → POST)

app/.well-known/agent.json/route.ts      Ecosystem entry + new bounty-system skill
app/llms.txt/route.ts                    L2+ ecosystem guidance + bounty board link
app/llms-full.txt/route.ts               L2+ guidance + ecosystem services section + ecosystem resources
app/api/heartbeat/route.ts               L2+ nextAction
app/skill.md/route.ts                    Actions table — three bounty endpoints
app/docs/route.ts                        Index — adds bounties topic
app/docs/[topic]/route.ts                Adds full bounties topic doc
app/api/openapi.json/route.ts            9 paths + 14 schemas
CLAUDE.md                                New Bounty System section + KV row
PLAN.md                                  Full design doc

Verification

$ npm run lint
clean for all bounty / discovery files

$ npx tsc --noEmit
clean for all bounty / discovery files
(pre-existing test-file failures elsewhere are unrelated)

$ npm run test -- lib/bounty
✓ lib/bounty/__tests__/types.test.ts        (9 tests)
✓ lib/bounty/__tests__/txid-verify.test.ts  (15 tests)
✓ lib/bounty/__tests__/signatures.test.ts   (15 tests)
Test Files  3 passed (3)
     Tests  39 passed (39)

Test plan

  • Apply migration 012 in preview: npx wrangler d1 migrations apply landing-page --remote
  • GET /api/bounties returns empty page + self-doc envelope when called with no params
  • POST /api/bounties (Genesis only) creates a bounty with derived status open; without signature returns 400; with a non-Genesis poster returns 403
  • GET /api/bounties/{id} shows the new bounty with status open; once expiresAt passes status flips to judging at read time
  • POST /api/bounties/{id}/submit (L1+) creates a submission; self-submit (poster == submitter) returns 400
  • POST /api/bounties/{id}/accept (poster only) flips status to winner-announced; detail GET now shows the winner block + the payment block with expectedMemo = BNTY:{bountyId}
  • POST /api/bounties/{id}/paid rejects an unrelated txid (memo mismatch); rejects an unconfirmed txid with TX_NOT_CONFIRMED; accepts a real sBTC transfer matching all eight checks and flips status to paid; same txid resubmitted to another bounty returns 409
  • POST /api/bounties/{id}/cancel works only while status is open or judging
  • /bounty lists bounties with the new filter chips; /bounty/{id} renders the new timeline; /bounty/new renders the instructions page
  • GET /llms.txt, GET /.well-known/agent.json, GET /api/heartbeat, GET /docs/bounties.txt, GET /api/openapi.json reflect the native bounty surface (no bounty.drx4.xyz references)

Notes for reviewers

  • Sample real records via wrangler kv key get / wrangler d1 execute before locking review nits — names follow the new lib (posterBtcAddress, acceptedAt, etc.).
  • The agent submits the txid as-is from their wallet — no normalization on our side. The server passes it through to Hiro and persists Hiro's canonical 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).
  • No participant locking ("claimed" status) intentionally — submissions are open and append-only, which is what other modern bounty platforms converge on.

biwasxyz added 3 commits May 14, 2026 13:34
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).
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 14, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
landing-page 1658ab3 May 16 2026, 12:15 PM

Comment thread lib/bounty/d1-helpers.ts Fixed
biwasxyz added 2 commits May 14, 2026 14:03
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.
Copy link
Copy Markdown
Contributor

@whoabuddy whoabuddy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 012013 (next free slot) before merge.

Comment thread lib/bounty/txid-verify.ts
Comment thread lib/bounty/txid-verify.ts
Comment thread lib/bounty/d1-helpers.ts Outdated
Comment thread lib/bounty/d1-helpers.ts
Comment thread lib/bounty/d1-helpers.ts
Comment thread app/api/bounties/[id]/paid/route.ts Outdated
Comment thread migrations/012_bounties.sql Outdated
Comment thread lib/bounty/types.ts Outdated
Comment thread lib/bounty/d1-helpers.ts Outdated
Comment thread lib/bounty/txid-verify.ts Outdated
@sunzhihuabj
Copy link
Copy Markdown

I want to work on this

Copy link
Copy Markdown
Contributor

@arc0btc arc0btc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 statusToSql keeping 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; canonical tx_id storage 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).

@sunzhihuabj
Copy link
Copy Markdown

I want to work on this

9 similar comments
@sunzhihuabj
Copy link
Copy Markdown

I want to work on this

@sunzhihuabj
Copy link
Copy Markdown

I want to work on this

@sunzhihuabj
Copy link
Copy Markdown

I want to work on this

@sunzhihuabj
Copy link
Copy Markdown

I want to work on this

@sunzhihuabj
Copy link
Copy Markdown

I want to work on this

@sunzhihuabj
Copy link
Copy Markdown

I want to work on this

@sunzhihuabj
Copy link
Copy Markdown

I want to work on this

@sunzhihuabj
Copy link
Copy Markdown

I want to work on this

@sunzhihuabj
Copy link
Copy Markdown

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`
@biwasxyz
Copy link
Copy Markdown
Contributor Author

@arc0btc thanks for the read — addressed in 658862b alongside @whoabuddy's items:

  • Drop PLAN.md — gone. Agree the PR description is the right home for design rationale.
  • linkify rel="noopener noreferrer" — already on every target="_blank" in BountyDetail.tsx (lines 24, 204, 238, 283, 297). The current linkify wraps URLs with target="_blank" rel="noopener noreferrer" and an inline-flex external-link style. No change needed; flagging here so the thread reflects reality.
  • Hoist Section in bounty/new/page.tsx — moved above NewBountyPage for readability.
  • Migration slot collision — bumped to 013_bounties.sql (main's 012_agent_inbox_stats.sql confirmed via git ls-tree origin/main migrations/).

@biwasxyz
Copy link
Copy Markdown
Contributor Author

@whoabuddy all eleven inline threads addressed in 658862b — see the per-thread replies for the per-fix detail. Summary:

  • Memo path — switched principal/uint/memo readers to deserializeCV + ClarityType matching; dropped both the includes substring fallback and the arg.hex fallback.
  • TOCTOU on grace boundaries — baked the cutoff into the SQL WHERE for setAccepted / setPaid / setCancelled. abandoned is now SQL-terminal, not just probabilistically terminal.
  • Status-boundary parity — picked half-open intervals; flipped TS bountyStatus() to >= and SQL abandoned to <= so they agree at the exact tick. New types.test.ts > status boundary parity (TS vs SQL) suite locks it. Also caught a real expiresAt format-drift bug: the create route now normalizes expiresAt to .000Z-suffixed ISO at insert so the SQL lex-compares against now.toISOString() are well-defined.
  • COUNT(*) hot pathlistBounties takes withCount?: boolean (default false); total becomes rows.length + offset and /api/bounties returns a separate hasMore cursor.
  • /paid route — normalize txid once at the top; wrap reserveTxid in try/catch (log-and-continue).
  • Migration — renamed to 013_bounties.sql; added idx_bounties_active_created partial index for the default ?status=active predicate.
  • Logger to stacksApiFetch — threaded through verifyPayoutTxid.

All 45 bounty tests + 1243 total pass.

@sunzhihuabj
Copy link
Copy Markdown

I want to work on this

Comment thread app/api/bounties/[id]/paid/route.ts Outdated
}

// Cheap pre-check: txid not already redeemed by another bounty.
const existingBountyId = await isTxidRedeemed(kv, normalizedTxid);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/bounty/txid-verify.ts Outdated
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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
if (assetId && assetId.startsWith(expectedAssetId) && sender && recipient && amount) {
if (assetId && assetId.startsWith(`${expectedAssetId}::`) && sender && recipient && amount) {

Copy link
Copy Markdown
Contributor

@secret-mars secret-mars left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 statusToSql parity is genuinely good architecture. The TS/SQL boundary-tick parity is asserted by types.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.ts is thorough. Memo binding (BNTY:{bountyId}) is the right primitive against cross-bounty replay; storing Hiro's canonical tx_id rather 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_txid is the durable enforcement of cross-bounty uniqueness; idx_bounties_active_created partial-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.ts is 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:

  1. 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).
  2. Existing URLs in the wild — any bounty.drx4.xyz/... links posted in Nostr / agent inboxes / signals that should 301 to aibtc.com/bounty/{id} post-merge?
  3. 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.

Copy link
Copy Markdown
Contributor

@arc0btc arc0btc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

PaginationwithCount 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.

Copy link
Copy Markdown
Contributor

@secret-mars secret-mars left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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/, "");

route.ts:131-132 — read:

const existingBountyId = await isTxidRedeemed(kv, normalizedTxid);

route.ts:199 — write:

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:412assetId.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.
@biwasxyz biwasxyz merged commit 1904698 into main May 16, 2026
8 checks passed
@biwasxyz biwasxyz deleted the feat/bounty-system-backend branch May 16, 2026 12:17
biwasxyz added a commit that referenced this pull request May 16, 2026
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.
biwasxyz added a commit that referenced this pull request May 16, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants