@paydlux/agent 0.1.2 → 0.1.4
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 +33 -3
- package/dist/desk.d.ts +35 -2
- package/dist/desk.js +240 -2
- package/dist/gateway.d.ts +8 -0
- package/dist/gateway.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/phone.d.ts +65 -0
- package/dist/phone.js +163 -0
- package/dist/portal.js +22 -5
- package/dist/vault.d.ts +2 -1
- package/dist/vault.js +28 -1
- package/package.json +5 -1
- package/phone-app/app.js +253 -0
- package/phone-app/index.html +148 -0
- package/phone-app/xdr.js +436 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="theme-color" content="#0b0f14">
|
|
7
|
+
<title>Paydlux Approver</title>
|
|
8
|
+
<!--
|
|
9
|
+
The approver half of a Paydlux vault.
|
|
10
|
+
|
|
11
|
+
This page holds ONE key: the approver's. It cannot spend anything by itself.
|
|
12
|
+
It reads a pending payment, shows a human what they are approving, and signs
|
|
13
|
+
the transaction hash. Signing is a purely local operation: no network call can
|
|
14
|
+
change what was signed.
|
|
15
|
+
|
|
16
|
+
The page talks to one origin only — the desk that served it — to fetch the
|
|
17
|
+
pending transaction and hand back the signature. connect-src 'self' in the
|
|
18
|
+
desk's Content-Security-Policy enforces that.
|
|
19
|
+
|
|
20
|
+
What the key model actually guarantees:
|
|
21
|
+
- the seed becomes a NON-extractable WebCrypto key; after enrolment nothing
|
|
22
|
+
on this page, including an injected script, can read it back;
|
|
23
|
+
- the signature covers the transaction hash, so a changed amount or payee
|
|
24
|
+
produces a different hash and a signature the ledger refuses;
|
|
25
|
+
- the approver key alone cannot move funds — the vault needs 2 signatures.
|
|
26
|
+
|
|
27
|
+
What it does NOT guarantee: a person who roots this device, or who approves a
|
|
28
|
+
transaction without reading it. The human reading the amount and payee is part
|
|
29
|
+
of the security model, not a UX step.
|
|
30
|
+
|
|
31
|
+
Serve it over http(s) on a device you control: npx tsx tools/phone-serve.ts
|
|
32
|
+
-->
|
|
33
|
+
<style>
|
|
34
|
+
:root {
|
|
35
|
+
--bg:#0b0f14; --panel:#121821; --line:#22303f; --ink:#e8eef6; --dim:#8fa3b8;
|
|
36
|
+
--ok:#3ddc97; --warn:#ffb454; --bad:#ff6b6b; --accent:#4da3ff;
|
|
37
|
+
}
|
|
38
|
+
* { box-sizing:border-box }
|
|
39
|
+
body {
|
|
40
|
+
margin:0; background:var(--bg); color:var(--ink); font:16px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;
|
|
41
|
+
padding:max(16px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom));
|
|
42
|
+
max-width:560px; margin-inline:auto;
|
|
43
|
+
}
|
|
44
|
+
h1 { font-size:17px; margin:0 0 4px; letter-spacing:.02em }
|
|
45
|
+
.sub { color:var(--dim); font-size:13px; margin-bottom:18px }
|
|
46
|
+
.panel { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:14px; margin-bottom:14px }
|
|
47
|
+
label { display:block; font-size:12px; color:var(--dim); margin:10px 0 4px; text-transform:uppercase; letter-spacing:.06em }
|
|
48
|
+
textarea, input {
|
|
49
|
+
width:100%; background:#0d131a; color:var(--ink); border:1px solid var(--line);
|
|
50
|
+
border-radius:10px; padding:10px; font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
51
|
+
word-break:break-all;
|
|
52
|
+
}
|
|
53
|
+
textarea { min-height:84px; resize:vertical }
|
|
54
|
+
button {
|
|
55
|
+
width:100%; margin-top:12px; padding:14px; border:0; border-radius:12px;
|
|
56
|
+
background:var(--accent); color:#04121f; font-size:16px; font-weight:700; cursor:pointer;
|
|
57
|
+
}
|
|
58
|
+
button.ghost { background:transparent; color:var(--dim); border:1px solid var(--line); font-weight:600 }
|
|
59
|
+
button.danger { background:var(--bad); color:#2a0707 }
|
|
60
|
+
button:disabled { opacity:.4 }
|
|
61
|
+
.row { display:flex; gap:10px }
|
|
62
|
+
.row > * { flex:1 }
|
|
63
|
+
.kv { display:grid; grid-template-columns:96px 1fr; gap:6px 10px; font-size:14px }
|
|
64
|
+
.kv dt { color:var(--dim); font-size:12px; text-transform:uppercase; letter-spacing:.06em; padding-top:2px }
|
|
65
|
+
.kv dd { margin:0; font-family:ui-monospace,Menlo,monospace; word-break:break-all; font-size:13px }
|
|
66
|
+
.amount { font-size:34px; font-weight:800; margin:2px 0 10px; font-variant-numeric:tabular-nums }
|
|
67
|
+
.tag { display:inline-block; font-size:11px; padding:3px 8px; border-radius:999px; letter-spacing:.06em; text-transform:uppercase }
|
|
68
|
+
.tag.ok { background:rgba(61,220,151,.14); color:var(--ok) }
|
|
69
|
+
.tag.warn { background:rgba(255,180,84,.14); color:var(--warn) }
|
|
70
|
+
.tag.bad { background:rgba(255,107,107,.14); color:var(--bad) }
|
|
71
|
+
.err { color:var(--bad); font-size:13px; margin-top:8px; word-break:break-word }
|
|
72
|
+
.hint { color:var(--dim); font-size:12px; margin-top:8px }
|
|
73
|
+
.hidden { display:none }
|
|
74
|
+
.out { background:#0d131a; border:1px solid var(--line); border-radius:10px; padding:10px;
|
|
75
|
+
font:12px/1.4 ui-monospace,Menlo,monospace; word-break:break-all; max-height:180px; overflow:auto }
|
|
76
|
+
.who { font-family:ui-monospace,Menlo,monospace; font-size:12px; color:var(--dim); word-break:break-all }
|
|
77
|
+
</style>
|
|
78
|
+
</head>
|
|
79
|
+
<body>
|
|
80
|
+
<h1>Paydlux Approver</h1>
|
|
81
|
+
<div class="sub">Signs payments on this device. The key never leaves it.</div>
|
|
82
|
+
|
|
83
|
+
<!-- ---------------------------------------------------------- enrolment -->
|
|
84
|
+
<div class="panel" id="enrol">
|
|
85
|
+
<div class="tag warn">No approver key on this device</div>
|
|
86
|
+
<label for="seed">Approver secret seed (S…)</label>
|
|
87
|
+
<input id="seed" type="password" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
|
88
|
+
placeholder="S">
|
|
89
|
+
<button id="enrolBtn">Import key</button>
|
|
90
|
+
<div class="hint">
|
|
91
|
+
The seed becomes a non-extractable signing key and is then dropped. Only that key is
|
|
92
|
+
kept: it can sign, nothing can read the seed back out of it. If this device is wiped,
|
|
93
|
+
re-enter the seed.
|
|
94
|
+
</div>
|
|
95
|
+
<div class="err" id="enrolErr"></div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<!-- ------------------------------------------------------------ ready -->
|
|
99
|
+
<div class="panel hidden" id="ready">
|
|
100
|
+
<div class="tag ok">Key loaded</div>
|
|
101
|
+
<dl class="kv" style="margin-top:10px">
|
|
102
|
+
<dt>Approver</dt><dd class="who" id="who"></dd>
|
|
103
|
+
</dl>
|
|
104
|
+
<button class="ghost" id="forget">Remove key from this device</button>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<!-- --------------------------------------------------------- approval -->
|
|
108
|
+
<div class="panel">
|
|
109
|
+
<label for="xdr">Pending transaction (paste the XDR from the seller)</label>
|
|
110
|
+
<textarea id="xdr" autocorrect="off" autocapitalize="off" spellcheck="false"
|
|
111
|
+
placeholder="Paste the transaction the seller sent you"></textarea>
|
|
112
|
+
<button id="read">Read it</button>
|
|
113
|
+
<div class="err" id="readErr"></div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div class="panel hidden" id="review">
|
|
117
|
+
<div class="amount" id="amount"></div>
|
|
118
|
+
<dl class="kv">
|
|
119
|
+
<dt>Token</dt><dd id="token"></dd>
|
|
120
|
+
<dt>Payee</dt><dd id="payee"></dd>
|
|
121
|
+
<dt>From</dt><dd id="vault"></dd>
|
|
122
|
+
<dt>Reference</dt><dd id="memo"></dd>
|
|
123
|
+
<dt>Deadline</dt><dd id="deadline"></dd>
|
|
124
|
+
<dt>Network</dt><dd id="network"></dd>
|
|
125
|
+
<dt>Hash</dt><dd id="hash"></dd>
|
|
126
|
+
</dl>
|
|
127
|
+
<div class="hint">
|
|
128
|
+
Only the hash is signed. If anything above differs from what the seller told you,
|
|
129
|
+
do not approve — a changed amount or payee produces a different hash.
|
|
130
|
+
</div>
|
|
131
|
+
<button id="approve">Approve & sign</button>
|
|
132
|
+
<button class="ghost" id="reject">Reject</button>
|
|
133
|
+
<div class="err" id="signErr"></div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<!-- ----------------------------------------------------------- result -->
|
|
137
|
+
<div class="panel hidden" id="result">
|
|
138
|
+
<div class="tag ok">Signed</div>
|
|
139
|
+
<label>Signed transaction — send this back to the seller</label>
|
|
140
|
+
<div class="out" id="signed"></div>
|
|
141
|
+
<button id="copy">Copy</button>
|
|
142
|
+
<button class="ghost" id="again">Done</button>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<script src="xdr.js"></script>
|
|
146
|
+
<script src="app.js"></script>
|
|
147
|
+
</body>
|
|
148
|
+
</html>
|
package/phone-app/xdr.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// Minimal Stellar transaction codec for the phone. No dependencies, no network.
|
|
2
|
+
//
|
|
3
|
+
// The phone must be able to (a) read a payment so a human can see what they are
|
|
4
|
+
// approving, (b) recompute the transaction hash so it can prove the bytes it was
|
|
5
|
+
// given are the bytes it was shown, and (c) append its signature.
|
|
6
|
+
//
|
|
7
|
+
// It deliberately understands only the payment envelope shape. Anything it does
|
|
8
|
+
// not recognise is an error, not a guess — refusing to sign is always safe.
|
|
9
|
+
//
|
|
10
|
+
// Field order below is taken from the Stellar XDR schema (Transaction,
|
|
11
|
+
// Operation, PaymentOp, Preconditions, Memo, Asset, TransactionExt,
|
|
12
|
+
// DecoratedSignature). phone-app/xdr-check.ts asserts this decoder agrees with
|
|
13
|
+
// the official SDK on a real vault payment.
|
|
14
|
+
//
|
|
15
|
+
// This file runs unchanged in the browser and in Node (tests).
|
|
16
|
+
(function (root) {
|
|
17
|
+
"use strict";
|
|
18
|
+
|
|
19
|
+
// ------------------------------------------------------------------ reader
|
|
20
|
+
function Reader(bytes) {
|
|
21
|
+
this.b = bytes; this.i = 0;
|
|
22
|
+
}
|
|
23
|
+
Reader.prototype.u32 = function () {
|
|
24
|
+
if (this.i + 4 > this.b.length) throw new Error("xdr: truncated reading uint32");
|
|
25
|
+
const v = (this.b[this.i] << 24 | this.b[this.i + 1] << 16 | this.b[this.i + 2] << 8 | this.b[this.i + 3]) >>> 0;
|
|
26
|
+
this.i += 4; return v;
|
|
27
|
+
};
|
|
28
|
+
// Sequence numbers and amounts are int64 and routinely exceed
|
|
29
|
+
// Number.MAX_SAFE_INTEGER, so 64-bit values stay BigInt until displayed.
|
|
30
|
+
Reader.prototype.u64 = function () {
|
|
31
|
+
const hi = BigInt(this.u32()), lo = BigInt(this.u32());
|
|
32
|
+
return (hi << 32n) | lo;
|
|
33
|
+
};
|
|
34
|
+
Reader.prototype.i64 = function () {
|
|
35
|
+
const v = this.u64();
|
|
36
|
+
return v >= (1n << 63n) ? v - (1n << 64n) : v;
|
|
37
|
+
};
|
|
38
|
+
Reader.prototype.fixed = function (n) {
|
|
39
|
+
if (this.i + n > this.b.length) throw new Error("xdr: truncated fixed bytes");
|
|
40
|
+
const out = this.b.slice(this.i, this.i + n);
|
|
41
|
+
this.i += n;
|
|
42
|
+
const pad = (4 - (n % 4)) % 4;
|
|
43
|
+
for (let p = 0; p < pad; p++) {
|
|
44
|
+
if (this.b[this.i + p] !== 0) throw new Error("xdr: bad padding");
|
|
45
|
+
}
|
|
46
|
+
this.i += pad;
|
|
47
|
+
return out;
|
|
48
|
+
};
|
|
49
|
+
Reader.prototype.varOpaque = function (max) {
|
|
50
|
+
const n = this.u32();
|
|
51
|
+
if (n > max) throw new Error(`xdr: opaque length ${n} exceeds the ${max}-byte limit`);
|
|
52
|
+
return this.fixed(n);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ------------------------------------------------------------------ writer
|
|
56
|
+
function Writer() { this.b = []; }
|
|
57
|
+
Writer.prototype.u32 = function (v) {
|
|
58
|
+
this.b.push((v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255);
|
|
59
|
+
};
|
|
60
|
+
Writer.prototype.u64 = function (v) {
|
|
61
|
+
const b = BigInt(v);
|
|
62
|
+
this.u32(Number((b >> 32n) & 0xffffffffn)); this.u32(Number(b & 0xffffffffn));
|
|
63
|
+
};
|
|
64
|
+
Writer.prototype.fixed = function (bytes) {
|
|
65
|
+
for (const x of bytes) this.b.push(x);
|
|
66
|
+
const pad = (4 - (bytes.length % 4)) % 4;
|
|
67
|
+
for (let p = 0; p < pad; p++) this.b.push(0);
|
|
68
|
+
};
|
|
69
|
+
Writer.prototype.varOpaque = function (bytes) { this.u32(bytes.length); this.fixed(bytes); };
|
|
70
|
+
Writer.prototype.bytes = function () { return Uint8Array.from(this.b); };
|
|
71
|
+
|
|
72
|
+
// ------------------------------------------------------------------ constants
|
|
73
|
+
const ENVELOPE_TYPE_TX = 2; // TransactionEnvelope holding a v1 tx
|
|
74
|
+
const KEY_TYPE_ED25519 = 0;
|
|
75
|
+
const KEY_TYPE_MUXED_ED25519 = 256;
|
|
76
|
+
const SC_ADDRESS = 4; // soroban account: we refuse it
|
|
77
|
+
const ASSET_NATIVE = 0, ASSET_ALPHANUM4 = 1, ASSET_ALPHANUM12 = 2;
|
|
78
|
+
const OP_PAYMENT = 1;
|
|
79
|
+
const PRECOND_NONE = 0, PRECOND_TIME = 1, PRECOND_V2 = 2;
|
|
80
|
+
const MEMO_NONE = 0, MEMO_TEXT = 1, MEMO_ID = 2, MEMO_HASH = 3, MEMO_RETURN = 4;
|
|
81
|
+
const STROOPS = 10000000;
|
|
82
|
+
|
|
83
|
+
// ------------------------------------------------------------------ strkey
|
|
84
|
+
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
85
|
+
|
|
86
|
+
/** Stellar account id (G...) from a 32-byte ed25519 public key. */
|
|
87
|
+
function encodeAccountEd25519(pub) {
|
|
88
|
+
if (pub.length !== 32) throw new Error("public key must be 32 bytes");
|
|
89
|
+
return encodeStrKey(6 << 3, pub);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 32-byte public key from a Stellar account id. */
|
|
93
|
+
function decodeAccountEd25519(str) {
|
|
94
|
+
if (typeof str !== "string" || str[0] !== "G") {
|
|
95
|
+
throw new Error("not a Stellar account id (expected G...)");
|
|
96
|
+
}
|
|
97
|
+
return decodeStrKey(str, 6 << 3, 32);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Stellar secret seed (S...) -> 32-byte seed. Same base32 envelope as an
|
|
102
|
+
* account id, different version byte.
|
|
103
|
+
*/
|
|
104
|
+
function decodeSecretSeed(str) {
|
|
105
|
+
if (typeof str !== "string" || str[0] !== "S") {
|
|
106
|
+
throw new Error("not a Stellar secret seed (expected S...)");
|
|
107
|
+
}
|
|
108
|
+
return decodeStrKey(str, 18 << 3, 32);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Stellar secret seed (S...) from a 32-byte seed. */
|
|
112
|
+
function encodeSecretSeed(seed) {
|
|
113
|
+
if (seed.length !== 32) throw new Error("seed must be 32 bytes");
|
|
114
|
+
return encodeStrKey(18 << 3, seed);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function encodeStrKey(version, body) {
|
|
118
|
+
const bytes = [version, ...body, 0, 0];
|
|
119
|
+
const crc = crc16Xmodem(bytes.slice(0, 1 + body.length));
|
|
120
|
+
bytes[bytes.length - 2] = crc & 255;
|
|
121
|
+
bytes[bytes.length - 1] = (crc >> 8) & 255;
|
|
122
|
+
const bits = [];
|
|
123
|
+
for (const byte of bytes) for (let k = 7; k >= 0; k--) bits.push((byte >> k) & 1);
|
|
124
|
+
let out = "";
|
|
125
|
+
for (let k = 0; k + 5 <= bits.length; k += 5) {
|
|
126
|
+
out += B32[bits[k] * 16 + bits[k + 1] * 8 + bits[k + 2] * 4 + bits[k + 3] * 2 + bits[k + 4]];
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function decodeStrKey(str, version, bodyLen) {
|
|
132
|
+
if (str.length !== 56) throw new Error("strkey must be 56 characters");
|
|
133
|
+
const bits = [];
|
|
134
|
+
for (const ch of str) {
|
|
135
|
+
const v = B32.indexOf(ch);
|
|
136
|
+
if (v < 0) throw new Error("not a valid strkey: bad character " + ch);
|
|
137
|
+
for (let k = 4; k >= 0; k--) bits.push((v >> k) & 1);
|
|
138
|
+
}
|
|
139
|
+
const bytes = [];
|
|
140
|
+
for (let k = 0; k + 8 <= bits.length; k += 8) {
|
|
141
|
+
let byte = 0;
|
|
142
|
+
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[k + j];
|
|
143
|
+
bytes.push(byte);
|
|
144
|
+
}
|
|
145
|
+
if (bytes[0] !== version) throw new Error("wrong strkey version byte");
|
|
146
|
+
const body = Uint8Array.from(bytes.slice(1, 1 + bodyLen));
|
|
147
|
+
const checksum = (bytes[1 + bodyLen] | (bytes[2 + bodyLen] << 8)) >>> 0;
|
|
148
|
+
if (checksum !== crc16Xmodem(bytes.slice(0, 1 + bodyLen))) {
|
|
149
|
+
throw new Error("strkey checksum does not match — the key was mistyped");
|
|
150
|
+
}
|
|
151
|
+
return body;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** CRC16-XMODEM, the checksum Stellar strkeys use. */
|
|
155
|
+
function crc16Xmodem(bytes) {
|
|
156
|
+
let crc = 0;
|
|
157
|
+
for (const b of bytes) {
|
|
158
|
+
crc ^= b << 8;
|
|
159
|
+
for (let i = 0; i < 8; i++) {
|
|
160
|
+
crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return crc & 0xffff;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Turn a Stellar secret seed into a WebCrypto signing key.
|
|
168
|
+
*
|
|
169
|
+
* The result is NON-extractable: the app can sign with it, but nothing — not
|
|
170
|
+
* the page's own JavaScript, not an injected script — can read the seed back
|
|
171
|
+
* out of it. That is the point of the phone holding the approval half.
|
|
172
|
+
* The public half is captured at enrolment and stored separately.
|
|
173
|
+
*/
|
|
174
|
+
async function importSecretSeed(seedStr) {
|
|
175
|
+
const seed = decodeSecretSeed(seedStr);
|
|
176
|
+
const pkcs8 = new Uint8Array(16 + 32);
|
|
177
|
+
pkcs8.set([
|
|
178
|
+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
|
|
179
|
+
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
|
180
|
+
], 0);
|
|
181
|
+
pkcs8.set(seed, 16);
|
|
182
|
+
const privateKey = await crypto.subtle.importKey(
|
|
183
|
+
"pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
|
|
184
|
+
seed.fill(0); // drop the plaintext copy as soon as we can
|
|
185
|
+
return privateKey;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ------------------------------------------------------------------ parsing
|
|
189
|
+
function readMuxedAccount(r) {
|
|
190
|
+
const type = r.u32();
|
|
191
|
+
if (type === KEY_TYPE_ED25519) return { kind: "ed25519", pub: r.fixed(32) };
|
|
192
|
+
if (type === KEY_TYPE_MUXED_ED25519) {
|
|
193
|
+
const id = r.u64();
|
|
194
|
+
const pub = r.fixed(32);
|
|
195
|
+
return { kind: "muxed", id: id.toString(), pub };
|
|
196
|
+
}
|
|
197
|
+
if (type === SC_ADDRESS) throw new Error("a smart-contract address cannot approve a payment");
|
|
198
|
+
throw new Error("unknown account key type " + type);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readAsset(r) {
|
|
202
|
+
const type = r.u32();
|
|
203
|
+
if (type === ASSET_NATIVE) return { code: "XLM", issuer: null };
|
|
204
|
+
if (type === ASSET_ALPHANUM4 || type === ASSET_ALPHANUM12) {
|
|
205
|
+
const code = asciiTrim(r.fixed(type === ASSET_ALPHANUM4 ? 4 : 12));
|
|
206
|
+
return { code, issuer: encodeAccountEd25519(readPublicKey(r)) };
|
|
207
|
+
}
|
|
208
|
+
throw new Error("asset type " + type + " is not supported; refusing to sign");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Asset issuers use public_key, which is a union: a 4-byte key-type word
|
|
213
|
+
* before the 32 bytes. Missing that word shifts every later field by four
|
|
214
|
+
* bytes and silently misreads the amount, so it is read explicitly.
|
|
215
|
+
*/
|
|
216
|
+
function readPublicKey(r) {
|
|
217
|
+
const type = r.u32();
|
|
218
|
+
if (type !== KEY_TYPE_ED25519) {
|
|
219
|
+
throw new Error("issuer key type " + type + " is not supported; refusing to sign");
|
|
220
|
+
}
|
|
221
|
+
return r.fixed(32);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function asciiTrim(bytes) {
|
|
225
|
+
let s = "";
|
|
226
|
+
for (const b of bytes) { if (b === 0) break; s += String.fromCharCode(b); }
|
|
227
|
+
if (!/^[A-Za-z0-9]{1,12}$/.test(s)) {
|
|
228
|
+
throw new Error("asset code is not alphanumeric: " + JSON.stringify(s));
|
|
229
|
+
}
|
|
230
|
+
return s;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readMemo(r) {
|
|
234
|
+
const type = r.u32();
|
|
235
|
+
if (type === MEMO_NONE) return null;
|
|
236
|
+
if (type === MEMO_TEXT) {
|
|
237
|
+
const s = r.varOpaque(28);
|
|
238
|
+
let text = "";
|
|
239
|
+
for (const b of s) {
|
|
240
|
+
if (b < 0x20 || b > 0x7e) throw new Error("memo text is not printable");
|
|
241
|
+
text += String.fromCharCode(b);
|
|
242
|
+
}
|
|
243
|
+
return text;
|
|
244
|
+
}
|
|
245
|
+
if (type === MEMO_ID) return "id:" + r.u64().toString();
|
|
246
|
+
if (type === MEMO_HASH) return "hash:" + hex(r.fixed(32));
|
|
247
|
+
if (type === MEMO_RETURN) return "return:" + hex(r.fixed(32));
|
|
248
|
+
throw new Error("unknown memo type " + type);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readPreconditions(r) {
|
|
252
|
+
const type = r.u32();
|
|
253
|
+
if (type === PRECOND_NONE) return null;
|
|
254
|
+
if (type === PRECOND_TIME) {
|
|
255
|
+
const minTime = r.u64().toString(), maxTime = r.u64().toString();
|
|
256
|
+
return { minTime, maxTime };
|
|
257
|
+
}
|
|
258
|
+
if (type === PRECOND_V2) {
|
|
259
|
+
// timeBounds? ledgerBounds? minSeqNum? minSeqAge minSeqLedgerGap extraSigners[2]
|
|
260
|
+
const hasTime = r.u32();
|
|
261
|
+
const bounds = hasTime
|
|
262
|
+
? { minTime: r.u64().toString(), maxTime: r.u64().toString() }
|
|
263
|
+
: null;
|
|
264
|
+
const hasLedger = r.u32();
|
|
265
|
+
if (hasLedger) throw new Error("ledger bounds are not supported; refusing to sign");
|
|
266
|
+
const hasSeq = r.u32();
|
|
267
|
+
if (hasSeq) throw new Error("sequence preconditions are not supported; refusing to sign");
|
|
268
|
+
r.u64(); // minSeqAge
|
|
269
|
+
r.u32(); // minSeqLedgerGap
|
|
270
|
+
const n = r.u32();
|
|
271
|
+
if (n !== 0) throw new Error("extra signers are not supported; refusing to sign");
|
|
272
|
+
return bounds;
|
|
273
|
+
}
|
|
274
|
+
throw new Error("unknown precondition type " + type);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function readOperation(r) {
|
|
278
|
+
const hasSource = r.u32();
|
|
279
|
+
const source = hasSource ? readMuxedAccount(r) : null;
|
|
280
|
+
const type = r.u32(); // OperationBody union switch
|
|
281
|
+
if (type !== OP_PAYMENT) {
|
|
282
|
+
throw new Error("operation type " + type + " is not a payment; this app only signs payments");
|
|
283
|
+
}
|
|
284
|
+
const destination = readMuxedAccount(r);
|
|
285
|
+
const asset = readAsset(r);
|
|
286
|
+
const amount = r.i64();
|
|
287
|
+
if (amount < 0n) throw new Error("payment amount is negative; refusing to sign");
|
|
288
|
+
return { type, source, destination, asset, amount: amount.toString() };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function readTransaction(r) {
|
|
292
|
+
const source = readMuxedAccount(r);
|
|
293
|
+
const fee = r.u32();
|
|
294
|
+
const seq = r.u64();
|
|
295
|
+
const bounds = readPreconditions(r);
|
|
296
|
+
const memo = readMemo(r);
|
|
297
|
+
const numOps = r.u32();
|
|
298
|
+
if (numOps !== 1) {
|
|
299
|
+
throw new Error("this app signs single-payment transactions, and this one has "
|
|
300
|
+
+ numOps + " operations");
|
|
301
|
+
}
|
|
302
|
+
const ops = [];
|
|
303
|
+
for (let i = 0; i < numOps; i++) ops.push(readOperation(r));
|
|
304
|
+
// TransactionExt is a union: v0 carries nothing at all.
|
|
305
|
+
const ext = r.u32();
|
|
306
|
+
if (ext !== 0) throw new Error("transaction carries soroban data; refusing to sign");
|
|
307
|
+
return { source, fee, seq, bounds, memo, ops };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function readDecorativeSignature(r) {
|
|
311
|
+
const hint = r.fixed(4);
|
|
312
|
+
const sig = r.varOpaque(64);
|
|
313
|
+
return { hint, sig };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Parse a base64 TransactionEnvelope, returning the tx plus its raw bytes. */
|
|
317
|
+
function parseEnvelope(base64) {
|
|
318
|
+
const bytes = base64ToBytes(base64);
|
|
319
|
+
const r = new Reader(bytes);
|
|
320
|
+
const type = r.u32();
|
|
321
|
+
if (type !== ENVELOPE_TYPE_TX) {
|
|
322
|
+
throw new Error("expected a v1 transaction envelope (type 2), got " + type
|
|
323
|
+
+ " — a fee-bump or v0 envelope is not supported");
|
|
324
|
+
}
|
|
325
|
+
const txStart = r.i;
|
|
326
|
+
const tx = readTransaction(r);
|
|
327
|
+
const txEnd = r.i;
|
|
328
|
+
const n = r.u32();
|
|
329
|
+
if (n === 0 || n > 20) throw new Error("envelope has " + n + " signatures; expected 1 or 2");
|
|
330
|
+
const signatures = [];
|
|
331
|
+
for (let i = 0; i < n; i++) signatures.push(readDecorativeSignature(r));
|
|
332
|
+
if (r.i !== bytes.length) {
|
|
333
|
+
throw new Error("trailing bytes after the envelope (" + (bytes.length - r.i) + ")");
|
|
334
|
+
}
|
|
335
|
+
return { tx, signatures, txBytes: bytes.slice(txStart, txEnd), bytes };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ------------------------------------------------------------------ hashing
|
|
339
|
+
/**
|
|
340
|
+
* The hash a Stellar signature covers:
|
|
341
|
+
* sha256( sha256(passphrase) || ENVELOPE_TYPE_TX(4) || transactionBytes )
|
|
342
|
+
*
|
|
343
|
+
* The leading 32 bytes are the hash of the network passphrase, not the
|
|
344
|
+
* passphrase itself. Recomputing this on the phone is what stops a swapped
|
|
345
|
+
* transaction: the human is shown this value, and the bytes actually signed
|
|
346
|
+
* have to produce it.
|
|
347
|
+
*/
|
|
348
|
+
async function transactionHash(base64, passphraseStr) {
|
|
349
|
+
const env = parseEnvelope(base64);
|
|
350
|
+
const networkId = new Uint8Array(await crypto.subtle.digest(
|
|
351
|
+
"SHA-256", new TextEncoder().encode(passphraseStr)));
|
|
352
|
+
const buf = new Uint8Array(32 + 4 + env.txBytes.length);
|
|
353
|
+
buf.set(networkId, 0);
|
|
354
|
+
buf[32] = 0; buf[33] = 0; buf[34] = 0; buf[35] = ENVELOPE_TYPE_TX;
|
|
355
|
+
buf.set(env.txBytes, 36);
|
|
356
|
+
const digest = await crypto.subtle.digest("SHA-256", buf);
|
|
357
|
+
return { hash: hex(new Uint8Array(digest)), env };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ------------------------------------------------------------------ signing
|
|
361
|
+
/**
|
|
362
|
+
* The bytes an approver signature must cover: the transaction hash itself,
|
|
363
|
+
* as 32 raw bytes. NOT sha256 of the hex text the human reads. Signing the
|
|
364
|
+
* wrong preimage produces a well-formed envelope the ledger refuses with
|
|
365
|
+
* BAD_AUTH, so this is exported rather than left to each caller to guess.
|
|
366
|
+
*/
|
|
367
|
+
function signaturePreimage(hashHex) { return hexToBytes(hashHex); }
|
|
368
|
+
|
|
369
|
+
/** Append one ed25519 signature to the envelope. Returns new base64 XDR. */
|
|
370
|
+
function addSignature(base64, signatureBytes, pubKeyBytes) {
|
|
371
|
+
if (signatureBytes.length !== 64) throw new Error("signature must be 64 bytes");
|
|
372
|
+
if (pubKeyBytes.length !== 32) throw new Error("public key must be 32 bytes");
|
|
373
|
+
const env = parseEnvelope(base64);
|
|
374
|
+
if (env.signatures.length >= 2) {
|
|
375
|
+
throw new Error("envelope already has " + env.signatures.length + " signatures");
|
|
376
|
+
}
|
|
377
|
+
const w = new Writer();
|
|
378
|
+
w.u32(ENVELOPE_TYPE_TX);
|
|
379
|
+
w.fixed(env.txBytes);
|
|
380
|
+
const sigs = env.signatures.concat([{
|
|
381
|
+
hint: pubKeyBytes.slice(28), sig: signatureBytes,
|
|
382
|
+
}]);
|
|
383
|
+
w.u32(sigs.length);
|
|
384
|
+
for (const s of sigs) { w.fixed(s.hint); w.varOpaque(s.sig); }
|
|
385
|
+
return bytesToBase64(w.bytes());
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ------------------------------------------------------------------ amounts
|
|
389
|
+
/** int64 stroops -> "4.5000000", the way the ledger prints it. */
|
|
390
|
+
function formatAmount(stroopAmount) {
|
|
391
|
+
const v = typeof stroopAmount === "bigint" ? stroopAmount : BigInt(stroopAmount);
|
|
392
|
+
if (v < 0n) throw new Error("negative payment amount");
|
|
393
|
+
const whole = v / BigInt(STROOPS);
|
|
394
|
+
const frac = v % BigInt(STROOPS);
|
|
395
|
+
return whole.toString() + "." + frac.toString().padStart(7, "0");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ------------------------------------------------------------------ base64
|
|
399
|
+
function base64ToBytes(s) {
|
|
400
|
+
const bin = atob(s);
|
|
401
|
+
const out = new Uint8Array(bin.length);
|
|
402
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
function bytesToBase64(bytes) {
|
|
406
|
+
let bin = "";
|
|
407
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
408
|
+
return btoa(bin);
|
|
409
|
+
}
|
|
410
|
+
function hexToBytes(s) {
|
|
411
|
+
if (!/^[0-9a-fA-F]+$/.test(s) || s.length % 2) throw new Error("not a hex string");
|
|
412
|
+
const out = new Uint8Array(s.length / 2);
|
|
413
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function hex(bytes) {
|
|
418
|
+
let s = "";
|
|
419
|
+
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
|
420
|
+
return s;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const NETWORK_ID = {
|
|
424
|
+
testnet: "Test SDF Network ; September 2015",
|
|
425
|
+
public: "Public Global Stellar Network ; September 2015",
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
root.PaydluxXdr = {
|
|
429
|
+
parseEnvelope, transactionHash, addSignature, formatAmount,
|
|
430
|
+
hex, hexToBytes, signaturePreimage,
|
|
431
|
+
encodeAccountEd25519, decodeAccountEd25519,
|
|
432
|
+
decodeSecretSeed, encodeSecretSeed, importSecretSeed,
|
|
433
|
+
NETWORK_ID, crc16Xmodem,
|
|
434
|
+
ENVELOPE_TYPE_TX, OP_PAYMENT, STROOPS,
|
|
435
|
+
};
|
|
436
|
+
})(typeof globalThis !== "undefined" ? globalThis : this);
|