Skip to content

Commit 41a4997

Browse files
Add primary market: buy tokens with sat balance
POST /pay/.buy lets authenticated users purchase MRC20 tokens from the pod's trail using their deposited sat balance. Advances the Bitcoin trail via transferToken() and returns a portable proof with state chain and anchor data for independent verification. New options: --pay-token <ticker>, --pay-rate <n> (sats per token) Env vars: JSS_PAY_TOKEN, JSS_PAY_RATE Closes JavaScriptSolidServer#177
1 parent fd12aab commit 41a4997

4 files changed

Lines changed: 122 additions & 5 deletions

File tree

bin/jss.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ program
8585
.option('--pay-cost <n>', 'Cost per request in satoshis (default: 1)', parseInt)
8686
.option('--pay-mempool-url <url>', 'Mempool API URL for deposit verification')
8787
.option('--pay-address <addr>', 'Address for receiving deposits')
88+
.option('--pay-token <ticker>', 'Token to sell (enables primary market)')
89+
.option('--pay-rate <n>', 'Sats per token for primary market (default: 1)', parseInt)
8890
.option('--mongo', 'Enable MongoDB-backed /db/ route')
8991
.option('--no-mongo', 'Disable MongoDB-backed /db/ route')
9092
.option('--mongo-url <url>', 'MongoDB connection URL (default: mongodb://localhost:27017)')
@@ -155,6 +157,8 @@ program
155157
payCost: config.payCost,
156158
payMempoolUrl: config.payMempoolUrl,
157159
payAddress: config.payAddress,
160+
payToken: config.payToken,
161+
payRate: config.payRate,
158162
mongo: config.mongo,
159163
mongoUrl: config.mongoUrl,
160164
mongoDatabase: config.mongoDatabase,
@@ -193,7 +197,10 @@ program
193197
}
194198
console.log(' Do not expose to the internet!');
195199
}
196-
if (config.pay) console.log(` Pay: ${config.payCost} sat/req (402 enabled)`);
200+
if (config.pay) {
201+
console.log(` Pay: ${config.payCost} sat/req (402 enabled)`);
202+
if (config.payToken) console.log(` Token: ${config.payToken} @ ${config.payRate} sat/token`);
203+
}
197204
if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
198205
if (config.readOnly) console.log(' Read-only: enabled (PUT/DELETE/PATCH disabled)');
199206
console.log('\n Press Ctrl+C to stop\n');

