diff options
| author | Adam Soltys <[email protected]> | 2026-06-28 09:22:45 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-28 09:22:45 -0700 |
| commit | d459e4aa196b356cfc24f2118cc1ef54eda7600e (patch) | |
| tree | 4cd0bd4bed365121417e7a57b2371c6295c86908 /src/wallet.js | |
| parent | 5e19371642b0d2edc7540f96f53a17c47cf51a15 (diff) | |
| download | coinosv3-d459e4aa196b356cfc24f2118cc1ef54eda7600e.tar.gz coinosv3-d459e4aa196b356cfc24f2118cc1ef54eda7600e.zip | |
Lock a gift to a nostr key: public link, claim code DM'd to the recipient
A gift can now be locked to a recipient's nostr npub. The shareable link
(/lg/<blob>) carries the amount + recipient in the clear (anyone can see what it
is and who it's for), but the claim payload is encrypted under a one-time code.
That code is delivered as an encrypted nostr DM (NIP-04) to the recipient — so
only they can read it. They paste the code to decrypt, then claim into a fresh
wallet exactly like a normal bearer gift.
- nostr.js: parseNostrPubkey/npubOf, encryptWithCode/decryptWithCode (random
32-byte code), fetchNostrProfile, NostrSync.sendDM (broad relays).
- wallet.js: lockGift → { blob, claimCode }, previewLockedGift, nostr identity +
sendNostrDM.
- app.js: gift form gains an optional 'Lock to npub' field that shows the
recipient's avatar + name (nostr kind-0 profile); create encrypts + DMs the
code (with sent/failed status + a copyable fallback); /lg/ links open a claim
view showing the gift + recipient where you paste the code; account settings
shows your own npub to share.
Verified: code lock/unlock round-trip, a real nostr DM published and read back
from relay.damus.io, and the UI loads + renders with no errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/wallet.js')
| -rw-r--r-- | src/wallet.js | 32 |
1 files changed, 31 insertions, 1 deletions
diff --git a/src/wallet.js b/src/wallet.js index dbb9b73..b2ff3e3 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -23,7 +23,7 @@ import { concatBytes } from '@scure/btc-signer/utils.js'; import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js'; import { ElectrumApi } from './electrum.js'; -import { NostrSync, getSyncConfig } from './nostr.js'; +import { NostrSync, getSyncConfig, encryptWithCode, npubOf } from './nostr.js'; import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js'; import { schnorr } from '@noble/curves/secp256k1'; @@ -1628,6 +1628,14 @@ export class Wallet { return this._buildGiftPsbt(sel, gift); } + // Our nostr identity (used to DM a locked gift's claim code to the recipient). + nostrPubkey() { return (this.nostr && this.nostr.pk) || null; } + nostrNpub() { const pk = this.nostrPubkey(); return pk ? npubOf(pk) : null; } + // Send a nostr DM (e.g. a locked gift's claim code) to a recipient pubkey. + async sendNostrDM(recipientPkHex, text) { + return this.nostr && this.nostr.sk ? this.nostr.sendDM(recipientPkHex, text) : false; + } + // Split + gift in one shot, with no confirmation wait: broadcast a self-send // that carves out a gift+dust coin, then build the gift spending that // still-unconfirmed carve-out. The funding and the claim form a chain that @@ -2562,6 +2570,28 @@ export function buildClaimTx(code, toAddress, feeRate, net) { return { hex: tx.hex, txid: tx.id, amount: Number(out), fee: Number(fee) }; } +// A gift locked to a recipient's nostr key. The amount + recipient pubkey are +// public (anyone can see what it is and who it's for), but the claim payload (the +// gift code) is NIP-44-encrypted to the recipient via an ephemeral key — so the +// link can be shared openly yet only the recipient's nostr key can claim it. +const LOCKED_GIFT_VERSION = 1; +// Encrypt the gift's claim payload under a one-time code. The code is delivered +// to the recipient via a nostr DM; the public link carries only the ciphertext. +// Returns { blob (for the /lg/ link), claimCode (to DM) }. +export function lockGift(code, amount, recipientPkHex) { + const { code: claimCode, ct } = encryptWithCode(code); + const blob = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify({ v: LOCKED_GIFT_VERSION, amount, to: recipientPkHex, ct }))); + return { blob, claimCode }; +} +// The public fields of a locked-gift blob (or null). Decryption needs the code. +export function previewLockedGift(blob) { + try { + const o = JSON.parse(new TextDecoder().decode(base64urlnopad.decode(blob))); + if (o.v === LOCKED_GIFT_VERSION && typeof o.amount === 'number' && o.to && o.ct) return o; + } catch {} + return null; +} + function firstUnused(chain) { const u = chain.find((a) => !a.used); return u ? u.index : chain.length; |
