Skip to content

Commit 26e0a47

Browse files
ae2079claude
andcommitted
feat: add SEP-7 batch-donation QR sizing tool + upper-bound analysis
Parametric tool (N is dynamic, not hardcoded to 10) that builds the real donate_many tx against the deployed testnet contract, encodes it as a SEP-7 web+stellar:tx URI, and measures tx/URI/QR sizes to find the upper bound on the number of projects per QR. Findings (testnet, native XLM SAC): - raw flow (wallet re-simulates): ~25 projects fit one QR (V40), ~5-7 reliably. Requested N=10 is a dense V26 code. - assembled flow (dApp pre-builds footprint+auth): only ~3 projects per QR; even N=1 is V28. - Network is never the QR bottleneck (tx_max_size_bytes=132096; assembled N=200 ~86KB). For larger batches use WalletConnect; on-chain ceiling ~150-200. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 71c1b69 commit 26e0a47

9 files changed

Lines changed: 1395 additions & 0 deletions

File tree

stellar/tools/qr-batch/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules/
2+
out/

stellar/tools/qr-batch/README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# SEP-7 batch-donation QR sizing tool
2+
3+
Finds the **upper bound on the number of projects (N)** that fit in a single
4+
SEP-7 (`web+stellar:tx?xdr=…`) QR code for a `donate_many` call to the Soroban
5+
`DonationHandler`, by building the real transaction against the deployed
6+
testnet contract and measuring it.
7+
8+
## Usage
9+
10+
```bash
11+
npm install
12+
DONOR=$(stellar keys address giveth-dh-testnet) \
13+
TOKEN=$(stellar contract id asset --asset native --network testnet) \
14+
node build.mjs sweep # size/QR table across N
15+
16+
DONOR=... TOKEN=... node build.mjs emit 10 # writes ./out/donate_many_n10_*.png + .uri.txt
17+
```
18+
19+
Env: `CONTRACT` (default = deployed testnet id), `RPC_URL`, `OUTDIR`,
20+
`PAYLOAD_BYTES` (per-project `data`/memo, default 8), `DISTINCT` (1/0).
21+
No secret key is needed — the tool only simulates.
22+
23+
## Two flows measured
24+
25+
A Soroban `InvokeHostFunction` tx needs `SorobanTransactionData` (resource
26+
fees + footprint) and an auth tree to be **submittable**. Who fills those in
27+
decides the QR payload size:
28+
29+
* **raw** — the dApp sends just the invoke op; the **wallet re-simulates** and
30+
builds the auth tree before signing. Small QR. Depends on the wallet
31+
supporting Soroban re-simulation of an incoming SEP-7 tx.
32+
* **assembled** — the dApp pre-simulates and sends the full tx (footprint +
33+
unsigned auth entries); the wallet only signs. Universally valid, but the
34+
auth tree + footprint dominate the size (~430 B/project vs ~76 B/project raw).
35+
36+
## Results (testnet, native XLM SAC, 8-byte payload/project, distinct recipients)
37+
38+
| N | raw tx (B) | raw URI | raw QR (ECC-L) | assembled tx (B) | assembled URI | assembled QR (ECC-L) |
39+
|--:|-----------:|--------:|:--------------:|-----------------:|--------------:|:--------------------:|
40+
| 1 | 376 | 541 | V15 | 1140 | 1599 | V28 |
41+
| 3 | 528 | 741 | V18 | 1996 | 2787 | V38 |
42+
| 5 | 680 | 947 | V21 | 2852 | 3949 | ✗ > V40 |
43+
| 10 | 1060 | 1465 | V26 | 4992 | 6897 | ✗ > V40 |
44+
| 20 | 1820 | 2511 | V35 | 9272 | 12837 | ✗ > V40 |
45+
| 25 | 2200 | 3023 | V39 | 11412 | 15719 | ✗ > V40 |
46+
| 30 | 2580 | 3545 | ✗ > V40 | 13552 | 18781 | ✗ > V40 |
47+
| 200 | 15500 | 21261 || 86312 | 119313 ||
48+
49+
(QR version is from `qrcode`'s real mixed alphanumeric/byte segmentation, so it
50+
beats the pure byte-mode V40 cap of 2953 bytes.)
51+
52+
## Upper bound
53+
54+
| Flow | Hard cap (fits ONE QR at all, V40) | Reliably scannable (≈ ≤ V20 from a screen) |
55+
|------|-----------------------------------:|-------------------------------------------:|
56+
| **raw** (wallet re-simulates) | **~25 projects** (ECC-L) / ~20 (ECC-M) | **~5–7 projects** |
57+
| **assembled** (dApp pre-builds) | **~3 projects** (ECC-L) / ~2 (ECC-M) | **~1 project** (even N=1 is V28) |
58+
59+
The requested **N=10** fits only via the **raw** flow, as a dense **V26** code —
60+
technically scannable but at the edge of reliability from a phone-to-screen
61+
scan; **assembled N=10 is impossible** (URI ~6,900 chars).
62+
63+
**The network is never the QR bottleneck.** `tx_max_size_bytes = 132096`; even an
64+
assembled N=200 tx is ~86 KB. The QR capacity binds ~10–60× earlier. For batches
65+
beyond a handful of projects, use **WalletConnect** (only the short pairing URI
66+
is in the QR; the tx travels over the relay), per the feasibility report. The
67+
on-chain batch ceiling is then ~150–200 projects (`tx_max_write_ledger_entries
68+
= 200`, `tx_max_footprint_entries = 400`, `tx_max_contract_events_size_bytes =
69+
16384`), confirm by real submission.
70+
71+
## Caveats
72+
73+
* **Wallet re-simulation** is the load-bearing assumption for the raw flow and
74+
for the requested 10-project QR. If LOBSTR (or another wallet) will not
75+
re-simulate an incoming SEP-7 Soroban tx, only the assembled flow works → ~3
76+
projects max per QR. **This still needs the physical LOBSTR scan test.**
77+
* Sizes scale with per-project payload (`PAYLOAD_BYTES`) and whether recipients
78+
are distinct (footprint). Numbers above use 8-byte payloads + distinct
79+
recipients.
80+
* `samples/` holds example PNGs for scannability testing only. The encoded tx
81+
carries the donor's **sequence number**, which goes stale — regenerate with
82+
`emit` for an end-to-end signing test.

stellar/tools/qr-batch/build.mjs

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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

Comments
 (0)