Skip to content

Commit 3425872

Browse files
Merge pull request JavaScriptSolidServer#269 from JavaScriptSolidServer/issue-267-phase2-tweaked-addresses
feat: per-user tweaked deposit addresses (Phase 2 of JavaScriptSolidServer#267)
2 parents 60dcf30 + 5cae0d0 commit 3425872

2 files changed

Lines changed: 50 additions & 19 deletions

File tree

src/handlers/pay.js

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import crypto from 'crypto';
2929
import { getNostrPubkey, pubkeyToDidNostr } from '../auth/nostr.js';
3030
import { readLedger, writeLedger, getBalance, credit, debit } from '../webledger.js';
3131
import { verifyMrc20Deposit, verifyMrc20Anchor, jcs, btAddress } from '../mrc20.js';
32-
import { loadTrail, transferToken, buildTransaction, broadcastTx, p2trScript } from '../token.js';
32+
import { loadTrail, transferToken, buildTransaction, broadcastTx, p2trScript, btDeriveChainedPrivkey } from '../token.js';
3333
import { secp256k1 } from '@noble/curves/secp256k1';
3434
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
3535
import fs from 'fs-extra';
@@ -282,7 +282,7 @@ export function createPayHandler(options = {}) {
282282
return reply.send(info);
283283
}
284284

285-
// --- GET /pay/.address — public deposit address ---
285+
// --- GET /pay/.address — deposit address (optional per-user tweak) ---
286286
if (url === '/pay/.address' && request.method === 'GET') {
287287
const chain = request.query?.chain || (payChains ? payChains[0] : 'tbtc4');
288288
if (!CHAIN_REGISTRY[chain]) {
@@ -293,8 +293,15 @@ export function createPayHandler(options = {}) {
293293
}
294294
const kp = await loadOrCreateKeypair();
295295
const network = chain === 'btc' ? 'mainnet' : (chain === 'tbtc3' ? 'testnet' : 'testnet4');
296-
const address = btAddress(kp.pubkey, [], network);
297-
return reply.send({ address, chain, pubkey: kp.pubkey });
296+
const user = request.query?.user?.trim().toLowerCase() || null;
297+
if (user && !/^did:nostr:[0-9a-f]{64}$/.test(user)) {
298+
return reply.code(400).send({ error: 'Invalid user DID. Expected: did:nostr:<64-hex>' });
299+
}
300+
const states = user ? [user] : [];
301+
const address = btAddress(kp.pubkey, states, network);
302+
const response = { address, chain, pubkey: kp.pubkey };
303+
if (user) response.user = user;
304+
return reply.send(response);
298305
}
299306

300307
// --- GET /pay/.balance ---
@@ -435,8 +442,10 @@ export function createPayHandler(options = {}) {
435442
return reply.code(400).send({ error: `Unknown chain: ${chainId}` });
436443
}
437444

438-
// Derive pod's address for this chain
445+
// Derive address — try per-user tweaked address first, fall back to generic
439446
const network = chainId === 'btc' ? 'mainnet' : (chainId === 'tbtc3' ? 'testnet' : 'testnet4');
447+
const didUri = pubkeyToDidNostr(pubkey);
448+
const userAddress = btAddress(kp.pubkey, [didUri], network);
440449
const podAddress = btAddress(kp.pubkey, [], network);
441450

442451
// Fetch transaction from mempool
@@ -455,24 +464,24 @@ export function createPayHandler(options = {}) {
455464
return reply.code(400).send({ error: `Output ${deposit.vout} not found` });
456465
}
457466

458-
// Verify output pays our address
459-
if (output.scriptpubkey_address !== podAddress) {
460-
return reply.code(400).send({ error: 'Output does not pay this pod\'s address', expected: podAddress });
467+
// Verify output pays our address (per-user tweaked or generic pod address)
468+
const outputAddr = output.scriptpubkey_address;
469+
const tweak = outputAddr === userAddress ? didUri : null;
470+
if (outputAddr !== userAddress && outputAddr !== podAddress) {
471+
return reply.code(400).send({ error: 'Output does not pay this pod\'s address', expected: { user: userAddress, pod: podAddress } });
461472
}
462473

463474
const amount = output.value;
464475
const currency = chain.unit;
465476

466477
// Replay protection + UTXO tracking
467478
const utxos = await loadUtxos();
468-
const utxoKey = `${deposit.txid}:${deposit.vout}`;
469479
if (utxos.find(u => u.txid === deposit.txid && u.vout === deposit.vout)) {
470480
return reply.code(400).send({ error: 'This output has already been claimed' });
471481
}
472-
utxos.push({ txid: deposit.txid, vout: deposit.vout, amount, scriptpubkey: output.scriptpubkey, chain: chainId, spent: false });
482+
utxos.push({ txid: deposit.txid, vout: deposit.vout, amount, scriptpubkey: output.scriptpubkey, chain: chainId, tweak, spent: false });
473483
await saveUtxos(utxos);
474484

475-
const didUri = pubkeyToDidNostr(pubkey);
476485
const ledger = await readLedger();
477486
const newBalance = credit(ledger, didUri, amount, currency);
478487
await writeLedger(ledger);
@@ -752,23 +761,45 @@ export function createPayHandler(options = {}) {
752761
return reply.code(400).send({ error: 'No UTXOs available for withdrawal' });
753762
}
754763

755-
// Select UTXOs (simple: pick first one that's big enough, or accumulate)
756-
let selected = [];
757-
let total = 0;
764+
// Load pod keypair
765+
const kp = await loadOrCreateKeypair();
766+
767+
// Select UTXOs — group by tweak so we can sign with one key
768+
// Prefer untweaked UTXOs first, then tweaked ones
758769
const fee = 300;
759770
const needed = withdrawAmount + fee;
760-
for (const utxo of available) {
771+
let selected = [];
772+
let total = 0;
773+
let selectedTweak = null;
774+
775+
// Try untweaked first
776+
for (const utxo of available.filter(u => !u.tweak)) {
761777
selected.push(utxo);
762778
total += utxo.amount;
763779
if (total >= needed) break;
764780
}
781+
// If not enough, try tweaked (same tweak group only)
782+
if (total < needed) {
783+
const tweaked = available.filter(u => u.tweak);
784+
selected = [];
785+
total = 0;
786+
selectedTweak = null;
787+
for (const utxo of tweaked) {
788+
if (selectedTweak && utxo.tweak !== selectedTweak) continue;
789+
selected.push(utxo);
790+
selectedTweak = utxo.tweak;
791+
total += utxo.amount;
792+
if (total >= needed) break;
793+
}
794+
}
765795
if (total < needed) {
766796
return reply.code(400).send({ error: 'Not enough UTXO value for withdrawal + fee', available: total, needed });
767797
}
768798

769-
// Load pod keypair
770-
const kp = await loadOrCreateKeypair();
771-
const privkeyBytes = hexToBytes(kp.privkey);
799+
// Derive signing key (tweaked if UTXOs are tweaked)
800+
const privkeyBytes = selectedTweak
801+
? btDeriveChainedPrivkey(hexToBytes(kp.privkey), [selectedTweak])
802+
: hexToBytes(kp.privkey);
772803

773804
// Generate a new keypair for the voucher recipient
774805
const voucherPrivkey = secp256k1.utils.randomPrivateKey();

src/token.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ function btDeriveChainedPubkey(pubkeyBase, states) {
9898
return cur;
9999
}
100100

101-
function btDeriveChainedPrivkey(privkeyBytes, states) {
101+
export function btDeriveChainedPrivkey(privkeyBytes, states) {
102102
let d = bytesToBigInt(privkeyBytes);
103103
let cur = new Uint8Array(secp256k1.getPublicKey(privkeyBytes, true));
104104
for (const s of states) {

0 commit comments

Comments
 (0)