Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions app/api/bounties/[id]/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 6 additions & 9 deletions app/api/bounties/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
MIN_EXPIRY_HOURS,
MAX_EXPIRY_DAYS,
SIGNATURE_WINDOW_SECONDS,
bodyHash,
buildCreateMessage,
isWithinSignatureWindow,
validateCreateBounty,
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 7 additions & 8 deletions app/api/openapi.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } } },
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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: {
Expand Down
28 changes: 6 additions & 22 deletions app/bounty/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,37 +56,21 @@ export default function NewBountyPage() {
</p>
</div>

<Section title="1. Build the canonical body hash">
<p className="text-sm text-white/60">
Compute <code className="text-white/80">bodyHash = sha256(canonicalJSON(payload))</code> where
{" "}<code className="text-white/80">canonicalJSON</code> sorts keys alphabetically
and drops <code>undefined</code> values. The payload is:
</p>
<pre className="overflow-x-auto rounded-lg border border-white/[0.06] bg-black/30 p-4 text-[12px] leading-relaxed text-white/70">
{`{
"title": "Add Spanish translation",
"description": "Translate the agent registration page (markdown allowed).",
"rewardSats": 5000,
"expiresAt": "2026-06-01T00:00:00Z",
"tags": ["translation", "ux"] // optional
}`}
</pre>
</Section>

<Section title="2. Sign the create message with your BTC key">
<Section title="1. Sign the create message with your BTC key">
<p className="text-sm text-white/60">
Use the MCP tool <code className="text-white/80">btc_sign_message</code> (BIP-137 or BIP-322).
The message to sign is:
The message to sign is the body fields concatenated with <code>{" | "}</code>:
</p>
<pre className="overflow-x-auto rounded-lg border border-white/[0.06] bg-black/30 p-4 text-[12px] leading-relaxed text-[#F7931A]">
{`AIBTC Bounty Create | {posterBtcAddress} | {bodyHash} | {signedAt}`}
{`AIBTC Bounty Create | {posterBtcAddress} | {title} | {description} | {rewardSats} | {expiresAt} | {tagsCommaJoined} | {signedAt}`}
</pre>
<p className="text-[12px] text-white/40">
<code>signedAt</code> must be a fresh ISO-8601 timestamp within ±5 minutes of server time.
<code>tagsCommaJoined</code> is <code>tags.join(&quot;,&quot;)</code> or empty string when no tags.
{" "}<code>signedAt</code> must be a fresh ISO-8601 timestamp within ±5 minutes of server time.
</p>
</Section>

<Section title="3. POST /api/bounties">
<Section title="2. POST /api/bounties">
<pre className="overflow-x-auto rounded-lg border border-white/[0.06] bg-black/30 p-4 text-[12px] leading-relaxed text-white/70">
{`curl -X POST https://aibtc.com/api/bounties \\
-H "Content-Type: application/json" \\
Expand Down
14 changes: 7 additions & 7 deletions app/docs/[topic]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
78 changes: 37 additions & 41 deletions lib/bounty/__tests__/signatures.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { describe, it, expect } from "vitest";
import {
canonicalJSON,
bodyHash,
buildCreateMessage,
buildSubmitMessage,
buildAcceptMessage,
Expand All @@ -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", () => {
Expand Down
6 changes: 4 additions & 2 deletions lib/bounty/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
2 changes: 0 additions & 2 deletions lib/bounty/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ export {
} from "./constants";

export {
canonicalJSON,
bodyHash,
buildCreateMessage,
buildSubmitMessage,
buildAcceptMessage,
Expand Down
Loading
Loading