@paydlux/agent 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/dist/cli.js +405 -24
- package/dist/desk.js +4 -0
- package/dist/donate.d.ts +48 -0
- package/dist/donate.js +232 -0
- package/dist/phone.d.ts +7 -0
- package/dist/phone.js +7 -1
- package/dist/policy.d.ts +9 -0
- package/dist/policy.js +17 -0
- package/dist/qr.d.ts +12 -0
- package/dist/qr.js +52 -0
- package/dist/receive.d.ts +147 -0
- package/dist/receive.js +348 -0
- package/dist/showcase.d.ts +54 -0
- package/dist/showcase.js +385 -0
- package/dist/tunnel.d.ts +20 -0
- package/dist/tunnel.js +40 -2
- package/dist/vault.d.ts +54 -2
- package/dist/vault.js +151 -9
- package/package.json +19 -3
- package/phone-app/app.js +12 -1
- package/phone-app/index.html +1 -0
package/dist/donate.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// The donation page: a stranger's agent pays us from THEIR vault.
|
|
2
|
+
//
|
|
3
|
+
// This is the other half of the seller door (stellar/receive.ts). The showcase
|
|
4
|
+
// on demo.paydlux.com spends the SELLER's money to show the loop. A donation
|
|
5
|
+
// spends the VISITOR's money, which is the whole point of the product: an
|
|
6
|
+
// agent that can pay, with a human holding the brake on the payer's side.
|
|
7
|
+
//
|
|
8
|
+
// Why the payer approves and the seller does not:
|
|
9
|
+
// the seller's risk is "I served content and never got paid". The ledger
|
|
10
|
+
// answers that with a public read, so no seller signature is needed.
|
|
11
|
+
// the payer's risk is "an agent spent my money". That needs a human, and it
|
|
12
|
+
// has to be *their* human, on *their* phone, against *their* mandate.
|
|
13
|
+
//
|
|
14
|
+
// Nothing here holds a buyer key. We publish terms; they sign at home; the
|
|
15
|
+
// ledger is the only witness we accept.
|
|
16
|
+
import { startReceive } from "./receive";
|
|
17
|
+
import { qrSvg } from "./qr";
|
|
18
|
+
export const DONATE_HOST = "donate.paydlux.com";
|
|
19
|
+
export const DONATE_PORT = 4024;
|
|
20
|
+
/**
|
|
21
|
+
* The page. No inline script and nothing visitor-controlled is interpolated, so
|
|
22
|
+
* the strict CSP is real rather than decorative — the same rule the showcase
|
|
23
|
+
* follows, because this port is public.
|
|
24
|
+
*/
|
|
25
|
+
export function donatePage(cfg) {
|
|
26
|
+
const asset = cfg.asset === "XLM" ? "XLM (Stellar's own currency)" : cfg.asset;
|
|
27
|
+
return `<!doctype html>
|
|
28
|
+
<html lang="en"><head>
|
|
29
|
+
<meta charset="utf-8">
|
|
30
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
31
|
+
<title>Fund Paydlux — an agent pays, a human decides</title>
|
|
32
|
+
<meta name="description" content="Pay Paydlux from your own Stellar vault. Your agent asks, your phone approves, the ledger proves it. No seller key touches your money.">
|
|
33
|
+
<style>
|
|
34
|
+
:root{color-scheme:dark}
|
|
35
|
+
body{margin:0;background:#14181a;color:#e8ecee;font:16px/1.6 Georgia,serif}
|
|
36
|
+
main{max-width:760px;margin:0 auto;padding:48px 24px 80px}
|
|
37
|
+
h1{font-size:34px;line-height:1.25;margin:0 0 10px}
|
|
38
|
+
.sub{color:#9fb0b6;margin:0 0 34px}
|
|
39
|
+
.card{background:#1b2124;border:1px solid #2b3338;border-radius:12px;padding:20px 22px;margin:0 0 18px}
|
|
40
|
+
.price{font-size:30px;margin:0}
|
|
41
|
+
.muted{color:#8fa0a6;font-size:14px}
|
|
42
|
+
code,pre{font:13px/1.7 Menlo,monospace;background:#0f1315;border-radius:8px;padding:2px 6px;color:#cfe3ea;overflow-x:auto}
|
|
43
|
+
pre{padding:14px 16px;white-space:pre-wrap;word-break:break-word}
|
|
44
|
+
ol{padding-left:22px} li{margin:0 0 12px}
|
|
45
|
+
a{color:#7fd1c8}
|
|
46
|
+
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:22px 0}
|
|
47
|
+
button{background:#2f6f66;color:#fff;border:0;border-radius:9px;padding:13px 22px;font-size:16px;cursor:pointer}
|
|
48
|
+
button:disabled{opacity:.5;cursor:default}
|
|
49
|
+
#out{white-space:pre-wrap;word-break:break-word;font:13px/1.7 Menlo,monospace;background:#0f1315;border-radius:10px;padding:16px;margin-top:16px;display:none}
|
|
50
|
+
.ok{color:#8fe3b0}.bad{color:#e8a09a}
|
|
51
|
+
.qrrow{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin:14px 0 4px}
|
|
52
|
+
.qr img{background:#fff;border-radius:10px;padding:8px;width:188px;height:188px;display:block}
|
|
53
|
+
pre.addr{flex:1;min-width:260px;margin:0;font-size:12px;word-break:break-all;white-space:pre-wrap}
|
|
54
|
+
.warn{border-left:3px solid #6b5a2f;padding-left:12px;margin:14px 0 0}
|
|
55
|
+
#copy.done{background:#24524b}
|
|
56
|
+
footer{margin-top:40px;color:#7f8f95;font-size:13px}
|
|
57
|
+
</style></head>
|
|
58
|
+
<body><main>
|
|
59
|
+
<h1>An agent can pay.<br>Whether it <em>may</em> is a human decision.</h1>
|
|
60
|
+
<p class="sub">This page takes a real payment drawn on <strong>your</strong> vault. We never
|
|
61
|
+
hold your key, your seed, or your approval. The money moves because your phone said so.</p>
|
|
62
|
+
|
|
63
|
+
<div class="card">
|
|
64
|
+
<p class="price">${cfg.price} ${asset}</p>
|
|
65
|
+
<p class="muted">one payment, one order, one memo. The memo is what ties your payment to
|
|
66
|
+
the thing you asked for, so it cannot be reused for anyone else's order.</p>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="row">
|
|
70
|
+
<button id="go">Ask my agent to pay</button>
|
|
71
|
+
<span class="muted">never paid with an agent before? <a href="https://demo.paydlux.com/">run the loop free on testnet first</a> — same mechanics, costs nothing</span>
|
|
72
|
+
</div>
|
|
73
|
+
<pre id="cmd">curl -s -X POST https://${DONATE_HOST}/donate \
|
|
74
|
+
-d '{"vault":"G…your vault…"}'
|
|
75
|
+
# → 402 with: order, memo, payTo, maxAmountRequired
|
|
76
|
+
# the order is now bound to YOUR vault: a payment from anyone else's account
|
|
77
|
+
# is refused, so nobody can pay for your order and take the goods
|
|
78
|
+
# pay it from your vault with a mandate carrying that memo, then:
|
|
79
|
+
curl -s "https://${DONATE_HOST}/donate?order=ORDER&tx=TXHASH"</pre>
|
|
80
|
+
<div id="out"></div>
|
|
81
|
+
|
|
82
|
+
<div class="card qr">
|
|
83
|
+
<p><strong>Or just send it yourself</strong></p>
|
|
84
|
+
<p class="muted">No agent, no order, no memo. Scan or copy the address and send XLM
|
|
85
|
+
from any wallet or exchange that supports Stellar. This is the front door for a
|
|
86
|
+
human; the 402 loop above is the door for an agent.</p>
|
|
87
|
+
<div class="qrrow">
|
|
88
|
+
<img src="/qr.svg" width="188" height="188" alt="QR code for the Paydlux receiving address">
|
|
89
|
+
<div>
|
|
90
|
+
<pre class="addr">${cfg.payee}</pre>
|
|
91
|
+
<div class="row">
|
|
92
|
+
<button id="copy" type="button">Copy address</button>
|
|
93
|
+
<a class="muted" href="${cfg.explorer}/${cfg.payee}">watch it on the public ledger</a>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
<p class="muted">${cfg.payee === cfg.vault
|
|
98
|
+
? "That address is the <strong>vault itself</strong> — a 2-of-2 account. Money sent here cannot be moved by one key, including ours: spending it needs the founder's key <em>and</em> a phone, against a daily ceiling, and every payment out is public."
|
|
99
|
+
: `Money lands in our operating account and is swept into the <a href="${cfg.explorer}/${cfg.vault}">2-of-2 vault</a>. Spending it needs the founder's key <em>and</em> a phone, against a daily ceiling, and every payment out is public.`}</p>
|
|
100
|
+
<p class="muted warn"><strong>Network: Stellar</strong>, asset <strong>${asset}</strong>.
|
|
101
|
+
Not Ethereum, not BSC, not Tron. Sending on the wrong chain loses the money — there is
|
|
102
|
+
no recovery for that. No memo is needed for this address.</p>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div class="card">
|
|
106
|
+
<p><strong>What actually happens</strong></p>
|
|
107
|
+
<ol>
|
|
108
|
+
<li>Your agent asks for an order. We answer <code>402</code> with the price, our
|
|
109
|
+
account, and a one-time memo. Nothing is paid yet.</li>
|
|
110
|
+
<li>Your agent builds a <em>mandate</em> on your own Stellar vault: this amount, this
|
|
111
|
+
payee, this memo, expiring if you never answer.</li>
|
|
112
|
+
<li>Your vault needs <strong>two signatures</strong>. Your agent holds one. The other is on
|
|
113
|
+
your phone, and you hold it down for a second to give it. Without that second
|
|
114
|
+
signature the ledger refuses — not our server, the ledger.</li>
|
|
115
|
+
<li>We read the public ledger. If the payment really landed, in full, naming this
|
|
116
|
+
order, we serve the thing. If it did not, we serve nothing, and say why.</li>
|
|
117
|
+
</ol>
|
|
118
|
+
<p class="muted">We cannot see your seed. We cannot approve for you. You cannot pay more
|
|
119
|
+
than your own mandate allows, even if the agent asks twice.</p>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div class="card">
|
|
123
|
+
<p><strong>Where the money goes</strong></p>
|
|
124
|
+
<p class="muted">Straight into the Paydlux operating vault, which is itself a 2-of-2
|
|
125
|
+
account: the founder's key and a phone key. Development spend has to be approved by a
|
|
126
|
+
human on that same phone, against a daily ceiling, and every payment is public.</p>
|
|
127
|
+
<p class="muted">vault <a href="${cfg.explorer}/${cfg.vault}">${cfg.vault}</a> ·
|
|
128
|
+
payments land in <a href="${cfg.explorer}/${cfg.payee}">${cfg.payee}</a> ·
|
|
129
|
+
network <code>${cfg.network === "public" ? "mainnet" : "testnet"}</code></p>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<div class="card">
|
|
133
|
+
<p><strong>What it funds</strong></p>
|
|
134
|
+
<p class="muted">The protocol is built, it runs, and it is public — every refusal this
|
|
135
|
+
page makes is covered by a test that forges a payment and checks that it is rejected.
|
|
136
|
+
What funding pays for next:</p>
|
|
137
|
+
<ul class="muted">
|
|
138
|
+
<li><strong>Production infrastructure.</strong> Always-on gateways and approval desks,
|
|
139
|
+
monitored, with no single machine in the path.</li>
|
|
140
|
+
<li><strong>Settlement capital.</strong> A payment rail needs money moving through it.
|
|
141
|
+
Stellar pays grants in XLM — the same asset this settles in, which is why this is a
|
|
142
|
+
Stellar project and not an EVM one.</li>
|
|
143
|
+
<li><strong>An installable approver app.</strong> The money-critical half of this product
|
|
144
|
+
is a signing app on your phone, not a browser tab.</li>
|
|
145
|
+
<li><strong>An external audit.</strong> The whole promise is that a human authorises the
|
|
146
|
+
money. If the signing path has a bug, the promise is worth less than nothing, and we
|
|
147
|
+
should not be the only people who looked.</li>
|
|
148
|
+
</ul>
|
|
149
|
+
<p class="muted">None of this changes what the door does: it verifies your payment on the
|
|
150
|
+
ledger and serves the receipt, or serves nothing and says why.</p>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<footer>Paydlux · HTTP 402 for agents, a thumb for humans · <a href="https://paydlux.com">paydlux.com</a></footer>
|
|
154
|
+
</main>
|
|
155
|
+
<script src="/donate.js"></script>
|
|
156
|
+
</body></html>`;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The browser half. It does NOT sign anything — it cannot, and that is the
|
|
160
|
+
* design. It fetches an order, prints the exact command your agent runs, and
|
|
161
|
+
* then polls until the ledger says the money landed.
|
|
162
|
+
*/
|
|
163
|
+
export function donateScript() {
|
|
164
|
+
return `// no signing here on purpose. The browser's job is to show you the order and
|
|
165
|
+
// wait. The signature happens on your phone, against your own vault.
|
|
166
|
+
var out = document.getElementById("out"), btn = document.getElementById("go");
|
|
167
|
+
function say(t, cls) { out.style.display = "block"; out.textContent += (out.textContent ? "\\n" : "") + t; if (cls) out.className = cls; }
|
|
168
|
+
|
|
169
|
+
// Copy the receiving address. The address is read from the page rather than
|
|
170
|
+
// baked into this script, so there is exactly one copy of it and the two can
|
|
171
|
+
// never disagree. A 56-character base32 address with B/8, I/1, O/0 in it is not
|
|
172
|
+
// something to retype.
|
|
173
|
+
var cp = document.getElementById("copy");
|
|
174
|
+
if (cp) cp.onclick = async function () {
|
|
175
|
+
var addr = (document.querySelector("pre.addr") || {}).textContent || "";
|
|
176
|
+
var done = function (msg) { cp.textContent = msg; cp.classList.add("done"); setTimeout(function () { cp.textContent = "Copy address"; cp.classList.remove("done"); }, 2500); };
|
|
177
|
+
try {
|
|
178
|
+
await navigator.clipboard.writeText(addr.trim());
|
|
179
|
+
done("copied");
|
|
180
|
+
} catch (e) {
|
|
181
|
+
// No clipboard permission (http, or a browser that refuses). Fall back to a
|
|
182
|
+
// selection the person can copy by hand rather than failing silently.
|
|
183
|
+
var sel = document.createRange(); sel.selectNodeContents(document.querySelector("pre.addr"));
|
|
184
|
+
var s = getSelection(); s.removeAllRanges(); s.addRange(sel);
|
|
185
|
+
done("selected — copy it");
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
btn.onclick = async function () {
|
|
190
|
+
btn.disabled = true; out.textContent = "";
|
|
191
|
+
try {
|
|
192
|
+
var r = await fetch("/donate", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
|
193
|
+
var j = await r.json();
|
|
194
|
+
if (!j.order) { say("no order: " + (j.error || r.status), "bad"); btn.disabled = false; return; }
|
|
195
|
+
say("order " + j.order + "\\nmemo " + j.memo + "\\npay " + j.maxAmountRequired + " " + (j.token && j.token.code) + "\\nto " + j.payTo, "");
|
|
196
|
+
document.getElementById("cmd").textContent =
|
|
197
|
+
"curl -s \"" + location.origin + "/donate?order=" + j.order + "&tx=TXHASH\"";
|
|
198
|
+
say("\\nnow run that command, or pay from your own agent with this memo.", "");
|
|
199
|
+
say("\\nwhen your agent has the tx hash, the page confirms it here:", "");\n var h = prompt("tx hash");\n if (!h) { say("no hash given — check yourself with the curl above", ""); return; }\n var v = await (await fetch("/donate?order=" + j.order + "&tx=" + encodeURIComponent(h))).json();\n if (v.data) { say("\\nPAID — ledger " + v.proof.tx + "\\nledger #" + v.proof.ledger + " at " + v.proof.paidAt, "ok"); }\n else { say("not served: " + (v.reason || v.error), "bad"); }
|
|
200
|
+
} catch (e) { say("network error: " + e.message, "bad"); }
|
|
201
|
+
btn.disabled = false;
|
|
202
|
+
};`;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Start the donation endpoint: the seller door plus a page a human can read.
|
|
206
|
+
*/
|
|
207
|
+
export function startDonate(cfg) {
|
|
208
|
+
const explorer = cfg.explorer ?? `https://stellar.expert/explorer/${cfg.network === "public" ? "account" : "testnet/account"}`;
|
|
209
|
+
const page = donatePage({
|
|
210
|
+
price: cfg.price, asset: cfg.asset.code, network: cfg.network,
|
|
211
|
+
payee: cfg.payee, vault: cfg.vault, explorer,
|
|
212
|
+
});
|
|
213
|
+
const r = startReceive({
|
|
214
|
+
port: cfg.port, hostname: cfg.hostname, payee: cfg.payee, network: cfg.network,
|
|
215
|
+
price: cfg.price, asset: cfg.asset, page: () => page, script: () => donateScript(),
|
|
216
|
+
// The QR encodes the payee account this server actually verifies payments
|
|
217
|
+
// into — not a constant. If the vault or network ever changes, the picture
|
|
218
|
+
// changes with it, because there is only one source for the address.
|
|
219
|
+
qr: () => qrSvg(cfg.payee),
|
|
220
|
+
fetchJson: cfg.fetchJson,
|
|
221
|
+
deliver: (order) => ({
|
|
222
|
+
content: {
|
|
223
|
+
served: new Date().toISOString(),
|
|
224
|
+
order: order.id,
|
|
225
|
+
note: "served because the ledger says your own vault paid us for this order",
|
|
226
|
+
thank_you: "this funds development. every payment out of the vault needs a human thumb too",
|
|
227
|
+
},
|
|
228
|
+
receipt: `paydlux donation ${order.id} — paid by ${order.buyer} in ${order.amount} ${order.asset.code}`,
|
|
229
|
+
}),
|
|
230
|
+
});
|
|
231
|
+
return r;
|
|
232
|
+
}
|
package/dist/phone.d.ts
CHANGED
|
@@ -30,6 +30,13 @@ export declare function createApprovalChallenge(opts: {
|
|
|
30
30
|
amount: string;
|
|
31
31
|
tokenCode: string;
|
|
32
32
|
tokenIssuer: string;
|
|
33
|
+
/**
|
|
34
|
+
* Set explicitly when the settlement asset is the ledger's own XLM. Without
|
|
35
|
+
* it the code falls back to "named XLM and has no issuer", which is right for
|
|
36
|
+
* every caller today but is a name-based guess, and a name is something an
|
|
37
|
+
* attacker controls.
|
|
38
|
+
*/
|
|
39
|
+
native?: boolean;
|
|
33
40
|
network: Network;
|
|
34
41
|
horizon: import("@stellar/stellar-sdk").Horizon.Server;
|
|
35
42
|
id: string;
|
package/dist/phone.js
CHANGED
|
@@ -27,7 +27,13 @@ import { passphrase, StellarTxError, txResultName } from "./vault.js";
|
|
|
27
27
|
export async function createApprovalChallenge(opts) {
|
|
28
28
|
const pass = passphrase(opts.network);
|
|
29
29
|
const acc = await opts.horizon.loadAccount(opts.vaultAddress);
|
|
30
|
-
|
|
30
|
+
// Do not decide the asset from its NAME. Anyone can issue a token and call it
|
|
31
|
+
// XLM; if the name alone chose the asset, that token would be built here as
|
|
32
|
+
// native XLM and the human would sign a different payment than the one the
|
|
33
|
+
// seller described. The caller states the asset type; the name is only a
|
|
34
|
+
// fallback for callers that predate the flag.
|
|
35
|
+
const native = opts.native ?? (opts.tokenCode === "XLM" && !opts.tokenIssuer);
|
|
36
|
+
const asset = native ? Asset.native() : new Asset(opts.tokenCode, opts.tokenIssuer);
|
|
31
37
|
const built = new TransactionBuilder(acc, { fee: "100", networkPassphrase: pass })
|
|
32
38
|
.addOperation(Operation.payment({ destination: opts.payee, asset, amount: opts.amount }))
|
|
33
39
|
.setTimeout(opts.decideWithinSeconds ?? 1800);
|
package/dist/policy.d.ts
CHANGED
|
@@ -36,6 +36,15 @@ export declare class PolicyEngine {
|
|
|
36
36
|
policy: Policy;
|
|
37
37
|
constructor(policy?: Partial<Policy>);
|
|
38
38
|
record(e: SpendEvent): void;
|
|
39
|
+
/**
|
|
40
|
+
* Seed the day's total from the ledger instead of from this process's memory.
|
|
41
|
+
*
|
|
42
|
+
* `events` starts empty, so a fresh seller process believes it has spent
|
|
43
|
+
* nothing today. The mandate printed in the banner ("20 per day") then only
|
|
44
|
+
* holds until the next restart. Seeding from the ledger makes the budget a
|
|
45
|
+
* property of the vault, not of the uptime of whichever machine ran `sell`.
|
|
46
|
+
*/
|
|
47
|
+
seedFromLedger(amount: number): void;
|
|
39
48
|
/** Everything spent in the current UTC day. */
|
|
40
49
|
spentToday(): number;
|
|
41
50
|
today(): SpendEvent[];
|
package/dist/policy.js
CHANGED
|
@@ -13,6 +13,23 @@ export class PolicyEngine {
|
|
|
13
13
|
this.policy = { ...DEFAULT_POLICY, ...policy };
|
|
14
14
|
}
|
|
15
15
|
record(e) { this.events.push(e); }
|
|
16
|
+
/**
|
|
17
|
+
* Seed the day's total from the ledger instead of from this process's memory.
|
|
18
|
+
*
|
|
19
|
+
* `events` starts empty, so a fresh seller process believes it has spent
|
|
20
|
+
* nothing today. The mandate printed in the banner ("20 per day") then only
|
|
21
|
+
* holds until the next restart. Seeding from the ledger makes the budget a
|
|
22
|
+
* property of the vault, not of the uptime of whichever machine ran `sell`.
|
|
23
|
+
*/
|
|
24
|
+
seedFromLedger(amount) {
|
|
25
|
+
if (!(amount > 0))
|
|
26
|
+
return;
|
|
27
|
+
// One synthetic event at the start of the day: it counts toward the total
|
|
28
|
+
// without pretending to be a payment this process can re-enact.
|
|
29
|
+
const start = new Date();
|
|
30
|
+
start.setUTCHours(0, 0, 0, 0);
|
|
31
|
+
this.record({ at: start.getTime(), amount, payee: "ledger" });
|
|
32
|
+
}
|
|
16
33
|
/** Everything spent in the current UTC day. */
|
|
17
34
|
spentToday() {
|
|
18
35
|
const start = new Date();
|
package/dist/qr.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render `text` as a standalone SVG QR code.
|
|
3
|
+
*
|
|
4
|
+
* `dark`/`light` follow the page it sits on. A QR on a dark background needs a
|
|
5
|
+
* light quiet zone or some scanners struggle with the contrast inversion, so
|
|
6
|
+
* the module colour stays dark and the background stays light by default.
|
|
7
|
+
*/
|
|
8
|
+
export declare function qrSvg(text: string, opts?: {
|
|
9
|
+
cell?: number;
|
|
10
|
+
dark?: string;
|
|
11
|
+
light?: string;
|
|
12
|
+
}): string;
|
package/dist/qr.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// A QR code for a Stellar address, as SVG.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: a Stellar account address is 56 characters of base32
|
|
4
|
+
// (A–Z and 2–7). Asking a human to retype one is how money gets lost. Most
|
|
5
|
+
// typos fail the account's own checksum and get rejected, but not all of them:
|
|
6
|
+
// enough wrong characters can land on a different account that is perfectly
|
|
7
|
+
// valid and belongs to someone else. A scanned code removes the transcription
|
|
8
|
+
// step entirely, which is also what fixed the "exchange says this address is
|
|
9
|
+
// invalid" problem — the paste was never the issue, the exchange's address
|
|
10
|
+
// field was.
|
|
11
|
+
//
|
|
12
|
+
// SVG rather than PNG: it stays crisp at any size, it is a fraction of the
|
|
13
|
+
// bytes, and it can be served as its own route so the page keeps a strict CSP
|
|
14
|
+
// (img-src 'self') instead of a giant inline data URI.
|
|
15
|
+
import qrcode from "qrcode-generator";
|
|
16
|
+
/**
|
|
17
|
+
* Render `text` as a standalone SVG QR code.
|
|
18
|
+
*
|
|
19
|
+
* `dark`/`light` follow the page it sits on. A QR on a dark background needs a
|
|
20
|
+
* light quiet zone or some scanners struggle with the contrast inversion, so
|
|
21
|
+
* the module colour stays dark and the background stays light by default.
|
|
22
|
+
*/
|
|
23
|
+
export function qrSvg(text, opts = {}) {
|
|
24
|
+
const qr = qrcode(0, "M");
|
|
25
|
+
qr.addData(text);
|
|
26
|
+
qr.make();
|
|
27
|
+
const cell = opts.cell ?? 4;
|
|
28
|
+
const dark = opts.dark ?? "#0f1315";
|
|
29
|
+
const light = opts.light ?? "#ffffff";
|
|
30
|
+
const n = qr.getModuleCount();
|
|
31
|
+
const size = n * cell;
|
|
32
|
+
// One path of rectangles rather than n^2 <rect> elements: a 33x33 code is
|
|
33
|
+
// ~1k path commands instead of ~110k bytes of markup.
|
|
34
|
+
let d = "";
|
|
35
|
+
for (let r = 0; r < n; r++) {
|
|
36
|
+
for (let c = 0; c < n; c++) {
|
|
37
|
+
if (!qr.isDark(r, c))
|
|
38
|
+
continue;
|
|
39
|
+
// Extend right while the run stays dark: fewer path segments.
|
|
40
|
+
let w = 1;
|
|
41
|
+
while (c + w < n && qr.isDark(r, c + w))
|
|
42
|
+
w++;
|
|
43
|
+
d += `M${c * cell} ${r * cell}h${w * cell}v${cell}h-${w * cell}z`;
|
|
44
|
+
c += w - 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" `
|
|
48
|
+
+ `viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges" role="img" `
|
|
49
|
+
+ `aria-label="QR code for ${text}">`
|
|
50
|
+
+ `<rect width="${size}" height="${size}" fill="${light}"/>`
|
|
51
|
+
+ `<path d="${d}" fill="${dark}"/></svg>`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
export type Network = "testnet" | "public";
|
|
3
|
+
export type Want = {
|
|
4
|
+
/** the account the money must land in */
|
|
5
|
+
payee: string;
|
|
6
|
+
/** minimum amount, as a decimal string */
|
|
7
|
+
amount: string;
|
|
8
|
+
/** "XLM" for native, otherwise a code+issuer pair */
|
|
9
|
+
asset: {
|
|
10
|
+
code: string;
|
|
11
|
+
issuer?: string;
|
|
12
|
+
};
|
|
13
|
+
/** the order the payment must name in its memo */
|
|
14
|
+
memo: string;
|
|
15
|
+
/** the vault the payment must come from (bound when the order was issued) */
|
|
16
|
+
from?: string;
|
|
17
|
+
/** how old a payment may be and still count */
|
|
18
|
+
maxAgeSeconds?: number;
|
|
19
|
+
/** which network to read */
|
|
20
|
+
network?: Network;
|
|
21
|
+
};
|
|
22
|
+
export type Verdict = {
|
|
23
|
+
ok: true;
|
|
24
|
+
tx: string;
|
|
25
|
+
amount: string;
|
|
26
|
+
paidAt: string;
|
|
27
|
+
from: string;
|
|
28
|
+
ledger: number;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
reason: string;
|
|
32
|
+
};
|
|
33
|
+
type FetchJson = (url: string) => Promise<{
|
|
34
|
+
status: number;
|
|
35
|
+
body: any;
|
|
36
|
+
} | null>;
|
|
37
|
+
/**
|
|
38
|
+
* Has this transaction really paid us, in full, for this order?
|
|
39
|
+
*
|
|
40
|
+
* Every field below is a separate forgery attempt, so each is checked against
|
|
41
|
+
* the ledger's own answer rather than the caller's:
|
|
42
|
+
*
|
|
43
|
+
* hash unknown / tx failed → nothing happened
|
|
44
|
+
* pays a different account → not our money
|
|
45
|
+
* underpays → partial payment is not payment
|
|
46
|
+
* a fake token called "XLM" → anyone can name a token anything; only
|
|
47
|
+
* native XLM, or an exact code+issuer pair,
|
|
48
|
+
* is the asset we priced the order in
|
|
49
|
+
* wrong or missing memo → cannot be tied to this order, so one
|
|
50
|
+
* real payment could be reused for many
|
|
51
|
+
* pays us for someone else's order→ same reason, from the other side
|
|
52
|
+
* ancient payment → a payment from last year is not this one
|
|
53
|
+
* from a vault we never issued → the order binds its buyer at 402 time
|
|
54
|
+
*
|
|
55
|
+
* Amounts are compared as numbers because Stellar normalises to 7 decimals.
|
|
56
|
+
*/
|
|
57
|
+
export declare function verifyIncomingPayment(hash: string, want: Want, fetchJson?: FetchJson): Promise<Verdict>;
|
|
58
|
+
type Order = {
|
|
59
|
+
id: string;
|
|
60
|
+
memo: string;
|
|
61
|
+
amount: string;
|
|
62
|
+
asset: {
|
|
63
|
+
code: string;
|
|
64
|
+
issuer?: string;
|
|
65
|
+
};
|
|
66
|
+
buyer?: string;
|
|
67
|
+
createdAt: number;
|
|
68
|
+
/** set once a payment has been matched, so one payment buys one order */
|
|
69
|
+
redeemedTx?: string;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Orders live in memory on purpose. An unpaid order is worth nothing to anyone,
|
|
73
|
+
* and a redeemed one is recorded on the ledger forever — the durable record is
|
|
74
|
+
* the payment, not our book. A restart loses pending orders, which is correct:
|
|
75
|
+
* the buyer just asks again.
|
|
76
|
+
*/
|
|
77
|
+
export declare class OrderBook {
|
|
78
|
+
private ttlSeconds;
|
|
79
|
+
private orders;
|
|
80
|
+
private byMemo;
|
|
81
|
+
/** tx hashes already spent, so one payment can never buy two orders */
|
|
82
|
+
private spent;
|
|
83
|
+
constructor(ttlSeconds?: number);
|
|
84
|
+
issue(amount: string, asset: {
|
|
85
|
+
code: string;
|
|
86
|
+
issuer?: string;
|
|
87
|
+
}, buyer?: string): Order;
|
|
88
|
+
get(id: string): Order | undefined;
|
|
89
|
+
isSpent(tx: string): boolean;
|
|
90
|
+
markSpent(tx: string, id: string): void;
|
|
91
|
+
get size(): number;
|
|
92
|
+
private sweep;
|
|
93
|
+
}
|
|
94
|
+
export type ReceiveConfig = {
|
|
95
|
+
port: number;
|
|
96
|
+
host?: string;
|
|
97
|
+
hostname: string;
|
|
98
|
+
payee: string;
|
|
99
|
+
network: Network;
|
|
100
|
+
price: string;
|
|
101
|
+
asset: {
|
|
102
|
+
code: string;
|
|
103
|
+
issuer?: string;
|
|
104
|
+
};
|
|
105
|
+
/** what the buyer gets once the ledger confirms they paid */
|
|
106
|
+
deliver: (order: Order) => {
|
|
107
|
+
content: any;
|
|
108
|
+
receipt: string;
|
|
109
|
+
};
|
|
110
|
+
explorer?: string;
|
|
111
|
+
fetchJson?: FetchJson;
|
|
112
|
+
/**
|
|
113
|
+
* Optional human-readable page for `/`. The /donate routes speak JSON to
|
|
114
|
+
* agents; a person who opens the link in a browser would otherwise see a
|
|
115
|
+
* 404 and leave. The page interpolates nothing a visitor controls, so it
|
|
116
|
+
* cannot be a way in.
|
|
117
|
+
*/
|
|
118
|
+
page?: () => string;
|
|
119
|
+
/** Served at /donate.js so the page can keep a strict script-src 'self'. */
|
|
120
|
+
script?: () => string;
|
|
121
|
+
/**
|
|
122
|
+
* Served at /qr.svg. A Stellar address is 56 base32 characters whose alphabet
|
|
123
|
+
* contains B/8, I/1, O/0 and U/2 — retyping one is how money is lost. The QR
|
|
124
|
+
* is its own route rather than an inline data URI so the page can keep
|
|
125
|
+
* img-src 'self' instead of widening the CSP.
|
|
126
|
+
*/
|
|
127
|
+
qr?: () => string;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* A public seller endpoint. Two routes only:
|
|
131
|
+
*
|
|
132
|
+
* GET /donate → 402 with the price and how to pay
|
|
133
|
+
* POST /donate → same, and issues an order to name in the memo
|
|
134
|
+
* GET /donate?tx=HASH → verify on the ledger, then deliver
|
|
135
|
+
*
|
|
136
|
+
* Nothing else is reachable: no queue, no keys, no admin. The worst this port
|
|
137
|
+
* can do is hand out content we were actually paid for.
|
|
138
|
+
*/
|
|
139
|
+
export declare function startReceive(cfg: ReceiveConfig): {
|
|
140
|
+
server: import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
141
|
+
book: OrderBook;
|
|
142
|
+
/** Loopback only: the public path is the named tunnel, never this port. */
|
|
143
|
+
listen: (port: number) => Promise<void>;
|
|
144
|
+
};
|
|
145
|
+
/** Stable, non-secret id for an order, used in logs. */
|
|
146
|
+
export declare function orderTag(id: string): string;
|
|
147
|
+
export {};
|