settld 0.2.0 → 0.2.2

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.
@@ -0,0 +1,197 @@
1
+ import { generateJwt } from "@coinbase/cdp-sdk/auth";
2
+
3
+ function normalizeHttpUrl(value) {
4
+ const raw = String(value ?? "").trim();
5
+ if (!raw) return null;
6
+ try {
7
+ const u = new URL(raw);
8
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
9
+ return u.toString();
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ function safeTrim(value) {
16
+ return typeof value === "string" ? value.trim() : "";
17
+ }
18
+
19
+ function normalizeClientIp(value) {
20
+ const raw = safeTrim(value);
21
+ if (!raw) return null;
22
+ if (raw.startsWith("::ffff:")) return raw.slice(7);
23
+ return raw;
24
+ }
25
+
26
+ function mapBlockchainToCoinbaseNetwork(raw) {
27
+ const value = safeTrim(raw).toLowerCase();
28
+ if (!value) return null;
29
+ if (value === "base" || value === "base-mainnet" || value === "base_mainnet") return "base";
30
+ if (value === "ethereum" || value === "eth" || value === "ethereum-mainnet" || value === "ethereum_mainnet") return "ethereum";
31
+ if (value === "polygon" || value === "polygon-mainnet" || value === "polygon_mainnet") return "polygon";
32
+ if (value === "solana" || value === "sol") return "solana";
33
+ if (value === "arbitrum" || value === "arbitrum-mainnet" || value === "arbitrum_mainnet") return "arbitrum";
34
+ if (value === "optimism" || value === "optimism-mainnet" || value === "optimism_mainnet") return "optimism";
35
+ if (value === "bitcoin" || value === "btc") return "bitcoin";
36
+ return null;
37
+ }
38
+
39
+ function buildHostedUrl({ payBaseUrl, sessionToken, defaultNetwork, defaultAsset, redirectUrl, fiatCurrency, paymentMethod }) {
40
+ const u = new URL(payBaseUrl);
41
+ u.searchParams.set("sessionToken", sessionToken);
42
+ if (defaultNetwork) u.searchParams.set("defaultNetwork", defaultNetwork);
43
+ if (defaultAsset) u.searchParams.set("defaultAsset", defaultAsset);
44
+ if (redirectUrl) u.searchParams.set("redirectUrl", redirectUrl);
45
+ if (fiatCurrency) u.searchParams.set("fiatCurrency", fiatCurrency);
46
+ if (paymentMethod) u.searchParams.set("defaultPaymentMethod", paymentMethod);
47
+ return u.toString();
48
+ }
49
+
50
+ function hostedMethodUrls({
51
+ requestedMethod,
52
+ payBaseUrl,
53
+ sessionToken,
54
+ defaultNetwork,
55
+ defaultAsset,
56
+ redirectUrl,
57
+ fiatCurrency,
58
+ cardPaymentMethod,
59
+ bankPaymentMethod
60
+ }) {
61
+ const cardUrl = buildHostedUrl({
62
+ payBaseUrl,
63
+ sessionToken,
64
+ defaultNetwork,
65
+ defaultAsset,
66
+ redirectUrl,
67
+ fiatCurrency,
68
+ paymentMethod: safeTrim(cardPaymentMethod) || null
69
+ });
70
+ const bankUrl = buildHostedUrl({
71
+ payBaseUrl,
72
+ sessionToken,
73
+ defaultNetwork,
74
+ defaultAsset,
75
+ redirectUrl,
76
+ fiatCurrency,
77
+ paymentMethod: safeTrim(bankPaymentMethod) || null
78
+ });
79
+
80
+ if (requestedMethod === "card") return { card: cardUrl, bank: null, preferredMethod: "card" };
81
+ if (requestedMethod === "bank") return { card: null, bank: bankUrl, preferredMethod: "bank" };
82
+ return { card: cardUrl, bank: bankUrl, preferredMethod: "card" };
83
+ }
84
+
85
+ function readJsonSafe(raw) {
86
+ try {
87
+ return JSON.parse(String(raw ?? ""));
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ export async function buildCoinbaseHostedUrls({
94
+ requestedMethod = null,
95
+ walletAddress = null,
96
+ blockchain = null,
97
+ clientIp = null,
98
+ config = {},
99
+ fetchImpl = fetch,
100
+ generateJwtImpl = generateJwt
101
+ } = {}) {
102
+ const apiKeyId = safeTrim(config.apiKeyId);
103
+ const apiKeySecret = safeTrim(config.apiKeySecret);
104
+ if (!apiKeyId || !apiKeySecret) {
105
+ return { card: null, bank: null, preferredMethod: null, provider: "coinbase", unavailableReason: "MISSING_API_KEYS" };
106
+ }
107
+ const normalizedApiKeySecret = apiKeySecret.includes("\\n") ? apiKeySecret.replace(/\\n/g, "\n") : apiKeySecret;
108
+
109
+ const tokenUrl = normalizeHttpUrl(config.tokenUrl ?? "https://api.developer.coinbase.com/onramp/v1/token");
110
+ const payBaseUrl = normalizeHttpUrl(config.payBaseUrl ?? "https://pay.coinbase.com/buy/select-asset");
111
+ if (!tokenUrl || !payBaseUrl) {
112
+ return { card: null, bank: null, preferredMethod: null, provider: "coinbase", unavailableReason: "INVALID_URLS" };
113
+ }
114
+
115
+ const parsedTokenUrl = new URL(tokenUrl);
116
+ const networkOverride = safeTrim(config.destinationNetwork);
117
+ const destinationNetwork = networkOverride || mapBlockchainToCoinbaseNetwork(blockchain);
118
+ if (!destinationNetwork) {
119
+ return { card: null, bank: null, preferredMethod: null, provider: "coinbase", unavailableReason: "UNSUPPORTED_NETWORK" };
120
+ }
121
+
122
+ const destinationAddress = safeTrim(walletAddress);
123
+ if (!destinationAddress) {
124
+ return { card: null, bank: null, preferredMethod: null, provider: "coinbase", unavailableReason: "MISSING_WALLET_ADDRESS" };
125
+ }
126
+
127
+ const purchaseAsset = safeTrim(config.purchaseAsset).toUpperCase() || "USDC";
128
+ const fiatCurrency = safeTrim(config.fiatCurrency).toUpperCase() || "USD";
129
+ const redirectUrl = normalizeHttpUrl(config.redirectUrl);
130
+ const partnerUserRef = safeTrim(config.partnerUserRef) || null;
131
+ const resolvedClientIp = normalizeClientIp(config.clientIp) || normalizeClientIp(clientIp) || null;
132
+
133
+ const jwt = await generateJwtImpl({
134
+ apiKeyId,
135
+ apiKeySecret: normalizedApiKeySecret,
136
+ requestMethod: "POST",
137
+ requestHost: parsedTokenUrl.host,
138
+ requestPath: parsedTokenUrl.pathname,
139
+ expiresIn: 120
140
+ });
141
+
142
+ const requestBody = {
143
+ addresses: [{
144
+ address: destinationAddress,
145
+ blockchains: [destinationNetwork]
146
+ }],
147
+ assets: [purchaseAsset]
148
+ };
149
+ if (resolvedClientIp) requestBody.clientIp = resolvedClientIp;
150
+ if (partnerUserRef) requestBody.partnerUserRef = partnerUserRef;
151
+
152
+ const res = await fetchImpl(tokenUrl, {
153
+ method: "POST",
154
+ headers: {
155
+ authorization: `Bearer ${jwt}`,
156
+ "content-type": "application/json",
157
+ accept: "application/json"
158
+ },
159
+ body: JSON.stringify(requestBody)
160
+ });
161
+ const raw = await res.text();
162
+ const json = readJsonSafe(raw);
163
+ if (!res.ok) {
164
+ const detail = json && typeof json === "object" ? json : { message: raw || `HTTP ${res.status}` };
165
+ const err = new Error(`coinbase session token request failed (${res.status})`);
166
+ err.detail = detail;
167
+ throw err;
168
+ }
169
+
170
+ const sessionToken = safeTrim(json?.token || json?.sessionToken || json?.session?.token);
171
+ if (!sessionToken) {
172
+ const err = new Error("coinbase session token response missing token");
173
+ err.detail = json;
174
+ throw err;
175
+ }
176
+
177
+ const urls = hostedMethodUrls({
178
+ requestedMethod,
179
+ payBaseUrl,
180
+ sessionToken,
181
+ defaultNetwork: destinationNetwork,
182
+ defaultAsset: purchaseAsset,
183
+ redirectUrl,
184
+ fiatCurrency,
185
+ cardPaymentMethod: config.cardPaymentMethod,
186
+ bankPaymentMethod: config.bankPaymentMethod
187
+ });
188
+
189
+ return {
190
+ ...urls,
191
+ provider: "coinbase",
192
+ sessionToken,
193
+ destinationNetwork,
194
+ purchaseAsset,
195
+ fiatCurrency
196
+ };
197
+ }
@@ -0,0 +1,155 @@
1
+ import crypto from "node:crypto";
2
+
3
+ function normalizeHttpUrl(value) {
4
+ const raw = String(value ?? "").trim();
5
+ if (!raw) return null;
6
+ try {
7
+ const u = new URL(raw);
8
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
9
+ return u.toString();
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ function parseCsvList(raw) {
16
+ const input = String(raw ?? "").trim();
17
+ if (!input) return [];
18
+ return [...new Set(input.split(",").map((x) => String(x ?? "").trim()).filter(Boolean))];
19
+ }
20
+
21
+ function normalizeOnramperNetworkId(raw) {
22
+ const value = String(raw ?? "").trim();
23
+ if (!value) return null;
24
+ const key = value.toLowerCase();
25
+ if (key === "base-sepolia") return "base_sepolia";
26
+ if (key === "base") return "base";
27
+ if (key === "ethereum" || key === "eth") return "ethereum";
28
+ if (key === "ethereum-sepolia" || key === "sepolia") return "ethereum_sepolia";
29
+ if (key === "polygon" || key === "matic") return "polygon";
30
+ if (key === "solana" || key === "sol") return "solana";
31
+ return key.replace(/[^a-z0-9_-]+/g, "_");
32
+ }
33
+
34
+ function normalizedList(raw) {
35
+ return parseCsvList(raw)
36
+ .map((x) => String(x).trim().toLowerCase())
37
+ .filter(Boolean);
38
+ }
39
+
40
+ function addIfPresent(searchParams, key, value) {
41
+ const raw = String(value ?? "").trim();
42
+ if (!raw) return;
43
+ searchParams.set(key, raw);
44
+ }
45
+
46
+ function buildSensitiveSignature({ signingSecret, sensitivePairs }) {
47
+ const secret = String(signingSecret ?? "").trim();
48
+ if (!secret || !Array.isArray(sensitivePairs) || !sensitivePairs.length) return null;
49
+ const rows = sensitivePairs
50
+ .map((entry) => {
51
+ const k = String(entry?.key ?? "").trim();
52
+ const v = String(entry?.value ?? "").trim();
53
+ if (!k || !v) return null;
54
+ return { key: k, value: v };
55
+ })
56
+ .filter(Boolean)
57
+ .sort((a, b) => a.key.localeCompare(b.key));
58
+ if (!rows.length) return null;
59
+ const signContent = rows.map(({ key, value }) => `${key}=${value}`).join("&");
60
+ return crypto.createHmac("sha256", secret).update(signContent, "utf8").digest("hex");
61
+ }
62
+
63
+ function buildMethodUrl({
64
+ baseUrl,
65
+ apiKey,
66
+ method,
67
+ defaultFiat,
68
+ defaultCrypto,
69
+ onlyCryptos,
70
+ onlyCryptoNetworks,
71
+ walletAddress,
72
+ networkId,
73
+ signingSecret,
74
+ successRedirectUrl,
75
+ failureRedirectUrl
76
+ }) {
77
+ const u = new URL(baseUrl);
78
+ const sp = u.searchParams;
79
+ sp.set("apiKey", apiKey);
80
+ sp.set("mode", "buy");
81
+
82
+ addIfPresent(sp, "defaultFiat", defaultFiat);
83
+ addIfPresent(sp, "defaultCrypto", defaultCrypto);
84
+
85
+ if (onlyCryptos.length) sp.set("onlyCryptos", onlyCryptos.join(","));
86
+ if (onlyCryptoNetworks.length) sp.set("onlyCryptoNetworks", onlyCryptoNetworks.join(","));
87
+
88
+ if (method === "card") sp.set("defaultPaymentMethod", "creditcard");
89
+ if (method === "bank") sp.set("defaultPaymentMethod", "banktransfer");
90
+
91
+ addIfPresent(sp, "successRedirectUrl", successRedirectUrl);
92
+ addIfPresent(sp, "failureRedirectUrl", failureRedirectUrl);
93
+
94
+ const sensitivePairs = [];
95
+ if (walletAddress && networkId && signingSecret) {
96
+ const networkWallets = `${networkId}:${walletAddress}`;
97
+ sp.set("networkWallets", networkWallets);
98
+ sensitivePairs.push({ key: "networkWallets", value: networkWallets });
99
+ }
100
+
101
+ const signature = buildSensitiveSignature({ signingSecret, sensitivePairs });
102
+ if (signature) sp.set("signature", signature);
103
+
104
+ return u.toString();
105
+ }
106
+
107
+ export function buildOnramperHostedUrls({
108
+ requestedMethod = null,
109
+ walletAddress = null,
110
+ blockchain = null,
111
+ config = {}
112
+ } = {}) {
113
+ const apiKey = String(config.apiKey ?? "").trim();
114
+ if (!apiKey) return { card: null, bank: null, preferredMethod: null, provider: "onramper" };
115
+
116
+ const baseUrl = normalizeHttpUrl(config.baseUrl ?? "https://buy.onramper.com");
117
+ if (!baseUrl) return { card: null, bank: null, preferredMethod: null, provider: "onramper" };
118
+
119
+ const onlyCryptos = normalizedList(config.onlyCryptos);
120
+ const onlyCryptoNetworks = normalizedList(config.onlyCryptoNetworks);
121
+ const defaultCrypto = String(config.defaultCrypto ?? "usdc").trim().toLowerCase();
122
+ const defaultFiat = String(config.defaultFiat ?? "usd").trim().toLowerCase();
123
+
124
+ const configuredNetworkId = normalizeOnramperNetworkId(config.networkId);
125
+ const inferredNetworkId = normalizeOnramperNetworkId(blockchain);
126
+ const networkId = configuredNetworkId || inferredNetworkId;
127
+ const signingSecret = String(config.signingSecret ?? "").trim();
128
+ const successRedirectUrl = normalizeHttpUrl(config.successRedirectUrl);
129
+ const failureRedirectUrl = normalizeHttpUrl(config.failureRedirectUrl);
130
+
131
+ const make = (method) =>
132
+ buildMethodUrl({
133
+ baseUrl,
134
+ apiKey,
135
+ method,
136
+ defaultFiat,
137
+ defaultCrypto,
138
+ onlyCryptos,
139
+ onlyCryptoNetworks,
140
+ walletAddress: String(walletAddress ?? "").trim() || null,
141
+ networkId,
142
+ signingSecret,
143
+ successRedirectUrl,
144
+ failureRedirectUrl
145
+ });
146
+
147
+ let card = make("card");
148
+ let bank = make("bank");
149
+
150
+ if (requestedMethod === "card") bank = null;
151
+ if (requestedMethod === "bank") card = null;
152
+
153
+ const preferredMethod = card ? "card" : bank ? "bank" : null;
154
+ return { card, bank, preferredMethod, provider: "onramper" };
155
+ }
@@ -86,6 +86,44 @@ function pickUsdcTokenId(payload) {
86
86
  return null;
87
87
  }
88
88
 
89
+ function pickUsdcBalance(payload, { tokenIdHint = null } = {}) {
90
+ const root = payload && typeof payload === "object" ? payload : {};
91
+ const balances =
92
+ (Array.isArray(root?.data?.tokenBalances) && root.data.tokenBalances) ||
93
+ (Array.isArray(root?.tokenBalances) && root.tokenBalances) ||
94
+ [];
95
+ const normalizedHint = String(tokenIdHint ?? "").trim();
96
+ const ranked = [];
97
+ for (const row of balances) {
98
+ if (!row || typeof row !== "object") continue;
99
+ const token = row.token && typeof row.token === "object" ? row.token : null;
100
+ const tokenId = String(token?.id ?? row.tokenId ?? row.id ?? "").trim();
101
+ const symbol = String(token?.symbol ?? row.symbol ?? "").trim().toUpperCase();
102
+ const amountRaw = row.amount ?? row.amountUsdc ?? row.balance ?? null;
103
+ const amountText = amountRaw === null || amountRaw === undefined ? "" : String(amountRaw).trim();
104
+ if (!amountText) continue;
105
+ const amount = Number(amountText);
106
+ if (!Number.isFinite(amount) || amount < 0) continue;
107
+ const score =
108
+ normalizedHint && tokenId === normalizedHint
109
+ ? 3
110
+ : symbol === "USDC"
111
+ ? 2
112
+ : tokenId && normalizedHint && tokenId.toLowerCase().includes("usdc")
113
+ ? 1
114
+ : 0;
115
+ ranked.push({
116
+ score,
117
+ tokenId: tokenId || null,
118
+ symbol: symbol || null,
119
+ amount,
120
+ amountText
121
+ });
122
+ }
123
+ ranked.sort((a, b) => b.score - a.score || b.amount - a.amount);
124
+ return ranked[0] ?? null;
125
+ }
126
+
89
127
  function inferModeFromBaseUrl(baseUrl) {
90
128
  const u = normalizeHttpUrl(baseUrl);
91
129
  if (!u) return null;
@@ -198,6 +236,27 @@ async function resolveUsdcTokenId({ baseUrl, apiKey, walletIds, fetchImpl = fetc
198
236
  return null;
199
237
  }
200
238
 
239
+ async function resolveWalletUsdcBalance({ baseUrl, apiKey, walletId, tokenIdHint = null, fetchImpl = fetch }) {
240
+ const out = await callCircle({
241
+ baseUrl,
242
+ apiKey,
243
+ method: "GET",
244
+ endpoint: `/v1/w3s/wallets/${encodeURIComponent(walletId)}/balances`,
245
+ fetchImpl
246
+ });
247
+ if (out.status < 200 || out.status >= 300) {
248
+ throw new Error(`wallet balance lookup failed for ${walletId} (HTTP ${out.status})`);
249
+ }
250
+ const picked = pickUsdcBalance(out.json, { tokenIdHint });
251
+ return {
252
+ walletId,
253
+ usdcAmount: picked ? picked.amount : null,
254
+ usdcAmountText: picked ? picked.amountText : null,
255
+ tokenId: picked?.tokenId ?? null,
256
+ symbol: picked?.symbol ?? null
257
+ };
258
+ }
259
+
201
260
  async function requestFaucet({ baseUrl, apiKey, address, blockchain, native, usdc, fetchImpl = fetch }) {
202
261
  const out = await callCircle({
203
262
  baseUrl,
@@ -229,6 +288,7 @@ export async function bootstrapCircleProvider({
229
288
  escrowWalletId = null,
230
289
  tokenIdUsdc = null,
231
290
  faucet = null,
291
+ includeBalances = false,
232
292
  includeApiKey = false,
233
293
  entitySecretHex = null,
234
294
  fetchImpl = fetch
@@ -284,6 +344,40 @@ export async function bootstrapCircleProvider({
284
344
  throw new Error("could not discover USDC token id; pass tokenIdUsdc explicitly");
285
345
  }
286
346
 
347
+ let balances = null;
348
+ if (includeBalances) {
349
+ try {
350
+ const [spendBalance, escrowBalance] = await Promise.all([
351
+ resolveWalletUsdcBalance({
352
+ baseUrl: detected.baseUrl,
353
+ apiKey: circleApiKey,
354
+ walletId: chosen.spendWalletId,
355
+ tokenIdHint: resolvedTokenIdUsdc,
356
+ fetchImpl
357
+ }),
358
+ resolveWalletUsdcBalance({
359
+ baseUrl: detected.baseUrl,
360
+ apiKey: circleApiKey,
361
+ walletId: chosen.escrowWalletId,
362
+ tokenIdHint: resolvedTokenIdUsdc,
363
+ fetchImpl
364
+ })
365
+ ]);
366
+ balances = {
367
+ asOf: new Date().toISOString(),
368
+ spend: spendBalance,
369
+ escrow: escrowBalance
370
+ };
371
+ } catch (err) {
372
+ balances = {
373
+ asOf: new Date().toISOString(),
374
+ error: err?.message ?? "balance_lookup_failed",
375
+ spend: null,
376
+ escrow: null
377
+ };
378
+ }
379
+ }
380
+
287
381
  const faucetEnabled =
288
382
  typeof faucet === "boolean"
289
383
  ? faucet
@@ -342,6 +436,7 @@ export async function bootstrapCircleProvider({
342
436
  escrow: escrowMeta
343
437
  },
344
438
  tokenIdUsdc: resolvedTokenIdUsdc,
439
+ balances,
345
440
  entitySecretHex: resolvedEntitySecretHex,
346
441
  faucetEnabled,
347
442
  faucetResults,