Skip to content

feat(utxo): on-demand backend-side UTXO derivation + delete-my-account + receive precheck#37

Merged
AquiGorka merged 7 commits into
mainfrom
feat/utxo-key-delegation
Jun 4, 2026
Merged

feat(utxo): on-demand backend-side UTXO derivation + delete-my-account + receive precheck#37
AquiGorka merged 7 commits into
mainfrom
feat/utxo-key-delegation

Conversation

@AquiGorka

@AquiGorka AquiGorka commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Flip the moonlight-pay receive-UTXO model from a 100-key signup pre-pool to an encrypted delegation root stored on the user's pay_accounts row. Pay-platform derives merchant UTXO public keys on demand by walking the privacy channel's utxo_balances view, so the receive-side is no longer bounded by a one-shot batch.

What changed in this repo

Schema / migration

  • pay_accounts.encrypted_delegation_key (text, nullable) — added in migration/0008_drop_receive_utxos_add_delegation_key.sql.
  • receive_utxos table + its enum + its repository: dropped.

Crypto + on-chain helpers

  • src/core/crypto/utxo-derivation.ts — server-side per-index P-256 keypair derivation from a 32-byte root (HKDF + SHA-256 + P-256), mirror of the moonlight-pay client's one-shot computeUtxoRoot.
  • src/core/channel-client/index.ts — cached PrivacyChannel factory.
  • src/core/service/utxo/utxo-balance.tsfetchUtxoBalances, walkFundedBalances, findFreeUtxoIndexes, validateReceiveDestinations. Walk terminates on 3 consecutive -1 (never-created) returns. The receive-side precheck (validateReceiveDestinations) re-queries on-chain at execute time and substitutes free indexes if the proposed set was funded between prepare and execute.
  • src/core/service/utxo/utxo-balance_test.ts — 6 unit tests, including the precheck non-zero-balance and spent-UTXO rejection cases.

