@integraledger/lcp-binding-stellar 0.9.0
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/CHANGELOG.md +13 -0
- package/LICENSE +202 -0
- package/NOTICE +14 -0
- package/README.md +95 -0
- package/dist/adapter.d.ts +128 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +132 -0
- package/dist/adapter.js.map +1 -0
- package/dist/constants.d.ts +47 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +46 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +32 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +49 -0
- package/dist/manifest.js.map +1 -0
- package/dist/mux.d.ts +27 -0
- package/dist/mux.d.ts.map +1 -0
- package/dist/mux.js +70 -0
- package/dist/mux.js.map +1 -0
- package/package.json +62 -0
- package/src/adapter.ts +316 -0
- package/src/constants.ts +74 -0
- package/src/index.ts +31 -0
- package/src/manifest.ts +50 -0
- package/src/mux.ts +99 -0
- package/src/stellar-js-xdr.d.ts +31 -0
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Stellar CAP-67 muxed-address adapter — thin I/O with @stellar/stellar-sdk isolated here. Like
|
|
3
|
+
* binding-solana it intentionally does NOT implement binding-core's `WeldAdapter`: that port is EVM-shaped
|
|
4
|
+
* (`SettlementRef.txHash` is a `0x`-hex value, `ChainReader` speaks `eth_getLogs`), and Stellar speaks
|
|
5
|
+
* hex tx hashes over Horizon + CAP-67 M-address destinations. Rather than lie through those types, this
|
|
6
|
+
* exposes a Stellar-native surface (a tx-hash ref, a `StellarReader` port) alongside the shared,
|
|
7
|
+
* chain-agnostic `BindingManifest`. Unifying the port shape across EVM and non-EVM rails is future work;
|
|
8
|
+
* forcing it here would misrepresent the rail.
|
|
9
|
+
*
|
|
10
|
+
* ★ THE SURFACE IS CONFIRM-NOT-RECOVER, BY THE RAIL'S NATURE. Only `atrHash[:8]` rides in the CAP-67 mux
|
|
11
|
+
* id (8 bytes); the full 32-byte atrHash never touches the chain. So:
|
|
12
|
+
* - `propose(atrHash, basePubkey)` builds the muxed M-address destination the buyer transfers to.
|
|
13
|
+
* - `verify(atrHash, ref)` is the PRIMARY honest surface: it confirms the known atrHash's prefix-8 equals
|
|
14
|
+
* the settlement's on-chain mux id → `Outcome<{ confirmed: true; muxIdPrefix8Hex }>` or a
|
|
15
|
+
* `verification-failure` Refusal. (You bring the atrHash — from `extensions.legalContext.info` — the chain
|
|
16
|
+
* confirms the match.)
|
|
17
|
+
* - `recover(ref)` does NOT return a full atrHash (it cannot — the chain holds 8 bytes). It returns an
|
|
18
|
+
* explicitly-PARTIAL `{ muxIdPrefix8Hex }` documenting that only the prefix is on-chain, so a caller
|
|
19
|
+
* cannot mistake it for a recovered hash. If you want the atrHash, use `verify` with the off-chain hash.
|
|
20
|
+
* - `observe` reports the settled transition alongside the on-chain mux prefix.
|
|
21
|
+
* - `enumerate` is the best-effort account scan the manifest declares (`forwardIndexable: false`).
|
|
22
|
+
*
|
|
23
|
+
* ★ EVERY SURFACE GATES ON TRANSACTION SUCCESS FIRST (`StellarSettlementView.successful`). A failed
|
|
24
|
+
* Stellar transaction is still recorded in the ledger with its fee charged, and the operation's `to`
|
|
25
|
+
* destination lives in the transaction ENVELOPE — so the M-address stays readable on a `txFAILED` result.
|
|
26
|
+
* The confirm-not-recover posture does NOT mitigate that: it changes WHAT is proven (a prefix match rather
|
|
27
|
+
* than a recovered hash), never WHETHER settlement occurred. Without the gate, `verify` returns
|
|
28
|
+
* `confirmed: true` and `observe` returns `state: "settled"` for a transaction that moved nothing.
|
|
29
|
+
*/
|
|
30
|
+
import type {
|
|
31
|
+
BindingManifest,
|
|
32
|
+
Outcome,
|
|
33
|
+
Refusal,
|
|
34
|
+
} from "@integraledger/lcp-binding-core";
|
|
35
|
+
import {
|
|
36
|
+
deriveMuxId,
|
|
37
|
+
encodeMuxedAddress,
|
|
38
|
+
recoverMuxIdPrefix8,
|
|
39
|
+
verifyMuxedBinding,
|
|
40
|
+
} from "./mux.js";
|
|
41
|
+
|
|
42
|
+
/** A Stellar settlement reference — a transaction hash (64-hex, as Horizon reports it). */
|
|
43
|
+
export interface StellarSettlementRef {
|
|
44
|
+
txHash: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The minimal on-chain view of a settlement this binding cares about: the CAP-67 M-address the SAC
|
|
48
|
+
* `transfer` paid to, plus whether the transaction actually succeeded. A reader implementation decodes the
|
|
49
|
+
* destination from the tx's single `transfer(from, to, amount)` op. `null` when the tx has no muxed
|
|
50
|
+
* destination. */
|
|
51
|
+
export interface StellarSettlementView {
|
|
52
|
+
/** The `to` destination of the SAC transfer, a CAP-67 M-address (starts with `M`), or null if none. */
|
|
53
|
+
muxedDestination: string | null;
|
|
54
|
+
/**
|
|
55
|
+
* Horizon's `successful` field for the transaction. ONLY `true` is a settlement: a Stellar transaction
|
|
56
|
+
* that fails is still recorded in the ledger with its fee charged, and the operation's `to` destination
|
|
57
|
+
* lives in the transaction ENVELOPE — so the M-address stays readable on a `txFAILED` result. Confirming
|
|
58
|
+
* it would mint a settlement record out of a failure. Fail-closed: `false` AND absent are both refused,
|
|
59
|
+
* because a reader that supplied no outcome gave no evidence of success (mirrors binding-solana's
|
|
60
|
+
* `err === null` gate and binding-xrpl's validated/tesSUCCESS gate). A faithful Horizon reader always
|
|
61
|
+
* supplies this field; note `/accounts/{id}/transactions` omits failed transactions unless
|
|
62
|
+
* `include_failed=true`, while `/transactions/{hash}` returns them either way.
|
|
63
|
+
*/
|
|
64
|
+
successful?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Did this transaction actually settle? Pure, fail-closed — only an explicit `true` counts. Every surface
|
|
69
|
+
* in this binding (`verify`, `recover`, `observe`, `enumerate`) gates on this before honouring a mux id:
|
|
70
|
+
* the confirm-not-recover posture changes WHAT is proven (a prefix match rather than a recovered hash),
|
|
71
|
+
* never WHETHER settlement occurred.
|
|
72
|
+
*/
|
|
73
|
+
export function isSettledSuccessfully(view: StellarSettlementView): boolean {
|
|
74
|
+
return view.successful === true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Reads confirmed Stellar transactions — an injected port over Horizon / the SDK. */
|
|
78
|
+
export interface StellarReader {
|
|
79
|
+
settlementView(txHash: string): Promise<StellarSettlementView>;
|
|
80
|
+
/** Tx hashes touching an account's payment history (best-effort scan input for `enumerate`). */
|
|
81
|
+
transactionsFor(account: string, limit?: number): Promise<string[]>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Build the CAP-67 muxed M-address destination carrying `atrHash[:8]` for a seller base G-pubkey. This is
|
|
85
|
+
* the `payTo` the buyer's SAC transfer targets; the buyer's signature over that transfer welds the mux id
|
|
86
|
+
* (signature-grade). Throws on a malformed atrHash / base pubkey (fail-fast). */
|
|
87
|
+
export function buildMuxedDestination(
|
|
88
|
+
atrHash: string,
|
|
89
|
+
baseGPubkey: string,
|
|
90
|
+
): string {
|
|
91
|
+
return encodeMuxedAddress(baseGPubkey, deriveMuxId(atrHash));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function toHex(bytes: Uint8Array): string {
|
|
95
|
+
let out = "0x";
|
|
96
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The result of `recover`: explicitly PARTIAL. `muxIdPrefix8Hex` is `0x` + 16 hex chars = `atrHash[:8]`,
|
|
101
|
+
* NOT a full atrHash. `note` states the truncation so no caller mistakes it for a recovered hash. */
|
|
102
|
+
export interface StellarMuxPrefixRecovery {
|
|
103
|
+
/** `0x`-prefixed hex of the 8 on-chain mux bytes (`atrHash[:8]`). NOT the full 32-byte atrHash. */
|
|
104
|
+
muxIdPrefix8Hex: string;
|
|
105
|
+
partial: true;
|
|
106
|
+
note: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The result of `verify`: a confirmed prefix-8 match against a KNOWN atrHash. */
|
|
110
|
+
export interface StellarMuxConfirmation {
|
|
111
|
+
confirmed: true;
|
|
112
|
+
/** `0x`-prefixed hex of the on-chain mux id that matched `atrHash[:8]`. */
|
|
113
|
+
muxIdPrefix8Hex: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The Stellar rail's surface, and the one whose shape differs most from the others — because only 8 bytes
|
|
117
|
+
* ride on-chain. A CAP-67 mux id is 8 bytes, so the full atrHash does not fit, and `recover` therefore
|
|
118
|
+
* answers a PREFIX, never a hash. {@link StellarAdapter.verify} is the primary method here: you bring the
|
|
119
|
+
* atrHash from off-chain and the chain confirms its first 8 bytes match. A caller reaching for `recover`
|
|
120
|
+
* expecting the other rails' behaviour is the mistake this rail is shaped to make impossible. */
|
|
121
|
+
export interface StellarAdapter {
|
|
122
|
+
manifest: BindingManifest;
|
|
123
|
+
/** Build the muxed M-address destination the buyer transfers to (welds `atrHash[:8]`). */
|
|
124
|
+
propose(atrHash: string, baseGPubkey: string): string;
|
|
125
|
+
/**
|
|
126
|
+
* CONFIRM (primary): does the settlement's on-chain mux id equal the KNOWN atrHash's prefix-8? Returns a
|
|
127
|
+
* confirmation with the matched prefix, or a `verification-failure` Refusal on mismatch / no muxed
|
|
128
|
+
* destination. You supply the atrHash (from off-chain `extensions.legalContext.info`); the chain confirms it.
|
|
129
|
+
*/
|
|
130
|
+
verify(
|
|
131
|
+
atrHash: string,
|
|
132
|
+
ref: StellarSettlementRef,
|
|
133
|
+
reader: StellarReader,
|
|
134
|
+
): Promise<Outcome<StellarMuxConfirmation>>;
|
|
135
|
+
/**
|
|
136
|
+
* RECOVER — explicitly PARTIAL. Returns only the 8-byte on-chain mux prefix (`atrHash[:8]`), never a full
|
|
137
|
+
* atrHash (the chain does not carry one). A `verification-failure` Refusal when the tx has no muxed
|
|
138
|
+
* destination. To obtain the atrHash, use `verify` with the off-chain hash.
|
|
139
|
+
*/
|
|
140
|
+
recover(
|
|
141
|
+
ref: StellarSettlementRef,
|
|
142
|
+
reader: StellarReader,
|
|
143
|
+
): Promise<Outcome<StellarMuxPrefixRecovery>>;
|
|
144
|
+
/** Report the settled transition (the tx is confirmed and its destination is a CAP-67 muxed M-address). */
|
|
145
|
+
observe(
|
|
146
|
+
ref: StellarSettlementRef,
|
|
147
|
+
reader: StellarReader,
|
|
148
|
+
): Promise<Outcome<{ state: "settled"; muxIdPrefix8Hex: string }>>;
|
|
149
|
+
/**
|
|
150
|
+
* Best-effort account scan for settlements whose mux id matches `atrHash[:8]` (NOT a native index — see
|
|
151
|
+
* the manifest's `forwardIndexable: false`). Confirms, per tx, against the known atrHash.
|
|
152
|
+
*/
|
|
153
|
+
enumerate(
|
|
154
|
+
atrHash: string,
|
|
155
|
+
account: string,
|
|
156
|
+
reader: StellarReader,
|
|
157
|
+
limit?: number,
|
|
158
|
+
): Promise<StellarSettlementRef[]>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The `note` every partial recovery carries, exported because callers receive it and matching on a string
|
|
163
|
+
* they cannot import is guesswork. It names the off-chain slot the x402 placement actually writes —
|
|
164
|
+
* `extensions.legalContext.info` — and `rail-invariants` asserts that against the placement's own manifest,
|
|
165
|
+
* because a reader who follows this instruction to a slot nothing writes concludes the reference is absent.
|
|
166
|
+
*/
|
|
167
|
+
export const STELLAR_PREFIX_NOTE =
|
|
168
|
+
"PARTIAL: only atrHash[:8] rides in the CAP-67 mux id — this is the 8-byte on-chain prefix, NOT the full atrHash; obtain the full hash off-chain (extensions.legalContext.info) and use verify() to confirm the match";
|
|
169
|
+
|
|
170
|
+
/** Construct the Stellar adapter. **The manifest is injected, not baked in** — pass this package's own
|
|
171
|
+
* `STELLAR_MANIFEST`; a manifest whose `rail` is not `"stellar"` throws, because an adapter over another
|
|
172
|
+
* rail's manifest would publish that rail's claims as its own. This is the package's entry point. */
|
|
173
|
+
export function createStellarAdapter(
|
|
174
|
+
manifest: BindingManifest,
|
|
175
|
+
): StellarAdapter {
|
|
176
|
+
// Fail-fast: an adapter constructed over another rail's manifest would report that rail's claims as
|
|
177
|
+
// this one's. The EVM adapters bake their module const in; the injectable factories refuse instead.
|
|
178
|
+
// Stryker disable next-line all: the guard runs during test-module load (the repository's
|
|
179
|
+
// test suite constructs the adapter at describe scope), so its mutants are 'static' — outside the vitest
|
|
180
|
+
// runner's per-test attribution and unkillable by any test that in fact kills them behaviorally
|
|
181
|
+
// (each rail pins both arms: valid manifest constructs, wrong rail throws by message).
|
|
182
|
+
if (manifest.rail !== "stellar")
|
|
183
|
+
throw new Error(
|
|
184
|
+
`createStellarAdapter: manifest.rail "${manifest.rail}" is not "stellar"`,
|
|
185
|
+
);
|
|
186
|
+
// Closure helpers (not `this`) so the returned methods stay destructure-safe.
|
|
187
|
+
/** Every refusal on this rail is a verification failure, namespaced `stellar/…` (mirrors placement.ts). */
|
|
188
|
+
const refuse = (code: string, detail: string): Refusal => ({
|
|
189
|
+
refused: true,
|
|
190
|
+
haltClass: "verification-failure",
|
|
191
|
+
code: `stellar/${code}`,
|
|
192
|
+
detail,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/** The refusal a transaction that did not succeed earns — never a mismatch, never a missing destination. */
|
|
196
|
+
const unsuccessful = (txHash: string): Refusal =>
|
|
197
|
+
refuse(
|
|
198
|
+
"unsuccessful-transaction",
|
|
199
|
+
`transaction ${txHash} did not succeed (Horizon successful !== true) — its CAP-67 destination is not a settlement`,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const noMuxedDestination = (txHash: string): Refusal =>
|
|
203
|
+
refuse("no-muxed-destination", `no CAP-67 muxed destination in ${txHash}`);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The on-chain mux prefix of a SUCCESSFUL settlement. Returns the refusal reason as a discriminated
|
|
207
|
+
* value so `recover` and `observe` report WHY: a failed transaction is not "no destination".
|
|
208
|
+
*/
|
|
209
|
+
async function onChainPrefix(
|
|
210
|
+
ref: StellarSettlementRef,
|
|
211
|
+
reader: StellarReader,
|
|
212
|
+
): Promise<
|
|
213
|
+
| { prefix: Uint8Array; muxedDestination: string }
|
|
214
|
+
| { reason: "no-muxed-destination" }
|
|
215
|
+
| { reason: "unsuccessful" }
|
|
216
|
+
> {
|
|
217
|
+
const view = await reader.settlementView(ref.txHash);
|
|
218
|
+
if (view.muxedDestination === null)
|
|
219
|
+
return { reason: "no-muxed-destination" };
|
|
220
|
+
if (!isSettledSuccessfully(view)) return { reason: "unsuccessful" };
|
|
221
|
+
// DECODE BEFORE COMPARING. A destination that is not a valid M-address (a plain G-address, a truncated
|
|
222
|
+
// StrKey) carries no mux id at all — that is "no muxed destination", never "the wrong atrHash". Every
|
|
223
|
+
// surface routes through here so the four of them cannot disagree about what a given view means.
|
|
224
|
+
const prefix = recoverMuxIdPrefix8(view.muxedDestination);
|
|
225
|
+
if (prefix === null) return { reason: "no-muxed-destination" };
|
|
226
|
+
return { prefix, muxedDestination: view.muxedDestination };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The refusal for whichever reason `onChainPrefix` gave — shared by all four surfaces. */
|
|
230
|
+
const refusalFor = (
|
|
231
|
+
reason: "no-muxed-destination" | "unsuccessful",
|
|
232
|
+
txHash: string,
|
|
233
|
+
): Refusal =>
|
|
234
|
+
reason === "unsuccessful"
|
|
235
|
+
? unsuccessful(txHash)
|
|
236
|
+
: noMuxedDestination(txHash);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
manifest,
|
|
240
|
+
|
|
241
|
+
propose(atrHash: string, baseGPubkey: string): string {
|
|
242
|
+
return buildMuxedDestination(atrHash, baseGPubkey);
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
async verify(
|
|
246
|
+
atrHash: string,
|
|
247
|
+
ref: StellarSettlementRef,
|
|
248
|
+
reader: StellarReader,
|
|
249
|
+
): Promise<Outcome<StellarMuxConfirmation>> {
|
|
250
|
+
// ★ The success gate and the decode both run BEFORE the prefix match: a failed transaction is not
|
|
251
|
+
// "the wrong ATR" (its envelope still carries the M-address), and an undecodable destination is not
|
|
252
|
+
// one either. `mux-prefix-mismatch` therefore means strictly: there WAS a mux id, and it was wrong.
|
|
253
|
+
const found = await onChainPrefix(ref, reader);
|
|
254
|
+
if ("reason" in found) return refusalFor(found.reason, ref.txHash);
|
|
255
|
+
if (!verifyMuxedBinding({ muxedM: found.muxedDestination, atrHash }))
|
|
256
|
+
return refuse(
|
|
257
|
+
"mux-prefix-mismatch",
|
|
258
|
+
`on-chain mux id != atrHash[:8] for ${ref.txHash}`,
|
|
259
|
+
);
|
|
260
|
+
return {
|
|
261
|
+
ok: true,
|
|
262
|
+
value: { confirmed: true, muxIdPrefix8Hex: toHex(found.prefix) },
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
async recover(
|
|
267
|
+
ref: StellarSettlementRef,
|
|
268
|
+
reader: StellarReader,
|
|
269
|
+
): Promise<Outcome<StellarMuxPrefixRecovery>> {
|
|
270
|
+
const found = await onChainPrefix(ref, reader);
|
|
271
|
+
if ("reason" in found) return refusalFor(found.reason, ref.txHash);
|
|
272
|
+
return {
|
|
273
|
+
ok: true,
|
|
274
|
+
value: {
|
|
275
|
+
muxIdPrefix8Hex: toHex(found.prefix),
|
|
276
|
+
partial: true,
|
|
277
|
+
note: STELLAR_PREFIX_NOTE,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
async observe(
|
|
283
|
+
ref: StellarSettlementRef,
|
|
284
|
+
reader: StellarReader,
|
|
285
|
+
): Promise<Outcome<{ state: "settled"; muxIdPrefix8Hex: string }>> {
|
|
286
|
+
const found = await onChainPrefix(ref, reader);
|
|
287
|
+
if ("reason" in found) return refusalFor(found.reason, ref.txHash);
|
|
288
|
+
return {
|
|
289
|
+
ok: true,
|
|
290
|
+
value: { state: "settled", muxIdPrefix8Hex: toHex(found.prefix) },
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
async enumerate(
|
|
295
|
+
atrHash: string,
|
|
296
|
+
account: string,
|
|
297
|
+
reader: StellarReader,
|
|
298
|
+
limit?: number,
|
|
299
|
+
): Promise<StellarSettlementRef[]> {
|
|
300
|
+
const hashes = await reader.transactionsFor(account, limit);
|
|
301
|
+
const out: StellarSettlementRef[] = [];
|
|
302
|
+
for (const txHash of hashes) {
|
|
303
|
+
const view = await reader.settlementView(txHash);
|
|
304
|
+
// A failed transaction's envelope still carries the M-address — the scan must skip it, or a
|
|
305
|
+
// ledger full of failures enumerates as a ledger full of settlements.
|
|
306
|
+
if (
|
|
307
|
+
view.muxedDestination !== null &&
|
|
308
|
+
isSettledSuccessfully(view) &&
|
|
309
|
+
verifyMuxedBinding({ muxedM: view.muxedDestination, atrHash })
|
|
310
|
+
)
|
|
311
|
+
out.push({ txHash });
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stellar network constants for the LCP CAP-67 muxed-address binding. The binding rides
|
|
3
|
+
* `mux_id = atrHash[:8]` inside a CAP-67 muxed M-address that the seller advertises as `payTo`; the buyer
|
|
4
|
+
* signs a Soroban SAC `transfer(from, to, amount)` whose `to` is that M-address, so the buyer's signature
|
|
5
|
+
* commits the 8-byte prefix atomically with the transfer (canonical LCP §8.3.1 Native Field per the LCP
|
|
6
|
+
* per-chain binding table — the CAP-67 muxed id, not an overlay contract).
|
|
7
|
+
*
|
|
8
|
+
* ★ ONLY 8 bytes ride on-chain. A CAP-67 muxed id is exactly 8 bytes, so the FULL 32-byte atrHash does not
|
|
9
|
+
* fit — the on-chain artifact confirms `atrHash[:8]`, it does not recover the whole hash (that comes from
|
|
10
|
+
* off-chain `extensions.legalContext.info`). See `mux.ts` and the manifest for how the surface stays honest.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The single canonical mux-id derivation scheme LCP-on-Stellar commits to: `mux_id = atrHash[:8]`. The
|
|
14
|
+
* prefix is deliberately public (project_stellar_mux_prefix8_audit) — any observer holding the atrHash can
|
|
15
|
+
* confirm the on-chain mux id matches, which IS the audit-trail property the protocol wants. No HMAC /
|
|
16
|
+
* per-seller-key variant is wired (that would trade auditability for unlinkability, which is not wanted). */
|
|
17
|
+
export const MUX_SCHEME = "atrHash-prefix-8" as const;
|
|
18
|
+
|
|
19
|
+
/** The mux id is the first 8 bytes of the 32-byte atrHash. */
|
|
20
|
+
export const MUX_ID_BYTES = 8;
|
|
21
|
+
|
|
22
|
+
/** The two networks. Stellar's mainnet is `pubnet`, not `"mainnet"` — the SDK's own name, kept. */
|
|
23
|
+
export type StellarNetwork = "testnet" | "pubnet";
|
|
24
|
+
|
|
25
|
+
/** Per-network constants. Two endpoints, because the write and the read use different ones: the transfer
|
|
26
|
+
* is a Soroban SAC call (`sorobanRpcUrl`, for the caller that builds and submits it) while recovery reads
|
|
27
|
+
* the transaction envelope back through Horizon (`horizonUrl`). This package itself performs no HTTP —
|
|
28
|
+
* `sorobanRpcUrl` is carried for the caller's benefit and nothing here consumes it. */
|
|
29
|
+
export interface StellarNetworkConfig {
|
|
30
|
+
network: StellarNetwork;
|
|
31
|
+
/** Network passphrase used for tx signing / envelope decoding. */
|
|
32
|
+
networkPassphrase: string;
|
|
33
|
+
/** Horizon REST base URL. */
|
|
34
|
+
horizonUrl: string;
|
|
35
|
+
/** Soroban RPC URL. */
|
|
36
|
+
sorobanRpcUrl: string;
|
|
37
|
+
/** Circle-issued USDC Soroban Asset Contract id on this network. */
|
|
38
|
+
usdcSacContractId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Stellar network passphrases (from the SDK's `Networks`; inlined here so constants stays SDK-free). */
|
|
42
|
+
export const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015";
|
|
43
|
+
/** The pubnet passphrase. Note the spacing — ` ; ` with spaces either side is part of the string, and the
|
|
44
|
+
* passphrase is hashed into every signature, so a single wrong character produces a transaction that
|
|
45
|
+
* verifies against nothing. */
|
|
46
|
+
export const PUBNET_PASSPHRASE =
|
|
47
|
+
"Public Global Stellar Network ; September 2015";
|
|
48
|
+
|
|
49
|
+
const TESTNET: StellarNetworkConfig = {
|
|
50
|
+
network: "testnet",
|
|
51
|
+
networkPassphrase: TESTNET_PASSPHRASE,
|
|
52
|
+
horizonUrl: "https://horizon-testnet.stellar.org",
|
|
53
|
+
sorobanRpcUrl: "https://soroban-testnet.stellar.org",
|
|
54
|
+
usdcSacContractId: "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const PUBNET: StellarNetworkConfig = {
|
|
58
|
+
network: "pubnet",
|
|
59
|
+
networkPassphrase: PUBNET_PASSPHRASE,
|
|
60
|
+
horizonUrl: "https://horizon.stellar.org",
|
|
61
|
+
sorobanRpcUrl: "https://mainnet.sorobanrpc.com",
|
|
62
|
+
usdcSacContractId: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The constants for one network. Both are usable — the CAP-67 muxed id is a ledger primitive, so nothing
|
|
66
|
+
* has to be deployed first. */
|
|
67
|
+
export function getStellarConfig(
|
|
68
|
+
network: StellarNetwork,
|
|
69
|
+
): StellarNetworkConfig {
|
|
70
|
+
return network === "testnet" ? TESTNET : PUBNET;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** USDC on Stellar has 7 decimals — 1 USDC = 10_000_000 base units. */
|
|
74
|
+
export const USDC_DECIMALS = 7;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export {
|
|
2
|
+
buildMuxedDestination,
|
|
3
|
+
createStellarAdapter,
|
|
4
|
+
isSettledSuccessfully,
|
|
5
|
+
STELLAR_PREFIX_NOTE,
|
|
6
|
+
type StellarAdapter,
|
|
7
|
+
type StellarMuxConfirmation,
|
|
8
|
+
type StellarMuxPrefixRecovery,
|
|
9
|
+
type StellarReader,
|
|
10
|
+
type StellarSettlementRef,
|
|
11
|
+
type StellarSettlementView,
|
|
12
|
+
} from "./adapter.js";
|
|
13
|
+
export {
|
|
14
|
+
getStellarConfig,
|
|
15
|
+
MUX_ID_BYTES,
|
|
16
|
+
MUX_SCHEME,
|
|
17
|
+
PUBNET_PASSPHRASE,
|
|
18
|
+
type StellarNetwork,
|
|
19
|
+
type StellarNetworkConfig,
|
|
20
|
+
TESTNET_PASSPHRASE,
|
|
21
|
+
USDC_DECIMALS,
|
|
22
|
+
} from "./constants.js";
|
|
23
|
+
export { STELLAR_MANIFEST } from "./manifest.js";
|
|
24
|
+
export {
|
|
25
|
+
type DecodedMuxedAddress,
|
|
26
|
+
decodeMuxedAddress,
|
|
27
|
+
deriveMuxId,
|
|
28
|
+
encodeMuxedAddress,
|
|
29
|
+
recoverMuxIdPrefix8,
|
|
30
|
+
verifyMuxedBinding,
|
|
31
|
+
} from "./mux.js";
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { BindingManifest } from "@integraledger/lcp-binding-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Stellar CAP-67 muxed-address binding manifest.
|
|
5
|
+
*
|
|
6
|
+
* **pattern = "native-field"** — the binding rides `mux_id` inside a CAP-67 muxed M-address (an existing
|
|
7
|
+
* Stellar account primitive), not an overlay contract (canonical LCP §8.3.1 per the LCP per-chain table). The
|
|
8
|
+
* buyer signs the Soroban SAC `transfer` whose `to` is that M-address, so the weld is **signature-grade** —
|
|
9
|
+
* the payer's signature commits the mux id atomically with the transfer.
|
|
10
|
+
*
|
|
11
|
+
* **★ recovery.zeroPartyRecoverable = false AND recovery.forwardIndexable = false — deliberately honest.**
|
|
12
|
+
* A CAP-67 muxed id is exactly **8 bytes**, so only `atrHash[:8]` rides on-chain; the FULL 32-byte atrHash
|
|
13
|
+
* does NOT fit. From a settlement alone you can recover only the 8-byte prefix, which is not the atrHash —
|
|
14
|
+
* so the binding CONFIRMS a known atrHash's prefix-8 against the on-chain mux id (`verifyMuxedBinding`), it
|
|
15
|
+
* does NOT reconstruct the full hash from the chain (that comes from off-chain `extensions.legalContext.info`). Hence
|
|
16
|
+
* `zeroPartyRecoverable: false` (a party with only the settlement cannot derive the atrHash) and
|
|
17
|
+
* `forwardIndexable: false` (no native index over mux prefixes; `enumerate` is a best-effort account scan,
|
|
18
|
+
* `indexing: "account-scan:mux-prefix8"`). The prefix-8 truncation is stated in `finality.note` too.
|
|
19
|
+
*
|
|
20
|
+
* **NO `protocol`, and here is precisely what it is neutral OF.** This binding welds into the destination
|
|
21
|
+
* of a CLASSIC Stellar payment. x402's `exact` scheme for Stellar (read 2026-08-08) is a different flow
|
|
22
|
+
* entirely: "This spec covers SEP-41-compliant Soroban tokens **only**. Classic Stellar assets are not
|
|
23
|
+
* supported", and the payment is an `invokeHostFunction` calling `transfer(from, to, amount)` where
|
|
24
|
+
* "Argument 1 (to): MUST equal `requirements.payTo` exactly".
|
|
25
|
+
*
|
|
26
|
+
* A muxed destination cannot appear in that call — there is no classic payment to mux, and an `M…` address
|
|
27
|
+
* would not equal `payTo` exactly. So a deployment settling through an x402-Stellar facilitator does not
|
|
28
|
+
* get this carrier. That is a scoping fact, not a defect: the binding has never declared `protocol`, and
|
|
29
|
+
* the mux weld is correct on the rail it targets. Stated here so the absence is a measured claim rather
|
|
30
|
+
* than an unexamined default.
|
|
31
|
+
*/
|
|
32
|
+
export const STELLAR_MANIFEST: BindingManifest = {
|
|
33
|
+
rail: "stellar",
|
|
34
|
+
pattern: "native-field",
|
|
35
|
+
nativeField: "cap67-mux-id",
|
|
36
|
+
recovery: {
|
|
37
|
+
onChain: true,
|
|
38
|
+
zeroPartyRecoverable: false,
|
|
39
|
+
forwardIndexable: false,
|
|
40
|
+
},
|
|
41
|
+
assetBinding: "none", // envelope weld — the mux id carries atrHash[:8] only; the asset never appears in the view
|
|
42
|
+
successGate: "raw-field", // Horizon successful; a txFAILED tx keeps its M-address in the envelope
|
|
43
|
+
indexing: "account-scan:mux-prefix8",
|
|
44
|
+
finality: {
|
|
45
|
+
reversible: false,
|
|
46
|
+
note: "final on ledger close (Soroban SAC transfer) — no on-rail reversal; only atrHash[:8] rides in the CAP-67 mux id, so settlement alone confirms the prefix-8 match but does NOT recover the full hash (the full atrHash comes from off-chain extensions.legalContext.info); recourse is the record's elected forum (PAY-3/RCS-5), never dispute resolution",
|
|
47
|
+
},
|
|
48
|
+
weldGrades: { "cap67-mux": "signature" },
|
|
49
|
+
lifecycleStates: ["proposed", "settled"],
|
|
50
|
+
};
|
package/src/mux.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CAP-67 muxed-address atrHash codec — the honest heart of the Stellar binding. Ported from the proven
|
|
3
|
+
* reference mux codec, restricted to the single canonical scheme `mux_id = atrHash[:8]`
|
|
4
|
+
* (project_stellar_mux_prefix8_audit — the HMAC variant is deliberately NOT ported; it trades the wanted
|
|
5
|
+
* auditability for unwanted per-seller unlinkability).
|
|
6
|
+
*
|
|
7
|
+
* ★ CONFIRM-NOT-RECOVER. A CAP-67 muxed id is exactly 8 bytes. `mux_id = atrHash[:8]` therefore carries
|
|
8
|
+
* only the FIRST 8 bytes of the 32-byte atrHash on-chain — the full hash does NOT fit. So this module can:
|
|
9
|
+
* - DERIVE the 8-byte prefix from a known atrHash (`deriveMuxId`);
|
|
10
|
+
* - ENCODE/DECODE a CAP-67 M-address (`encodeMuxedAddress` / `decodeMuxedAddress`), round-tripping the
|
|
11
|
+
* 8-byte id;
|
|
12
|
+
* - CONFIRM that a known atrHash's prefix-8 equals an M-address's on-chain mux id (`verifyMuxedBinding`).
|
|
13
|
+
* It CANNOT recover the full atrHash from a settlement alone — the settlement holds 8 bytes, not 32. The
|
|
14
|
+
* full atrHash is obtained off-chain from `extensions.legalContext.info`; the on-chain mux id is the public match
|
|
15
|
+
* check, not the source of the hash. `recoverMuxIdPrefix8` returns exactly those 8 bytes, labelled as a
|
|
16
|
+
* prefix — never a `0x…64hex` value that would falsely read as a full atrHash.
|
|
17
|
+
*/
|
|
18
|
+
import { hexToBytes } from "@integraledger/lcp-binding-core";
|
|
19
|
+
import {
|
|
20
|
+
bytesEqual,
|
|
21
|
+
canonicalAtrHash,
|
|
22
|
+
isAtrHash,
|
|
23
|
+
} from "@integraledger/lcp-kernel";
|
|
24
|
+
import { StrKey } from "@stellar/stellar-sdk";
|
|
25
|
+
import { MUX_ID_BYTES } from "./constants.js";
|
|
26
|
+
|
|
27
|
+
/** Derive the 8-byte mux id from a known atrHash: `mux_id = atrHash[:8]`. Throws on a malformed atrHash
|
|
28
|
+
* (fail-fast). Pure — no SDK. */
|
|
29
|
+
export function deriveMuxId(atrHash: string): Uint8Array {
|
|
30
|
+
return hexToBytes(canonicalAtrHash(atrHash, "deriveMuxId")).slice(
|
|
31
|
+
0,
|
|
32
|
+
MUX_ID_BYTES,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Encode a base G-pubkey (StrKey) + 8-byte mux id as a 69-character CAP-67 M-address. Throws on a
|
|
37
|
+
* wrong-length mux id (fail-fast). */
|
|
38
|
+
export function encodeMuxedAddress(
|
|
39
|
+
basePubkey: string,
|
|
40
|
+
muxId: Uint8Array,
|
|
41
|
+
): string {
|
|
42
|
+
if (muxId.length !== MUX_ID_BYTES)
|
|
43
|
+
throw new Error(
|
|
44
|
+
`encodeMuxedAddress: mux id must be exactly ${MUX_ID_BYTES} bytes, got ${muxId.length}`,
|
|
45
|
+
);
|
|
46
|
+
const baseBytes = StrKey.decodeEd25519PublicKey(basePubkey);
|
|
47
|
+
const combined = new Uint8Array(32 + MUX_ID_BYTES);
|
|
48
|
+
combined.set(
|
|
49
|
+
new Uint8Array(
|
|
50
|
+
baseBytes.buffer,
|
|
51
|
+
baseBytes.byteOffset,
|
|
52
|
+
baseBytes.byteLength,
|
|
53
|
+
),
|
|
54
|
+
0,
|
|
55
|
+
);
|
|
56
|
+
combined.set(muxId, 32);
|
|
57
|
+
return StrKey.encodeMed25519PublicKey(Buffer.from(combined));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The decoded parts of a CAP-67 M-address: the base G-pubkey and the 8-byte mux id prefix. */
|
|
61
|
+
export interface DecodedMuxedAddress {
|
|
62
|
+
basePubkey: string;
|
|
63
|
+
muxId: Uint8Array;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Decode a CAP-67 M-address into its base G-pubkey and 8-byte mux id. Returns `null` for anything that is
|
|
67
|
+
* not a valid M-address (so a settlement scan skips a non-muxed destination without treating it as an
|
|
68
|
+
* error — mirrors the codec's null-vs-throw convention). */
|
|
69
|
+
export function decodeMuxedAddress(mAddr: string): DecodedMuxedAddress | null {
|
|
70
|
+
if (!StrKey.isValidMed25519PublicKey(mAddr)) return null;
|
|
71
|
+
const bytes = StrKey.decodeMed25519PublicKey(mAddr);
|
|
72
|
+
const muxId = new Uint8Array(MUX_ID_BYTES);
|
|
73
|
+
muxId.set(bytes.subarray(32, 32 + MUX_ID_BYTES));
|
|
74
|
+
return {
|
|
75
|
+
basePubkey: StrKey.encodeEd25519PublicKey(bytes.subarray(0, 32)),
|
|
76
|
+
muxId,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Recover the 8-byte mux id PREFIX from a settlement's CAP-67 M-address. This is the ONLY thing on-chain —
|
|
81
|
+
* it is `atrHash[:8]`, NOT the full atrHash. Returned as raw 8 bytes (never a `0x…64hex` value that would
|
|
82
|
+
* read as a full hash). `null` if the address is not a valid M-address. */
|
|
83
|
+
export function recoverMuxIdPrefix8(mAddr: string): Uint8Array | null {
|
|
84
|
+
const decoded = decodeMuxedAddress(mAddr);
|
|
85
|
+
return decoded === null ? null : decoded.muxId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** CONFIRM (not recover): does a known atrHash's prefix-8 equal the mux id carried by `mAddr`? This is the
|
|
89
|
+
* primary honest surface — an observer holding the atrHash checks the on-chain destination binds to it.
|
|
90
|
+
* `false` for a non-M-address or a malformed atrHash (never throws on those; fail-CLOSED). */
|
|
91
|
+
export function verifyMuxedBinding(inputs: {
|
|
92
|
+
muxedM: string;
|
|
93
|
+
atrHash: string;
|
|
94
|
+
}): boolean {
|
|
95
|
+
if (!isAtrHash(inputs.atrHash)) return false;
|
|
96
|
+
const onChain = recoverMuxIdPrefix8(inputs.muxedM);
|
|
97
|
+
if (onChain === null) return false;
|
|
98
|
+
return bytesEqual(onChain, deriveMuxId(inputs.atrHash));
|
|
99
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient types for `@stellar/js-xdr` — an UNTYPED transitive of `@stellar/stellar-sdk` (its `lib/xdr.js`
|
|
3
|
+
* ships no `.d.ts` and no `@types/stellar__js-xdr` exists on the registry). Because `skipLibCheck` is
|
|
4
|
+
* `false` workspace-wide, the SDK's own `.d.ts` files (`base/index.d.ts`, `base/jsxdr.d.ts`,
|
|
5
|
+
* `base/numbers/*.d.ts`) fail with TS7016 unless this module is declared. These declarations mirror the
|
|
6
|
+
* four symbols the SDK re-exports; the adapter never touches js-xdr directly (it uses StrKey /
|
|
7
|
+
* MuxedAccount / Horizon / typed classes only), so the surface here is intentionally minimal — enough to
|
|
8
|
+
* satisfy the SDK's re-exports without leaking an `any`-typed value into this package's exports.
|
|
9
|
+
*/
|
|
10
|
+
declare module "@stellar/js-xdr" {
|
|
11
|
+
export class XdrWriter {
|
|
12
|
+
constructor(buffer?: Uint8Array);
|
|
13
|
+
toArray(): Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
export class XdrReader {
|
|
16
|
+
constructor(buffer: Uint8Array);
|
|
17
|
+
read(count: number): Uint8Array;
|
|
18
|
+
}
|
|
19
|
+
/** Base class the SDK's fixed-width XDR integers (Int128/UInt128/Int256/UInt256, XdrLargeInt.int) extend. */
|
|
20
|
+
export class LargeInt {
|
|
21
|
+
constructor(...parts: Array<bigint | number | string>);
|
|
22
|
+
toBigInt(): bigint;
|
|
23
|
+
toString(): string;
|
|
24
|
+
}
|
|
25
|
+
export class UnsignedHyper extends LargeInt {
|
|
26
|
+
static fromString(value: string): UnsignedHyper;
|
|
27
|
+
}
|
|
28
|
+
export class Hyper extends LargeInt {
|
|
29
|
+
static fromString(value: string): Hyper;
|
|
30
|
+
}
|
|
31
|
+
}
|