src/config.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export const defaults = {
8888
payCost: 1,
8989
payMempoolUrl: 'https://mempool.space/testnet4',
9090
payAddress: null,
91+
payToken: null,
92+
payRate: 1,
9193

9294
// MongoDB-backed /db/ route
9395
mongo: false,
@@ -148,6 +150,8 @@ const envMap = {
148150
JSS_PAY_COST: 'payCost',
149151
JSS_PAY_MEMPOOL_URL: 'payMempoolUrl',
150152
JSS_PAY_ADDRESS: 'payAddress',
153+
JSS_PAY_TOKEN: 'payToken',
154+
JSS_PAY_RATE: 'payRate',
151155
JSS_MONGO: 'mongo',
152156
JSS_MONGO_URL: 'mongoUrl',
153157
JSS_MONGO_DATABASE: 'mongoDatabase',
@@ -177,7 +181,7 @@ function parseEnvValue(value, key) {
177181
if (value.toLowerCase() === 'false') return false;
178182

179183
// Numeric values for known numeric keys
180-
if ((key === 'port' || key === 'nostrMaxEvents' || key === 'payCost') && !isNaN(value)) {
184+
if ((key === 'port' || key === 'nostrMaxEvents' || key === 'payCost' || key === 'payRate') && !isNaN(value)) {
181185
return parseInt(value, 10);
182186
}
183187

@@ -315,7 +319,10 @@ export function printConfig(config) {
315319
console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
316320
console.log(` Mashlib: ${config.mashlibModule ? `module (${config.mashlibModule})` : config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
317321
console.log(` SolidOS UI: ${config.solidosUi ? 'enabled' : 'disabled'}`);
318-
if (config.pay) console.log(` Pay: ${config.payCost} sat/req`);
322+
if (config.pay) {
323+
console.log(` Pay: ${config.payCost} sat/req`);
324+
if (config.payToken) console.log(` Token: ${config.payToken} @ ${config.payRate} sat/token`);
325+
}
319326
if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
320327
console.log('─'.repeat(40));
321328
}

src/handlers/pay.js

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121

2222
import { getNostrPubkey, pubkeyToDidNostr } from '../auth/nostr.js';
2323
import { readLedger, writeLedger, getBalance, credit, debit } from '../webledger.js';
24-
import { verifyMrc20Deposit, verifyMrc20Anchor, jcs } from '../mrc20.js';
24+
import { verifyMrc20Deposit, verifyMrc20Anchor, jcs, sha256Hex } from '../mrc20.js';
25+
import { loadTrail, transferToken } from '../token.js';
2526
import fs from 'fs-extra';
2627
import path from 'path';
2728

@@ -147,6 +148,8 @@ export function createPayHandler(options = {}) {
147148
const cost = options.cost ?? DEFAULT_COST;
148149
const mempoolUrl = options.mempoolUrl ?? 'https://mempool.space/testnet4';
149150
const payAddress = options.payAddress ?? null;
151+
const payToken = options.payToken ?? null;
152+
const payRate = options.payRate ?? 1;
150153

151154
return async function payHandler(request, reply) {
152155
const url = request.url.split('?')[0];
@@ -262,6 +265,104 @@ export function createPayHandler(options = {}) {
262265
});
263266
}
264267

268+
// --- POST /pay/.buy — primary market: buy tokens with sats ---
269+
if (url === '/pay/.buy' && request.method === 'POST') {
270+
const pubkey = await getNostrPubkey(request);
271+
if (!pubkey) {
272+
return reply.code(401).send({ error: 'NIP-98 authentication required' });
273+
}
274+
275+
if (!payToken) {
276+
return reply.code(400).send({ error: 'Primary market not configured (no --pay-token set)' });
277+
}
278+
279+
// Parse buy request
280+
let body = request.body;
281+
if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
282+
if (typeof body === 'string') body = JSON.parse(body);
283+
284+
const ticker = body?.ticker || payToken;
285+
if (ticker !== payToken) {
286+
return reply.code(400).send({ error: `This pod only sells ${payToken}` });
287+
}
288+
289+
// Calculate amount and cost
290+
let tokenAmount, satCost;
291+
if (body?.amount) {
292+
tokenAmount = Math.floor(body.amount);
293+
satCost = tokenAmount * payRate;
294+
} else if (body?.sats) {
295+
satCost = Math.floor(body.sats);
296+
tokenAmount = Math.floor(satCost / payRate);
297+
} else {
298+
return reply.code(400).send({
299+
error: 'Specify amount (tokens to buy) or sats (sats to spend)',
300+
rate: payRate,
301+
unit: 'sat/token'
302+
});
303+
}
304+
305+
if (tokenAmount <= 0) {
306+
return reply.code(400).send({ error: 'Amount must be positive' });
307+
}
308+
309+
// Check sat balance
310+
const didUri = pubkeyToDidNostr(pubkey);
311+
const ledger = await readLedger();
312+
const balance = getBalance(ledger, didUri);
313+
if (balance < satCost) {
314+
return reply.code(402).send({
315+
error: 'Insufficient sat balance',
316+
balance,
317+
cost: satCost,
318+
rate: payRate,
319+
deposit: '/pay/.deposit'
320+
});
321+
}
322+
323+
// Load token trail
324+
const trail = await loadTrail(ticker);
325+
if (!trail) {
326+
return reply.code(500).send({ error: `Token ${ticker} not minted on this pod` });
327+
}
328+
329+
// Transfer tokens to buyer
330+
let result;
331+
try {
332+
result = await transferToken({
333+
ticker,
334+
to: pubkey,
335+
amount: tokenAmount,
336+
mempoolUrl
337+
});
338+
} catch (err) {
339+
return reply.code(500).send({ error: `Transfer failed: ${err.message}` });
340+
}
341+
342+
// Debit sats from buyer
343+
debit(ledger, didUri, satCost);
344+
await writeLedger(ledger);
345+
346+
return reply.send({
347+
bought: tokenAmount,
348+
ticker,
349+
cost: satCost,
350+
rate: payRate,
351+
balance: getBalance(ledger, didUri),
352+
unit: 'sat',
353+
txid: result.txid,
354+
proof: {
355+
state: result.state,
356+
prevState: result.prevState,
357+
anchor: {
358+
pubkey: result.trail.pubkeyBase,
359+
stateStrings: result.trail.stateStrings,
360+
network: result.trail.network
361+
}
362+
}
363+
});
364+
}
365+
265366
// --- GET/HEAD /pay/* — paid resource access ---
266367
if (request.method === 'GET' || request.method === 'HEAD') {
267368
const pubkey = await getNostrPubkey(request);

src/server.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ export function createServer(options = {}) {
100100
const payCost = options.payCost ?? 1;
101101
const payMempoolUrl = options.payMempoolUrl ?? 'https://mempool.space/testnet4';
102102
const payAddress = options.payAddress ?? null; // Pod's MRC20 address for token deposits
103+
const payToken = options.payToken ?? null; // Token ticker for primary market
104+
const payRate = options.payRate ?? 1; // Sats per token
103105

104106
// Set data root via environment variable if provided
105107
if (options.root) {
@@ -374,7 +376,7 @@ export function createServer(options = {}) {
374376

375377
// HTTP 402 Payment Required handler for /pay/* routes
376378
if (payEnabled) {
377-
fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress }));
379+
fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress, payToken, payRate }));
378380
}
379381

380382
// Authorization hook - check WAC permissions

0 commit comments

Comments
 (0)