Skip to content

feat: per-user tweaked deposit addresses (Phase 2 of #267) - #269

Merged
melvincarvalho merged 2 commits into
gh-pagesfrom
issue-267-phase2-tweaked-addresses
Apr 3, 2026
Merged

feat: per-user tweaked deposit addresses (Phase 2 of #267)#269
melvincarvalho merged 2 commits into
gh-pagesfrom
issue-267-phase2-tweaked-addresses

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

Summary

  • GET /pay/.address?user=did:nostr:... returns a unique per-user taproot address
  • Derived via key chaining: btAddress(podPubkey, [userDid], network)
  • Claim handler accepts both per-user tweaked and generic pod addresses
  • UTXOs track the tweak for correct key derivation on withdrawal
  • Withdraw selects UTXOs by tweak group, derives signing key accordingly

Phase 2 of #267

Test plan

  • All 353 tests pass
  • Per-user address differs from generic pod address
  • Send sats to tweaked address → claim → balance credited
  • Withdraw from tweaked UTXO → voucher returned (real Bitcoin tx)
  • Generic (untweaked) deposits still work

- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 btDeriveChainedPrivkey so handlers can derive per-tweak signing keys.
  • Extends GET /pay/.address to 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.

Comment thread src/handlers/pay.js Outdated
Comment on lines +782 to +785
for (const utxo of available) {
if (selected.length > 0 && utxo.tweak !== selectedTweak) continue;
selected.push(utxo);
selectedTweak = utxo.tweak;

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment thread src/handlers/pay.js Outdated
Comment on lines +778 to +788
// 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;
}

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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;
}
}

Copilot uses AI. Check for mistakes.
Comment thread src/handlers/pay.js Outdated
Comment on lines +296 to +298
const user = request.query?.user;
const states = user ? [user] : [];
const address = btAddress(kp.pubkey, states, network);

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/handlers/pay.js
Comment on lines +464 to +468
// 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 } });

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/handlers/pay.js
Comment on lines +467 to 472
// 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 } });
}

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/handlers/pay.js
Comment on lines +783 to +792
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;

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
}

Copilot uses AI. Check for mistakes.
@melvincarvalho
melvincarvalho merged commit 3425872 into gh-pages Apr 3, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants