From 088bcf50daadaee8d0f1ffdd7a46d80541a5bf05 Mon Sep 17 00:00:00 2001
From: biwasbhandari
- 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) };