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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@

### Changed

- Mirror gateway Wave 1 surface docs: skill endpoints and batch details,
three-word `@bitplan.dev` paymail, live router / own-key fees, x402 v2
`PAYMENT-SIGNATURE`, and CLI deposit help that points at live terms.

- Added internal encrypted annotation checkpoint parsing/replay and no-send
multi-output signing helpers. These are not yet connected to native Publish;
transaction journaling, lineage verification, and verified handles remain pending.
Expand Down
1 change: 1 addition & 0 deletions apps/web/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"jsPlugins": ["@shadcn/lint"],
"ignorePatterns": ["public/.well-known/agent-skills/gateway/scripts/**"],
"rules": {}
}
3 changes: 3 additions & 0 deletions apps/web/biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"includes": [
// Standalone publishable documents have their own upload-policy and behavior tests.
"!!public/templates",
// Canonical copies live in skills/gateway/scripts; keep the published
// well-known tree byte-identical so $SKILL_DIR/scripts works.
"!!public/.well-known/agent-skills/gateway/scripts",
"!src/components/ui",
"!src/hooks/use-mobile.ts"
]
Expand Down
564 changes: 564 additions & 0 deletions apps/web/public/.well-known/agent-skills/gateway/SKILL.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
bun.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "gateway-skill-scripts",
"private": true,
"type": "module",
"dependencies": {
"@bsv/sdk": "^2.7.1",
"bitcoin-auth": "^0.0.8"
}
}
173 changes: 173 additions & 0 deletions apps/web/public/.well-known/agent-skills/gateway/scripts/pay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env bun
/**
* Build the payment for a gateway.bitplan.dev 402 and print the header value.
* Nothing is broadcast; the gateway broadcasts when it receives the proof.
* Never prints the key.
*
* printf '%s' "$CHALLENGE_JSON" | bun pay.ts --wif <WIF> --x402
* bun pay.ts --wif <WIF> --x402 --challenge '<json>'
*
* `--x402` prints the x402 v2 `PAYMENT-SIGNATURE` value (base64 JSON).
* Without it, the script prints the legacy `X402-Proof` value.
*
* `--x402` needs the whole 402 body (`accepts[0]`, including
* `maxTimeoutSeconds`). Challenge-only input is for the legacy proof.
* Funds come from the P2PKH address of the WIF; UTXOs are read from
* WhatsOnChain. Run `bun install` in this directory once.
*/
import { P2PKH, PrivateKey, SatoshisPerKilobyte, Transaction } from '@bsv/sdk'

const WOC = 'https://api.whatsonchain.com/v1/bsv/main'
const FEE_SATS_PER_KB = 50

function flag(name: string): string | undefined {
const argv = process.argv.slice(2)
const i = argv.indexOf(`--${name}`)
if (i === -1) return undefined
const value = argv[i + 1]
if (value === undefined || value.startsWith('--'))
throw new Error(`--${name} requires a value`)
return value
}

function hasFlag(name: string): boolean {
return process.argv.slice(2).includes(`--${name}`)
}

interface Challenge {
version: string
challenge_id: string
amount_sats: number
payee_locking_script_hex: string
payee_address?: string
expires_at: string
}

interface Accepted {
scheme: string
network: string
amount: string
asset: string
payTo: string
maxTimeoutSeconds: number
extra: {
challengeId: string
lockingScript: string
expiresAt?: string
payUrl?: string
}
}

interface Body {
challenge?: Challenge
x402Version?: number
resource?: { url?: string; description?: string; mimeType?: string }
accepts?: Accepted[]
}

async function readBody(): Promise<{ raw: Body; challenge: Challenge }> {
const text =
flag('challenge') ?? (await new Response(Bun.stdin.stream()).text())
const parsed = JSON.parse(text) as Body & Challenge
const c = parsed.challenge ?? parsed
if (c.version !== 'bsv-tx-v1')
throw new Error('unsupported challenge version')
if (!Number.isSafeInteger(c.amount_sats) || c.amount_sats <= 0)
throw new Error('bad amount_sats')
if (!/^[0-9a-f]+$/i.test(c.payee_locking_script_hex))
throw new Error('bad payee_locking_script_hex')
if (Date.parse(c.expires_at) < Date.now())
throw new Error('challenge expired; request a new one')
if (
c.payee_address &&
new P2PKH().lock(c.payee_address).toHex() !==
c.payee_locking_script_hex.toLowerCase()
) {
throw new Error('payee_address does not match payee_locking_script_hex')
}
return { raw: parsed, challenge: c }
}

function acceptedOf(raw: Body): Accepted {
const a = Array.isArray(raw.accepts) ? raw.accepts[0] : undefined
if (
a?.scheme === 'exact' &&
typeof a.extra?.lockingScript === 'string' &&
typeof a.amount === 'string' &&
typeof a.maxTimeoutSeconds === 'number'
) {
return a
}
throw new Error(
'--x402 needs the full 402 body (accepts[0] with maxTimeoutSeconds); challenge-only is for the legacy proof',
)
}

