feat: per-user tweaked deposit addresses (Phase 2 of #267) - #269
Conversation
- GET /pay/.address?user=did:nostr:... returns a unique tweaked taproot address - Claim verifies against both per-user and generic pod address - UTXOs store the tweak so withdraw can derive the correct signing key - Export btDeriveChainedPrivkey from token.js Tested end-to-end: tweaked address → send sats → claim → withdraw voucher
There was a problem hiding this comment.
Pull request overview
Implements Phase 2 of #267 by adding per-user “tweaked” taproot deposit addresses and plumbing the tweak through claim + sats-withdrawal so the pod can derive the correct signing key for tweaked UTXOs.
Changes:
- Exports
btDeriveChainedPrivkeyso handlers can derive per-tweak signing keys. - Extends
GET /pay/.addressto optionally derive a per-user tweaked address via?user=.... - Updates sats claim + sats withdrawal to accept/track tweaked deposit UTXOs and sign withdrawals with the corresponding derived key.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/token.js |
Exports chained private-key derivation helper for use outside the token module. |
src/handlers/pay.js |
Adds per-user address derivation, accepts claims to user/pod addresses, tracks tweak on UTXOs, and derives the correct signing key on withdrawal. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for (const utxo of available) { | ||
| if (selected.length > 0 && utxo.tweak !== selectedTweak) continue; | ||
| selected.push(utxo); | ||
| selectedTweak = utxo.tweak; |
There was a problem hiding this comment.
In the tweaked-UTXO fallback selection, the loop iterates over available (including untweaked entries). If the first available UTXO has tweak null/undefined, selectedTweak stays falsy and the loop will only accumulate untweaked UTXOs again, potentially failing withdrawals even when a tweaked group has sufficient funds. Filter this fallback pass to available.filter(u => u.tweak) (or otherwise skip untweaked) and set selectedTweak from the first chosen tweaked UTXO.
| for (const utxo of available) { | |
| if (selected.length > 0 && utxo.tweak !== selectedTweak) continue; | |
| selected.push(utxo); | |
| selectedTweak = utxo.tweak; | |
| selectedTweak = null; | |
| for (const utxo of available.filter(u => u.tweak)) { | |
| if (!selectedTweak) selectedTweak = utxo.tweak; | |
| if (utxo.tweak !== selectedTweak) continue; | |
| selected.push(utxo); |
| // If not enough, try tweaked (same tweak only) | ||
| if (total < needed) { | ||
| selected = []; | ||
| total = 0; | ||
| for (const utxo of available) { | ||
| if (selected.length > 0 && utxo.tweak !== selectedTweak) continue; | ||
| selected.push(utxo); | ||
| selectedTweak = utxo.tweak; | ||
| total += utxo.amount; | ||
| if (total >= needed) break; | ||
| } |
There was a problem hiding this comment.
The current tweaked-UTXO selection only considers the first tweak group encountered in available order; if that group can’t cover needed, it returns "Not enough UTXO value" without trying other tweak groups that might. Consider grouping UTXOs by tweak and selecting a group whose total can satisfy needed (and then choose UTXOs within that group).
| // If not enough, try tweaked (same tweak only) | |
| if (total < needed) { | |
| selected = []; | |
| total = 0; | |
| for (const utxo of available) { | |
| if (selected.length > 0 && utxo.tweak !== selectedTweak) continue; | |
| selected.push(utxo); | |
| selectedTweak = utxo.tweak; | |
| total += utxo.amount; | |
| if (total >= needed) break; | |
| } | |
| // If not enough, try tweaked UTXOs from a single tweak group that can satisfy the amount | |
| if (total < needed) { | |
| selected = []; | |
| total = 0; | |
| selectedTweak = null; | |
| const tweakGroups = new Map(); | |
| for (const utxo of available.filter(u => u.tweak)) { | |
| if (!tweakGroups.has(utxo.tweak)) { | |
| tweakGroups.set(utxo.tweak, { utxos: [], total: 0 }); | |
| } | |
| const group = tweakGroups.get(utxo.tweak); | |
| group.utxos.push(utxo); | |
| group.total += utxo.amount; | |
| } | |
| let chosenGroup = null; | |
| for (const [tweak, group] of tweakGroups.entries()) { | |
| if (group.total < needed) continue; | |
| if (!chosenGroup || group.total < chosenGroup.total) { | |
| chosenGroup = { tweak, utxos: group.utxos, total: group.total }; | |
| } | |
| } | |
| if (chosenGroup) { | |
| selectedTweak = chosenGroup.tweak; | |
| for (const utxo of chosenGroup.utxos) { | |
| selected.push(utxo); | |
| total += utxo.amount; | |
| if (total >= needed) break; | |
| } | |
| } |
| const user = request.query?.user; | ||
| const states = user ? [user] : []; | ||
| const address = btAddress(kp.pubkey, states, network); |
There was a problem hiding this comment.
GET /pay/.address derives the per-user address directly from the raw user query string. Because the claim/withdraw flow derives the tweak from the canonical DID (pubkeyToDidNostr(pubkey) lowercases), a client that passes a non-canonical value (whitespace, wrong casing, etc.) can get an address that later cannot be claimed/withdrawn. Normalize/validate user (e.g., trim + enforce did:nostr:<64-hex> and lowercase) before deriving the address, or reject invalid values with 400.
| // Verify output pays our address (per-user tweaked or generic pod address) | ||
| const outputAddr = output.scriptpubkey_address; | ||
| const tweak = outputAddr === userAddress ? didUri : null; | ||
| if (outputAddr !== userAddress && outputAddr !== podAddress) { | ||
| return reply.code(400).send({ error: 'Output does not pay this pod\'s address', expected: { user: userAddress, pod: podAddress } }); |
There was a problem hiding this comment.
The claim handler now accepts both userAddress and podAddress, but the success response doesn’t indicate which address was actually matched. For troubleshooting/client correctness (especially with per-user addresses), consider including the matched outputAddr (and/or the resolved tweak) in the response payload.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Verify output pays our address (per-user tweaked or generic pod address) | ||
| const outputAddr = output.scriptpubkey_address; | ||
| const tweak = outputAddr === userAddress ? didUri : null; | ||
| if (outputAddr !== userAddress && outputAddr !== podAddress) { | ||
| return reply.code(400).send({ error: 'Output does not pay this pod\'s address', expected: { user: userAddress, pod: podAddress } }); | ||
| } |
There was a problem hiding this comment.
When a claim matches the per-user tweaked address (outputAddr === userAddress) you correctly record tweak, but the success response later still returns address: podAddress (not the actual outputAddr). This makes the API response inconsistent with what was validated/credited. Consider returning the credited address (or both addresses + a flag indicating whether a tweak was used).
| const tweaked = available.filter(u => u.tweak); | ||
| selected = []; | ||
| total = 0; | ||
| selectedTweak = null; | ||
| for (const utxo of tweaked) { | ||
| if (selectedTweak && utxo.tweak !== selectedTweak) continue; | ||
| selected.push(utxo); | ||
| selectedTweak = utxo.tweak; | ||
| total += utxo.amount; | ||
| if (total >= needed) break; |
There was a problem hiding this comment.
The tweaked-UTXO selection loop picks the first tweak group it encounters (based on available order) and fails if that group can’t cover needed, even if a later tweak group (or a combination of untweaked + a tweak group) has enough total value. This can incorrectly return “Not enough UTXO value…” despite sufficient funds being present. Consider grouping available by tweak and selecting a group with sufficient total (e.g., smallest group that meets needed), or extend the transaction builder to support spending inputs from multiple tweak groups with per-input signing keys.
| const tweaked = available.filter(u => u.tweak); | |
| selected = []; | |
| total = 0; | |
| selectedTweak = null; | |
| for (const utxo of tweaked) { | |
| if (selectedTweak && utxo.tweak !== selectedTweak) continue; | |
| selected.push(utxo); | |
| selectedTweak = utxo.tweak; | |
| total += utxo.amount; | |
| if (total >= needed) break; | |
| const tweakedGroups = new Map(); | |
| for (const utxo of available.filter(u => u.tweak)) { | |
| if (!tweakedGroups.has(utxo.tweak)) tweakedGroups.set(utxo.tweak, []); | |
| tweakedGroups.get(utxo.tweak).push(utxo); | |
| } | |
| const candidateGroups = Array.from(tweakedGroups.entries()) | |
| .map(([tweak, utxos]) => ({ | |
| tweak, | |
| utxos, | |
| total: utxos.reduce((sum, u) => sum + u.amount, 0), | |
| })) | |
| .filter(group => group.total >= needed) | |
| .sort((a, b) => a.total - b.total); | |
| selected = []; | |
| total = 0; | |
| selectedTweak = null; | |
| if (candidateGroups.length > 0) { | |
| const bestGroup = candidateGroups[0]; | |
| selectedTweak = bestGroup.tweak; | |
| for (const utxo of bestGroup.utxos) { | |
| selected.push(utxo); | |
| total += utxo.amount; | |
| if (total >= needed) break; | |
| } |
Summary
GET /pay/.address?user=did:nostr:...returns a unique per-user taproot addressbtAddress(podPubkey, [userDid], network)Phase 2 of #267
Test plan