aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-25 16:50:21 -0700
committerAdam Soltys <[email protected]>2026-06-25 16:50:21 -0700
commitdae260d1a2cfb866d7b0f5c5620d5e0392bd4723 (patch)
tree064babaaff99e0e4249bfece63814b97e2b4c641 /src
parent654d029fba939027d9decec852ebb3efe6037c95 (diff)
downloadcoinosv3-dae260d1a2cfb866d7b0f5c5620d5e0392bd4723.tar.gz
coinosv3-dae260d1a2cfb866d7b0f5c5620d5e0392bd4723.zip
Compact gift links: base32 codec + uppercase /g/ path for a smaller QR
Gift links now carry a compact binary encoding of just the claim-essential data (presigned inputs + the committed change output) instead of a full PSBT (~25% fewer bytes), base32-encoded and served at an uppercase /g/<code> path. The QR renders the uppercase URL in the QR alphanumeric mode (5.5 bits/char vs byte mode's 8), so the grid drops from ~73 to ~53 modules — much easier to scan. - wallet: _encodeCompactGift/_decodeCompactGift + _giftTx dispatcher; legacy base64url-PSBT links still claim (auto-detected by a magic byte). Verified round-trip + claim-byte-identical across 1/2/3-input and whole-balance gifts, against the real exported preview/claim/outpoint functions. - app: giftUrl uses /g/<code>; opener parses /g/ (and legacy #gift=); QR uses uppercase + Alphanumeric mode (qrSvg gains a mode option w/ byte fallback). - nginx already SPA-routes /g/*; the SW serves the shell for the new path. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/app.js28
-rw-r--r--src/qr.js18
-rw-r--r--src/wallet.js70
3 files changed, 98 insertions, 18 deletions
diff --git a/src/app.js b/src/app.js
index fdc5b3f..708a56e 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1081,7 +1081,7 @@ function pubkeyCard() {
// Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims
// into a fresh wallet only they control. The coin is reserved until claimed.
function giftUrl() {
- return `${location.origin}/#gift=${ui.giftCode}`;
+ return `${location.origin}/g/${ui.giftCode}`;
}
function giftRate() {
return (wallet.feeRates && wallet.feeRates.halfHourFee) || 5;
@@ -1170,7 +1170,10 @@ function giftCard() {
h('div', { class: 'warn-box' }, t('giftLinkWarn')),
ui.giftCode
? h('div', { class: 'col', style: 'align-items:center;gap:10px' },
- h('div', { html: qrSvg(giftUrl(), { ec: 'L' }) }),
+ // Uppercase the whole URL + alphanumeric mode → much smaller QR. The
+ // domain is case-insensitive and the base32 code is already uppercase;
+ // the opener parses /g/ and the code case-insensitively.
+ h('div', { html: qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }),
h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, giftUrl()),
h('div', { class: 'row gap6 wrap' },
copyBtn(giftUrl(), t('copyLink')),
@@ -1393,15 +1396,22 @@ function goHome() {
}
// ================================================================ GIFT / CLAIM
-// Read a #gift=<psbt> link, returning the code (and scrubbing it from the URL so
-// the bearer instrument doesn't linger in the address bar / history). Validates
-// that it decodes to a real gift.
+// Read a gift link, returning the code (and scrubbing it from the URL so the
+// bearer instrument doesn't linger in the address bar / history). New links are
+// /g/<base32-compact>; legacy links are #gift=<base64url-psbt>. Both validate by
+// decoding to a real gift.
function readGiftHash() {
try {
- const m = location.hash.match(/^#gift=([A-Za-z0-9_-]+)$/);
- if (!m) return null;
- history.replaceState(null, '', location.pathname + location.search);
- return previewGift(m[1]) ? m[1] : null;
+ let code = null;
+ const pm = location.pathname.match(/^\/g\/([A-Za-z0-9]+)\/?$/i); // new path form
+ if (pm) code = pm[1];
+ else {
+ const hm = location.hash.match(/^#gift=([A-Za-z0-9_-]+)$/); // legacy hash form
+ if (hm) code = hm[1];
+ }
+ if (!code) return null;
+ history.replaceState(null, '', '/');
+ return previewGift(code) ? code : null;
} catch {
return null;
}
diff --git a/src/qr.js b/src/qr.js
index fe49a02..d02aed3 100644
--- a/src/qr.js
+++ b/src/qr.js
@@ -6,10 +6,20 @@ import qrcode from 'qrcode-generator';
// ec: error-correction level. 'M' by default; pass 'L' for long payloads (e.g.
// gift links) where a smaller, chunkier grid scans more easily — on-screen QRs
// are scanned immediately and don't need the damage tolerance of higher levels.
-export function qrSvg(text, { margin = 2, ec = 'M' } = {}) {
- const qr = qrcode(0, ec); // type 0 = auto-size
- qr.addData(text);
- qr.make();
+// mode: pass 'Alphanumeric' for an all-uppercase QR-alphanumeric string (0-9 A-Z
+// space $%*+-./:) to pack it at 5.5 bits/char instead of byte mode's 8 — a much
+// smaller grid for the same data. Falls back to byte mode if the text doesn't fit.
+export function qrSvg(text, { margin = 2, ec = 'M', mode } = {}) {
+ let qr;
+ try {
+ qr = qrcode(0, ec);
+ qr.addData(text, mode);
+ qr.make();
+ } catch {
+ qr = qrcode(0, ec); // mode mismatch (e.g. a non-alphanumeric char) — plain byte mode
+ qr.addData(text);
+ qr.make();
+ }
const count = qr.getModuleCount();
const size = count + margin * 2;
diff --git a/src/wallet.js b/src/wallet.js
index e21710c..4e6a0d5 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -11,7 +11,7 @@
import { HDKey } from '@scure/bip32';
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
-import { hex, base64urlnopad, base58check } from '@scure/base';
+import { hex, base64urlnopad, base32nopad, base58check } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
import { scrypt } from '@noble/hashes/scrypt';
import { randomBytes } from '@noble/hashes/utils';
@@ -19,6 +19,7 @@ import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
import { utf8ToBytes, bytesToUtf8 } from '@noble/ciphers/utils.js';
import * as btc from '@scure/btc-signer';
import { p2wpkh } from '@scure/btc-signer/payment';
+import { concatBytes } from '@scure/btc-signer/utils.js';
import { Api, pool, wsUrl } from './api.js';
import { NostrSync, getSyncConfig } from './nostr.js';
@@ -1067,7 +1068,7 @@ export class Wallet {
for (const u of sel) this.reservedSet().add(utxoId(u));
this._saveReserved();
- return { code: base64urlnopad.encode(t.toPSBT()), amount: Number(keepChange ? gift : sum), reserved: sel.map(utxoId) };
+ return { code: base32nopad.encode(_encodeCompactGift(t)), amount: Number(keepChange ? gift : sum), reserved: sel.map(utxoId) };
}
// Gift the entire spendable balance: a no-change sweep of all unreserved
@@ -1738,6 +1739,65 @@ export function giftMinimum(feeRate) {
return Math.max(546, 294 + claimFee);
}
+// ---- compact gift codec ----------------------------------------------------
+// A gift link used to carry a full PSBT (verbose). The claimer only needs the
+// presigned inputs (outpoint, amount, pubkey, signature) and the sender's
+// committed change output, so we serialize just those — ~25% smaller, and (via
+// base32 + an uppercase /g/ URL) it scans in the QR's denser alphanumeric mode.
+// Legacy base64url-PSBT links still claim; _giftTx auto-detects the format.
+const GIFT_MAGIC = 0x01;
+const _u8 = (n) => Uint8Array.of(n & 0xff);
+const _le32 = (n) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n >>> 0, true); return b; };
+const _le64 = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; };
+
+function _encodeCompactGift(tx) {
+ const hasChange = tx.outputsLength > 0;
+ const parts = [_u8(GIFT_MAGIC), _u8(hasChange ? 1 : 0), _u8(tx.inputsLength)];
+ for (let i = 0; i < tx.inputsLength; i++) {
+ const inp = tx.getInput(i);
+ const [pubkey, sig] = inp.partialSig[0]; // pubkey(33), sig = DER + sighash byte
+ parts.push(inp.txid, _le32(inp.index), _le64(inp.witnessUtxo.amount), pubkey, _u8(sig.length), sig);
+ }
+ if (hasChange) {
+ const o = tx.getOutput(0); // p2wpkh change: script = [00 14 <20-byte hash>]
+ parts.push(_le64(o.amount), o.script.slice(2));
+ }
+ return concatBytes(...parts);
+}
+
+function _decodeCompactGift(bytes) {
+ let p = 0;
+ const rd = (n) => bytes.slice(p, (p += n));
+ const r32 = () => { const v = new DataView(bytes.buffer, bytes.byteOffset + p, 4).getUint32(0, true); p += 4; return v; };
+ const r64 = () => { const v = new DataView(bytes.buffer, bytes.byteOffset + p, 8).getBigUint64(0, true); p += 8; return v; };
+ p++; // magic
+ const hasChange = !!(bytes[p++] & 1);
+ const n = bytes[p++];
+ const t = new btc.Transaction({ allowUnknownOutputs: true });
+ for (let i = 0; i < n; i++) {
+ const txid = rd(32), vout = r32(), amount = r64(), pubkey = rd(33), sigLen = bytes[p++], sig = rd(sigLen);
+ const pay = p2wpkh(pubkey, btc.NETWORK); // witness-program .script is network-independent
+ // second arg true: re-add an already-signed input without tripping the sign-status guard
+ t.addInput({ txid, index: vout, witnessUtxo: { script: pay.script, amount }, sighashType: sig[sig.length - 1], partialSig: [[pubkey, sig]] }, true);
+ }
+ if (hasChange) {
+ const amount = r64();
+ const hash = rd(20);
+ t.addOutput({ script: concatBytes(_u8(0x00), _u8(0x14), hash), amount }, true);
+ }
+ return t;
+}
+
+// Parse a gift code to a Transaction. New format: base32(compact). Legacy:
+// base64url(PSBT). Auto-detected via the compact magic byte.
+function _giftTx(code) {
+ try {
+ const b = base32nopad.decode(code.toUpperCase());
+ if (b[0] === GIFT_MAGIC) return _decodeCompactGift(b);
+ } catch {}
+ return btc.Transaction.fromPSBT(base64urlnopad.decode(code));
+}
+
function _sumInputs(tx) {
let inAmt = 0n;
for (let i = 0; i < tx.inputsLength; i++) inAmt += tx.getInput(i).witnessUtxo.amount;
@@ -1753,7 +1813,7 @@ function _giftChange(tx) {
// re-claiming would be a doomed double-spend. Used to gate the claim screen.
export function giftOutpoints(code) {
try {
- const tx = btc.Transaction.fromPSBT(base64urlnopad.decode(code));
+ const tx = _giftTx(code);
const out = [];
for (let i = 0; i < tx.inputsLength; i++) {
const inp = tx.getInput(i);
@@ -1766,7 +1826,7 @@ export function giftOutpoints(code) {
}
export function previewGift(code) {
try {
- const tx = btc.Transaction.fromPSBT(base64urlnopad.decode(code));
+ const tx = _giftTx(code);
// room is the full amount the claimer receives (inputs minus our change);
// the claim fee is subtracted from it at claim time, so report inputs too
// so the caller can size that fee for this PSBT.
@@ -1777,7 +1837,7 @@ export function previewGift(code) {
}
export function buildClaimTx(code, toAddress, feeRate, net) {
- const tx = btc.Transaction.fromPSBT(base64urlnopad.decode(code));
+ const tx = _giftTx(code);
const room = _sumInputs(tx) - _giftChange(tx);
const fee = BigInt(Math.max(1, Math.ceil((11 + 68 * tx.inputsLength + 31 * 2) * Math.max(1, Math.round(feeRate)))));
const out = room - fee;