@0xtrails/svm 1.0.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/LICENSE +202 -0
- package/README.md +175 -0
- package/dist/index.d.ts +179 -0
- package/dist/index.js +962 -0
- package/package.json +62 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { EDGE_RAIL, EdgePluginContext, createSolanaEdgeDestinationConfig, createSolanaEdgeOriginConfig } from "0xtrails/plugins/edge";
|
|
4
|
+
import { SOLANA_EDGE_CHAIN_ID, SOLANA_MAINNET_CHAIN_ID, getEdgeSolanaRpcUrls, getSolanaTokens, isSolanaAddress, isSolanaChainId, resolveSolanaMainnetJsonRpcUrls, setSolanaSupportEnabled, useTrails } from "0xtrails";
|
|
5
|
+
import { UnifiedWalletProvider } from "0xtrails/widget";
|
|
6
|
+
import { AccountRole, address, appendTransactionMessageInstructions, compileTransaction, createTransactionMessage, getBase64Decoder, getBase64Encoder, getTransactionDecoder, getTransactionEncoder, setTransactionMessageFeePayer, setTransactionMessageLifetimeUsingBlockhash } from "@solana/kit";
|
|
7
|
+
import bs58 from "bs58";
|
|
8
|
+
import { hexToBytes } from "viem";
|
|
9
|
+
import { getWallets } from "@wallet-standard/app";
|
|
10
|
+
import { jsx } from "react/jsx-runtime";
|
|
11
|
+
//#region src/edge/executeRelaySolanaQuote.ts
|
|
12
|
+
var ErrEVMStepInSolanaQuote = class extends Error {
|
|
13
|
+
constructor() {
|
|
14
|
+
super("EVM transaction step in Solana provider quote");
|
|
15
|
+
this.name = "ErrEVMStepInSolanaQuote";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
function normalizeRelayStatus(status) {
|
|
19
|
+
return (status ?? "").trim().toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
function providerQuoteToJson(providerQuote) {
|
|
22
|
+
if (providerQuote == null) throw new Error("provider quote is nil");
|
|
23
|
+
if (typeof providerQuote === "string") return providerQuote;
|
|
24
|
+
return JSON.stringify(providerQuote);
|
|
25
|
+
}
|
|
26
|
+
function instructionsEmptyOrNull(raw) {
|
|
27
|
+
if (raw == null) return true;
|
|
28
|
+
if (typeof raw === "string") {
|
|
29
|
+
const s = raw.trim();
|
|
30
|
+
if (s === "" || s === "null") return true;
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(raw)) return raw.length === 0;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
function relayInstructionDataLooksHex(s) {
|
|
36
|
+
if (s.length % 2 !== 0) return false;
|
|
37
|
+
for (let i = 0; i < s.length; i++) {
|
|
38
|
+
const c = s.charCodeAt(i);
|
|
39
|
+
if (!(c >= 48 && c <= 57 || c >= 97 && c <= 102 || c >= 65 && c <= 70)) return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
function base64ToBytes(s) {
|
|
44
|
+
return new Uint8Array(getBase64Encoder().encode(s));
|
|
45
|
+
}
|
|
46
|
+
function decodeRelayInstructionDataSync(s) {
|
|
47
|
+
const t = s.trim();
|
|
48
|
+
if (t === "") return /* @__PURE__ */ new Uint8Array(0);
|
|
49
|
+
let h = t;
|
|
50
|
+
if (h.startsWith("0x") || h.startsWith("0X")) h = h.slice(2);
|
|
51
|
+
if (h.length % 2 === 0 && relayInstructionDataLooksHex(h)) return hexToBytes(`0x${h}`);
|
|
52
|
+
try {
|
|
53
|
+
return bs58.decode(t);
|
|
54
|
+
} catch {}
|
|
55
|
+
return base64ToBytes(t);
|
|
56
|
+
}
|
|
57
|
+
function parseSolanaStepData(data) {
|
|
58
|
+
if (data == null || typeof data !== "object") return { kind: "skip" };
|
|
59
|
+
const m = data;
|
|
60
|
+
const toStr = typeof m.to === "string" ? m.to.trim() : "";
|
|
61
|
+
const hasChainId = "chainId" in m;
|
|
62
|
+
const evmLike = toStr.startsWith("0x") && hasChainId;
|
|
63
|
+
const rawInst = m.instructions;
|
|
64
|
+
const hasInstKey = "instructions" in m;
|
|
65
|
+
if (evmLike && (!hasInstKey || instructionsEmptyOrNull(rawInst))) throw new ErrEVMStepInSolanaQuote();
|
|
66
|
+
const txB64 = typeof m.transaction === "string" ? m.transaction.trim() : "";
|
|
67
|
+
if (txB64 !== "") return {
|
|
68
|
+
kind: "wire",
|
|
69
|
+
b64: txB64
|
|
70
|
+
};
|
|
71
|
+
if (!hasInstKey || instructionsEmptyOrNull(rawInst)) return { kind: "skip" };
|
|
72
|
+
const instList = rawInst;
|
|
73
|
+
if (!Array.isArray(instList) || instList.length === 0) return { kind: "skip" };
|
|
74
|
+
const feePayerStr = typeof m.feePayer === "string" ? m.feePayer.trim() : "";
|
|
75
|
+
let feePayer = null;
|
|
76
|
+
if (feePayerStr) try {
|
|
77
|
+
feePayer = address(feePayerStr);
|
|
78
|
+
} catch {
|
|
79
|
+
feePayer = null;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
kind: "instructions",
|
|
83
|
+
feePayer,
|
|
84
|
+
list: instList
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function svmInstructionToInstruction(inst) {
|
|
88
|
+
const programAddress = address(inst.programId.trim());
|
|
89
|
+
const data = inst.data?.trim() ? decodeRelayInstructionDataSync(inst.data) : /* @__PURE__ */ new Uint8Array(0);
|
|
90
|
+
return {
|
|
91
|
+
accounts: inst.keys.map((k, j) => {
|
|
92
|
+
const pks = (k.pubkey ?? k.publicKey ?? "").trim();
|
|
93
|
+
if (!pks) throw new Error(`instruction account ${j}: missing pubkey`);
|
|
94
|
+
return {
|
|
95
|
+
address: address(pks),
|
|
96
|
+
role: k.isSigner ? k.isWritable || k.writable ? AccountRole.WRITABLE_SIGNER : AccountRole.READONLY_SIGNER : k.isWritable || k.writable ? AccountRole.WRITABLE : AccountRole.READONLY
|
|
97
|
+
};
|
|
98
|
+
}),
|
|
99
|
+
data,
|
|
100
|
+
programAddress
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async function callSolanaRpc({ rpcUrl, method, params, parseResult }) {
|
|
104
|
+
const response = await fetch(rpcUrl, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/json" },
|
|
107
|
+
body: JSON.stringify({
|
|
108
|
+
jsonrpc: "2.0",
|
|
109
|
+
id: Date.now(),
|
|
110
|
+
method,
|
|
111
|
+
params
|
|
112
|
+
})
|
|
113
|
+
});
|
|
114
|
+
if (!response.ok) throw new Error(`Solana RPC ${method} failed: HTTP ${response.status}`);
|
|
115
|
+
const payload = await response.json();
|
|
116
|
+
if (payload.error) throw new Error(payload.error.message || "Solana RPC returned an error");
|
|
117
|
+
const parsedResult = parseResult(payload.result);
|
|
118
|
+
if (parsedResult === void 0) throw new Error(`Invalid Solana RPC ${method} response`);
|
|
119
|
+
return parsedResult;
|
|
120
|
+
}
|
|
121
|
+
async function getLatestBlockhash(rpcUrl) {
|
|
122
|
+
return callSolanaRpc({
|
|
123
|
+
rpcUrl,
|
|
124
|
+
method: "getLatestBlockhash",
|
|
125
|
+
params: [{ commitment: "finalized" }],
|
|
126
|
+
parseResult: (result) => {
|
|
127
|
+
const value = result?.value;
|
|
128
|
+
return typeof value?.blockhash === "string" && typeof value.lastValidBlockHeight === "number" ? {
|
|
129
|
+
blockhash: value.blockhash,
|
|
130
|
+
lastValidBlockHeight: value.lastValidBlockHeight
|
|
131
|
+
} : void 0;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async function sendRawTransaction(rpcUrl, serializedTransaction, options) {
|
|
136
|
+
const config = {
|
|
137
|
+
...typeof options === "object" && options !== null ? options : {},
|
|
138
|
+
encoding: "base64"
|
|
139
|
+
};
|
|
140
|
+
return callSolanaRpc({
|
|
141
|
+
rpcUrl,
|
|
142
|
+
method: "sendTransaction",
|
|
143
|
+
params: [getBase64Decoder().decode(serializedTransaction), config],
|
|
144
|
+
parseResult: (result) => typeof result === "string" ? result : void 0
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
async function signTransactionBytes(tx, signSerializedTransaction) {
|
|
148
|
+
return signSerializedTransaction(new Uint8Array(getTransactionEncoder().encode(tx)));
|
|
149
|
+
}
|
|
150
|
+
async function callSolanaRpcWithFallback$1(rpcUrls, method, call) {
|
|
151
|
+
const errors = [];
|
|
152
|
+
for (const rpcUrl of rpcUrls) try {
|
|
153
|
+
return await call(rpcUrl);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const message = error instanceof Error ? error.message : "unknown Solana RPC error";
|
|
156
|
+
errors.push(`${rpcUrl}: ${message}`);
|
|
157
|
+
console.warn("[trails-sdk] Solana RPC failed; trying fallback", {
|
|
158
|
+
rpcUrl,
|
|
159
|
+
method,
|
|
160
|
+
error: message
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
throw new Error(`Solana RPC ${method} failed on all configured endpoints: ${errors.join(" | ")}`);
|
|
164
|
+
}
|
|
165
|
+
async function getLatestBlockhashWithFallback(walletConnection, rpcUrls) {
|
|
166
|
+
if (walletConnection?.getLatestBlockhash) try {
|
|
167
|
+
return await walletConnection.getLatestBlockhash("finalized");
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const message = error instanceof Error ? error.message : "unknown Solana RPC error";
|
|
170
|
+
console.warn("[trails-sdk] Solana wallet RPC failed; trying fallback", {
|
|
171
|
+
method: "getLatestBlockhash",
|
|
172
|
+
error: message
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return callSolanaRpcWithFallback$1(rpcUrls, "getLatestBlockhash", (rpcUrl) => getLatestBlockhash(rpcUrl));
|
|
176
|
+
}
|
|
177
|
+
async function sendRawTransactionWithFallback(walletConnection, rpcUrls, serializedTransaction, options) {
|
|
178
|
+
if (walletConnection) try {
|
|
179
|
+
return await walletConnection.sendRawTransaction(serializedTransaction, options);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const message = error instanceof Error ? error.message : "unknown Solana RPC error";
|
|
182
|
+
console.warn("[trails-sdk] Solana wallet RPC failed; trying fallback", {
|
|
183
|
+
method: "sendRawTransaction",
|
|
184
|
+
error: message
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return callSolanaRpcWithFallback$1(rpcUrls, "sendRawTransaction", (rpcUrl) => sendRawTransaction(rpcUrl, serializedTransaction, options));
|
|
188
|
+
}
|
|
189
|
+
async function fetchSignatureStatus(rpcUrls, sig) {
|
|
190
|
+
return callSolanaRpcWithFallback$1(rpcUrls, "getSignatureStatuses", (rpcUrl) => callSolanaRpc({
|
|
191
|
+
rpcUrl,
|
|
192
|
+
method: "getSignatureStatuses",
|
|
193
|
+
params: [[sig]],
|
|
194
|
+
parseResult: (result) => {
|
|
195
|
+
return (result?.value)?.[0] ?? null;
|
|
196
|
+
}
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
/** Poll getSignatureStatuses (HTTP) instead of WebSocket confirmTransaction to avoid 403/timeout. */
|
|
200
|
+
async function pollConfirmation(rpcUrls, sig, timeoutMs = 12e4) {
|
|
201
|
+
const deadline = Date.now() + timeoutMs;
|
|
202
|
+
while (Date.now() < deadline) {
|
|
203
|
+
const status = await fetchSignatureStatus(rpcUrls, sig);
|
|
204
|
+
if (status?.err) throw new Error(`Solana transaction failed: ${JSON.stringify(status.err)}`);
|
|
205
|
+
const cs = status?.confirmationStatus;
|
|
206
|
+
if (cs === "confirmed" || cs === "finalized") return;
|
|
207
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`Transaction not confirmed within ${timeoutMs / 1e3}s. Check signature ${sig} on Solana Explorer.`);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Signs and submits Solana txs from Relay edge.providerQuote.
|
|
213
|
+
* Uses Wallet Standard-style serialized transaction signing so the SVM package does not need web3.js transaction classes.
|
|
214
|
+
* Returns the submitted transaction signatures.
|
|
215
|
+
*/
|
|
216
|
+
async function executeRelaySolanaQuote(params) {
|
|
217
|
+
const raw = providerQuoteToJson(params.providerQuote);
|
|
218
|
+
if (!raw.trim()) throw new Error("empty provider quote");
|
|
219
|
+
const steps = JSON.parse(raw).steps ?? [];
|
|
220
|
+
const rpcUrls = resolveSolanaMainnetJsonRpcUrls(params.rpcUrls);
|
|
221
|
+
const wc = params.walletConnection;
|
|
222
|
+
const signerPk = address(params.signerPublicKeyBase58.trim());
|
|
223
|
+
const signatures = [];
|
|
224
|
+
for (const step of steps) {
|
|
225
|
+
const items = step.items ?? [];
|
|
226
|
+
for (const item of items) {
|
|
227
|
+
const st = normalizeRelayStatus(item.status);
|
|
228
|
+
if (st !== "" && st !== "incomplete") continue;
|
|
229
|
+
if (item.data == null || item.data === "null") continue;
|
|
230
|
+
const action = parseSolanaStepData(item.data);
|
|
231
|
+
if (action.kind === "skip") continue;
|
|
232
|
+
if (action.kind === "wire") {
|
|
233
|
+
const sig = await sendRawTransactionWithFallback(wc, rpcUrls, await signTransactionBytes(getTransactionDecoder().decode(base64ToBytes(action.b64)), params.signSerializedTransaction), {
|
|
234
|
+
skipPreflight: false,
|
|
235
|
+
maxRetries: 5
|
|
236
|
+
});
|
|
237
|
+
console.log("[trails-sdk] Solana edge wire tx sent:", sig);
|
|
238
|
+
signatures.push(sig);
|
|
239
|
+
await pollConfirmation(rpcUrls, sig);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const bh = await getLatestBlockhashWithFallback(wc, rpcUrls);
|
|
243
|
+
const ixs = action.list.map((i) => svmInstructionToInstruction(i));
|
|
244
|
+
const payer = action.feePayer ?? signerPk;
|
|
245
|
+
const sig = await sendRawTransactionWithFallback(wc, rpcUrls, await signTransactionBytes(compileTransaction(appendTransactionMessageInstructions(ixs, setTransactionMessageLifetimeUsingBlockhash({
|
|
246
|
+
blockhash: bh.blockhash,
|
|
247
|
+
lastValidBlockHeight: BigInt(bh.lastValidBlockHeight)
|
|
248
|
+
}, setTransactionMessageFeePayer(payer, createTransactionMessage({ version: "legacy" }))))), params.signSerializedTransaction), {
|
|
249
|
+
skipPreflight: false,
|
|
250
|
+
maxRetries: 5
|
|
251
|
+
});
|
|
252
|
+
console.log("[trails-sdk] Solana edge ix tx sent:", sig);
|
|
253
|
+
signatures.push(sig);
|
|
254
|
+
await pollConfirmation(rpcUrls, sig);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return signatures;
|
|
258
|
+
}
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region src/walletDetection/rpcConnection.ts
|
|
261
|
+
function uniqueNonEmpty(values) {
|
|
262
|
+
const seen = /* @__PURE__ */ new Set();
|
|
263
|
+
const out = [];
|
|
264
|
+
for (const value of values) {
|
|
265
|
+
const trimmed = value?.trim();
|
|
266
|
+
if (!trimmed || seen.has(trimmed)) continue;
|
|
267
|
+
seen.add(trimmed);
|
|
268
|
+
out.push(trimmed);
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
function resolveSolanaRpcUrls(options = {}) {
|
|
273
|
+
return resolveSolanaMainnetJsonRpcUrls(options.rpcUrls);
|
|
274
|
+
}
|
|
275
|
+
function toBase64(data) {
|
|
276
|
+
if (data instanceof Uint8Array) {
|
|
277
|
+
if (typeof btoa === "function") {
|
|
278
|
+
let binary = "";
|
|
279
|
+
for (const byte of data) binary += String.fromCharCode(byte);
|
|
280
|
+
return btoa(binary);
|
|
281
|
+
}
|
|
282
|
+
const bytes = Array.from(data, (byte) => String.fromCharCode(byte)).join("");
|
|
283
|
+
return globalThis.btoa(bytes);
|
|
284
|
+
}
|
|
285
|
+
if (data instanceof ArrayBuffer) return toBase64(new Uint8Array(data));
|
|
286
|
+
if (ArrayBuffer.isView(data)) return toBase64(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
287
|
+
throw new Error("Unsupported serialized transaction format");
|
|
288
|
+
}
|
|
289
|
+
async function callSolanaRpcWithFallback({ rpcUrls, method, params, parseResult }) {
|
|
290
|
+
const errors = [];
|
|
291
|
+
for (const rpcUrl of rpcUrls) try {
|
|
292
|
+
const response = await fetch(rpcUrl, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: { "Content-Type": "application/json" },
|
|
295
|
+
body: JSON.stringify({
|
|
296
|
+
jsonrpc: "2.0",
|
|
297
|
+
id: Date.now(),
|
|
298
|
+
method,
|
|
299
|
+
params
|
|
300
|
+
})
|
|
301
|
+
});
|
|
302
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
303
|
+
const payload = await response.json();
|
|
304
|
+
if (payload.error) throw new Error(payload.error.message || "Solana RPC returned an error");
|
|
305
|
+
const parsedResult = parseResult(payload.result);
|
|
306
|
+
if (parsedResult === null) throw new Error("Invalid Solana RPC response");
|
|
307
|
+
return parsedResult;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
const message = error instanceof Error ? error.message : "unknown Solana RPC error";
|
|
310
|
+
errors.push(`${rpcUrl}: ${message}`);
|
|
311
|
+
console.warn("[trails-svm] Solana RPC call failed; trying fallback", {
|
|
312
|
+
method,
|
|
313
|
+
rpcUrl,
|
|
314
|
+
error: message
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
throw new Error(`Solana RPC ${method} failed on all configured endpoints: ${errors.join(" | ")}`);
|
|
318
|
+
}
|
|
319
|
+
function createRpcConnection(rpcUrlsOrOptions = {}) {
|
|
320
|
+
const rpcUrls = Array.isArray(rpcUrlsOrOptions) ? uniqueNonEmpty(rpcUrlsOrOptions) : resolveSolanaRpcUrls(rpcUrlsOrOptions);
|
|
321
|
+
return {
|
|
322
|
+
rpcEndpoint: rpcUrls[0],
|
|
323
|
+
rpcUrls,
|
|
324
|
+
async getLatestBlockhash(commitment = "finalized") {
|
|
325
|
+
return callSolanaRpcWithFallback({
|
|
326
|
+
rpcUrls,
|
|
327
|
+
method: "getLatestBlockhash",
|
|
328
|
+
params: [{ commitment }],
|
|
329
|
+
parseResult: (result) => {
|
|
330
|
+
if (typeof result !== "object" || result === null || !("value" in result)) return null;
|
|
331
|
+
const value = result.value;
|
|
332
|
+
if (typeof value !== "object" || value === null) return null;
|
|
333
|
+
const blockhash = value.blockhash;
|
|
334
|
+
const lastValidBlockHeight = value.lastValidBlockHeight;
|
|
335
|
+
if (typeof blockhash !== "string" || blockhash.length === 0 || typeof lastValidBlockHeight !== "number") return null;
|
|
336
|
+
return {
|
|
337
|
+
blockhash,
|
|
338
|
+
lastValidBlockHeight
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
async sendRawTransaction(serializedTransaction, options) {
|
|
344
|
+
const parsedOptions = options || {};
|
|
345
|
+
const encodedTx = toBase64(serializedTransaction);
|
|
346
|
+
return callSolanaRpcWithFallback({
|
|
347
|
+
rpcUrls,
|
|
348
|
+
method: "sendTransaction",
|
|
349
|
+
params: [encodedTx, {
|
|
350
|
+
encoding: "base64",
|
|
351
|
+
skipPreflight: parsedOptions.skipPreflight ?? false,
|
|
352
|
+
preflightCommitment: parsedOptions.preflightCommitment ?? "confirmed",
|
|
353
|
+
maxRetries: parsedOptions.maxRetries ?? 5
|
|
354
|
+
}],
|
|
355
|
+
parseResult: (result) => typeof result === "string" && result.length > 0 ? result : null
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/walletHelpers.ts
|
|
362
|
+
function isObject(value) {
|
|
363
|
+
return typeof value === "object" && value !== null;
|
|
364
|
+
}
|
|
365
|
+
function isWalletStandardSolanaWallet(value) {
|
|
366
|
+
if (!isObject(value)) return false;
|
|
367
|
+
const features = value.features;
|
|
368
|
+
if (!isObject(features)) return false;
|
|
369
|
+
const signTransaction = features["solana:signTransaction"];
|
|
370
|
+
return isObject(signTransaction) && typeof signTransaction.signTransaction === "function";
|
|
371
|
+
}
|
|
372
|
+
function unwrapWalletStandardSolanaWallet(value) {
|
|
373
|
+
if (isWalletStandardSolanaWallet(value)) return { wallet: value };
|
|
374
|
+
if (!isObject(value)) return null;
|
|
375
|
+
const candidate = value;
|
|
376
|
+
const directWallet = candidate.wallet;
|
|
377
|
+
if (isWalletStandardSolanaWallet(directWallet)) return {
|
|
378
|
+
wallet: directWallet,
|
|
379
|
+
connected: typeof candidate.connected === "boolean" ? candidate.connected : void 0
|
|
380
|
+
};
|
|
381
|
+
const adapter = candidate.adapter;
|
|
382
|
+
const adapterWallet = isObject(adapter) ? adapter.wallet : void 0;
|
|
383
|
+
if (isWalletStandardSolanaWallet(adapterWallet)) return {
|
|
384
|
+
wallet: adapterWallet,
|
|
385
|
+
connected: isObject(adapter) && typeof adapter.connected === "boolean" ? adapter.connected : void 0
|
|
386
|
+
};
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
function findSolanaAccount(wallet) {
|
|
390
|
+
return wallet.accounts?.find((account) => account.chains?.some((chain) => chain.startsWith("solana:"))) ?? wallet.accounts?.[0] ?? null;
|
|
391
|
+
}
|
|
392
|
+
function isSerializedSolanaWallet(value) {
|
|
393
|
+
if (!isObject(value)) return false;
|
|
394
|
+
return typeof value.signSerializedTransaction === "function";
|
|
395
|
+
}
|
|
396
|
+
function isKitWalletSession(value) {
|
|
397
|
+
if (!isObject(value)) return false;
|
|
398
|
+
if (typeof value.signTransaction !== "function") return false;
|
|
399
|
+
if ("wallet" in value || "adapter" in value) return false;
|
|
400
|
+
const account = value.account;
|
|
401
|
+
if (!isObject(account)) return false;
|
|
402
|
+
const address = account.address;
|
|
403
|
+
return typeof address === "string" || address != null;
|
|
404
|
+
}
|
|
405
|
+
function getKitSessionAddress(session) {
|
|
406
|
+
const address = session.account?.address;
|
|
407
|
+
if (typeof address === "string") return address;
|
|
408
|
+
return address?.toString() ?? null;
|
|
409
|
+
}
|
|
410
|
+
function createKitSessionSolanaWallet({ wallet, connection, connected }) {
|
|
411
|
+
if (!wallet.signTransaction) return null;
|
|
412
|
+
const walletAddress = getKitSessionAddress(wallet);
|
|
413
|
+
if (!walletAddress) throw new Error("Solana wallet session has no account address");
|
|
414
|
+
return {
|
|
415
|
+
connected: connected ?? true,
|
|
416
|
+
publicKey: { toBase58: () => walletAddress },
|
|
417
|
+
signSerializedTransaction: async (transactionBytes) => {
|
|
418
|
+
const transaction = getTransactionDecoder().decode(transactionBytes);
|
|
419
|
+
const signedTransaction = await wallet.signTransaction(transaction);
|
|
420
|
+
return new Uint8Array(getTransactionEncoder().encode(signedTransaction));
|
|
421
|
+
},
|
|
422
|
+
connection,
|
|
423
|
+
walletIcon: wallet.connector?.icon,
|
|
424
|
+
walletName: wallet.connector?.name
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Creates a Trails Solana wallet from a Wallet Standard wallet,
|
|
429
|
+
* standard-compatible `@solana/wallet-adapter` adapter, or Kit-style wallet
|
|
430
|
+
* session such as `@solana/react-hooks`' connected `wallet` value.
|
|
431
|
+
*
|
|
432
|
+
* This helper signs serialized Solana transaction bytes without requiring
|
|
433
|
+
* `@solana/web3.js` transaction classes.
|
|
434
|
+
*/
|
|
435
|
+
function createSolanaWallet({ wallet: inputWallet, account: inputAccount, connection, connected, chain }) {
|
|
436
|
+
if (isSerializedSolanaWallet(inputWallet)) return {
|
|
437
|
+
...inputWallet,
|
|
438
|
+
connected: connected ?? inputWallet.connected,
|
|
439
|
+
connection: connection ?? inputWallet.connection
|
|
440
|
+
};
|
|
441
|
+
const unwrapped = unwrapWalletStandardSolanaWallet(inputWallet);
|
|
442
|
+
if (!unwrapped) {
|
|
443
|
+
if (isKitWalletSession(inputWallet)) return createKitSessionSolanaWallet({
|
|
444
|
+
wallet: inputWallet,
|
|
445
|
+
connection,
|
|
446
|
+
connected
|
|
447
|
+
});
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
const { wallet } = unwrapped;
|
|
451
|
+
const signTransaction = wallet.features?.["solana:signTransaction"];
|
|
452
|
+
if (typeof signTransaction?.signTransaction !== "function") throw new Error(`${wallet.name ?? "Solana wallet"} does not support solana:signTransaction`);
|
|
453
|
+
const account = inputAccount ?? findSolanaAccount(wallet);
|
|
454
|
+
if (!account) throw new Error(`${wallet.name ?? "Solana wallet"} has no Solana account`);
|
|
455
|
+
return {
|
|
456
|
+
connected: connected ?? unwrapped.connected ?? true,
|
|
457
|
+
publicKey: { toBase58: () => account.address },
|
|
458
|
+
signSerializedTransaction: async (transactionBytes) => {
|
|
459
|
+
const [signed] = await signTransaction.signTransaction({
|
|
460
|
+
account,
|
|
461
|
+
transaction: transactionBytes,
|
|
462
|
+
chain: chain ?? account.chains?.find((value) => value.startsWith("solana:"))
|
|
463
|
+
});
|
|
464
|
+
if (!signed) throw new Error(`${wallet.name ?? "Solana wallet"} did not sign transaction`);
|
|
465
|
+
return signed.signedTransaction;
|
|
466
|
+
},
|
|
467
|
+
connection,
|
|
468
|
+
walletName: wallet.name,
|
|
469
|
+
walletIcon: wallet.icon
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
//#endregion
|
|
473
|
+
//#region src/walletDetection/disconnectPersistence.ts
|
|
474
|
+
const DISCONNECTED_WALLET_IDS_STORAGE_KEY = "0xtrails:svm:disconnected-wallet-ids";
|
|
475
|
+
const disconnectedWalletIdsMemory = /* @__PURE__ */ new Set();
|
|
476
|
+
function readDisconnectedWalletIds() {
|
|
477
|
+
const walletIds = new Set(disconnectedWalletIdsMemory);
|
|
478
|
+
if (typeof window === "undefined") return walletIds;
|
|
479
|
+
try {
|
|
480
|
+
const stored = window.localStorage?.getItem(DISCONNECTED_WALLET_IDS_STORAGE_KEY);
|
|
481
|
+
if (!stored) return walletIds;
|
|
482
|
+
const parsed = JSON.parse(stored);
|
|
483
|
+
if (!Array.isArray(parsed)) return walletIds;
|
|
484
|
+
for (const walletId of parsed) if (typeof walletId === "string") walletIds.add(walletId);
|
|
485
|
+
} catch {}
|
|
486
|
+
return walletIds;
|
|
487
|
+
}
|
|
488
|
+
function writeDisconnectedWalletIds(walletIds) {
|
|
489
|
+
disconnectedWalletIdsMemory.clear();
|
|
490
|
+
for (const walletId of walletIds) disconnectedWalletIdsMemory.add(walletId);
|
|
491
|
+
if (typeof window === "undefined") return;
|
|
492
|
+
try {
|
|
493
|
+
if (walletIds.size === 0) {
|
|
494
|
+
window.localStorage?.removeItem(DISCONNECTED_WALLET_IDS_STORAGE_KEY);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
window.localStorage?.setItem(DISCONNECTED_WALLET_IDS_STORAGE_KEY, JSON.stringify([...walletIds]));
|
|
498
|
+
} catch {}
|
|
499
|
+
}
|
|
500
|
+
/** Remember that the user explicitly disconnected this wallet in the app. */
|
|
501
|
+
function markWalletDisconnected(walletId) {
|
|
502
|
+
const walletIds = readDisconnectedWalletIds();
|
|
503
|
+
walletIds.add(walletId);
|
|
504
|
+
writeDisconnectedWalletIds(walletIds);
|
|
505
|
+
}
|
|
506
|
+
/** Clear persisted disconnect state after an explicit reconnect. */
|
|
507
|
+
function clearWalletDisconnected(walletId) {
|
|
508
|
+
const walletIds = readDisconnectedWalletIds();
|
|
509
|
+
walletIds.delete(walletId);
|
|
510
|
+
writeDisconnectedWalletIds(walletIds);
|
|
511
|
+
}
|
|
512
|
+
/** Wrap disconnect so explicit widget disconnects survive page refresh. */
|
|
513
|
+
function withPersistentSolanaDisconnect(wallet) {
|
|
514
|
+
if (!wallet?.disconnect) return wallet;
|
|
515
|
+
const walletId = wallet.walletId?.trim();
|
|
516
|
+
if (!walletId) return wallet;
|
|
517
|
+
const disconnect = wallet.disconnect;
|
|
518
|
+
return {
|
|
519
|
+
...wallet,
|
|
520
|
+
disconnect: async () => {
|
|
521
|
+
markWalletDisconnected(walletId);
|
|
522
|
+
await disconnect();
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/walletDetection/walletStandardProvider.ts
|
|
528
|
+
const SOLANA_MAINNET_CHAIN = "solana:mainnet";
|
|
529
|
+
function isSolanaMainnetChain(chain) {
|
|
530
|
+
return chain === SOLANA_MAINNET_CHAIN || chain?.startsWith("solana:") === true;
|
|
531
|
+
}
|
|
532
|
+
function accountHasSolanaScope(account) {
|
|
533
|
+
return account.chains?.some(isSolanaMainnetChain) === true;
|
|
534
|
+
}
|
|
535
|
+
function accountIsUnscoped(account) {
|
|
536
|
+
return !account.chains || account.chains.length === 0;
|
|
537
|
+
}
|
|
538
|
+
function getSolanaAccountFromAccounts(accounts) {
|
|
539
|
+
return accounts?.find((account) => accountHasSolanaScope(account)) ?? accounts?.find((account) => accountIsUnscoped(account)) ?? accounts?.[0] ?? null;
|
|
540
|
+
}
|
|
541
|
+
function getSolanaAccount(wallet) {
|
|
542
|
+
return getSolanaAccountFromAccounts(wallet.accounts);
|
|
543
|
+
}
|
|
544
|
+
function isSolanaWallet(wallet) {
|
|
545
|
+
return wallet.chains?.some(isSolanaMainnetChain) === true || wallet.accounts?.some((account) => account.chains?.some(isSolanaMainnetChain)) === true || typeof wallet.features?.["solana:signTransaction"]?.signTransaction === "function";
|
|
546
|
+
}
|
|
547
|
+
function wrapWalletStandardWallet(wallet) {
|
|
548
|
+
const connectFeature = wallet.features?.["standard:connect"];
|
|
549
|
+
if (typeof connectFeature?.connect !== "function") return null;
|
|
550
|
+
if (typeof (wallet.features?.["solana:signTransaction"])?.signTransaction !== "function") return null;
|
|
551
|
+
const disconnectFeature = wallet.features?.["standard:disconnect"];
|
|
552
|
+
const eventsFeature = wallet.features?.["standard:events"];
|
|
553
|
+
let currentAccount = getSolanaAccount(wallet);
|
|
554
|
+
const refresh = () => {
|
|
555
|
+
currentAccount = getSolanaAccount(wallet);
|
|
556
|
+
provider.account = currentAccount;
|
|
557
|
+
provider.isConnected = !!currentAccount;
|
|
558
|
+
};
|
|
559
|
+
const provider = {
|
|
560
|
+
wallet,
|
|
561
|
+
account: currentAccount,
|
|
562
|
+
isConnected: !!currentAccount,
|
|
563
|
+
connect: async (opts) => {
|
|
564
|
+
currentAccount = getSolanaAccountFromAccounts((await connectFeature.connect({ silent: opts?.onlyIfTrusted })).accounts) ?? getSolanaAccount(wallet);
|
|
565
|
+
provider.account = currentAccount;
|
|
566
|
+
provider.isConnected = !!currentAccount;
|
|
567
|
+
if (!currentAccount) throw new Error(`${wallet.name ?? "Solana wallet"} did not return a Solana account`);
|
|
568
|
+
return currentAccount;
|
|
569
|
+
},
|
|
570
|
+
disconnect: async () => {
|
|
571
|
+
try {
|
|
572
|
+
await disconnectFeature?.disconnect?.();
|
|
573
|
+
} finally {
|
|
574
|
+
currentAccount = null;
|
|
575
|
+
provider.account = null;
|
|
576
|
+
provider.isConnected = false;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
if (typeof eventsFeature?.on === "function") {
|
|
581
|
+
const unsubscribers = /* @__PURE__ */ new Map();
|
|
582
|
+
provider.on = (_event, handler) => {
|
|
583
|
+
const unsubscribe = eventsFeature.on("change", () => {
|
|
584
|
+
refresh();
|
|
585
|
+
handler(provider.account);
|
|
586
|
+
});
|
|
587
|
+
unsubscribers.set(handler, unsubscribe);
|
|
588
|
+
};
|
|
589
|
+
provider.off = (_event, handler) => {
|
|
590
|
+
unsubscribers.get(handler)?.();
|
|
591
|
+
unsubscribers.delete(handler);
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return provider;
|
|
595
|
+
}
|
|
596
|
+
function dedupeProviders(providers) {
|
|
597
|
+
const seen = /* @__PURE__ */ new Set();
|
|
598
|
+
return providers.filter((provider) => {
|
|
599
|
+
const id = inferWalletId(provider);
|
|
600
|
+
if (seen.has(id)) return false;
|
|
601
|
+
seen.add(id);
|
|
602
|
+
return true;
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
function listStandardSolanaProviders() {
|
|
606
|
+
if (typeof window === "undefined") return [];
|
|
607
|
+
return dedupeProviders(getWallets().get().map((wallet) => wallet).filter(isSolanaWallet).map(wrapWalletStandardWallet).filter((provider) => !!provider));
|
|
608
|
+
}
|
|
609
|
+
function inferWalletLabel(provider) {
|
|
610
|
+
return provider.wallet.name?.trim() || "Solana wallet";
|
|
611
|
+
}
|
|
612
|
+
function inferWalletId(provider) {
|
|
613
|
+
return inferWalletLabel(provider).toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "") || "solana-wallet";
|
|
614
|
+
}
|
|
615
|
+
//#endregion
|
|
616
|
+
//#region src/walletDetection/useDetectedSolanaWallet.ts
|
|
617
|
+
const SOLANA_CHAIN = {
|
|
618
|
+
chainId: SOLANA_MAINNET_CHAIN_ID,
|
|
619
|
+
name: "Solana",
|
|
620
|
+
chainType: "SOLANA",
|
|
621
|
+
nativeCurrency: {
|
|
622
|
+
name: "Solana",
|
|
623
|
+
symbol: "SOL",
|
|
624
|
+
decimals: 9
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
function normalizeWalletId(walletId) {
|
|
628
|
+
return walletId.endsWith("-solana") ? walletId.slice(0, -7) : walletId;
|
|
629
|
+
}
|
|
630
|
+
function findProviderByWalletId(providers, walletId) {
|
|
631
|
+
if (!walletId) return null;
|
|
632
|
+
const normalizedWalletId = normalizeWalletId(walletId);
|
|
633
|
+
return providers.find((candidate) => {
|
|
634
|
+
const candidateId = inferWalletId(candidate);
|
|
635
|
+
return candidateId === walletId || candidateId === normalizedWalletId || normalizeWalletId(candidateId) === normalizedWalletId;
|
|
636
|
+
}) ?? null;
|
|
637
|
+
}
|
|
638
|
+
function uniqueProviders(providers) {
|
|
639
|
+
const seen = /* @__PURE__ */ new Set();
|
|
640
|
+
const unique = [];
|
|
641
|
+
for (const provider of providers) {
|
|
642
|
+
if (!provider) continue;
|
|
643
|
+
const id = inferWalletId(provider);
|
|
644
|
+
if (seen.has(id)) continue;
|
|
645
|
+
seen.add(id);
|
|
646
|
+
unique.push(provider);
|
|
647
|
+
}
|
|
648
|
+
return unique;
|
|
649
|
+
}
|
|
650
|
+
function useDetectedSolanaWallet(options = {}) {
|
|
651
|
+
const enabled = options.enabled ?? true;
|
|
652
|
+
const silentReconnect = options.silentReconnect ?? true;
|
|
653
|
+
const preferredWalletId = options.preferredWalletId;
|
|
654
|
+
const [providers, setProviders] = useState([]);
|
|
655
|
+
const [provider, setProvider] = useState(null);
|
|
656
|
+
const [connected, setConnected] = useState(false);
|
|
657
|
+
const [connecting, setConnecting] = useState(false);
|
|
658
|
+
const selectedWalletIdRef = useRef(null);
|
|
659
|
+
const connection = useMemo(() => createRpcConnection({ rpcUrls: options.rpcUrls }), [options.rpcUrls]);
|
|
660
|
+
useEffect(() => {
|
|
661
|
+
if (!enabled) {
|
|
662
|
+
selectedWalletIdRef.current = null;
|
|
663
|
+
setProviders([]);
|
|
664
|
+
setProvider(null);
|
|
665
|
+
setConnected(false);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (typeof window === "undefined") return;
|
|
669
|
+
let cancelled = false;
|
|
670
|
+
let reconnectRun = 0;
|
|
671
|
+
const detectProviders = () => {
|
|
672
|
+
const detectedProviders = listStandardSolanaProviders();
|
|
673
|
+
const disconnectedWalletIds = readDisconnectedWalletIds();
|
|
674
|
+
if (cancelled) return;
|
|
675
|
+
setProviders(detectedProviders);
|
|
676
|
+
const preferredProvider = findProviderByWalletId(detectedProviders, preferredWalletId);
|
|
677
|
+
const currentProvider = findProviderByWalletId(detectedProviders, selectedWalletIdRef.current);
|
|
678
|
+
const connectedProvider = detectedProviders.find((candidate) => candidate.isConnected && candidate.account && !disconnectedWalletIds.has(inferWalletId(candidate)));
|
|
679
|
+
const defaultProvider = preferredProvider ?? currentProvider ?? connectedProvider ?? detectedProviders[0] ?? null;
|
|
680
|
+
setProvider((stateProvider) => {
|
|
681
|
+
if (!detectedProviders.length) {
|
|
682
|
+
selectedWalletIdRef.current = null;
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
const nextProvider = stateProvider ? findProviderByWalletId(detectedProviders, inferWalletId(stateProvider)) ?? defaultProvider : defaultProvider;
|
|
686
|
+
selectedWalletIdRef.current = nextProvider ? inferWalletId(nextProvider) : null;
|
|
687
|
+
return nextProvider;
|
|
688
|
+
});
|
|
689
|
+
const selectedConnectedProvider = uniqueProviders([
|
|
690
|
+
preferredProvider,
|
|
691
|
+
currentProvider,
|
|
692
|
+
connectedProvider
|
|
693
|
+
]).find((candidate) => candidate.isConnected && candidate.account && !disconnectedWalletIds.has(inferWalletId(candidate)));
|
|
694
|
+
if (selectedConnectedProvider?.account) {
|
|
695
|
+
selectedWalletIdRef.current = inferWalletId(selectedConnectedProvider);
|
|
696
|
+
setProvider(selectedConnectedProvider);
|
|
697
|
+
setConnected(true);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
setConnected(false);
|
|
701
|
+
if (!silentReconnect) return;
|
|
702
|
+
const runId = ++reconnectRun;
|
|
703
|
+
const reconnectCandidates = uniqueProviders([
|
|
704
|
+
preferredProvider,
|
|
705
|
+
currentProvider,
|
|
706
|
+
defaultProvider,
|
|
707
|
+
...detectedProviders
|
|
708
|
+
]).filter((candidate) => !disconnectedWalletIds.has(inferWalletId(candidate)));
|
|
709
|
+
(async () => {
|
|
710
|
+
for (const candidate of reconnectCandidates) try {
|
|
711
|
+
await candidate.connect({ onlyIfTrusted: true });
|
|
712
|
+
if (cancelled || runId !== reconnectRun) return;
|
|
713
|
+
if (!candidate.account) continue;
|
|
714
|
+
selectedWalletIdRef.current = inferWalletId(candidate);
|
|
715
|
+
setProvider(candidate);
|
|
716
|
+
setConnected(true);
|
|
717
|
+
return;
|
|
718
|
+
} catch {}
|
|
719
|
+
})();
|
|
720
|
+
};
|
|
721
|
+
detectProviders();
|
|
722
|
+
const detectWithDelay = window.setTimeout(detectProviders, 1e3);
|
|
723
|
+
window.addEventListener("solana#initialized", detectProviders);
|
|
724
|
+
return () => {
|
|
725
|
+
cancelled = true;
|
|
726
|
+
window.clearTimeout(detectWithDelay);
|
|
727
|
+
window.removeEventListener("solana#initialized", detectProviders);
|
|
728
|
+
};
|
|
729
|
+
}, [
|
|
730
|
+
enabled,
|
|
731
|
+
preferredWalletId,
|
|
732
|
+
silentReconnect
|
|
733
|
+
]);
|
|
734
|
+
useEffect(() => {
|
|
735
|
+
if (!enabled || !provider) {
|
|
736
|
+
setConnected(false);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const walletId = inferWalletId(provider);
|
|
740
|
+
const handleChange = () => {
|
|
741
|
+
setConnected(provider.isConnected && !!provider.account && !readDisconnectedWalletIds().has(walletId));
|
|
742
|
+
};
|
|
743
|
+
provider.on?.("change", handleChange);
|
|
744
|
+
return () => {
|
|
745
|
+
provider.off?.("change", handleChange);
|
|
746
|
+
};
|
|
747
|
+
}, [enabled, provider]);
|
|
748
|
+
const connectProvider = useCallback(async (targetProvider) => {
|
|
749
|
+
setConnecting(true);
|
|
750
|
+
setConnected(false);
|
|
751
|
+
try {
|
|
752
|
+
const account = await targetProvider.connect();
|
|
753
|
+
const walletId = inferWalletId(targetProvider);
|
|
754
|
+
clearWalletDisconnected(walletId);
|
|
755
|
+
selectedWalletIdRef.current = walletId;
|
|
756
|
+
setProvider(targetProvider);
|
|
757
|
+
setConnected(!!targetProvider.account);
|
|
758
|
+
return { publicKey: { toBase58: () => account.address } };
|
|
759
|
+
} finally {
|
|
760
|
+
setConnecting(false);
|
|
761
|
+
}
|
|
762
|
+
}, []);
|
|
763
|
+
const connect = useCallback(async (walletId) => {
|
|
764
|
+
const requestedProvider = findProviderByWalletId(providers, walletId);
|
|
765
|
+
if (walletId && !requestedProvider) throw new Error(`Solana wallet ${walletId} is not available`);
|
|
766
|
+
const targetProvider = requestedProvider ?? provider ?? providers[0];
|
|
767
|
+
if (!targetProvider) throw new Error("No Solana wallet provider is available");
|
|
768
|
+
return connectProvider(targetProvider);
|
|
769
|
+
}, [
|
|
770
|
+
connectProvider,
|
|
771
|
+
provider,
|
|
772
|
+
providers
|
|
773
|
+
]);
|
|
774
|
+
const disconnect = useCallback(async () => {
|
|
775
|
+
if (!provider) return;
|
|
776
|
+
markWalletDisconnected(inferWalletId(provider));
|
|
777
|
+
selectedWalletIdRef.current = null;
|
|
778
|
+
try {
|
|
779
|
+
await provider.disconnect();
|
|
780
|
+
} finally {
|
|
781
|
+
setConnected(false);
|
|
782
|
+
}
|
|
783
|
+
}, [provider]);
|
|
784
|
+
return useMemo(() => {
|
|
785
|
+
if (!provider && providers.length === 0) return null;
|
|
786
|
+
const selectedProvider = provider ?? providers[0] ?? null;
|
|
787
|
+
return {
|
|
788
|
+
...(selectedProvider?.account && connected ? createSolanaWallet({
|
|
789
|
+
wallet: selectedProvider.wallet,
|
|
790
|
+
account: selectedProvider.account,
|
|
791
|
+
connection,
|
|
792
|
+
connected
|
|
793
|
+
}) : null) ?? {},
|
|
794
|
+
connected,
|
|
795
|
+
connecting,
|
|
796
|
+
connection,
|
|
797
|
+
walletId: selectedProvider ? inferWalletId(selectedProvider) : void 0,
|
|
798
|
+
walletName: selectedProvider ? inferWalletLabel(selectedProvider) : void 0,
|
|
799
|
+
walletIcon: selectedProvider?.wallet.icon ?? "",
|
|
800
|
+
availableWallets: providers.map((candidate) => ({
|
|
801
|
+
walletId: inferWalletId(candidate),
|
|
802
|
+
walletName: inferWalletLabel(candidate),
|
|
803
|
+
walletIcon: candidate.wallet.icon ?? ""
|
|
804
|
+
})),
|
|
805
|
+
activeChain: SOLANA_CHAIN,
|
|
806
|
+
connect,
|
|
807
|
+
disconnect
|
|
808
|
+
};
|
|
809
|
+
}, [
|
|
810
|
+
provider,
|
|
811
|
+
providers,
|
|
812
|
+
connected,
|
|
813
|
+
connecting,
|
|
814
|
+
connection,
|
|
815
|
+
connect,
|
|
816
|
+
disconnect
|
|
817
|
+
]);
|
|
818
|
+
}
|
|
819
|
+
//#endregion
|
|
820
|
+
//#region src/plugin.tsx
|
|
821
|
+
/**
|
|
822
|
+
* Resolves ordered Solana mainnet RPC endpoints for a wallet.
|
|
823
|
+
* Priority: TrailsProvider `solanaRpcUrl` → wallet connection → public mainnet default.
|
|
824
|
+
*/
|
|
825
|
+
function getWalletSolanaRpcUrls(configuredSolanaRpcUrl, connection) {
|
|
826
|
+
return getEdgeSolanaRpcUrls([
|
|
827
|
+
...configuredSolanaRpcUrl ? [configuredSolanaRpcUrl] : [],
|
|
828
|
+
...connection?.rpcEndpoint ? [connection.rpcEndpoint] : [],
|
|
829
|
+
...connection?.rpcUrls ?? []
|
|
830
|
+
]);
|
|
831
|
+
}
|
|
832
|
+
function resolveSolanaEdgeDestinationAddress(options, solanaWallet) {
|
|
833
|
+
const propAddress = options?.recipient?.trim();
|
|
834
|
+
if (propAddress && isSolanaAddress(propAddress)) return propAddress;
|
|
835
|
+
if (solanaWallet?.connected && solanaWallet.publicKey) return solanaWallet.publicKey.toBase58();
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
const SOLANA_TOKENS = getSolanaTokens();
|
|
839
|
+
/**
|
|
840
|
+
* Registers the SVM plugin and provides the Solana wallet context.
|
|
841
|
+
* Wrap your app (or TrailsWidget) with this provider to enable Solana edge payments.
|
|
842
|
+
* Composes with other EdgePlugin providers — each appends to the shared registry.
|
|
843
|
+
*/
|
|
844
|
+
function SvmPluginProvider({ account, chain, connected, connection, wallet, rpcUrls, preferredWalletId, silentReconnect, children }) {
|
|
845
|
+
const existingPlugins = useContext(EdgePluginContext);
|
|
846
|
+
const { solanaRpcUrl: configuredSolanaRpcUrl } = useTrails();
|
|
847
|
+
const detectedSolanaWallet = useDetectedSolanaWallet({
|
|
848
|
+
rpcUrls,
|
|
849
|
+
preferredWalletId,
|
|
850
|
+
silentReconnect,
|
|
851
|
+
enabled: wallet === void 0
|
|
852
|
+
});
|
|
853
|
+
const resolvedConnection = useMemo(() => {
|
|
854
|
+
if (connection) return connection;
|
|
855
|
+
if (rpcUrls && rpcUrls.length > 0) return createRpcConnection({ rpcUrls });
|
|
856
|
+
}, [connection, rpcUrls]);
|
|
857
|
+
const disconnectedSolanaWallet = useMemo(() => ({
|
|
858
|
+
connected: false,
|
|
859
|
+
connection: resolvedConnection ?? null,
|
|
860
|
+
publicKey: null,
|
|
861
|
+
signSerializedTransaction: void 0,
|
|
862
|
+
availableWallets: [],
|
|
863
|
+
connect: null,
|
|
864
|
+
disconnect: null
|
|
865
|
+
}), [resolvedConnection]);
|
|
866
|
+
const resolvedSolanaWallet = useMemo(() => {
|
|
867
|
+
return withPersistentSolanaDisconnect(wallet !== void 0 ? createSolanaWallet({
|
|
868
|
+
wallet,
|
|
869
|
+
account,
|
|
870
|
+
chain,
|
|
871
|
+
connected,
|
|
872
|
+
connection: resolvedConnection
|
|
873
|
+
}) : detectedSolanaWallet) ?? disconnectedSolanaWallet;
|
|
874
|
+
}, [
|
|
875
|
+
account,
|
|
876
|
+
chain,
|
|
877
|
+
connected,
|
|
878
|
+
detectedSolanaWallet,
|
|
879
|
+
disconnectedSolanaWallet,
|
|
880
|
+
resolvedConnection,
|
|
881
|
+
wallet
|
|
882
|
+
]);
|
|
883
|
+
const svmPlugin = useMemo(() => ({
|
|
884
|
+
rail: EDGE_RAIL.SOLANA,
|
|
885
|
+
supportsChain(fromChainId) {
|
|
886
|
+
return isSolanaChainId(fromChainId);
|
|
887
|
+
},
|
|
888
|
+
getOriginTokens() {
|
|
889
|
+
return SOLANA_TOKENS;
|
|
890
|
+
},
|
|
891
|
+
buildEdgeConfig(fromChainId) {
|
|
892
|
+
if (!isSolanaChainId(fromChainId)) return null;
|
|
893
|
+
if (!resolvedSolanaWallet?.connected || !resolvedSolanaWallet.publicKey || typeof resolvedSolanaWallet.signSerializedTransaction !== "function") return null;
|
|
894
|
+
return createSolanaEdgeOriginConfig({
|
|
895
|
+
rpcUrls: getWalletSolanaRpcUrls(configuredSolanaRpcUrl, resolvedSolanaWallet.connection),
|
|
896
|
+
signerPublicKeyBase58: resolvedSolanaWallet.publicKey.toBase58()
|
|
897
|
+
});
|
|
898
|
+
},
|
|
899
|
+
supportsDestinationChain(toChainId) {
|
|
900
|
+
return isSolanaChainId(toChainId);
|
|
901
|
+
},
|
|
902
|
+
getDestinationTokens() {
|
|
903
|
+
return SOLANA_TOKENS;
|
|
904
|
+
},
|
|
905
|
+
buildEdgeDestinationConfig(toChainId, options) {
|
|
906
|
+
if (!isSolanaChainId(toChainId)) return null;
|
|
907
|
+
const recipient = resolveSolanaEdgeDestinationAddress(options, resolvedSolanaWallet);
|
|
908
|
+
if (!recipient) return null;
|
|
909
|
+
return createSolanaEdgeDestinationConfig({
|
|
910
|
+
chainId: SOLANA_EDGE_CHAIN_ID,
|
|
911
|
+
recipient
|
|
912
|
+
});
|
|
913
|
+
},
|
|
914
|
+
async executeDeposit({ providerQuote }) {
|
|
915
|
+
if (!resolvedSolanaWallet?.connected || !resolvedSolanaWallet.publicKey || typeof resolvedSolanaWallet.signSerializedTransaction !== "function") throw new Error("Solana wallet not connected");
|
|
916
|
+
return executeRelaySolanaQuote({
|
|
917
|
+
rpcUrls: getWalletSolanaRpcUrls(configuredSolanaRpcUrl, resolvedSolanaWallet.connection),
|
|
918
|
+
signerPublicKeyBase58: resolvedSolanaWallet.publicKey.toBase58(),
|
|
919
|
+
signSerializedTransaction: resolvedSolanaWallet.signSerializedTransaction,
|
|
920
|
+
walletConnection: resolvedSolanaWallet.connection ?? void 0,
|
|
921
|
+
providerQuote
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
}), [configuredSolanaRpcUrl, resolvedSolanaWallet]);
|
|
925
|
+
const plugins = useMemo(() => [...existingPlugins, svmPlugin], [existingPlugins, svmPlugin]);
|
|
926
|
+
useEffect(() => {
|
|
927
|
+
setSolanaSupportEnabled(true);
|
|
928
|
+
return () => setSolanaSupportEnabled(false);
|
|
929
|
+
}, []);
|
|
930
|
+
return /* @__PURE__ */ jsx(UnifiedWalletProvider, {
|
|
931
|
+
solanaWallet: resolvedSolanaWallet,
|
|
932
|
+
children: /* @__PURE__ */ jsx(EdgePluginContext.Provider, {
|
|
933
|
+
value: plugins,
|
|
934
|
+
children
|
|
935
|
+
})
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
//#endregion
|
|
939
|
+
//#region src/svmAdapter.tsx
|
|
940
|
+
/**
|
|
941
|
+
* Creates a Trails wallet adapter for Solana (SVM) edge payments.
|
|
942
|
+
*
|
|
943
|
+
* Provides the Solana wallet context and auto-registers the SVM edge plugin
|
|
944
|
+
* when passed to `TrailsWidget` or `TrailsProvider` via the `adapters` prop.
|
|
945
|
+
*
|
|
946
|
+
* @example
|
|
947
|
+
* ```tsx
|
|
948
|
+
* import { evmAdapter } from "0xtrails"
|
|
949
|
+
* import { svmAdapter } from "@0xtrails/svm"
|
|
950
|
+
*
|
|
951
|
+
* <TrailsWidget adapters={[evmAdapter({ wallets: [...] }), svmAdapter({ wallet })]} />
|
|
952
|
+
* ```
|
|
953
|
+
*/
|
|
954
|
+
function svmAdapter(options) {
|
|
955
|
+
return {
|
|
956
|
+
Provider: SvmPluginProvider,
|
|
957
|
+
providerProps: options,
|
|
958
|
+
namespaces: ["solana"]
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
//#endregion
|
|
962
|
+
export { ErrEVMStepInSolanaQuote, SvmPluginProvider, clearWalletDisconnected, createSolanaWallet, executeRelaySolanaQuote, readDisconnectedWalletIds, svmAdapter, useDetectedSolanaWallet, withPersistentSolanaDisconnect };
|