@fivenorth/loop-sdk 0.6.5 → 0.7.1

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.
Files changed (3) hide show
  1. package/README.md +27 -1
  2. package/dist/index.js +288 -48
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -44,6 +44,7 @@ loop.init({
44
44
  network: 'local', // or 'devnet', 'mainnet'
45
45
  options: {
46
46
  openMode: 'popup', // 'popup' (default) or 'tab'
47
+ requestSigningMode: 'popup', // 'popup' (default) or 'tab'
47
48
  redirectUrl: 'https://myapp.com/after-connect', // optional redirect after approval
48
49
  },
49
50
  onAccept: (provider) => {
@@ -61,6 +62,7 @@ The `init` method takes a configuration object with the following properties:
61
62
  - `network`: The network to connect to. Can be `local`, `devnet`, or `mainnet`.
62
63
  - `options`: Optional object containing:
63
64
  - `openMode`: Controls how Loop opens: `'popup'` (default) or `'tab'`.
65
+ - `requestSigningMode`: Controls how signing/transaction requests open the wallet UI after you're connected: `'popup'` (default) or `'tab'`.
64
66
  - `redirectUrl`: Optional redirect URL the wallet will navigate back to after successful approval. If omitted, user stays on Loop dashboard.
65
67
  - `onAccept`: A callback function that is called when the user accepts the connection. It receives a `provider` object.
66
68
  - `onReject`: A callback function that is called when the user rejects the connection.
@@ -74,6 +76,7 @@ loop.connect();
74
76
  ```
75
77
 
76
78
  This will open a modal with a QR code for the user to scan with their Loop wallet.
79
+ If you set `requestSigningMode` to `'popup'` (or `'tab'`), each signing/transaction request will also open the wallet dashboard and auto-close the popup once the wallet responds.
77
80
 
78
81
  ### 3. Using the Provider
79
82
 
@@ -132,7 +135,10 @@ const damlCommand = {
132
135
  };
133
136
 
134
137
  try {
135
- const result = await provider.submitTransaction(damlCommand);
138
+ const result = await provider.submitTransaction(damlCommand, {
139
+ // Optional: show a custom message in the wallet prompt
140
+ message: 'Transfer 10 CC to RetailStore',
141
+ });
136
142
  console.log('Transaction successful:', result);
137
143
  } catch (error) {
138
144
  console.error('Transaction failed:', error);
@@ -165,6 +171,8 @@ await loop.wallet.transfer(
165
171
  instrument_id: 'Amulet', // optional
166
172
  },
167
173
  {
174
+ // Optional: show a custom message in the wallet prompt
175
+ message: 'Send 5 CC to Alice',
168
176
  requestedAt: new Date().toISOString(), // optional
169
177
  executeBefore: new Date(Date.now() + 24*60*60*1000).toISOString(), // optional
170
178
  requestTimeout: 5 * 60 * 1000, // optional (ms), defaults to 5 minutes
@@ -184,6 +192,24 @@ Common instrument overrides (pass into the `instrument` argument above):
184
192
 
185
193
  Swap in the admin/id for the specific instrument you hold in the Loop wallet.
186
194
 
195
+ #### USDC withdraw helper
196
+
197
+ ```javascript
198
+ await loop.wallet.extension.usdcBridge.withdrawalUSDCxToEthereum(
199
+ '0xYourEthAddress',
200
+ '10.5', // amount in USDCx
201
+ {
202
+ reference: 'optional memo',
203
+ message: 'Withdraw 10.5 USDCx to 0xabc', // optional custom prompt text
204
+ requestTimeout: 5 * 60 * 1000, // optional override (ms)
205
+ },
206
+ );
207
+ ```
208
+
209
+ Notes:
210
+ - Uses the connect-based withdraw endpoint to prepare the transaction and sends it over Wallet Connect.
211
+ - The helper auto-reconnects the websocket if it was closed before sending the request.
212
+
187
213
  # API
188
214
 
189
215
  Coming soon
package/dist/index.js CHANGED
@@ -2073,6 +2073,9 @@ class Connection {
2073
2073
  apiUrl = "https://cantonloop.com";
2074
2074
  ws = null;
2075
2075
  network = "main";
2076
+ ticketId = null;
2077
+ onMessageHandler = null;
2078
+ reconnectPromise = null;
2076
2079
  constructor({ network, walletUrl, apiUrl }) {
2077
2080
  this.network = network || "main";
2078
2081
  switch (this.network) {
@@ -2213,16 +2216,67 @@ class Connection {
2213
2216
  websocketUrl(ticketId) {
2214
2217
  return `${this.network === "local" ? "ws" : "wss"}://${this.apiUrl.replace("https://", "").replace("http://", "")}/api/v1/.connect/pair/ws/${ticketId}`;
2215
2218
  }
2216
- connectWebSocket(ticketId, onMessage) {
2219
+ attachWebSocket(ticketId, onMessage, onOpen, onError, onClose) {
2217
2220
  const wsUrl = this.websocketUrl(ticketId);
2218
- this.ws = new WebSocket(wsUrl);
2219
- this.ws.onmessage = onMessage;
2220
- this.ws.onopen = () => {
2221
+ const ws = new WebSocket(wsUrl);
2222
+ ws.onmessage = onMessage;
2223
+ ws.onopen = () => {
2221
2224
  console.log("Connected to ticket server.");
2225
+ onOpen?.();
2222
2226
  };
2223
- this.ws.onclose = () => {
2227
+ ws.onclose = (event) => {
2228
+ if (this.ws === ws) {
2229
+ this.ws = null;
2230
+ }
2224
2231
  console.log("Disconnected from ticket server.");
2232
+ onClose?.(event);
2225
2233
  };
2234
+ ws.onerror = (event) => {
2235
+ if (this.ws === ws) {
2236
+ this.ws = null;
2237
+ }
2238
+ onError?.(event);
2239
+ };
2240
+ this.ws = ws;
2241
+ }
2242
+ connectWebSocket(ticketId, onMessage) {
2243
+ this.ticketId = ticketId;
2244
+ this.onMessageHandler = onMessage;
2245
+ this.attachWebSocket(ticketId, onMessage);
2246
+ }
2247
+ reconnect() {
2248
+ if (!this.ticketId || !this.onMessageHandler) {
2249
+ return Promise.reject(new Error("Cannot reconnect without a known ticket."));
2250
+ }
2251
+ if (this.reconnectPromise) {
2252
+ return this.reconnectPromise;
2253
+ }
2254
+ this.reconnectPromise = new Promise((resolve, reject) => {
2255
+ let opened = false;
2256
+ this.attachWebSocket(this.ticketId, this.onMessageHandler, () => {
2257
+ opened = true;
2258
+ resolve();
2259
+ }, () => {
2260
+ if (opened) {
2261
+ return;
2262
+ }
2263
+ reject(new Error("Failed to reconnect to ticket server."));
2264
+ }, () => {
2265
+ if (opened) {
2266
+ return;
2267
+ }
2268
+ reject(new Error("Failed to reconnect to ticket server."));
2269
+ });
2270
+ }).finally(() => {
2271
+ this.reconnectPromise = null;
2272
+ });
2273
+ return this.reconnectPromise;
2274
+ }
2275
+ async reconnectWebSocket() {
2276
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
2277
+ return;
2278
+ }
2279
+ return this.reconnect();
2226
2280
  }
2227
2281
  }
2228
2282
 
@@ -2269,7 +2323,9 @@ class Provider {
2269
2323
  email;
2270
2324
  auth_token;
2271
2325
  requests = new Map;
2272
- constructor({ connection, party_id, public_key, auth_token, email }) {
2326
+ requestTimeout = DEFAULT_REQUEST_TIMEOUT_MS;
2327
+ hooks;
2328
+ constructor({ connection, party_id, public_key, auth_token, email, hooks }) {
2273
2329
  if (!connection) {
2274
2330
  throw new Error("Provider requires a connection object.");
2275
2331
  }
@@ -2278,6 +2334,10 @@ class Provider {
2278
2334
  this.public_key = public_key;
2279
2335
  this.email = email;
2280
2336
  this.auth_token = auth_token;
2337
+ this.hooks = hooks;
2338
+ }
2339
+ getAuthToken() {
2340
+ return this.auth_token;
2281
2341
  }
2282
2342
  handleResponse(message) {
2283
2343
  console.log("Received response:", message);
@@ -2297,6 +2357,7 @@ class Provider {
2297
2357
  async transfer(recipient, amount, instrument, options) {
2298
2358
  const amountStr = typeof amount === "number" ? amount.toString() : amount;
2299
2359
  const { requestedAt, executeBefore, requestTimeout } = options || {};
2360
+ const message = options?.message;
2300
2361
  const resolveDate = (value, fallbackMs) => {
2301
2362
  if (value instanceof Date) {
2302
2363
  return value.toISOString();
@@ -2329,53 +2390,164 @@ class Provider {
2329
2390
  actAs: preparedPayload.actAs,
2330
2391
  readAs: preparedPayload.readAs,
2331
2392
  synchronizerId: preparedPayload.synchronizerId
2332
- }, { requestTimeout });
2393
+ }, { requestTimeout, message });
2333
2394
  }
2334
2395
  async signMessage(message) {
2335
2396
  return this.sendRequest("sign_raw_message" /* SIGN_RAW_MESSAGE */, message);
2336
2397
  }
2398
+ async ensureConnected() {
2399
+ if (this.connection.ws && this.connection.ws.readyState === WebSocket.OPEN) {
2400
+ return;
2401
+ }
2402
+ if (typeof this.connection.reconnectWebSocket === "function") {
2403
+ await this.connection.reconnectWebSocket();
2404
+ if (this.connection.ws && this.connection.ws.readyState === WebSocket.OPEN) {
2405
+ return;
2406
+ }
2407
+ }
2408
+ throw new Error("Not connected.");
2409
+ }
2337
2410
  sendRequest(messageType, params = {}, options) {
2338
2411
  return new Promise((resolve, reject) => {
2339
- if (!this.connection.ws || this.connection.ws.readyState !== WebSocket.OPEN) {
2340
- return reject(new Error("Not connected."));
2341
- }
2342
2412
  const requestId = generateRequestId();
2343
- const requestTimeout = options?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
2344
- this.connection.ws.send(JSON.stringify({
2345
- request_id: requestId,
2346
- type: messageType,
2347
- payload: params
2348
- }));
2349
- const intervalTime = 300;
2350
- let elapsedTime = 0;
2351
- const intervalId = setInterval(() => {
2352
- const response = this.requests.get(requestId);
2353
- if (response) {
2354
- clearInterval(intervalId);
2355
- this.requests.delete(requestId);
2356
- if (response.type === "reject_request" /* REJECT_REQUEST */) {
2357
- reject(new RejectRequestError);
2358
- } else {
2359
- resolve(response.payload);
2413
+ const requestContext = this.hooks?.onRequestStart?.(messageType, options?.requestLabel);
2414
+ const ensure = async () => {
2415
+ try {
2416
+ await this.ensureConnected();
2417
+ } catch (error) {
2418
+ this.hooks?.onRequestFinish?.({
2419
+ status: "error",
2420
+ messageType,
2421
+ requestLabel: options?.requestLabel,
2422
+ requestContext
2423
+ });
2424
+ reject(error);
2425
+ return;
2426
+ }
2427
+ const requestBody = {
2428
+ request_id: requestId,
2429
+ type: messageType,
2430
+ payload: params
2431
+ };
2432
+ if (options?.message) {
2433
+ requestBody.ticket = { message: options.message };
2434
+ if (typeof params === "object" && params !== null && !Array.isArray(params)) {
2435
+ requestBody.payload = {
2436
+ ...params,
2437
+ ticket: { message: options.message }
2438
+ };
2360
2439
  }
2361
- } else {
2362
- elapsedTime += intervalTime;
2363
- if (elapsedTime >= requestTimeout) {
2440
+ }
2441
+ this.connection.ws.send(JSON.stringify(requestBody));
2442
+ const intervalTime = 300;
2443
+ let elapsedTime = 0;
2444
+ const timeoutMs = options?.requestTimeout ?? this.requestTimeout;
2445
+ const intervalId = setInterval(() => {
2446
+ const response = this.requests.get(requestId);
2447
+ if (response) {
2364
2448
  clearInterval(intervalId);
2365
2449
  this.requests.delete(requestId);
2366
- reject(new RequestTimeoutError(requestTimeout));
2450
+ if (response.type === "reject_request" /* REJECT_REQUEST */) {
2451
+ this.hooks?.onRequestFinish?.({
2452
+ status: "rejected",
2453
+ messageType,
2454
+ requestLabel: options?.requestLabel,
2455
+ requestContext
2456
+ });
2457
+ reject(new RejectRequestError);
2458
+ } else {
2459
+ this.hooks?.onRequestFinish?.({
2460
+ status: "success",
2461
+ messageType,
2462
+ requestLabel: options?.requestLabel,
2463
+ requestContext
2464
+ });
2465
+ resolve(response.payload);
2466
+ }
2467
+ } else {
2468
+ elapsedTime += intervalTime;
2469
+ if (elapsedTime >= timeoutMs) {
2470
+ clearInterval(intervalId);
2471
+ this.requests.delete(requestId);
2472
+ this.hooks?.onRequestFinish?.({
2473
+ status: "timeout",
2474
+ messageType,
2475
+ requestLabel: options?.requestLabel,
2476
+ requestContext
2477
+ });
2478
+ reject(new RequestTimeoutError(timeoutMs));
2479
+ }
2367
2480
  }
2368
- }
2369
- }, intervalTime);
2481
+ }, intervalTime);
2482
+ };
2483
+ ensure();
2370
2484
  });
2371
2485
  }
2372
2486
  }
2373
2487
 
2488
+ // src/extensions/usdc/index.ts
2489
+ class UsdcBridge {
2490
+ getProvider;
2491
+ constructor(getProvider) {
2492
+ this.getProvider = getProvider;
2493
+ }
2494
+ requireProvider() {
2495
+ const provider = this.getProvider();
2496
+ if (!provider) {
2497
+ throw new Error("SDK not connected. Call connect() and wait for acceptance first.");
2498
+ }
2499
+ return provider;
2500
+ }
2501
+ withdrawalUSDCxToEthereum(recipient, amount, options) {
2502
+ const provider = this.requireProvider();
2503
+ const amountStr = typeof amount === "number" ? amount.toString() : amount;
2504
+ const withdrawRequest = {
2505
+ recipient,
2506
+ amount: amountStr,
2507
+ reference: options?.reference
2508
+ };
2509
+ return prepareUsdcWithdraw(provider.connection, provider.getAuthToken(), withdrawRequest).then((preparedPayload) => provider.submitTransaction({
2510
+ commands: preparedPayload.commands,
2511
+ disclosedContracts: preparedPayload.disclosedContracts,
2512
+ packageIdSelectionPreference: preparedPayload.packageIdSelectionPreference,
2513
+ actAs: preparedPayload.actAs,
2514
+ readAs: preparedPayload.readAs,
2515
+ synchronizerId: preparedPayload.synchronizerId
2516
+ }, { requestTimeout: options?.requestTimeout, message: options?.message }));
2517
+ }
2518
+ }
2519
+ async function prepareUsdcWithdraw(connection, authToken, params) {
2520
+ const payload = {
2521
+ recipient: params.recipient,
2522
+ amount: params.amount
2523
+ };
2524
+ if (params.reference) {
2525
+ payload.reference = params.reference;
2526
+ }
2527
+ const response = await fetch(`${connection.apiUrl}/api/v1/.connect/pair/usdc/withdraw`, {
2528
+ method: "POST",
2529
+ headers: {
2530
+ "Content-Type": "application/json",
2531
+ Authorization: `Bearer ${authToken}`
2532
+ },
2533
+ body: JSON.stringify(payload)
2534
+ });
2535
+ if (!response.ok) {
2536
+ throw new Error("Failed to prepare USDC withdrawal.");
2537
+ }
2538
+ const data = await response.json();
2539
+ return data.payload;
2540
+ }
2541
+
2374
2542
  // src/wallet.ts
2375
2543
  class LoopWallet {
2376
2544
  getProvider;
2545
+ extension;
2377
2546
  constructor(getProvider) {
2378
2547
  this.getProvider = getProvider;
2548
+ this.extension = {
2549
+ usdcBridge: new UsdcBridge(this.getProvider)
2550
+ };
2379
2551
  }
2380
2552
  requireProvider() {
2381
2553
  const provider = this.getProvider();
@@ -2397,6 +2569,7 @@ class LoopSDK {
2397
2569
  connection = null;
2398
2570
  provider = null;
2399
2571
  openMode = "popup";
2572
+ requestSigningMode = "popup";
2400
2573
  popupWindow = null;
2401
2574
  redirectUrl;
2402
2575
  onAccept = null;
@@ -2421,10 +2594,12 @@ class LoopSDK {
2421
2594
  this.onReject = onReject || null;
2422
2595
  const resolvedOptions = {
2423
2596
  openMode: "popup",
2597
+ requestSigningMode: "popup",
2424
2598
  redirectUrl: undefined,
2425
2599
  ...options ?? {}
2426
2600
  };
2427
2601
  this.openMode = resolvedOptions.openMode;
2602
+ this.requestSigningMode = resolvedOptions.requestSigningMode;
2428
2603
  this.redirectUrl = resolvedOptions.redirectUrl;
2429
2604
  this.connection = new Connection({ network, walletUrl, apiUrl });
2430
2605
  }
@@ -2445,7 +2620,15 @@ class LoopSDK {
2445
2620
  try {
2446
2621
  const verifiedAccount = await this.connection.verifySession(authToken);
2447
2622
  if (verifiedAccount.party_id === partyId) {
2448
- this.provider = new Provider({ connection: this.connection, party_id: partyId, auth_token: authToken, public_key: publicKey, email });
2623
+ this.provider = new Provider({
2624
+ connection: this.connection,
2625
+ party_id: partyId,
2626
+ auth_token: authToken,
2627
+ public_key: publicKey,
2628
+ email,
2629
+ hooks: this.createProviderHooks()
2630
+ });
2631
+ this.ticketId = ticketId || null;
2449
2632
  this.onAccept?.(this.provider);
2450
2633
  if (ticketId) {
2451
2634
  this.connection.connectWebSocket(ticketId, this.handleWebSocketMessage.bind(this));
@@ -2484,12 +2667,7 @@ class LoopSDK {
2484
2667
  const { ticket_id: ticketId } = await this.connection.getTicket(this.appName, sessionId, this.version);
2485
2668
  this.ticketId = ticketId;
2486
2669
  localStorage.setItem("loop_connect", JSON.stringify({ sessionId, ticketId }));
2487
- const url = new URL("/.connect/", this.connection.walletUrl);
2488
- url.searchParams.set("ticketId", ticketId);
2489
- if (this.redirectUrl) {
2490
- url.searchParams.set("redirectUrl", this.redirectUrl);
2491
- }
2492
- const connectUrl = url.toString();
2670
+ const connectUrl = this.buildConnectUrl(ticketId);
2493
2671
  this.showQrCode(connectUrl);
2494
2672
  this.connection.connectWebSocket(ticketId, this.handleWebSocketMessage.bind(this));
2495
2673
  } catch (error) {
@@ -2504,11 +2682,19 @@ class LoopSDK {
2504
2682
  console.log("[LoopSDK] Entering HANDSHAKE_ACCEPT flow");
2505
2683
  const { authToken, partyId, publicKey, email } = message.payload || {};
2506
2684
  if (authToken && partyId && publicKey) {
2507
- this.provider = new Provider({ connection: this.connection, party_id: partyId, auth_token: authToken, public_key: publicKey, email });
2685
+ this.provider = new Provider({
2686
+ connection: this.connection,
2687
+ party_id: partyId,
2688
+ auth_token: authToken,
2689
+ public_key: publicKey,
2690
+ email,
2691
+ hooks: this.createProviderHooks()
2692
+ });
2508
2693
  const connectionInfoRaw = localStorage.getItem("loop_connect");
2509
2694
  if (connectionInfoRaw) {
2510
2695
  try {
2511
2696
  const connectionInfo = JSON.parse(connectionInfoRaw);
2697
+ this.ticketId = connectionInfo.ticketId || this.ticketId;
2512
2698
  connectionInfo.authToken = authToken;
2513
2699
  connectionInfo.partyId = partyId;
2514
2700
  connectionInfo.publicKey = publicKey;
@@ -2536,11 +2722,51 @@ class LoopSDK {
2536
2722
  this.provider.handleResponse(message);
2537
2723
  }
2538
2724
  }
2539
- openWallet(url) {
2725
+ buildConnectUrl(ticketId) {
2726
+ const url = new URL("/.connect/", this.connection.walletUrl);
2727
+ url.searchParams.set("ticketId", ticketId);
2728
+ if (this.redirectUrl) {
2729
+ url.searchParams.set("redirectUrl", this.redirectUrl);
2730
+ }
2731
+ return url.toString();
2732
+ }
2733
+ buildDashboardUrl() {
2734
+ if (!this.connection) {
2735
+ throw new Error("Connection not initialized");
2736
+ }
2737
+ return this.connection.walletUrl;
2738
+ }
2739
+ openRequestUi() {
2540
2740
  if (typeof window === "undefined") {
2541
- return;
2741
+ return null;
2742
+ }
2743
+ if (!this.ticketId) {
2744
+ console.warn("[LoopSDK] Cannot open wallet UI for request: no active ticket.");
2745
+ return null;
2746
+ }
2747
+ const dashboardUrl = this.buildDashboardUrl();
2748
+ const targetMode = this.requestSigningMode === "tab" ? "tab" : "popup";
2749
+ const opened = this.openWallet(dashboardUrl, targetMode);
2750
+ if (opened) {
2751
+ this.popupWindow = opened;
2752
+ return opened;
2753
+ }
2754
+ return null;
2755
+ }
2756
+ closePopupIfExists() {
2757
+ if (this.popupWindow && !this.popupWindow.closed) {
2758
+ try {
2759
+ this.popupWindow.close();
2760
+ } catch {}
2542
2761
  }
2543
- if (this.openMode === "popup") {
2762
+ this.popupWindow = null;
2763
+ }
2764
+ openWallet(url, mode) {
2765
+ if (typeof window === "undefined") {
2766
+ return null;
2767
+ }
2768
+ const targetMode = mode || this.openMode;
2769
+ if (targetMode === "popup") {
2544
2770
  const width = 480;
2545
2771
  const height = 720;
2546
2772
  const left = (window.innerWidth - width) / 2 + window.screenX;
@@ -2548,16 +2774,15 @@ class LoopSDK {
2548
2774
  const features = `width=${width},height=${height},` + `left=${left},top=${top},` + "menubar=no,toolbar=no,location=no," + "resizable=yes,scrollbars=yes,status=no";
2549
2775
  const popup = window.open(url, "loop-wallet", features);
2550
2776
  if (!popup) {
2551
- window.open(url, "_blank", "noopener,noreferrer");
2552
- return;
2777
+ return window.open(url, "_blank", "noopener,noreferrer");
2553
2778
  }
2554
2779
  this.popupWindow = popup;
2555
2780
  try {
2556
2781
  popup.focus();
2557
2782
  } catch {}
2558
- return;
2783
+ return popup;
2559
2784
  }
2560
- window.open(url, "_blank", "noopener,noreferrer");
2785
+ return window.open(url, "_blank", "noopener,noreferrer");
2561
2786
  }
2562
2787
  showQrCode(url) {
2563
2788
  if (typeof window === "undefined" || typeof document === "undefined") {
@@ -2622,6 +2847,21 @@ class LoopSDK {
2622
2847
  }
2623
2848
  return this.provider;
2624
2849
  }
2850
+ createProviderHooks() {
2851
+ return {
2852
+ onRequestStart: () => {
2853
+ return this.openRequestUi();
2854
+ },
2855
+ onRequestFinish: ({ requestContext }) => {
2856
+ const win = requestContext;
2857
+ if (win) {
2858
+ setTimeout(() => {
2859
+ this.closePopupIfExists();
2860
+ }, 800);
2861
+ }
2862
+ }
2863
+ };
2864
+ }
2625
2865
  }
2626
2866
  var loop = new LoopSDK;
2627
2867
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fivenorth/loop-sdk",
3
- "version": "0.6.5",
3
+ "version": "0.7.1",
4
4
  "author": "hello@fivenorth.io",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",