const wif = flag('wif')
if (!wif) throw new Error('--wif is required')
const key = PrivateKey.fromWif(wif)
const address = key.toAddress()
const { raw, challenge } = await readBody()

const utxoRes = await fetch(`${WOC}/address/${address}/unspent`)
if (!utxoRes.ok) throw new Error(`whatsonchain unspent ${utxoRes.status}`)
const utxos = (await utxoRes.json()) as Array<{
tx_hash: string
tx_pos: number
value: number
}>
utxos.sort((a, b) => b.value - a.value)

const tx = new Transaction()
tx.addOutput({
lockingScript: (await import('@bsv/sdk')).LockingScript.fromHex(
challenge.payee_locking_script_hex,
),
satoshis: challenge.amount_sats,
})
tx.addOutput({ lockingScript: new P2PKH().lock(address), change: true })

let total = 0
const template = new P2PKH()
for (const u of utxos) {
const hexRes = await fetch(`${WOC}/tx/${u.tx_hash}/hex`)
if (!hexRes.ok)
throw new Error(`whatsonchain tx ${u.tx_hash} ${hexRes.status}`)
tx.addInput({
sourceTransaction: Transaction.fromHex(await hexRes.text()),
sourceOutputIndex: u.tx_pos,
unlockingScriptTemplate: template.unlock(key),
})
total += u.value
if (total > challenge.amount_sats + 1000) break
}
if (total < challenge.amount_sats) {
throw new Error(
`insufficient funds at ${address}: have ${total} sats, need ${challenge.amount_sats} plus fee`,
)
}
await tx.fee(new SatoshisPerKilobyte(FEE_SATS_PER_KB))
await tx.sign()

const rawtx = Buffer.from(tx.toBinary()).toString('base64')
if (hasFlag('x402')) {
const payload = {
x402Version: 2,
...(raw.resource ? { resource: raw.resource } : {}),
accepted: acceptedOf(raw),
payload: { transaction: rawtx },
}
process.stdout.write(
`${Buffer.from(JSON.stringify(payload)).toString('base64')}\n`,
)
} else {
const proof = {
version: 'bsv-tx-v1',
challenge_id: challenge.challenge_id,
rawtx_base64: rawtx,
txid: tx.id('hex'),
}
process.stdout.write(
`${Buffer.from(JSON.stringify(proof)).toString('base64url')}\n`,
)
}
32 changes: 32 additions & 0 deletions apps/web/public/.well-known/agent-skills/gateway/scripts/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bun
/**
* Mint a self-signed bearer token for gateway.bitplan.dev from a WIF.
* Prints the token and nothing else. Never prints the key.
*
* bun token.ts --wif <WIF> # session token, 24 h, all /v1/* routes
* bun token.ts --wif <WIF> --origin https://... # session token for another origin
* bun token.ts --wif <WIF> --path /v1/account # per-request token, 5 min
*
* Run `bun install` in this directory once.
*/
import { getAuthToken } from "bitcoin-auth"

function flag(name: string): string | undefined {
const argv = process.argv.slice(2)
const i = argv.indexOf(`--${name}`)
if (i === -1) return undefined
const value = argv[i + 1]
if (value === undefined || value.startsWith("--"))
throw new Error(`--${name} requires a value`)
return value
}

const wif = flag("wif")
if (!wif) throw new Error("--wif is required")
const path = flag("path")
const origin = flag("origin") ?? "https://gateway.bitplan.dev"
const requestPath = path ?? `${origin}/v1`

process.stdout.write(
`${getAuthToken({ privateKeyWif: wif, requestPath, scheme: "brc77" })}\n`,
)
7 changes: 7 additions & 0 deletions apps/web/public/.well-known/agent-skills/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
"description": "Create, review, host, publish, update, fetch, and share encrypted HTML plans with a BRC-100 wallet.",
"url": "https://bitplan.dev/.well-known/agent-skills/bitplan/SKILL.md",
"digest": "sha256:bec609c5053fdc5f8c2ed4bd3a58eec76f3137aaa0ba5348dce7de532dc80af7"
},
{
"name": "gateway",
"type": "skill-md",
"description": "Use gateway.bitplan.dev, an OpenAI-compatible inference API paid in BSV with no API keys.",
"url": "https://bitplan.dev/.well-known/agent-skills/gateway/SKILL.md",
"digest": "sha256:7160993f41ca13161c3eeecb21d0d85daf700658bd4b3cc54e2699f1af2a53b0"
}
]
}
53 changes: 51 additions & 2 deletions apps/web/src/lib/agent-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,66 @@ describe("agent pages", () => {
);
const index = JSON.parse(
await readFile(new URL("agent-skills/index.json", publicRoot), "utf8")
) as { $schema: string; skills: Array<{ digest: string }> };
) as {
$schema: string;
skills: Array<{ name: string; digest: string }>;
};
const catalog = JSON.parse(
await readFile(new URL("ai-catalog.json", publicRoot), "utf8")
) as { entries: Array<{ data?: unknown; url?: unknown }> };
const digest = createHash("sha256").update(canonicalSkill).digest("hex");
const publishedGateway = await readFile(
new URL("agent-skills/gateway/SKILL.md", publicRoot),
"utf8"
);
const canonicalGateway = await readFile(
new URL("../../../../skills/gateway/SKILL.md", import.meta.url),
"utf8"
);
const gatewayDigest = createHash("sha256")
.update(canonicalGateway)
.digest("hex");
const bitplan = index.skills.find((skill) => skill.name === "bitplan");
const gateway = index.skills.find((skill) => skill.name === "gateway");

expect(index.$schema).toBe(
"https://schemas.agentskills.io/discovery/0.2.0/schema.json"
);
expect(index.skills[0].digest).toBe(`sha256:${digest}`);
expect(bitplan?.digest).toBe(`sha256:${digest}`);
expect(publishedSkill).toBe(canonicalSkill);
expect(gateway?.digest).toBe(`sha256:${gatewayDigest}`);
expect(publishedGateway).toBe(canonicalGateway);
expect(canonicalGateway).toContain("evaluate");
expect(canonicalGateway).toContain("@bitplan.dev");
expect(canonicalGateway).toContain("PAYMENT-SIGNATURE");
expect(canonicalGateway).toContain("byok_fee_bps");
expect(canonicalGateway).not.toContain("0.5 BSV");
expect(canonicalGateway).not.toContain("@gateway.bitplan.dev");
expect(canonicalGateway).not.toContain("30%");
expect(canonicalGateway).toContain("$SKILL_DIR/scripts/token.ts");
expect(canonicalGateway).toContain("$SKILL_DIR/scripts/pay.ts");
expect(canonicalGateway).toContain("when `byok_fee_bps` is above 0");
const gatewayScripts = ["token.ts", "pay.ts", "package.json"] as const;
const publishedScripts = await Promise.all(
gatewayScripts.map((name) =>
readFile(
new URL(`agent-skills/gateway/scripts/${name}`, publicRoot),
"utf8"
)
)
);
const canonicalScripts = await Promise.all(
gatewayScripts.map((name) =>
readFile(
new URL(
`../../../../skills/gateway/scripts/${name}`,
import.meta.url
),
"utf8"
)
)
);
expect(publishedScripts).toEqual(canonicalScripts);
expect(catalog.entries).toHaveLength(2);
expect(
catalog.entries.every(
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

### Changed

- `bitplan gateway deposit` help no longer states a retired 0.5 BSV floor.
The amount follows live terms (`GET /v1/rate`, `/.well-known/x402-info`).

## 0.0.21

### Changed
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ export function buildProgram(): Command {

gateway
.command('deposit [sats]')
.description('Add credits from the wallet (minimum 0.5 BSV).')
.description(
'Add credits from the wallet. Amount follows live terms (GET /v1/rate, /.well-known/x402-info).',
)
.option('--yes', 'Approve the payment; the wallet still confirms')
.option('--json', 'Print raw JSON')
.option('--wallet-url <url>', 'BRC-100 JSON API endpoint')
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/test/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,14 @@ describe('gateway token', () => {
const names = gateway?.commands.map((c: Command) => c.name()).sort()
expect(names).toEqual(['credits', 'deposit', 'models', 'token'])
})

test('deposit help points at live terms, not a retired floor', () => {
const program = buildProgram()
const gateway = program.commands.find((c: Command) => c.name() === 'gateway')
const deposit = gateway?.commands.find((c: Command) => c.name() === 'deposit')
const desc = deposit?.description() ?? ''
expect(desc).not.toMatch(/0\.5 BSV/)
expect(desc).toMatch(/x402-info/)
expect(desc).toMatch(/\/v1\/rate/)
})
})
10 changes: 5 additions & 5 deletions packages/opencode-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,11 @@ override) is merged on top of what the plugin injects.
`x-gateway-deposit: exact`, refreshing the token through the wallet 30
minutes before its 24-hour expiry. If the wallet is not running, a token that
is still valid keeps working; once it has expired the error says what to do.
- On 402, parses the `bsv-tx-v1` challenge, pays `amount_sats` to the payee
locking script with `createAction`, and repeats the identical request once
with `X402-Proof`. A second 402, a wallet refusal, or insufficient funds
surface as a clear error with the amount and the account's paymail; nothing
is paid twice.
- On 402, reads the x402 v2 `PAYMENT-REQUIRED` (scheme `exact` on BSV),
pays `amount` sats to `payTo` with `createAction`, and repeats the identical
request once with `PAYMENT-SIGNATURE`. A second 402, a wallet refusal, or
insufficient funds surface as a clear error with the amount and the
account's paymail; nothing is paid twice.
- Never logs the token, a signature, or a key.

## Without the plugin
Expand Down
Loading
Loading