API

  • POST /api/v1/account/delegation-key — one-shot handover; encrypts via the existing encrypt-sk.ts (AES-256-GCM + PBKDF2 from SERVICE_AUTH_SECRET).
  • DELETE /api/v1/account/me — hard delete; the existing transactionspay_accounts FK cascades.
  • GET /api/v1/account/:walletPublicKey/public — public merchant profile lookup. Replaces the old /utxo/receive/:wallet/available endpoint (the POS only needs merchant name / onboarded flag, not a UTXO list).
  • pay/instant/prepare derives the next free indexes on demand.
  • pay/instant/execute runs validateReceiveDestinations before submitting the bundle; the request body now passes merchantUtxoIndexes instead of DB row IDs.
  • transactions/balance switches from SUM(transactions) to chain-derived walk.
  • /utxo/* router is removed.

Version

  • deno.json: 0.5.18 → 0.5.19.

Companion PRs

Test plan

  • deno fmt --check
  • deno lint
  • deno check src/main.ts
  • deno task test:unit (27/27)
  • local-dev ./test.sh playwright 16/16 green (includes new Step 13b verifying DELETE /me removes the row)

…eceive precheck

Replace the 100-key signup pre-pool with a single encrypted delegation
root stored on the user's pay_accounts row. The backend derives merchant
UTXO public keys on demand by walking the privacy channel's
utxo_balances view, so the receive-side is no longer bounded by the
batch the user generated at signup.

- pay_accounts.encrypted_delegation_key (migration 0008).
  receive_utxos table dropped along with its enum + repository.
- core/crypto/utxo-derivation.ts: server-side per-index P-256 keypair
  derivation from a 32-byte root (HKDF + SHA-256 + P-256).
- core/service/utxo/utxo-balance.ts: chain walk, free-index finder, and
  receive-side precheck (validateReceiveDestinations). Walk terminates
  on 3 consecutive -1 (never-created) returns from the chain.
- POST /account/delegation-key (one-shot encrypted-at-rest handover via
  encrypt-sk.ts + SERVICE_AUTH_SECRET).
- DELETE /account/me (hard delete; transactions FK cascades).
- GET /account/:walletPublicKey/public (public profile lookup; replaces
  the old /utxo/receive/:wallet/available endpoint).
- pay/instant/prepare derives on demand; pay/instant/execute runs the
  precheck before submitting; merchantUtxoIndexes replaces the DB row
  ids.
- transactions/balance switches from SUM(transactions) to chain-derived
  walk.
- 6 unit tests covering the chain walk + precheck.
…nore)

The user's global ~/.gitignore.global excludes *.sql, so the new migration
file was silently dropped from the previous commit. _journal.json points
at it, so the integration suite's runMigrations() crashes with NotFound.
AquiGorka added a commit to Moonlight-Protocol/local-dev that referenced this pull request Jun 2, 2026
…ation-key

pay-platform's /utxo/receive endpoint was dropped in
Moonlight-Protocol/pay-platform#37; merchants now register a single
32-byte UTXO root and pay-platform derives receive UTXOs on demand
from it. The pos-instant e2e test was still POSTing 5 pre-generated
public keys to the gone endpoint and failing at Step 2.

Replace the 5-key generation + /utxo/receive POST with one random
32-byte root POSTed to /account/delegation-key. Same backend contract
as the production signup flow; the test bypasses the wallet UI, not
the contract.
AquiGorka added 5 commits June 3, 2026 15:54
The deposit + 5-temp-CREATE + 5-SPEND + 5-merchant-CREATE pattern made
provider-platform's fee classifier compute a NEGATIVE bundle fee
(totalInflows = deposit = netStroops, totalOutflows = 2 * netStroops),
so the executor's totalFee was -netStroops and MoonlightOperation.create
rejected it with "Amount too low".

Switch to browser-wallet's deposit pattern (lib/client/deposit.ts +
browser-wallet/src/background/handlers/private/deposit.ts):

  deposit(opex, netStroops + BUNDLE_FEE)
  + N merchant CREATEs summing to netStroops

The on-chain SAC transfer to the channel sends netStroops + BUNDLE_FEE
so inflows/outflows align with the bundle's deposit op. Fee classifier
sees inflows = deposit, outflows = merchant creates, fee = BUNDLE_FEE
(positive).

BUNDLE_FEE = 500_000 stroops (0.05 XLM), matches lifecycle DEPOSIT_FEE.

Also hardcode feePct = 0 — merchant-fee concept is being reworked
separately.

Removes the now-unused deriveP256Keypair + buildPkcs8P256 helpers.
Drop fee_pct column from pay_accounts (migration 0009), remove
feePct from /account/opex body + response, /pay/instant/prepare opex
sub-object, and instant-execute amount accounting (replaces feeStroops
/netStroops with the customer-paid amountStroops). The bundle's fee is
the fixed BUNDLE_FEE the executor pays the PP, not a merchant cut.

Sign the deposit op with the OpEx Ed25519 key (browser-wallet pattern)
so the chain accepts the bundle.
Hardcoded expirationLedger = 999_999_999 was too far ahead of the
chain's current ledger sequence (~3M on local), causing the channel
contract to reject the bundle:

  HostError: Error(Auth, InvalidInput)
  "signature expiration is too late"

Match lifecycle's lib/client/deposit.ts pattern — fetch latestLedger
from the RPC server and use `latestLedger + 1000` as the deposit op
expiration.
provider-platform's mempool → executor → verifier pipeline is async
(5 s executor tick + chain confirm + 10 s verifier tick), so the bundle
status doesn't reach COMPLETED until ~10-20 s after the POST returns.
Returning to the caller before then yielded "payment completed" status
but the merchant's chain-derived balance was still 0.

Poll GET /providers/:pp/entity/bundles/:id every 5 s up to 120 s
(matches local-dev's lib/client/bundle.ts:waitForBundle pattern). Only
record the IN transaction and return COMPLETED once the bundle
actually settles. FAILED / EXPIRED bundles surface as errors.

Also drops the stale feeStroops / netStroops / feePct references left
over from the merchant-fee removal — the transaction row uses the
amountStroops directly (feeStroops defaults to 0).
@AquiGorka
AquiGorka merged commit 1086c2f into main Jun 4, 2026
7 checks passed
@AquiGorka
AquiGorka deleted the feat/utxo-key-delegation branch June 4, 2026 11:36
AquiGorka added a commit to Moonlight-Protocol/local-dev that referenced this pull request Jun 4, 2026
## Summary

Extend the full-flow playwright spec with a Step 13b that exercises
`DELETE /api/v1/account/me` from the UI and asserts the `pay_accounts`
row is hard-deleted via a direct DB query (not API).

## What changed in this repo

**DB helpers** — `playwright/helpers/db.ts`
- `payAccountExists(walletPublicKey)` — `EXISTS (SELECT 1 ...)`.
- `fetchEncryptedDelegationKey(walletPublicKey)` — returns the stored
ciphertext, `null` if the column isn't set, `undefined` if the row is
gone. Used to assert the encrypted root was persisted at signup.

**Spec** — `playwright/tests/full-flow.spec.ts`
- New Step 13b after the existing Step 13: assert the row exists + has
an encrypted delegation key, click the Delete-account button in
moonlight-pay, accept the browser confirm dialog, wait for the `/login`
redirect, then assert the `pay_accounts` row is gone.

## Companion PRs

- pay-platform:
Moonlight-Protocol/pay-platform#37
- moonlight-pay:
Moonlight-Protocol/moonlight-pay#35

## Test plan
- [x] `./test.sh playwright` — 16/16 green in 6.5 min on this branch
(when run with `PAY_PLATFORM_PATH` / `MOONLIGHT_PAY_PATH` pointing at
the companion-PR branches).
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.

1 participant