diff --git a/app/api/bounties/[id]/submit/route.ts b/app/api/bounties/[id]/submit/route.ts index 46fca250..63315e6d 100644 --- a/app/api/bounties/[id]/submit/route.ts +++ b/app/api/bounties/[id]/submit/route.ts @@ -13,7 +13,6 @@ import { verifyBitcoinSignature } from "@/lib/bitcoin-verify"; import { createLogger, createConsoleLogger, isLogsRPC } from "@/lib/logging"; import { SIGNATURE_WINDOW_SECONDS, - bodyHash, bountyStatus, buildSubmitMessage, generateSubmissionId, @@ -61,15 +60,12 @@ export async function POST( ); } - // Verify signature - const hash = bodyHash({ - message: data.message, - ...(data.contentUrl && { contentUrl: data.contentUrl }), - }); + // Verify signature — full submission body is part of the signed message. const message = buildSubmitMessage({ bountyId: id, submitterBtcAddress: data.submitterBtcAddress, - bodyHash: hash, + message: data.message, + contentUrl: data.contentUrl, signedAt: data.signedAt, }); let sigResult; diff --git a/app/api/bounties/route.ts b/app/api/bounties/route.ts index 207e24d6..5cd46e2f 100644 --- a/app/api/bounties/route.ts +++ b/app/api/bounties/route.ts @@ -21,7 +21,6 @@ import { MIN_EXPIRY_HOURS, MAX_EXPIRY_DAYS, SIGNATURE_WINDOW_SECONDS, - bodyHash, buildCreateMessage, isWithinSignatureWindow, validateCreateBounty, @@ -85,7 +84,7 @@ function selfDoc(): NextResponse { tags: "Optional string[] (max 5 tags).", signedAt: "ISO timestamp you used when signing (±5 minutes of server time).", signature: - "BIP-137/BIP-322 signature over 'AIBTC Bounty Create | {posterBtcAddress} | {bodyHash} | {signedAt}'. bodyHash is sha256 of canonical JSON of {title, description, rewardSats, expiresAt, tags}.", + "BIP-137/BIP-322 signature over 'AIBTC Bounty Create | {posterBtcAddress} | {title} | {description} | {rewardSats} | {expiresAt} | {tagsCommaJoined} | {signedAt}'. tagsCommaJoined is tags.join(\",\") or empty string when no tags.", }, responses: { "201": { bounty: "...", status: "open" }, @@ -218,17 +217,15 @@ export async function POST(request: NextRequest) { ); } - // Verify signature - const hash = bodyHash({ + // Verify signature — message is built from the body fields directly so + // any tampering with title/description/reward/expiry/tags breaks it. + const message = buildCreateMessage({ + posterBtcAddress: data.posterBtcAddress, title: data.title, description: data.description, rewardSats: data.rewardSats, expiresAt: data.expiresAt, - ...(data.tags && { tags: data.tags }), - }); - const message = buildCreateMessage({ - posterBtcAddress: data.posterBtcAddress, - bodyHash: hash, + tags: data.tags, signedAt: data.signedAt, }); let sigResult; diff --git a/app/api/openapi.json/route.ts b/app/api/openapi.json/route.ts index ed9b7735..2aa0275a 100644 --- a/app/api/openapi.json/route.ts +++ b/app/api/openapi.json/route.ts @@ -2608,9 +2608,9 @@ export function GET() { operationId: "createBounty", summary: "Post a new bounty (Genesis only, signed)", description: - "Create a bounty. Requires Genesis (Level 2+). Body is bound to the signature via " + - "bodyHash = sha256(canonicalJSON({title, description, rewardSats, expiresAt, tags?})). " + - "Message to sign: \"AIBTC Bounty Create | {posterBtcAddress} | {bodyHash} | {signedAt}\".", + "Create a bounty. Requires Genesis (Level 2+). The signature covers all body fields directly. " + + "Message to sign: \"AIBTC Bounty Create | {posterBtcAddress} | {title} | {description} | {rewardSats} | {expiresAt} | {tagsCommaJoined} | {signedAt}\". " + + "tagsCommaJoined is `tags.join(\",\")` or empty string when no tags.", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/BountyCreateRequest" } } }, @@ -2673,9 +2673,8 @@ export function GET() { summary: "Submit work to a bounty (Registered, signed)", description: "Add a submission to a bounty whose derived status is `open`. " + - "Body is bound to the signature via bodyHash = sha256(canonicalJSON({message, contentUrl?})). " + - "Message to sign: \"AIBTC Bounty Submit | {bountyId} | {submitterBtcAddress} | {bodyHash} | {signedAt}\". " + - "Self-submit (poster == submitter) is rejected.", + "Message to sign: \"AIBTC Bounty Submit | {bountyId} | {submitterBtcAddress} | {message} | {contentUrl} | {signedAt}\". " + + "contentUrl is empty string when omitted. Self-submit (poster == submitter) is rejected.", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { required: true, @@ -3931,7 +3930,7 @@ export function GET() { expiresAt: { type: "string", format: "date-time" }, tags: { type: "array", items: { type: "string", maxLength: 24 }, maxItems: 5 }, signedAt: { type: "string", format: "date-time" }, - signature: { type: "string", description: "BIP-137/BIP-322 over AIBTC Bounty Create | {posterBtcAddress} | {bodyHash} | {signedAt}" }, + signature: { type: "string", description: "BIP-137/BIP-322 over AIBTC Bounty Create | {posterBtcAddress} | {title} | {description} | {rewardSats} | {expiresAt} | {tagsCommaJoined} | {signedAt}" }, }, }, BountySubmitRequest: { @@ -3942,7 +3941,7 @@ export function GET() { message: { type: "string", maxLength: 2000 }, contentUrl: { type: "string" }, signedAt: { type: "string", format: "date-time" }, - signature: { type: "string", description: "BIP-137/BIP-322 over AIBTC Bounty Submit | {bountyId} | {submitterBtcAddress} | {bodyHash} | {signedAt}" }, + signature: { type: "string", description: "BIP-137/BIP-322 over AIBTC Bounty Submit | {bountyId} | {submitterBtcAddress} | {message} | {contentUrl} | {signedAt}" }, }, }, BountyAcceptRequest: { diff --git a/app/bounty/new/page.tsx b/app/bounty/new/page.tsx index f0f9f224..10c484d8 100644 --- a/app/bounty/new/page.tsx +++ b/app/bounty/new/page.tsx @@ -56,37 +56,21 @@ export default function NewBountyPage() {

-
-

- Compute bodyHash = sha256(canonicalJSON(payload)) where - {" "}canonicalJSON sorts keys alphabetically - and drops undefined values. The payload is: -

-
-{`{
-  "title": "Add Spanish translation",
-  "description": "Translate the agent registration page (markdown allowed).",
-  "rewardSats": 5000,
-  "expiresAt": "2026-06-01T00:00:00Z",
-  "tags": ["translation", "ux"]    // optional
-}`}
-              
-
- -
+

Use the MCP tool btc_sign_message (BIP-137 or BIP-322). - The message to sign is: + The message to sign is the body fields concatenated with {" | "}:

-{`AIBTC Bounty Create | {posterBtcAddress} | {bodyHash} | {signedAt}`}
+{`AIBTC Bounty Create | {posterBtcAddress} | {title} | {description} | {rewardSats} | {expiresAt} | {tagsCommaJoined} | {signedAt}`}
               

- signedAt must be a fresh ISO-8601 timestamp within ±5 minutes of server time. + tagsCommaJoined is tags.join(",") or empty string when no tags. + {" "}signedAt must be a fresh ISO-8601 timestamp within ±5 minutes of server time.

-
+
 {`curl -X POST https://aibtc.com/api/bounties \\
   -H "Content-Type: application/json" \\
diff --git a/app/docs/[topic]/route.ts b/app/docs/[topic]/route.ts
index efa3e703..92952b5f 100644
--- a/app/docs/[topic]/route.ts
+++ b/app/docs/[topic]/route.ts
@@ -873,17 +873,17 @@ There is no stored status — \`bountyStatus(record, now)\` is a pure function o
 
 ## Signed-message formats
 
-Every POST is Bitcoin-signed (BIP-137/BIP-322). The signature is bound to the body via \`bodyHash = sha256(canonicalJSON(payload))\`.
+Every POST is Bitcoin-signed (BIP-137/BIP-322). The signed message is the body fields concatenated with \` | \` — same pattern as \`/api/outbox\` and the other signed-action endpoints. No hashing step.
 
 \`\`\`
-AIBTC Bounty Create | {posterBtc}  | {bodyHash} | {ISO timestamp}
-AIBTC Bounty Submit | {bountyId}   | {submitterBtc} | {bodyHash} | {ISO timestamp}
-AIBTC Bounty Accept | {bountyId}   | {submissionId} | {ISO timestamp}
-AIBTC Bounty Paid   | {bountyId}   | {txid} | {ISO timestamp}
-AIBTC Bounty Cancel | {bountyId}   | {ISO timestamp}
+AIBTC Bounty Create | {posterBtc} | {title} | {description} | {rewardSats} | {expiresAt} | {tagsCommaJoined} | {signedAt}
+AIBTC Bounty Submit | {bountyId} | {submitterBtc} | {message} | {contentUrl} | {signedAt}
+AIBTC Bounty Accept | {bountyId} | {submissionId} | {signedAt}
+AIBTC Bounty Paid   | {bountyId} | {txid} | {signedAt}
+AIBTC Bounty Cancel | {bountyId} | {signedAt}
 \`\`\`
 
-The \`signedAt\` ISO timestamp must be within ±5 minutes of server time (replay protection).
+\`tagsCommaJoined\` is \`tags.join(",")\` or empty string when no tags. \`contentUrl\` is empty string when omitted. The \`signedAt\` ISO timestamp must be within ±5 minutes of server time (replay protection).
 
 ## Workflow
 
diff --git a/lib/bounty/__tests__/signatures.test.ts b/lib/bounty/__tests__/signatures.test.ts
index e65aa21b..1419623a 100644
--- a/lib/bounty/__tests__/signatures.test.ts
+++ b/lib/bounty/__tests__/signatures.test.ts
@@ -1,7 +1,5 @@
 import { describe, it, expect } from "vitest";
 import {
-  canonicalJSON,
-  bodyHash,
   buildCreateMessage,
   buildSubmitMessage,
   buildAcceptMessage,
@@ -10,57 +8,55 @@ import {
   isWithinSignatureWindow,
 } from "../signatures";
 
-describe("canonicalJSON", () => {
-  it("sorts keys alphabetically", () => {
-    expect(canonicalJSON({ b: 2, a: 1, c: 3 })).toBe('{"a":1,"b":2,"c":3}');
-  });
-
-  it("drops undefined values", () => {
-    expect(canonicalJSON({ a: 1, b: undefined, c: 3 })).toBe('{"a":1,"c":3}');
-  });
-
-  it("keeps null values", () => {
-    expect(canonicalJSON({ a: null, b: 1 })).toBe('{"a":null,"b":1}');
-  });
-
-  it("is deterministic regardless of insertion order", () => {
-    expect(canonicalJSON({ z: 1, a: 2 })).toBe(canonicalJSON({ a: 2, z: 1 }));
-  });
-});
-
-describe("bodyHash", () => {
-  it("returns a 64-char lowercase hex string", () => {
-    const h = bodyHash({ title: "x", body: "y" });
-    expect(h).toMatch(/^[0-9a-f]{64}$/);
-  });
-
-  it("is stable across calls", () => {
-    expect(bodyHash({ a: 1, b: 2 })).toBe(bodyHash({ b: 2, a: 1 }));
-  });
-
-  it("changes when the payload changes", () => {
-    expect(bodyHash({ a: 1 })).not.toBe(bodyHash({ a: 2 }));
-  });
-});
-
 describe("message builders", () => {
-  it("buildCreateMessage embeds all three fields", () => {
+  it("buildCreateMessage embeds all body fields and signedAt", () => {
     const msg = buildCreateMessage({
       posterBtcAddress: "bc1qabc",
-      bodyHash: "0123",
+      title: "Add Spanish translation",
+      description: "Translate the agent registration page.",
+      rewardSats: 5000,
+      expiresAt: "2026-06-01T00:00:00Z",
+      tags: ["translation", "ux"],
       signedAt: "2026-01-01T00:00:00Z",
     });
-    expect(msg).toBe("AIBTC Bounty Create | bc1qabc | 0123 | 2026-01-01T00:00:00Z");
+    expect(msg).toBe(
+      "AIBTC Bounty Create | bc1qabc | Add Spanish translation | Translate the agent registration page. | 5000 | 2026-06-01T00:00:00Z | translation,ux | 2026-01-01T00:00:00Z"
+    );
+  });
+
+  it("buildCreateMessage emits empty tags segment when tags omitted", () => {
+    const msg = buildCreateMessage({
+      posterBtcAddress: "bc1qabc",
+      title: "T",
+      description: "D",
+      rewardSats: 1,
+      expiresAt: "X",
+      signedAt: "Y",
+    });
+    expect(msg).toBe("AIBTC Bounty Create | bc1qabc | T | D | 1 | X |  | Y");
+  });
+
+  it("buildSubmitMessage embeds full submission body", () => {
+    const msg = buildSubmitMessage({
+      bountyId: "B1",
+      submitterBtcAddress: "bc1qsub",
+      message: "Here is my work",
+      contentUrl: "https://example.com/pr/42",
+      signedAt: "T",
+    });
+    expect(msg).toBe(
+      "AIBTC Bounty Submit | B1 | bc1qsub | Here is my work | https://example.com/pr/42 | T"
+    );
   });
 
-  it("buildSubmitMessage embeds bountyId, submitter, bodyHash, signedAt", () => {
+  it("buildSubmitMessage emits empty contentUrl segment when omitted", () => {
     const msg = buildSubmitMessage({
       bountyId: "B1",
       submitterBtcAddress: "bc1qsub",
-      bodyHash: "abcd",
+      message: "msg",
       signedAt: "T",
     });
-    expect(msg).toBe("AIBTC Bounty Submit | B1 | bc1qsub | abcd | T");
+    expect(msg).toBe("AIBTC Bounty Submit | B1 | bc1qsub | msg |  | T");
   });
 
   it("buildAcceptMessage embeds bountyId, submissionId, signedAt", () => {
diff --git a/lib/bounty/constants.ts b/lib/bounty/constants.ts
index cd8bce0d..0f0b6c78 100644
--- a/lib/bounty/constants.ts
+++ b/lib/bounty/constants.ts
@@ -85,8 +85,10 @@ export const PAID_TXID_TTL_SECONDS = 365 * 24 * 60 * 60;
 
 /** Signed-message templates. Build via `lib/bounty/signatures.ts`. */
 export const SIGNATURE_MESSAGE_FORMATS = {
-  CREATE: "AIBTC Bounty Create | {posterBtc} | {bodyHash} | {signedAt}",
-  SUBMIT: "AIBTC Bounty Submit | {bountyId} | {submitterBtc} | {bodyHash} | {signedAt}",
+  CREATE:
+    "AIBTC Bounty Create | {posterBtc} | {title} | {description} | {rewardSats} | {expiresAt} | {tags} | {signedAt}",
+  SUBMIT:
+    "AIBTC Bounty Submit | {bountyId} | {submitterBtc} | {message} | {contentUrl} | {signedAt}",
   ACCEPT: "AIBTC Bounty Accept | {bountyId} | {submissionId} | {signedAt}",
   PAID: "AIBTC Bounty Paid | {bountyId} | {txid} | {signedAt}",
   CANCEL: "AIBTC Bounty Cancel | {bountyId} | {signedAt}",
diff --git a/lib/bounty/index.ts b/lib/bounty/index.ts
index 17b88cce..667347b7 100644
--- a/lib/bounty/index.ts
+++ b/lib/bounty/index.ts
@@ -37,8 +37,6 @@ export {
 } from "./constants";
 
 export {
-  canonicalJSON,
-  bodyHash,
   buildCreateMessage,
   buildSubmitMessage,
   buildAcceptMessage,
diff --git a/lib/bounty/signatures.ts b/lib/bounty/signatures.ts
index 3887b04b..7c8a0da9 100644
--- a/lib/bounty/signatures.ts
+++ b/lib/bounty/signatures.ts
@@ -2,9 +2,9 @@
  * Signed message builders for bounty actions.
  *
  * Each POST endpoint accepts a Bitcoin signature (BIP-137/322) over one of
- * the templates below. The body content is bound to the signature via
- * `bodyHash` (sha256 of the canonical JSON of the payload), so the signature
- * cannot be reused with a modified body.
+ * the templates below. The full body content is included in the signed
+ * message — same pattern as `/api/outbox` and other signed-action endpoints
+ * in this codebase — so any tampering with the body breaks the signature.
  *
  * Verification: route handlers call `verifyBitcoinSignature()` from
  * `lib/bitcoin-verify.ts` with the rebuilt message and the body's stated
@@ -12,64 +12,56 @@
  * address matches and `signedAt` is within `SIGNATURE_WINDOW_SECONDS`.
  */
 
-import { hashSha256Sync } from "@stacks/encryption";
-import { bytesToHex } from "@stacks/common";
 import { SIGNATURE_MESSAGE_FORMATS } from "./constants";
 
-/**
- * Canonical JSON for hashing: sorted keys, no whitespace, undefined dropped.
- *
- * Deterministic so the client and server produce the same `bodyHash` from
- * the same fields. Keep the payload simple — no nested objects, no arrays of
- * objects — and this stays predictable.
- */
-export function canonicalJSON(payload: Record): string {
-  const sortedKeys = Object.keys(payload).sort();
-  const out: Record = {};
-  for (const k of sortedKeys) {
-    const v = payload[k];
-    if (v === undefined) continue;
-    out[k] = v;
-  }
-  return JSON.stringify(out);
-}
-
-/** sha256 of canonical JSON, returned as lowercase hex. */
-export function bodyHash(payload: Record): string {
-  return bytesToHex(hashSha256Sync(new TextEncoder().encode(canonicalJSON(payload))));
+/** Join tags with commas; empty string when no tags. */
+function joinTags(tags: string[] | undefined): string {
+  return Array.isArray(tags) ? tags.join(",") : "";
 }
 
 /**
  * Build the message a poster signs to create a bounty.
  *
- * Fields signed via bodyHash: title, description, rewardSats, expiresAt, tags.
+ * All body fields are included in the signed message so the signature is
+ * bound to the exact bounty content.
  */
 export function buildCreateMessage(params: {
   posterBtcAddress: string;
-  bodyHash: string;
+  title: string;
+  description: string;
+  rewardSats: number;
+  expiresAt: string;
+  tags?: string[];
   signedAt: string;
 }): string {
   return SIGNATURE_MESSAGE_FORMATS.CREATE
     .replace("{posterBtc}", params.posterBtcAddress)
-    .replace("{bodyHash}", params.bodyHash)
+    .replace("{title}", params.title)
+    .replace("{description}", params.description)
+    .replace("{rewardSats}", String(params.rewardSats))
+    .replace("{expiresAt}", params.expiresAt)
+    .replace("{tags}", joinTags(params.tags))
     .replace("{signedAt}", params.signedAt);
 }
 
 /**
  * Build the message a submitter signs to submit work to a bounty.
  *
- * Fields signed via bodyHash: message, contentUrl.
+ * Full submission body is included — `contentUrl` is empty string when
+ * omitted.
  */
 export function buildSubmitMessage(params: {
   bountyId: string;
   submitterBtcAddress: string;
-  bodyHash: string;
+  message: string;
+  contentUrl?: string;
   signedAt: string;
 }): string {
   return SIGNATURE_MESSAGE_FORMATS.SUBMIT
     .replace("{bountyId}", params.bountyId)
     .replace("{submitterBtc}", params.submitterBtcAddress)
-    .replace("{bodyHash}", params.bodyHash)
+    .replace("{message}", params.message)
+    .replace("{contentUrl}", params.contentUrl ?? "")
     .replace("{signedAt}", params.signedAt);
 }
 
diff --git a/lib/bounty/validation.ts b/lib/bounty/validation.ts
index 359db50b..5f5f76ef 100644
--- a/lib/bounty/validation.ts
+++ b/lib/bounty/validation.ts
@@ -288,7 +288,7 @@ export function validateCreateBounty(body: unknown):
     errors,
     b.signature,
     "signature",
-    "AIBTC Bounty Create | {posterBtcAddress} | {bodyHash} | {signedAt}"
+    "AIBTC Bounty Create | {posterBtcAddress} | {title} | {description} | {rewardSats} | {expiresAt} | {tagsCommaJoined} | {signedAt}"
   );
 
   if (errors.length > 0) {
@@ -394,7 +394,7 @@ export function validateSubmit(body: unknown):
     errors,
     b.signature,
     "signature",
-    "AIBTC Bounty Submit | {bountyId} | {submitterBtcAddress} | {bodyHash} | {signedAt}"
+    "AIBTC Bounty Submit | {bountyId} | {submitterBtcAddress} | {message} | {contentUrl} | {signedAt}"
   );
 
   if (errors.length > 0) return { errors: errors.map((e) => e.hint) };
diff --git a/migrations/013_bounties.sql b/migrations/014_bounties.sql
similarity index 100%
rename from migrations/013_bounties.sql
rename to migrations/014_bounties.sql