feat: #/pay-email — send payment via email (provider-held UTXOs) - #8
Merged
Conversation
…gets/held endpoints, submit-time spend signing)
…ds include provider-held UTXOs
…ys one line, balance breakdown behind gear toggle
AquiGorka
added a commit
that referenced
this pull request
Jul 27, 2026
* feat: entity SEP-10 login at #/pay-utxo
Entity-facing login against the existing GET/POST /stellar/auth
endpoints: connect wallet, co-sign the server's SEP-10 challenge,
hold the entity JWT in module-local state.
Zero-persistence stance, mirroring the KYC route: entity address and
JWT never touch localStorage, nothing survives refresh or re-entry —
the entity reconnects and re-signs SEP-10 every visit. The operator
session (console_token / provider_admin_address) is never read or
written.
First step of the request-payment + transfer UI; the Balance,
Request Transfer, and Send sections build on this session. A
name-based surface lands later at #/pay-name.
* feat: add moonlight-sdk to the frontend
jsr:@moonlight/moonlight-sdk@^0.11.3, which requires unifying
@stellar/stellar-sdk at 15.1.0 across the dependency graph:
- bump the load-bearing stellar-sdk pin to ^15.1.0 (the sdk wants
^15.1.0; keeping ^15.0.1 left two sdk copies in the bundle — the
exact dual-XDR 'Bad union switch' failure the dedup plugin guards
against)
- pin stellar-wallets-kit to exactly 2.1.0: a fresh resolution pulls
2.5.0, which hard-depends on stellar-sdk 16.x and reintroduces the
dual copy
- point the stellar-sdk-dedup plugin at the 15.1.0 node_modules path
Bundling verified in-browser: the full sdk namespace (49 exports)
initializes with a single stellar-sdk/stellar-base copy in app.js.
* feat: Balance / Request transfer / Send sections on #/pay-utxo
The three entity payment sections, mirroring the reference clients
(local-dev/lib/client/{deposit,receive,send}.ts) with the wallet in
place of a raw Keypair:
- Balance: session balance + deposit — one wallet popup signing the
Soroban auth entry (wallet-kit signAuthEntry bridges the SDK's
Colibri Signer contract)
- Request transfer: fresh WebCrypto P-256 keys wrapped in the SDK's
UTXOKeypairBase, shared as CREATE-op MLXDR strings out of band,
secrets copyable and session-only
- Send: paste receiver CREATEs, SPEND session UTXOs signed silently
in memory, change CREATE back to the session; bundle order and wire
shape match POST /provider/entity/bundles
Channel comes from #/pay-utxo?channel=&asset= — there is no
entity-facing discovery endpoint. Bundle progress renders on the
@moonlight/ui stepper via 2s polling. Zero-persistence stance is
unchanged: keys, JWT, and address all die with the session.
* fix: bind aliased buffer imports to the polyfill
esbuild renames colliding Buffer imports (Buffer as Buffer18) when
several bundled modules import it — moonlight-sdk brought the first
such collisions. The post-build patch removed those import statements
without creating replacement vars, leaving the renamed references
dangling ('Buffer18 is not defined' at runtime). Fold the removal
pass into the alias-aware rewrite so every imported name gets a var
bound to the polyfill.
* fix: surface empty wallet signatures as a clear error
An unsigned/rejected signTransaction resolved with undefined and the
client POSTed {} to /stellar/auth — the user saw a bare 400 instead
of the actual problem (wallet on the wrong network or a dismissed
popup).
* fix: zero-fee SEP-10 challenge
SEP-10 mandates no fee and the challenge is never submitted; the 100
stroops was convention copied from the SDF reference implementation
and only made wallets display a phantom fee (and run balance checks)
on the sign-in prompt.
* feat: derived UTXO keys + ui nav on #/pay-utxo
Review feedback:
- The surface now uses @moonlight/ui renderNav + pageLayout instead of
bare cards.
- Balance is no longer session-limited: UTXO keys derive from the wallet
exactly like moonlight-pay's self-custodial flow (master seed =
SHA-256 of signMessage('Moonlight: Derive server key'), per-index
HKDF 'moonlight-p256' → P-256), and the balance is the channel's
on-chain utxo_balances over that key set — same wallet, same funds,
every visit. Receive keys come from the same derivation, so there is
no secret custody: everything re-derives from the wallet.
Deposits land on the next free derived key; sends spend funded derived
keys with change to a fresh one. Channel now also needs the channel-auth
id (#/pay-utxo?channel=&asset=&auth=) for the on-chain reads;
channel-selection UX stays an open item per review.
* fix: reject seed signatures from the wrong wallet account
Derived UTXO keys depend on who signed the derivation message; a
signature from another account (Freighter versions sign with the
active account, not the requested address) silently produces a
different key set and orphans funds. Verify signerAddress and fail
loudly.
* fix: treat the contract's -1 as unfunded and gap-scan for funded keys
utxo_balances returns i128 -1 for keys that never existed. The
balance refresh stored it verbatim, so no key ever matched the
free-key check (=== 0) — reservations silently walked into the next
batch while the refresh only ever read the first one, making any
deposit beyond index 9 invisible to later sessions. Free now means
<= 0, and the refresh scans batch by batch with a gap limit (stop
after >= 3 batches when the latest holds no funded key).
* fix: wrap derived P-256 scalars in PKCS8 for spend signing
The sdk's signPayload imports the private key as pkcs8 (moonlight-pay
wraps its raw scalar the same way via buildPkcs8P256); passing the
raw 32 bytes made WebCrypto's importKey fail inside signWithUTXO and
the send never reached submission.
* feat: resolve the entity surface's channel from the provider
No query params and no missing-channel banner on #/pay-utxo: the page
asks its own provider (GET /provider/entity/channels, entity-authed)
and auto-selects the single channel. The endpoint reuses the operator
dashboard's membership → council-platform lookup, so the entity sees
exactly the channels the operator console shows. Channel-picker UX for
multi-channel providers stays open.
* fix: the derivation message names a client-only UTXO seed, not a server key
* fix: derive the UTXO seed during sign-in, not from the rendered surface
* feat: user-land request card — asset picker, payment code copy, advanced UTXO count
* fix: request card copy button reads Copy / Copied, no status line
* feat: surface entity-not-approved on submit with a link to the KYC form
* feat: balance card lists per-asset balances; deposit gated on wallet funds
* feat: split Balance and Deposit into separate cards
* feat: withdraw card — channel funds back to the connected wallet, trustline ensured
* fix: KYC error shows the registration link itself, opening in a new tab
* feat: KYC banner + full button gate for unapproved entities; reorder cards
* fix: import getEntityStatus
* fix: KYC banner title+register link; send takes the payment code in one input; withdraw mimics deposit
* fix: request button reads Get code; drop unused SEND_FEE import
* fix: never re-reserve spent UTXO keys (UTXOAlreadyExists); spinner-in-button loading
* fix: clear session reservations once the key exists on-chain
* feat: Copied-to-clipboard toast on Get code (no toast in @moonlight/ui yet)
* feat: success toasts + input reset on completed deposit, send, withdraw
* style: cargo/deno fmt over branch changes
* chore: fix pre-existing fmt drift (page.ts, api.ts) so the fmt gate can pass
* feat: bump moonlight-sdk to 0.12.1 (A1 signed-payload XDR encoding, soroban-core 0.5.0 artifacts)
* feat: #/pay-email — send payment via email (provider-held UTXOs) (#8)
* feat: provider-held UTXOs for send-via-email (holding derivation, targets/held endpoints, submit-time spend signing)
* feat: #/pay-name surface — send to a registered email, balance + spends include provider-held UTXOs
* fix: review notes — route/branding pay-name → pay-email, Send row stays one line, balance breakdown behind gear toggle
* fix: drop unrequested Send section hint text
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Step 2 of the demo surface (ClickUp 86caeh3a1, parent 86caq47uw). Builds on #7 (base branch is
entity-sep10-loginso this diff is step-2 only).Design (as specified by Gorka)
Send [asset] [amount] to [email]: the backend derives holding UTXO keys of its own — deterministically from the PP secret + the recipient email, indexed from 0 — and the payer CREATEs onto them. The funds sit in provider custody until whoever KYC-registers with that email signs in: they then appear in that entity's balance and are spendable/withdrawable as if their own (the client submits their SPENDs unsigned; the provider recognises its holding keys and signs server-side, transparently). No storage, no claim step: derivation is the attribution, and unused keys are found by chain-scanning from index 0 (same -1/0/>0 conventions as the frontend sweep). Works for emails nobody has registered yet.
The KYC field stays DB column
name— only the registration form legend/input changed to "Email" (POC, it's just a string).Changes
Backend
crates/core/src/holding.rs— deterministic P-256 holding-key derivation + AuthPayload preimage/signing (client-parity construction: contract strkey ‖ conditions ScVal XDR ‖ LE32(exp); ECDSA P-256/SHA-256, low-S, raw r‖s)crates/core/src/mlxdr.rs—unsigned_spend_utxo(detect unsigned spend slots) andsign_spend_slot(sign + re-encode, conditions taken verbatim from the slot)GET /api/v1/provider/entity/holding/targets?email=&channel=&count=— never-used holding pubkeys for an email (the payer's CREATE targets)GET /api/v1/provider/entity/holding?channel=— funded UTXOs held for the authed entity's registered emailFrontend
#/pay-name— mirrors#/pay-utxo: same login ceremony (SEP-10 + seed derivation, zero persistence), KYC banner, then Balance, Send (asset + amount + email), Deposit, Withdraw. No Request section. Balance shows own + held (held total annotated). Send/Withdraw draw on held UTXOs transparently.moonlight-client.ts—fetchHeldUtxos/submitSendToEmail;submitWithdrawgains an optional held pool (default[],#/pay-utxounchanged); shared cross-pool selection + spend buildingVerification
cargo test --workspacegreen (holding derivation/signing unit-tested),deno task build/lint/fmtgreen (pre-existingdeno task checkbreakage on main untouched). End-to-end on the local stack (script: task tmpstack/local-dev/verify-pay-name.ts): payer registers with email + SEP-10 → deposits 5 XLM → gets a holding target for a not-yet-registered email → sends 2 XLM (COMPLETED on-chain) → recipient registers with that email → sees the 2 XLM held UTXO → withdraws with the held SPEND submitted unsigned → provider signs it server-side → COMPLETED, recipient wallet +1.9 XLM. The Rust-side P-256 signed-payload encoding verifies against the soroban-core 0.5.0 contracts.Notes
post_registerearly-return, untouched).