@pollar/core 0.9.1-rc.0 → 0.10.0-rc.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/dist/index.js CHANGED
@@ -189,7 +189,7 @@ function defaultKeyManager(storage, apiKey) {
189
189
 
190
190
  // src/lib/base64url.ts
191
191
  var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
192
- (() => {
192
+ var REVERSE = (() => {
193
193
  const m = /* @__PURE__ */ new Map();
194
194
  for (let i = 0; i < ALPHABET.length; i++) m.set(ALPHABET[i], i);
195
195
  return m;
@@ -220,6 +220,28 @@ function base64urlEncode(bytes) {
220
220
  }
221
221
  return result;
222
222
  }
223
+ function base64urlDecode(input) {
224
+ const clean = input.replace(/=+$/, "");
225
+ const out = new Uint8Array(Math.floor(clean.length * 3 / 4));
226
+ let byteIdx = 0;
227
+ for (let i = 0; i < clean.length; i += 4) {
228
+ const c1 = REVERSE.get(clean[i]);
229
+ const c2 = REVERSE.get(clean[i + 1]);
230
+ const c3 = i + 2 < clean.length ? REVERSE.get(clean[i + 2]) : void 0;
231
+ const c4 = i + 3 < clean.length ? REVERSE.get(clean[i + 3]) : void 0;
232
+ if (c1 === void 0 || c2 === void 0) {
233
+ throw new Error("[PollarClient] Invalid base64url input");
234
+ }
235
+ out[byteIdx++] = c1 << 2 | c2 >> 4;
236
+ if (c3 !== void 0) {
237
+ out[byteIdx++] = (c2 & 15) << 4 | c3 >> 2;
238
+ if (c4 !== void 0) {
239
+ out[byteIdx++] = (c3 & 3) << 6 | c4;
240
+ }
241
+ }
242
+ }
243
+ return out.slice(0, byteIdx);
244
+ }
223
245
  function base64urlEncodeString(s) {
224
246
  return base64urlEncode(new TextEncoder().encode(s));
225
247
  }
@@ -1086,7 +1108,19 @@ function createLogger(level = "info", sink = console) {
1086
1108
  }
1087
1109
 
1088
1110
  // src/lib/logging.ts
1089
- var SENSITIVE_BODY_KEYS = /* @__PURE__ */ new Set(["email", "code", "walletAddress", "dpopJwk", "response", "refreshToken"]);
1111
+ var SENSITIVE_BODY_KEYS = /* @__PURE__ */ new Set([
1112
+ "email",
1113
+ "code",
1114
+ "walletAddress",
1115
+ "dpopJwk",
1116
+ "response",
1117
+ "refreshToken",
1118
+ // SEP-10 challenge envelopes: a counter-signed challenge is a live, replayable
1119
+ // auth credential — never log it in the clear.
1120
+ "signedChallengeXdr",
1121
+ "challengeXdr",
1122
+ "signedTxXdr"
1123
+ ]);
1090
1124
  function redactBody(body) {
1091
1125
  if (!body || typeof body !== "object") return body;
1092
1126
  const out = {};
@@ -1183,8 +1217,36 @@ function defaultStorage(options = {}) {
1183
1217
  return createLocalStorageAdapter(options);
1184
1218
  }
1185
1219
 
1220
+ // src/types.ts
1221
+ var AUTH_ERROR_CODES = {
1222
+ SESSION_CREATE_FAILED: "SESSION_CREATE_FAILED",
1223
+ SESSION_EXPIRED: "SESSION_EXPIRED",
1224
+ SESSION_INVALID: "SESSION_INVALID",
1225
+ EMAIL_SEND_FAILED: "EMAIL_SEND_FAILED",
1226
+ EMAIL_VERIFY_FAILED: "EMAIL_VERIFY_FAILED",
1227
+ EMAIL_CODE_EXPIRED: "EMAIL_CODE_EXPIRED",
1228
+ EMAIL_CODE_INVALID: "EMAIL_CODE_INVALID",
1229
+ AUTH_FAILED: "AUTH_FAILED",
1230
+ WALLET_CONNECT_FAILED: "WALLET_CONNECT_FAILED",
1231
+ WALLET_AUTH_FAILED: "WALLET_AUTH_FAILED",
1232
+ WALLET_RESOLVER_TIMEOUT: "WALLET_RESOLVER_TIMEOUT",
1233
+ EXTERNAL_AUTH_FAILED: "EXTERNAL_AUTH_FAILED",
1234
+ PASSKEY_FAILED: "PASSKEY_FAILED",
1235
+ // Generic bucket for on-chain transaction failures; the precise reason is the
1236
+ // backend `code` (e.g. TX_FEE_LIMIT_EXCEEDED) carried alongside on the outcome.
1237
+ TX_FAILED: "TX_FAILED",
1238
+ UNEXPECTED_ERROR: "UNEXPECTED_ERROR"
1239
+ };
1240
+ var PollarFlowError = class extends Error {
1241
+ constructor(message) {
1242
+ super(message);
1243
+ this.code = "INVALID_FLOW";
1244
+ this.name = "PollarFlowError";
1245
+ }
1246
+ };
1247
+
1186
1248
  // src/version.ts
1187
- var POLLAR_CORE_VERSION = "0.9.1-rc.0" ;
1249
+ var POLLAR_CORE_VERSION = "0.10.0-rc.0" ;
1188
1250
 
1189
1251
  // src/visibility/noop.ts
1190
1252
  function createNoopVisibilityProvider() {
@@ -1237,30 +1299,6 @@ function defaultVisibilityProvider() {
1237
1299
  return createNoopVisibilityProvider();
1238
1300
  }
1239
1301
 
1240
- // src/types.ts
1241
- var AUTH_ERROR_CODES = {
1242
- SESSION_CREATE_FAILED: "SESSION_CREATE_FAILED",
1243
- SESSION_EXPIRED: "SESSION_EXPIRED",
1244
- SESSION_INVALID: "SESSION_INVALID",
1245
- EMAIL_SEND_FAILED: "EMAIL_SEND_FAILED",
1246
- EMAIL_VERIFY_FAILED: "EMAIL_VERIFY_FAILED",
1247
- EMAIL_CODE_EXPIRED: "EMAIL_CODE_EXPIRED",
1248
- EMAIL_CODE_INVALID: "EMAIL_CODE_INVALID",
1249
- AUTH_FAILED: "AUTH_FAILED",
1250
- WALLET_CONNECT_FAILED: "WALLET_CONNECT_FAILED",
1251
- WALLET_AUTH_FAILED: "WALLET_AUTH_FAILED",
1252
- WALLET_RESOLVER_TIMEOUT: "WALLET_RESOLVER_TIMEOUT",
1253
- PASSKEY_FAILED: "PASSKEY_FAILED",
1254
- UNEXPECTED_ERROR: "UNEXPECTED_ERROR"
1255
- };
1256
- var PollarFlowError = class extends Error {
1257
- constructor(message) {
1258
- super(message);
1259
- this.code = "INVALID_FLOW";
1260
- this.name = "PollarFlowError";
1261
- }
1262
- };
1263
-
1264
1302
  // src/wallets/FreighterAdapter.ts
1265
1303
  var import_freighter_api = __toESM(require_index_min());
1266
1304
 
@@ -1516,6 +1554,10 @@ function isValidSession(value, logger = console) {
1516
1554
  logger.debug("[PollarClient:session] Invalid session \u2014 wallet.type must be internal|smart|external");
1517
1555
  return false;
1518
1556
  }
1557
+ if (w["provider"] !== void 0 && typeof w["provider"] !== "string") {
1558
+ logger.debug("[PollarClient:session] Invalid session \u2014 wallet.provider must be a string if present");
1559
+ return false;
1560
+ }
1519
1561
  if (w["address"] !== null && !isBoundedString(w["address"], MAX_WALLET_PUBLIC_KEY)) {
1520
1562
  logger.debug("[PollarClient:session] Invalid session \u2014 wallet.address must be string|null");
1521
1563
  return false;
@@ -1811,14 +1853,91 @@ async function createAuthSession(deps) {
1811
1853
  return data.content.clientSessionId;
1812
1854
  }
1813
1855
 
1856
+ // src/client/auth/errorMessages.ts
1857
+ var CATALOG = {
1858
+ // ── Smart-account deploy / sponsor wallet ──────────────────────────────────
1859
+ SPONSOR_NOT_FUNDED: {
1860
+ message: "This app can't create your wallet yet \u2014 its sponsor account isn't funded. Please contact the app's developer.",
1861
+ errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1862
+ },
1863
+ APP_WALLET_NOT_FOUND: {
1864
+ message: "This app isn't fully set up to create wallets yet. Please contact the app's developer.",
1865
+ errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1866
+ },
1867
+ WALLET_NOT_FOUND: {
1868
+ message: "This app isn't fully set up to create wallets yet. Please contact the app's developer.",
1869
+ errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1870
+ },
1871
+ PASSKEY_DEPLOY_FAILED: {
1872
+ message: "We couldn't finish creating your wallet. Please try again in a moment.",
1873
+ errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1874
+ },
1875
+ // ── Passkey ceremony ────────────────────────────────────────────────────────
1876
+ PASSKEY_ALREADY_REGISTERED: {
1877
+ message: "A passkey is already registered for this account. Try signing in instead.",
1878
+ errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1879
+ },
1880
+ PASSKEY_UNKNOWN_CREDENTIAL: {
1881
+ message: "We don't recognize this passkey. Try creating a new one.",
1882
+ errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1883
+ },
1884
+ PASSKEY_VERIFICATION_FAILED: {
1885
+ message: "We couldn't verify your passkey. Please try again.",
1886
+ errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1887
+ },
1888
+ PASSKEY_CHALLENGE_MISSING: {
1889
+ message: "Your passkey session expired. Please start again.",
1890
+ errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1891
+ },
1892
+ // ── On-chain transaction failures (surfaced during deploy/transfer) ─────────
1893
+ // These map to the TX_FAILED bucket (not PASSKEY_FAILED) — the precise reason
1894
+ // is the entry key itself, surfaced as the raw `code` on the tx outcome.
1895
+ TX_INSUFFICIENT_BALANCE: {
1896
+ message: "Insufficient balance to complete this transaction.",
1897
+ errorCode: AUTH_ERROR_CODES.TX_FAILED
1898
+ },
1899
+ TX_INSUFFICIENT_FEE: {
1900
+ message: "Not enough XLM to cover the network fee. Add more XLM to your wallet and try again.",
1901
+ errorCode: AUTH_ERROR_CODES.TX_FAILED
1902
+ },
1903
+ TX_FEE_LIMIT_EXCEEDED: {
1904
+ message: "The transaction fee is above the allowed limit. Please try again.",
1905
+ errorCode: AUTH_ERROR_CODES.TX_FAILED
1906
+ },
1907
+ TX_CONTRACT_FAILED: {
1908
+ message: "The contract rejected this operation. Check the operation is allowed right now and try again.",
1909
+ errorCode: AUTH_ERROR_CODES.TX_FAILED
1910
+ },
1911
+ TX_DESTINATION_NOT_FOUND: {
1912
+ message: "The destination account doesn't exist on the network yet.",
1913
+ errorCode: AUTH_ERROR_CODES.TX_FAILED
1914
+ },
1915
+ TX_NO_TRUSTLINE: {
1916
+ message: "The destination can't receive this asset yet (no trustline).",
1917
+ errorCode: AUTH_ERROR_CODES.TX_FAILED
1918
+ },
1919
+ TX_BAD_SEQUENCE: {
1920
+ message: "Something went out of sync. Please try again.",
1921
+ errorCode: AUTH_ERROR_CODES.TX_FAILED
1922
+ }
1923
+ };
1924
+ function resolveAuthError(code, fallbackMessage) {
1925
+ if (code && CATALOG[code]) return CATALOG[code];
1926
+ return { message: fallbackMessage, errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED };
1927
+ }
1928
+ function extractErrorCode(error, data) {
1929
+ return error?.code ?? data?.code ?? void 0;
1930
+ }
1931
+
1814
1932
  // src/client/auth/emailFlow.ts
1815
- async function initEmailSession(deps) {
1816
- const clientSessionId = await createAuthSession(deps);
1817
- if (!clientSessionId) return;
1818
- deps.setAuthState({ step: "entering_email", clientSessionId });
1933
+ async function initEmailSession(ctx) {
1934
+ const clientSessionId = await ctx.createSession();
1935
+ if (!clientSessionId) return null;
1936
+ ctx.setAuthState({ step: "entering_email", clientSessionId });
1937
+ return clientSessionId;
1819
1938
  }
1820
- async function sendEmailCode(email, clientSessionId, deps) {
1821
- const { api, logger, signal, setAuthState } = deps;
1939
+ async function sendEmailCode(email, clientSessionId, ctx) {
1940
+ const { api, logger, signal, setAuthState } = ctx;
1822
1941
  setAuthState({ step: "sending_email", email });
1823
1942
  const body = { clientSessionId, email };
1824
1943
  const { data, error } = await api.POST("/auth/email", { body, signal });
@@ -1834,13 +1953,13 @@ async function sendEmailCode(email, clientSessionId, deps) {
1834
1953
  }
1835
1954
  setAuthState({ step: "entering_code", clientSessionId, email });
1836
1955
  }
1837
- async function verifyAndAuthenticate(code, clientSessionId, email, deps) {
1838
- const { api, logger, signal, setAuthState } = deps;
1956
+ async function verifyAndAuthenticate(code, clientSessionId, email, ctx) {
1957
+ const { api, logger, signal, setAuthState } = ctx;
1839
1958
  setAuthState({ step: "verifying_email_code", clientSessionId, email });
1840
1959
  const body = { clientSessionId, code };
1841
1960
  const { data, error } = await api.POST("/auth/email/verify-code", { body, signal });
1842
1961
  if (data?.code === "SDK_EMAIL_CODE_VERIFIED") {
1843
- await authenticate(clientSessionId, deps);
1962
+ await ctx.authenticate(clientSessionId);
1844
1963
  return;
1845
1964
  }
1846
1965
  const errCode = error?.error ?? data?.code;
@@ -1918,68 +2037,6 @@ async function loginOAuth(provider, deps) {
1918
2037
  await authenticate(clientSessionId, deps);
1919
2038
  }
1920
2039
 
1921
- // src/client/auth/errorMessages.ts
1922
- var CATALOG = {
1923
- // ── Smart-account deploy / sponsor wallet ──────────────────────────────────
1924
- SPONSOR_NOT_FUNDED: {
1925
- message: "This app can't create your wallet yet \u2014 its sponsor account isn't funded. Please contact the app's developer.",
1926
- errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1927
- },
1928
- APP_WALLET_NOT_FOUND: {
1929
- message: "This app isn't fully set up to create wallets yet. Please contact the app's developer.",
1930
- errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1931
- },
1932
- WALLET_NOT_FOUND: {
1933
- message: "This app isn't fully set up to create wallets yet. Please contact the app's developer.",
1934
- errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1935
- },
1936
- PASSKEY_DEPLOY_FAILED: {
1937
- message: "We couldn't finish creating your wallet. Please try again in a moment.",
1938
- errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1939
- },
1940
- // ── Passkey ceremony ────────────────────────────────────────────────────────
1941
- PASSKEY_ALREADY_REGISTERED: {
1942
- message: "A passkey is already registered for this account. Try signing in instead.",
1943
- errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1944
- },
1945
- PASSKEY_UNKNOWN_CREDENTIAL: {
1946
- message: "We don't recognize this passkey. Try creating a new one.",
1947
- errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1948
- },
1949
- PASSKEY_VERIFICATION_FAILED: {
1950
- message: "We couldn't verify your passkey. Please try again.",
1951
- errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1952
- },
1953
- PASSKEY_CHALLENGE_MISSING: {
1954
- message: "Your passkey session expired. Please start again.",
1955
- errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1956
- },
1957
- // ── On-chain transaction failures (surfaced during deploy/transfer) ─────────
1958
- TX_INSUFFICIENT_BALANCE: {
1959
- message: "Insufficient balance to complete this transaction.",
1960
- errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1961
- },
1962
- TX_DESTINATION_NOT_FOUND: {
1963
- message: "The destination account doesn't exist on the network yet.",
1964
- errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1965
- },
1966
- TX_NO_TRUSTLINE: {
1967
- message: "The destination can't receive this asset yet (no trustline).",
1968
- errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1969
- },
1970
- TX_BAD_SEQUENCE: {
1971
- message: "Something went out of sync. Please try again.",
1972
- errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED
1973
- }
1974
- };
1975
- function resolveAuthError(code, fallbackMessage) {
1976
- if (code && CATALOG[code]) return CATALOG[code];
1977
- return { message: fallbackMessage, errorCode: AUTH_ERROR_CODES.PASSKEY_FAILED };
1978
- }
1979
- function extractErrorCode(error, data) {
1980
- return error?.code ?? data?.code ?? void 0;
1981
- }
1982
-
1983
2040
  // src/client/auth/passkeyFlow.ts
1984
2041
  async function smartWalletFlow(deps, mode) {
1985
2042
  const { api, logger, signal, setAuthState, passkey } = deps;
@@ -2036,6 +2093,71 @@ function failPasskey(setAuthState, code, fallbackMessage) {
2036
2093
  setAuthState({ step: "error", previousStep: "creating_passkey", message, errorCode });
2037
2094
  }
2038
2095
 
2096
+ // src/client/auth/providers.ts
2097
+ function oauthProvider(provider) {
2098
+ return {
2099
+ id: provider,
2100
+ login: (ctx) => ctx.startHostedOAuth(provider)
2101
+ };
2102
+ }
2103
+ function emailProvider() {
2104
+ return {
2105
+ id: "email",
2106
+ login: async (ctx, options) => {
2107
+ const email = options.email ?? "";
2108
+ const clientSessionId = await initEmailSession(ctx);
2109
+ if (clientSessionId) await sendEmailCode(email, clientSessionId, ctx);
2110
+ },
2111
+ actions: {
2112
+ begin: async (ctx) => {
2113
+ await initEmailSession(ctx);
2114
+ },
2115
+ sendCode: (ctx, payload) => {
2116
+ const { email, clientSessionId } = payload ?? {};
2117
+ return sendEmailCode(email, clientSessionId, ctx);
2118
+ },
2119
+ verifyCode: (ctx, payload) => {
2120
+ const { code, clientSessionId, email } = payload ?? {};
2121
+ return verifyAndAuthenticate(code, clientSessionId, email, ctx);
2122
+ }
2123
+ }
2124
+ };
2125
+ }
2126
+
2127
+ // src/client/auth/sep10-challenge.ts
2128
+ var ENVELOPE_TYPE_TX_V0 = 0;
2129
+ var ENVELOPE_TYPE_TX = 2;
2130
+ var KEY_TYPE_ED25519 = 0;
2131
+ var SEQ_OFFSET_V1 = 44;
2132
+ var SEQ_OFFSET_V0 = 40;
2133
+ function base64ToBytes(b64) {
2134
+ return base64urlDecode(b64.replace(/\+/g, "-").replace(/\//g, "_"));
2135
+ }
2136
+ function isI64Zero(view, offset) {
2137
+ return view.getUint32(offset, false) === 0 && view.getUint32(offset + 4, false) === 0;
2138
+ }
2139
+ function isValidSep10Challenge(challengeXdr) {
2140
+ try {
2141
+ const bytes = base64ToBytes(challengeXdr.trim());
2142
+ if (bytes.length < 8) return false;
2143
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
2144
+ const envelopeType = view.getUint32(0, false);
2145
+ let seqOffset;
2146
+ if (envelopeType === ENVELOPE_TYPE_TX) {
2147
+ if (view.getUint32(4, false) !== KEY_TYPE_ED25519) return false;
2148
+ seqOffset = SEQ_OFFSET_V1;
2149
+ } else if (envelopeType === ENVELOPE_TYPE_TX_V0) {
2150
+ seqOffset = SEQ_OFFSET_V0;
2151
+ } else {
2152
+ return false;
2153
+ }
2154
+ if (bytes.length < seqOffset + 8) return false;
2155
+ return isI64Zero(view, seqOffset);
2156
+ } catch {
2157
+ return false;
2158
+ }
2159
+ }
2160
+
2039
2161
  // src/client/auth/walletFlow.ts
2040
2162
  function withSignal(promise, signal) {
2041
2163
  return Promise.race([
@@ -2049,6 +2171,16 @@ function withSignal(promise, signal) {
2049
2171
  })
2050
2172
  ]);
2051
2173
  }
2174
+ async function requestWalletChallenge(clientSessionId, walletAddress, deps) {
2175
+ const { api, logger, signal } = deps;
2176
+ const body = { clientSessionId, walletAddress };
2177
+ const { data, error } = await api.POST("/auth/wallet/challenge", { body, signal });
2178
+ if (error || !data?.success) {
2179
+ if (!error) logApiError(logger, "POST /auth/wallet/challenge", { body, data });
2180
+ return null;
2181
+ }
2182
+ return data.content.challengeXdr;
2183
+ }
2052
2184
  async function loginWallet(type, deps) {
2053
2185
  const { api, logger, signal, setAuthState } = deps;
2054
2186
  const clientSessionId = await createAuthSession(deps);
@@ -2065,8 +2197,33 @@ async function loginWallet(type, deps) {
2065
2197
  const { address } = await withSignal(adapter.connect(), signal);
2066
2198
  connectedWallet = address;
2067
2199
  deps.storeWalletAdapter(adapter, type);
2200
+ setAuthState({ step: "signing_wallet_challenge", walletType: type });
2201
+ const challengeXdr = await requestWalletChallenge(clientSessionId, address, deps);
2202
+ if (!challengeXdr) {
2203
+ setAuthState({
2204
+ step: "error",
2205
+ previousStep: "signing_wallet_challenge",
2206
+ message: "Failed to obtain wallet challenge",
2207
+ errorCode: AUTH_ERROR_CODES.WALLET_AUTH_FAILED
2208
+ });
2209
+ return;
2210
+ }
2211
+ if (!isValidSep10Challenge(challengeXdr)) {
2212
+ logApiError(logger, "SEP-10 challenge validation", { error: "unexpected challenge structure (sequence != 0?)" });
2213
+ setAuthState({
2214
+ step: "error",
2215
+ previousStep: "signing_wallet_challenge",
2216
+ message: "Invalid wallet challenge",
2217
+ errorCode: AUTH_ERROR_CODES.WALLET_AUTH_FAILED
2218
+ });
2219
+ return;
2220
+ }
2221
+ const { signedTxXdr } = await withSignal(
2222
+ adapter.signTransaction(challengeXdr, { networkPassphrase: deps.networkPassphrase }),
2223
+ signal
2224
+ );
2068
2225
  setAuthState({ step: "authenticating_wallet" });
2069
- const body = { clientSessionId, walletAddress: address };
2226
+ const body = { clientSessionId, walletAddress: address, signedChallengeXdr: signedTxXdr };
2070
2227
  const { data: walletData, error: walletError } = await api.POST("/auth/wallet", { body, signal });
2071
2228
  if (walletError || !walletData?.success) {
2072
2229
  if (!walletError) logApiError(logger, "POST /auth/wallet", { body, data: walletData });
@@ -2155,6 +2312,13 @@ var PollarClient = class {
2155
2312
  this._loginController = null;
2156
2313
  /** Aborts an in-flight `/auth/session/resume` on destroy() or re-trigger. */
2157
2314
  this._resumeController = null;
2315
+ /**
2316
+ * Registry of pluggable login strategies, keyed by provider id. Seeded with
2317
+ * the built-ins (`google`, `github`, `email`) and then any `config.providers`
2318
+ * (which can override a built-in by reusing its id). `wallet` is deliberately
2319
+ * absent — it keeps its own dedicated flow. See {@link PollarAuthProvider}.
2320
+ */
2321
+ this._providers = /* @__PURE__ */ new Map();
2158
2322
  this.apiKey = config.apiKey;
2159
2323
  this.id = randomUUID();
2160
2324
  this.basePath = `${config.baseUrl || "https://sdk.api.pollar.xyz"}/v1`;
@@ -2176,6 +2340,12 @@ var PollarClient = class {
2176
2340
  this._maxIdleMs = config.maxIdleMs;
2177
2341
  this._openAuthUrl = config.openAuthUrl ?? defaultWebOAuthOpener;
2178
2342
  this._oauthRedirectUri = config.oauthRedirectUri ?? (isBrowser ? window.location?.origin ?? "" : "");
2343
+ for (const provider of [oauthProvider("google"), oauthProvider("github"), emailProvider()]) {
2344
+ this._providers.set(provider.id, provider);
2345
+ }
2346
+ for (const provider of config.providers ?? []) {
2347
+ this._providers.set(provider.id, provider);
2348
+ }
2179
2349
  this._api = createApiClient(this.basePath);
2180
2350
  this._wireMiddlewares();
2181
2351
  this._networkState = { step: "connected", network: config.stellarNetwork ?? "testnet" };
@@ -2582,28 +2752,42 @@ var PollarClient = class {
2582
2752
  warnServerSide("login");
2583
2753
  return;
2584
2754
  }
2585
- if (options.provider === "google" || options.provider === "github" || options.provider === "email") {
2586
- const controller = this._newController();
2587
- const deps = this._flowDeps(controller.signal);
2588
- if (options.provider === "google" || options.provider === "github") {
2589
- loginOAuth(options.provider, {
2590
- ...deps,
2591
- basePath: this.basePath,
2592
- apiKey: this.apiKey,
2593
- openAuthUrl: this._openAuthUrl,
2594
- redirectUri: this._oauthRedirectUri
2595
- }).catch((err) => this._handleFlowError(err));
2596
- } else if (options.provider === "email") {
2597
- const { email } = options;
2598
- initEmailSession(deps).then(() => {
2599
- if (this._authState.step === "entering_email") {
2600
- return sendEmailCode(email, this._authState.clientSessionId, deps);
2601
- }
2602
- }).catch((err) => this._handleFlowError(err));
2603
- }
2604
- } else if (options.provider === "wallet") {
2755
+ if (options.provider === "wallet") {
2605
2756
  this.loginWallet(options.type);
2757
+ return;
2758
+ }
2759
+ const provider = this._providers.get(options.provider);
2760
+ if (!provider?.login) {
2761
+ this._setAuthState({
2762
+ step: "error",
2763
+ previousStep: this._authState.step,
2764
+ message: `No auth provider registered for '${options.provider}'`,
2765
+ errorCode: AUTH_ERROR_CODES.AUTH_FAILED
2766
+ });
2767
+ return;
2768
+ }
2769
+ const controller = this._newController();
2770
+ provider.login(this._providerContext(controller.signal), options).catch((err) => this._handleFlowError(err));
2771
+ }
2772
+ /**
2773
+ * Invoke a named secondary step on a registered provider (e.g. email's
2774
+ * `sendCode` / `verifyCode`, or a custom provider's multi-step continuation).
2775
+ * Reuses the in-flight login `AbortController` when one exists so the step
2776
+ * stays cancellable via `cancelLogin()`; otherwise starts a fresh one. The
2777
+ * built-in email steps also have dedicated typed methods
2778
+ * ({@link sendEmailCode} / {@link verifyEmailCode}) — prefer those for email.
2779
+ */
2780
+ providerAction(provider, action, payload) {
2781
+ if (!isClientRuntime) {
2782
+ warnServerSide("providerAction");
2783
+ return;
2784
+ }
2785
+ const fn = this._providers.get(provider)?.actions?.[action];
2786
+ if (!fn) {
2787
+ throw new PollarFlowError(`Auth provider '${provider}' has no action '${action}'`);
2606
2788
  }
2789
+ const signal = this._loginController?.signal ?? this._newController().signal;
2790
+ fn(this._providerContext(signal), payload).catch((err) => this._handleFlowError(err));
2607
2791
  }
2608
2792
  // ─── Email OTP flow (3 steps) ─────────────────────────────────────────────
2609
2793
  beginEmailLogin() {
@@ -2612,7 +2796,7 @@ var PollarClient = class {
2612
2796
  return;
2613
2797
  }
2614
2798
  const controller = this._newController();
2615
- initEmailSession(this._flowDeps(controller.signal)).catch((err) => this._handleFlowError(err));
2799
+ initEmailSession(this._providerContext(controller.signal)).catch((err) => this._handleFlowError(err));
2616
2800
  }
2617
2801
  sendEmailCode(email) {
2618
2802
  if (!isClientRuntime) {
@@ -2624,7 +2808,7 @@ var PollarClient = class {
2624
2808
  }
2625
2809
  const { clientSessionId } = this._authState;
2626
2810
  const signal = this._loginController.signal;
2627
- sendEmailCode(email, clientSessionId, this._flowDeps(signal)).catch((err) => this._handleFlowError(err));
2811
+ sendEmailCode(email, clientSessionId, this._providerContext(signal)).catch((err) => this._handleFlowError(err));
2628
2812
  }
2629
2813
  verifyEmailCode(code) {
2630
2814
  if (!isClientRuntime) {
@@ -2639,7 +2823,7 @@ var PollarClient = class {
2639
2823
  const clientSessionId = state.step === "entering_code" ? state.clientSessionId : state.clientSessionId;
2640
2824
  const email = state.step === "entering_code" ? state.email : state.email ?? "";
2641
2825
  const controller = this._newController();
2642
- verifyAndAuthenticate(code, clientSessionId, email, this._flowDeps(controller.signal)).catch(
2826
+ verifyAndAuthenticate(code, clientSessionId, email, this._providerContext(controller.signal)).catch(
2643
2827
  (err) => this._handleFlowError(err)
2644
2828
  );
2645
2829
  }
@@ -3004,6 +3188,29 @@ var PollarClient = class {
3004
3188
  getWalletType() {
3005
3189
  return this._walletAdapter?.type ?? null;
3006
3190
  }
3191
+ /**
3192
+ * The authenticated user's wallet as a {@link WalletInfo} discriminated union,
3193
+ * or `null` when there's no session (or the session carries no address yet).
3194
+ *
3195
+ * `custody` strictly determines `provider` (the mapping is 1:1 and fixed at
3196
+ * account creation server-side): `external` reports the connected adapter id
3197
+ * (`getWalletType()`), `smart` is always `'passkey'`, and `internal` reports
3198
+ * the login method the backend recorded (`null` for pre-provider sessions).
3199
+ */
3200
+ getWallet() {
3201
+ const w = this._session?.wallet;
3202
+ if (!w || !w.address) return null;
3203
+ switch (w.type) {
3204
+ case "external":
3205
+ return { custody: "external", address: w.address, provider: this._walletAdapter?.type ?? null };
3206
+ case "smart":
3207
+ return { custody: "smart", address: w.address, provider: "passkey" };
3208
+ case "internal":
3209
+ return { custody: "internal", address: w.address, provider: w.provider ?? null };
3210
+ default:
3211
+ return null;
3212
+ }
3213
+ }
3007
3214
  /**
3008
3215
  * Signs the given unsigned XDR and returns the signed XDR.
3009
3216
  *
@@ -3057,14 +3264,16 @@ var PollarClient = class {
3057
3264
  });
3058
3265
  return { status: "signed", signedXdr, submissionToken: idempotencyKey };
3059
3266
  }
3060
- const details = error?.details;
3267
+ const { details, code, message } = this._resolveTxApiError(error);
3061
3268
  this._setTransactionState({
3062
3269
  step: "error",
3063
3270
  phase: "signing",
3064
3271
  ...buildData && { buildData },
3065
- ...details && { details }
3272
+ ...details && { details },
3273
+ ...code && { code },
3274
+ ...message && { message }
3066
3275
  });
3067
- return { status: "error", ...details && { details } };
3276
+ return { status: "error", ...details && { details }, ...code && { code }, ...message && { message } };
3068
3277
  } catch (err) {
3069
3278
  const details = err instanceof Error ? err.message : void 0;
3070
3279
  this._setTransactionState({
@@ -3076,6 +3285,54 @@ var PollarClient = class {
3076
3285
  return { status: "error", ...details && { details } };
3077
3286
  }
3078
3287
  }
3288
+ /**
3289
+ * Sign a single Soroban authorization entry (`SorobanAuthorizationEntry`).
3290
+ *
3291
+ * Use this when a contract is the transaction source (e.g. it sponsors the
3292
+ * gas and swaps the fee out of the user's token) and only needs the user's
3293
+ * address-credentials authorization, not a full signed envelope. The signed
3294
+ * entry is returned as base64 XDR for the caller to compose into its tx.
3295
+ *
3296
+ * - External wallets (Freighter/Albedo) sign the entry via the provider.
3297
+ * - Custodial wallets are signed by the backend, which FIRST validates the
3298
+ * entry's invocation tree against the app's contract/function allowlist and
3299
+ * caps the validity window — entries touching a non-allowlisted contract or
3300
+ * function, or expiring too far ahead, are rejected.
3301
+ *
3302
+ * @param entryXdr base64 XDR of the unsigned `SorobanAuthorizationEntry`.
3303
+ * @param options.validUntilLedger absolute ledger the signature expires at
3304
+ * (computed from the network's latest ledger). Ignored on the external-wallet
3305
+ * path, where the provider sets its own expiration.
3306
+ */
3307
+ async signAuthEntry(entryXdr, options) {
3308
+ if (this._walletAdapter) {
3309
+ const accountToSign = this._session?.wallet?.address;
3310
+ try {
3311
+ const { signedAuthEntry } = await this._walletAdapter.signAuthEntry(
3312
+ entryXdr,
3313
+ accountToSign ? { accountToSign } : void 0
3314
+ );
3315
+ return { status: "signed", signedAuthEntry };
3316
+ } catch (err) {
3317
+ const details = err instanceof Error ? err.message : void 0;
3318
+ return { status: "error", ...details && { details } };
3319
+ }
3320
+ }
3321
+ const address = this._session?.wallet?.address ?? "";
3322
+ try {
3323
+ const { data, error } = await this._api.POST("/tx/sign-auth-entry", {
3324
+ body: { network: this.getNetwork(), address, entryXdr, validUntilLedger: options.validUntilLedger }
3325
+ });
3326
+ if (!error && data?.success && data.content?.signedAuthEntry) {
3327
+ return { status: "signed", signedAuthEntry: data.content.signedAuthEntry };
3328
+ }
3329
+ const details = error?.details;
3330
+ return { status: "error", ...details && { details } };
3331
+ } catch (err) {
3332
+ const details = err instanceof Error ? err.message : void 0;
3333
+ return { status: "error", ...details && { details } };
3334
+ }
3335
+ }
3079
3336
  /**
3080
3337
  * Submits a signed XDR via `/tx/submit` regardless of wallet type
3081
3338
  * (custodial or external). Routing through sdk-api gives us:
@@ -3094,6 +3351,21 @@ var PollarClient = class {
3094
3351
  * `submitted` on Horizon ack (pending), `success` on ledger confirmation,
3095
3352
  * or `error[phase: 'submitting']` on failure.
3096
3353
  */
3354
+ /**
3355
+ * Normalize a backend API error into { details, code, message }. `code` is the
3356
+ * precise backend ErrorCode (e.g. `TX_FEE_LIMIT_EXCEEDED`) for programmatic
3357
+ * handling; `message` is a friendly string from the error catalog; `details`
3358
+ * is the raw diagnostic. Lets tx flows surface a typed reason instead of an
3359
+ * opaque details string.
3360
+ */
3361
+ _resolveTxApiError(error) {
3362
+ const e = error;
3363
+ const details = e?.details ?? e?.message;
3364
+ const code = e?.code;
3365
+ if (!code) return details ? { details } : {};
3366
+ const { message } = resolveAuthError(code, details ?? code);
3367
+ return { code, message, ...details && { details } };
3368
+ }
3097
3369
  async submitTx(signedXdr, opts) {
3098
3370
  const buildData = this._currentBuildData();
3099
3371
  const outcomeExtra = buildData ? { buildData } : {};
@@ -3131,14 +3403,22 @@ var PollarClient = class {
3131
3403
  ...resultCode && { details: resultCode, resultCode }
3132
3404
  };
3133
3405
  }
3134
- const details = error?.details;
3406
+ const { details, code, message } = this._resolveTxApiError(error);
3135
3407
  this._setTransactionState({
3136
3408
  step: "error",
3137
3409
  phase: "submitting",
3138
3410
  ...buildData && { buildData },
3139
- ...details && { details }
3411
+ ...details && { details },
3412
+ ...code && { code },
3413
+ ...message && { message }
3140
3414
  });
3141
- return { status: "error", ...outcomeExtra, ...details && { details } };
3415
+ return {
3416
+ status: "error",
3417
+ ...outcomeExtra,
3418
+ ...details && { details },
3419
+ ...code && { code },
3420
+ ...message && { message }
3421
+ };
3142
3422
  } catch (err) {
3143
3423
  const details = err instanceof Error ? err.message : void 0;
3144
3424
  this._setTransactionState({
@@ -3225,14 +3505,22 @@ var PollarClient = class {
3225
3505
  ...resultCode && { details: resultCode, resultCode }
3226
3506
  };
3227
3507
  }
3228
- const details = error?.details;
3508
+ const { details, code, message } = this._resolveTxApiError(error);
3229
3509
  this._setTransactionState({
3230
3510
  step: "error",
3231
3511
  phase: "signing-submitting",
3232
3512
  ...buildData && { buildData },
3233
- ...details && { details }
3513
+ ...details && { details },
3514
+ ...code && { code },
3515
+ ...message && { message }
3234
3516
  });
3235
- return { status: "error", ...outcomeExtra, ...details && { details } };
3517
+ return {
3518
+ status: "error",
3519
+ ...outcomeExtra,
3520
+ ...details && { details },
3521
+ ...code && { code },
3522
+ ...message && { message }
3523
+ };
3236
3524
  } catch (err) {
3237
3525
  const details = err instanceof Error ? err.message : void 0;
3238
3526
  this._setTransactionState({
@@ -3307,13 +3595,15 @@ var PollarClient = class {
3307
3595
  });
3308
3596
  return { status: "error", hash, ...resultCode && { details: resultCode, resultCode } };
3309
3597
  }
3310
- const details = error?.details;
3598
+ const { details, code, message } = this._resolveTxApiError(error);
3311
3599
  this._setTransactionState({
3312
3600
  step: "error",
3313
3601
  phase: "building-signing-submitting",
3314
- ...details && { details }
3602
+ ...details && { details },
3603
+ ...code && { code },
3604
+ ...message && { message }
3315
3605
  });
3316
- return { status: "error", ...details && { details } };
3606
+ return { status: "error", ...details && { details }, ...code && { code }, ...message && { message } };
3317
3607
  } catch (err) {
3318
3608
  const details = err instanceof Error ? err.message : void 0;
3319
3609
  this._setTransactionState({
@@ -3426,9 +3716,22 @@ var PollarClient = class {
3426
3716
  });
3427
3717
  return { status: "error", hash, ...outcomeExtra, ...resultCode && { details: resultCode, resultCode } };
3428
3718
  }
3429
- const details = error?.details;
3430
- this._setTransactionState({ step: "error", phase: "submitting", buildData, ...details && { details } });
3431
- return { status: "error", ...outcomeExtra, ...details && { details } };
3719
+ const { details, code, message } = this._resolveTxApiError(error);
3720
+ this._setTransactionState({
3721
+ step: "error",
3722
+ phase: "submitting",
3723
+ buildData,
3724
+ ...details && { details },
3725
+ ...code && { code },
3726
+ ...message && { message }
3727
+ });
3728
+ return {
3729
+ status: "error",
3730
+ ...outcomeExtra,
3731
+ ...details && { details },
3732
+ ...code && { code },
3733
+ ...message && { message }
3734
+ };
3432
3735
  } catch (err) {
3433
3736
  const details = err instanceof Error ? err.message : void 0;
3434
3737
  this._setTransactionState({ step: "error", phase: "submitting", buildData, ...details && { details } });
@@ -3506,11 +3809,67 @@ var PollarClient = class {
3506
3809
  this._loginController = new AbortController();
3507
3810
  return this._loginController;
3508
3811
  }
3812
+ /**
3813
+ * Build the {@link AuthProviderContext} facade for one login attempt. Wraps
3814
+ * the internal `FlowDeps` so providers get only the curated primitives —
3815
+ * `createSession`, `authenticate`, `exchangeExternalToken`, `startHostedOAuth`
3816
+ * — while storage / wallet-adapter / key-manager internals stay private. All
3817
+ * legs share the same `signal`, so `cancelLogin()` aborts the whole chain.
3818
+ */
3819
+ _providerContext(signal) {
3820
+ const deps = this._flowDeps(signal);
3821
+ return {
3822
+ signal,
3823
+ api: this._api,
3824
+ basePath: this.basePath,
3825
+ apiKey: this.apiKey,
3826
+ logger: this._log,
3827
+ setAuthState: this._setAuthState.bind(this),
3828
+ createSession: () => createAuthSession(deps),
3829
+ authenticate: (clientSessionId) => authenticate(clientSessionId, deps),
3830
+ requestChallenge: (clientSessionId, walletAddress) => requestWalletChallenge(clientSessionId, walletAddress, deps),
3831
+ exchangeExternalToken: (clientSessionId, body) => this._exchangeExternalToken(clientSessionId, body, signal),
3832
+ startHostedOAuth: (provider) => loginOAuth(provider, {
3833
+ ...deps,
3834
+ basePath: this.basePath,
3835
+ apiKey: this.apiKey,
3836
+ openAuthUrl: this._openAuthUrl,
3837
+ redirectUri: this._oauthRedirectUri
3838
+ })
3839
+ };
3840
+ }
3841
+ /**
3842
+ * Generic external-provider exchange leg (`POST /auth/external`). Custom
3843
+ * providers call this (via the context) after their own SDK has authenticated
3844
+ * the user and the wallet has counter-signed the SEP-10 challenge
3845
+ * (`{ provider, walletAddress, signedChallengeXdr }`). On success the session
3846
+ * is marked READY server-side and the provider should then call
3847
+ * `ctx.authenticate(clientSessionId)`. Returns `false` (and sets an error
3848
+ * state) on failure.
3849
+ */
3850
+ async _exchangeExternalToken(clientSessionId, body, signal) {
3851
+ const { data, error } = await this._api.POST("/auth/external", {
3852
+ body: { clientSessionId, ...body },
3853
+ signal
3854
+ });
3855
+ if (error || !data?.success) {
3856
+ this._log.error("[PollarClient] External provider authentication failed", { error });
3857
+ this._setAuthState({
3858
+ step: "error",
3859
+ previousStep: this._authState.step,
3860
+ message: "External provider authentication failed",
3861
+ errorCode: AUTH_ERROR_CODES.EXTERNAL_AUTH_FAILED
3862
+ });
3863
+ return false;
3864
+ }
3865
+ return true;
3866
+ }
3509
3867
  _flowDeps(signal) {
3510
3868
  return {
3511
3869
  api: this._api,
3512
3870
  logger: this._log,
3513
3871
  basePath: this.basePath,
3872
+ networkPassphrase: this._networkPassphrase(),
3514
3873
  // SSE status streaming works on web; React Native's `fetch` has no
3515
3874
  // readable `response.body`, so those clients poll the non-streaming
3516
3875
  // status endpoint instead. `isBrowser` is false in RN and SSR alike.
@@ -3642,6 +4001,7 @@ var PollarClient = class {
3642
4001
  async _storeSession(session) {
3643
4002
  this._log.info("[PollarClient] Session stored");
3644
4003
  const w = session.wallet;
4004
+ const wireProvider = w.provider;
3645
4005
  const persisted = {
3646
4006
  clientSessionId: session.clientSessionId,
3647
4007
  userId: session.userId ?? null,
@@ -3655,6 +4015,7 @@ var PollarClient = class {
3655
4015
  // persisted session speak one vocabulary while the wire stays compatible.
3656
4016
  wallet: {
3657
4017
  type: w.type === "custodial" ? "internal" : w.type,
4018
+ ...wireProvider ? { provider: wireProvider } : {},
3658
4019
  address: w.address ?? w.publicKey ?? null,
3659
4020
  ...w.existsOnStellar !== void 0 ? { existsOnStellar: w.existsOnStellar } : {},
3660
4021
  ...w.createdAt !== void 0 ? { createdAt: w.createdAt } : {},