|
| 1 | +// SEP-7 batch-donation QR sizing tool. |
| 2 | +// |
| 3 | +// Builds the real `donate_many` InvokeHostFunction transaction against the |
| 4 | +// deployed Soroban DonationHandler for N projects, encodes it as a SEP-7 |
| 5 | +// `web+stellar:tx?xdr=...` URI, and reports the transaction/URI/QR sizes so we |
| 6 | +// can find the upper bound on N (number of projects) for the QR flow. |
| 7 | +// |
| 8 | +// Two payload shapes are measured because they bound the two realistic flows: |
| 9 | +// * raw – unsigned tx with only the invoke op (no SorobanData/auth). |
| 10 | +// This is what a dApp sends when the WALLET re-simulates and |
| 11 | +// builds the auth tree itself. Smaller QR. |
| 12 | +// * assembled – simulated + assembled tx (SorobanData footprint + unsigned |
| 13 | +// auth entries). This is what a dApp sends when it pre-builds |
| 14 | +// everything and the wallet only signs. Larger QR. |
| 15 | +// |
| 16 | +// Usage: |
| 17 | +// DONOR=G... TOKEN=C... [CONTRACT=C...] node build.mjs sweep |
| 18 | +// DONOR=G... TOKEN=C... [CONTRACT=C...] node build.mjs emit <N> |
| 19 | +// |
| 20 | +// Env: RPC_URL (default testnet), OUTDIR (default ./out), PAYLOAD_BYTES |
| 21 | +// (per-project `data`/memo size, default 8), DISTINCT ("1" distinct recipients, |
| 22 | +// "0" reuse one address — distinct grows the footprint realistically). |
| 23 | + |
| 24 | +import * as S from '@stellar/stellar-sdk' |
| 25 | +import QRCode from 'qrcode' |
| 26 | +import { writeFileSync, mkdirSync } from 'node:fs' |
| 27 | +import { randomBytes } from 'node:crypto' |
| 28 | + |
| 29 | +const RPC_URL = process.env.RPC_URL || 'https://soroban-testnet.stellar.org' |
| 30 | +const NETWORK = S.Networks.TESTNET |
| 31 | +const CONTRACT = |
| 32 | + process.env.CONTRACT || 'CCAWXIU37ILOKXRPJVL56VJAVLJRKGNFSIXCAOLO3YFEDJV6DZRMJLAL' |
| 33 | +const DONOR = process.env.DONOR |
| 34 | +const TOKEN = process.env.TOKEN |
| 35 | +const OUTDIR = process.env.OUTDIR || './out' |
| 36 | +const PAYLOAD_BYTES = Number(process.env.PAYLOAD_BYTES || 8) |
| 37 | +const DISTINCT = process.env.DISTINCT !== '0' |
| 38 | + |
| 39 | +// Network limit (testnet `stellar network settings`): max tx size in bytes. |
| 40 | +const TX_MAX_SIZE_BYTES = 132096 |
| 41 | +// QR Version-40 byte-mode capacities (ISO/IEC 18004) per error-correction level. |
| 42 | +const QR_V40 = { L: 2953, M: 2331, Q: 1663, H: 1273 } |
| 43 | + |
| 44 | +if (!DONOR || !TOKEN) { |
| 45 | + console.error('Set DONOR (G...) and TOKEN (C... SAC) env vars.') |
| 46 | + process.exit(1) |
| 47 | +} |
| 48 | + |
| 49 | +const server = new S.rpc.Server(RPC_URL) |
| 50 | + |
| 51 | +function randomContractAddress() { |
| 52 | + return S.StrKey.encodeContract(randomBytes(32)) |
| 53 | +} |
| 54 | + |
| 55 | +function buildDonateManyOp(n) { |
| 56 | + const contract = new S.Contract(CONTRACT) |
| 57 | + const reused = DISTINCT ? null : randomContractAddress() |
| 58 | + const recipients = [] |
| 59 | + const amounts = [] |
| 60 | + const data = [] |
| 61 | + for (let i = 0; i < n; i++) { |
| 62 | + const addr = DISTINCT ? randomContractAddress() : reused |
| 63 | + recipients.push(S.Address.fromString(addr).toScVal()) |
| 64 | + amounts.push(S.nativeToScVal(1n, { type: 'i128' })) |
| 65 | + const buf = Buffer.alloc(PAYLOAD_BYTES) |
| 66 | + buf.writeUInt32BE((i + 1) >>> 0, Math.max(0, PAYLOAD_BYTES - 4)) // fake projectId |
| 67 | + data.push(S.xdr.ScVal.scvBytes(buf)) |
| 68 | + } |
| 69 | + return contract.call( |
| 70 | + 'donate_many', |
| 71 | + S.Address.fromString(DONOR).toScVal(), // from |
| 72 | + S.Address.fromString(TOKEN).toScVal(), // token |
| 73 | + S.nativeToScVal(BigInt(n), { type: 'i128' }), // total_amount = n * 1 stroop |
| 74 | + S.xdr.ScVal.scvVec(recipients), |
| 75 | + S.xdr.ScVal.scvVec(amounts), |
| 76 | + S.xdr.ScVal.scvVec(data), |
| 77 | + ) |
| 78 | +} |
| 79 | + |
| 80 | +const sep7 = (b64) => `web+stellar:tx?xdr=${encodeURIComponent(b64)}` |
| 81 | + |
| 82 | +// Smallest QR byte-mode version (1..40) that fits `text` at the given ECC, or |
| 83 | +// null if it does not fit even at V40. |
| 84 | +function qrVersion(text, ecc) { |
| 85 | + try { |
| 86 | + const qr = QRCode.create(text, { errorCorrectionLevel: ecc }) |
| 87 | + return qr.version |
| 88 | + } catch { |
| 89 | + return null |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +async function measure(n) { |
| 94 | + const account = await server.getAccount(DONOR) |
| 95 | + const tx = new S.TransactionBuilder(account, { |
| 96 | + fee: S.BASE_FEE, |
| 97 | + networkPassphrase: NETWORK, |
| 98 | + }) |
| 99 | + .addOperation(buildDonateManyOp(n)) |
| 100 | + .setTimeout(300) |
| 101 | + .build() |
| 102 | + |
| 103 | + const rawB64 = tx.toEnvelope().toXDR('base64') |
| 104 | + const rawBytes = Buffer.from(rawB64, 'base64').length |
| 105 | + |
| 106 | + let assembledB64 = null |
| 107 | + let assembledBytes = null |
| 108 | + let simError = null |
| 109 | + try { |
| 110 | + const sim = await server.simulateTransaction(tx) |
| 111 | + if (S.rpc.Api.isSimulationError(sim)) { |
| 112 | + simError = sim.error |
| 113 | + } else { |
| 114 | + const assembled = S.rpc.assembleTransaction(tx, sim).build() |
| 115 | + assembledB64 = assembled.toEnvelope().toXDR('base64') |
| 116 | + assembledBytes = Buffer.from(assembledB64, 'base64').length |
| 117 | + } |
| 118 | + } catch (e) { |
| 119 | + simError = String(e.message || e) |
| 120 | + } |
| 121 | + |
| 122 | + const rawUri = sep7(rawB64) |
| 123 | + const asmUri = assembledB64 ? sep7(assembledB64) : null |
| 124 | + return { |
| 125 | + n, |
| 126 | + rawBytes, |
| 127 | + rawUriLen: rawUri.length, |
| 128 | + rawQrL: qrVersion(rawUri, 'L'), |
| 129 | + rawQrM: qrVersion(rawUri, 'M'), |
| 130 | + assembledBytes, |
| 131 | + asmUriLen: asmUri ? asmUri.length : null, |
| 132 | + asmQrL: asmUri ? qrVersion(asmUri, 'L') : null, |
| 133 | + asmQrM: asmUri ? qrVersion(asmUri, 'M') : null, |
| 134 | + simError, |
| 135 | + rawUri, |
| 136 | + asmUri, |
| 137 | + } |
| 138 | +} |
| 139 | + |
| 140 | +function fmtVer(v) { |
| 141 | + return v === null ? ' >40✗' : `V${String(v).padStart(2)}` |
| 142 | +} |
| 143 | + |
| 144 | +async function sweep() { |
| 145 | + const Ns = [1, 2, 3, 5, 8, 10, 12, 15, 20, 25, 30, 40, 60, 100, 150, 200] |
| 146 | + console.log( |
| 147 | + `Contract ${CONTRACT}\nDonor ${DONOR}\nToken ${TOKEN}\n` + |
| 148 | + `payloadBytes/project=${PAYLOAD_BYTES} distinctRecipients=${DISTINCT}\n` + |
| 149 | + `tx_max_size_bytes=${TX_MAX_SIZE_BYTES} QR V40 byte-mode cap: L=${QR_V40.L} M=${QR_V40.M}\n`, |
| 150 | + ) |
| 151 | + console.log( |
| 152 | + 'N'.padStart(4) + |
| 153 | + ' | rawTxB rawURI rawQRl rawQRm | asmTxB asmURI asmQRl asmQRm | note', |
| 154 | + ) |
| 155 | + console.log('-'.repeat(92)) |
| 156 | + const rows = [] |
| 157 | + for (const n of Ns) { |
| 158 | + let r |
| 159 | + try { |
| 160 | + r = await measure(n) |
| 161 | + } catch (e) { |
| 162 | + console.log(String(n).padStart(4) + ` | ERROR ${e.message || e}`) |
| 163 | + continue |
| 164 | + } |
| 165 | + rows.push(r) |
| 166 | + const note = r.simError ? `sim✗ ${String(r.simError).slice(0, 24)}` : '' |
| 167 | + console.log( |
| 168 | + String(r.n).padStart(4) + |
| 169 | + ` | ${String(r.rawBytes).padStart(6)} ${String(r.rawUriLen).padStart(6)} ` + |
| 170 | + `${fmtVer(r.rawQrL)} ${fmtVer(r.rawQrM)} | ` + |
| 171 | + `${String(r.assembledBytes ?? '-').padStart(6)} ${String(r.asmUriLen ?? '-').padStart(6)} ` + |
| 172 | + `${r.asmQrL === null ? ' - ' : fmtVer(r.asmQrL)} ${r.asmQrM === null ? ' - ' : fmtVer(r.asmQrM)} | ${note}`, |
| 173 | + ) |
| 174 | + } |
| 175 | + |
| 176 | + // Upper bounds: largest N that still fits a single QR (V40) for each flow/ECC, |
| 177 | + // and the tx-size ceiling. |
| 178 | + const fits = (rows, key, ecc) => |
| 179 | + rows.filter((r) => r[key] !== null && r[key] !== undefined).filter((r) => r[key] <= 40) |
| 180 | + const maxFit = (key) => { |
| 181 | + const ok = rows.filter((r) => r[key] !== null && r[key] !== undefined && r[key] <= 40) |
| 182 | + return ok.length ? Math.max(...ok.map((r) => r.n)) : 0 |
| 183 | + } |
| 184 | + console.log('\nUpper bound (largest N tested that fits ONE QR code, V40):') |
| 185 | + console.log(` raw flow @ ECC-L: N≈${maxFit('rawQrL')} @ ECC-M: N≈${maxFit('rawQrM')}`) |
| 186 | + console.log(` assembled flow@ ECC-L: N≈${maxFit('asmQrL')} @ ECC-M: N≈${maxFit('asmQrM')}`) |
| 187 | + const underTxLimit = rows.filter((r) => r.assembledBytes && r.assembledBytes <= TX_MAX_SIZE_BYTES) |
| 188 | + console.log( |
| 189 | + ` network tx-size ceiling: assembled tx stays < ${TX_MAX_SIZE_BYTES}B for all N tested ` + |
| 190 | + `(largest tested N=${Math.max(...rows.map((r) => r.n))}); the QR limit binds first.`, |
| 191 | + ) |
| 192 | +} |
| 193 | + |
| 194 | +async function emit(n) { |
| 195 | + mkdirSync(OUTDIR, { recursive: true }) |
| 196 | + const r = await measure(n) |
| 197 | + const variants = [ |
| 198 | + ['raw', r.rawUri, r.rawUriLen, r.rawQrL], |
| 199 | + ['assembled', r.asmUri, r.asmUriLen, r.asmQrL], |
| 200 | + ] |
| 201 | + for (const [name, uri, len, ver] of variants) { |
| 202 | + if (!uri) { |
| 203 | + console.log(`N=${n} ${name}: unavailable (${r.simError})`) |
| 204 | + continue |
| 205 | + } |
| 206 | + const png = `${OUTDIR}/donate_many_n${n}_${name}.png` |
| 207 | + const txt = `${OUTDIR}/donate_many_n${n}_${name}.uri.txt` |
| 208 | + writeFileSync(txt, uri) |
| 209 | + if (ver !== null) { |
| 210 | + await QRCode.toFile(png, uri, { errorCorrectionLevel: 'L', margin: 2, scale: 4 }) |
| 211 | + console.log(`N=${n} ${name}: URI ${len} chars, QR V${ver} (ECC-L) -> ${png}`) |
| 212 | + } else { |
| 213 | + console.log(`N=${n} ${name}: URI ${len} chars — EXCEEDS QR V40 capacity, no PNG. (${txt})`) |
| 214 | + } |
| 215 | + } |
| 216 | +} |
| 217 | + |
| 218 | +const cmd = process.argv[2] |
| 219 | +if (cmd === 'sweep') await sweep() |
| 220 | +else if (cmd === 'emit') await emit(Number(process.argv[3] || 10)) |
| 221 | +else { |
| 222 | + console.error('Usage: node build.mjs sweep | emit <N>') |
| 223 | + process.exit(1) |
| 224 | +} |
0 commit comments