@toon-protocol/client 0.14.6 → 0.14.8

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.d.ts CHANGED
@@ -1240,6 +1240,30 @@ declare class ToonClient {
1240
1240
  txHash?: string;
1241
1241
  depositTotal: string;
1242
1242
  }>;
1243
+ /**
1244
+ * Close a channel to begin the settlement grace period (first half of
1245
+ * withdraw). Records `closedAt`/`settleableAt` (unix seconds) on the tracked
1246
+ * channel — persisted, so the grace timer survives a daemon restart. Spends
1247
+ * on-chain. EVM today; Solana/Mina are follow-ups.
1248
+ */
1249
+ closeChannel(channelId: string): Promise<{
1250
+ channelId: string;
1251
+ txHash?: string;
1252
+ closedAt: string;
1253
+ settleableAt: string;
1254
+ }>;
1255
+ /**
1256
+ * Settle a closed channel to release collateral (second half of withdraw).
1257
+ * THE time guard: never settle before `settleableAt`. A too-early call throws
1258
+ * a retryable error (carrying the remaining seconds) BEFORE spending gas — the
1259
+ * contract would revert anyway. Spends on-chain. EVM today.
1260
+ */
1261
+ settleChannel(channelId: string): Promise<{
1262
+ channelId: string;
1263
+ txHash?: string;
1264
+ }>;
1265
+ /** Where a tracked channel sits in the withdraw journey. */
1266
+ getChannelCloseState(channelId: string): 'open' | 'closing' | 'settleable' | 'settled';
1243
1267
  /**
1244
1268
  * Read the on-chain settlement-token balance of this client's OWN wallet on
1245
1269
  * each configured chain (EVM token, Solana SPL, native MINA). A free read — no
@@ -2165,6 +2189,13 @@ declare class OnChainChannelClient implements ConnectorChannelClient {
2165
2189
  txHash?: string;
2166
2190
  depositTotal: bigint;
2167
2191
  }>;
2192
+ /**
2193
+ * Solana deposit: fire the standalone `deposit` instruction against the channel
2194
+ * vault. Incremental on-chain (the program adds `amount`), so the new total is
2195
+ * the caller-tracked current plus the delta. Requires the funded payer token
2196
+ * account (the funded ATA) from the Solana channel config.
2197
+ */
2198
+ private depositSolana;
2168
2199
  /**
2169
2200
  * EVM deposit: approve the token-network for the delta if the allowance is
2170
2201
  * short, then `setTotalDeposit(channelId, participant, current + delta)` —
@@ -2172,6 +2203,41 @@ declare class OnChainChannelClient implements ConnectorChannelClient {
2172
2203
  * caller-supplied current locked amount.
2173
2204
  */
2174
2205
  private depositEvm;
2206
+ /**
2207
+ * Close a channel to begin the settlement grace period. Dispatches by the
2208
+ * channel's cached chain context. EVM `closeChannel` is unilateral (channelId
2209
+ * only); after it confirms we read the `channels()` view for the AUTHORITATIVE
2210
+ * `closedAt` + `settlementTimeout` (block-timestamp seconds) and compute
2211
+ * `settleableAt = closedAt + settlementTimeout`. Solana/Mina are follow-ups.
2212
+ */
2213
+ closeChannel(channelId: string): Promise<{
2214
+ txHash?: string;
2215
+ closedAt: bigint;
2216
+ settlementTimeout: bigint;
2217
+ settleableAt: bigint;
2218
+ }>;
2219
+ /**
2220
+ * Settle a closed channel after its grace period to release collateral. EVM
2221
+ * `settleChannel` is unilateral (channelId only); the contract itself reverts
2222
+ * before `closedAt + settlementTimeout`, so an early call surfaces as a tx
2223
+ * revert here — but the caller (ToonClient/daemon) enforces the time guard
2224
+ * BEFORE spending gas. Solana/Mina are follow-ups.
2225
+ */
2226
+ settleChannel(channelId: string): Promise<{
2227
+ txHash?: string;
2228
+ }>;
2229
+ /**
2230
+ * Read the EVM channel's close-relevant fields so a restarted daemon can
2231
+ * recompute the grace timer from chain (chain is authoritative). EVM-only.
2232
+ */
2233
+ getChannelCloseInfo(channelId: string): Promise<{
2234
+ status: ChannelState['status'];
2235
+ closedAt: bigint;
2236
+ settlementTimeout: bigint;
2237
+ settleableAt: bigint;
2238
+ }>;
2239
+ /** Read + destructure the EVM `channels(bytes32)` view. */
2240
+ private readEvmChannel;
2175
2241
  /**
2176
2242
  * Opens a REAL on-chain Solana payment channel.
2177
2243
  *
@@ -2235,6 +2301,12 @@ declare class OnChainChannelClient implements ConnectorChannelClient {
2235
2301
  interface ChannelStoreEntry {
2236
2302
  nonce: number;
2237
2303
  cumulativeAmount: bigint;
2304
+ /** Unix SECONDS when close was initiated (withdraw flow). */
2305
+ closedAt?: bigint;
2306
+ /** Unix SECONDS the channel becomes settleable (= closedAt + settlementTimeout). */
2307
+ settleableAt?: bigint;
2308
+ /** Unix SECONDS the channel was settled (collateral released). */
2309
+ settledAt?: bigint;
2238
2310
  }
2239
2311
  /**
2240
2312
  * Persistence interface for payment channel nonce/amount state.
@@ -2348,6 +2420,23 @@ declare class ChannelManager {
2348
2420
  * collateral on the next read.
2349
2421
  */
2350
2422
  setDepositTotal(channelId: string, total: bigint): void;
2423
+ /** Persist a channel's full nonce/amount + withdraw-timer state to the store. */
2424
+ private persist;
2425
+ /**
2426
+ * Record that a channel was closed (withdraw flow): stores `closedAt` +
2427
+ * `settleableAt` (unix SECONDS) so the grace timer survives a daemon restart.
2428
+ */
2429
+ setChannelClosed(channelId: string, closedAt: bigint, settleableAt: bigint): void;
2430
+ /** Record that a channel was settled (collateral released). */
2431
+ setChannelSettled(channelId: string, settledAt: bigint): void;
2432
+ /** The `settleableAt` timestamp (unix seconds) for a closed channel, if set. */
2433
+ getSettleableAt(channelId: string): bigint | undefined;
2434
+ /**
2435
+ * Where a channel sits in the withdraw journey, from the tracked timers:
2436
+ * `open` (never closed) → `closing` (closed, grace not elapsed) →
2437
+ * `settleable` (grace elapsed) → `settled`. `nowSec` is injectable for tests.
2438
+ */
2439
+ getChannelCloseState(channelId: string, nowSec?: bigint): 'open' | 'closing' | 'settleable' | 'settled';
2351
2440
  /**
2352
2441
  * Gets all tracked channel IDs.
2353
2442
  */
package/dist/index.js CHANGED
@@ -2035,29 +2035,44 @@ async function openSolanaChannel(params) {
2035
2035
  ]);
2036
2036
  let depositTxSignature;
2037
2037
  if (params.deposit && params.deposit.amount > 0n) {
2038
- const depositData = new Uint8Array(16);
2039
- depositData.set(IX_DEPOSIT, 0);
2040
- writeU64LE(depositData, 8, params.deposit.amount);
2041
- depositTxSignature = await buildAndSendTransaction(rpcUrl, payer, [
2042
- {
2043
- programId,
2044
- keys: [
2045
- { pubkey: payerPubkey, isSigner: true, isWritable: false },
2046
- {
2047
- pubkey: params.deposit.payerTokenAccount,
2048
- isSigner: false,
2049
- isWritable: true
2050
- },
2051
- { pubkey: vaultPDA, isSigner: false, isWritable: true },
2052
- { pubkey: channelPDA, isSigner: false, isWritable: true },
2053
- { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }
2054
- ],
2055
- data: depositData
2056
- }
2057
- ]);
2038
+ ({ depositTxSignature } = await depositSolanaChannel({
2039
+ rpcUrl,
2040
+ programId,
2041
+ channelPDA,
2042
+ payerSeed,
2043
+ payerPubkey,
2044
+ payerTokenAccount: params.deposit.payerTokenAccount,
2045
+ amount: params.deposit.amount
2046
+ }));
2058
2047
  }
2059
2048
  return { channelPDA, opened: true, initTxSignature, depositTxSignature };
2060
2049
  }
2050
+ async function depositSolanaChannel(params) {
2051
+ const { rpcUrl, programId, channelPDA, payerSeed, payerPubkey, payerTokenAccount, amount } = params;
2052
+ if (amount <= 0n) throw new Error("Solana deposit amount must be positive.");
2053
+ const payer = {
2054
+ publicKey: padTo32(base58Decode(payerPubkey)),
2055
+ privateKey: payerSeed
2056
+ };
2057
+ const { pda: vaultPDA } = deriveVaultPDA(channelPDA, programId);
2058
+ const depositData = new Uint8Array(16);
2059
+ depositData.set(IX_DEPOSIT, 0);
2060
+ writeU64LE(depositData, 8, amount);
2061
+ const depositTxSignature = await buildAndSendTransaction(rpcUrl, payer, [
2062
+ {
2063
+ programId,
2064
+ keys: [
2065
+ { pubkey: payerPubkey, isSigner: true, isWritable: false },
2066
+ { pubkey: payerTokenAccount, isSigner: false, isWritable: true },
2067
+ { pubkey: vaultPDA, isSigner: false, isWritable: true },
2068
+ { pubkey: channelPDA, isSigner: false, isWritable: true },
2069
+ { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }
2070
+ ],
2071
+ data: depositData
2072
+ }
2073
+ ]);
2074
+ return { depositTxSignature };
2075
+ }
2061
2076
 
2062
2077
  // src/channel/mina-channel-open.ts
2063
2078
  import { hexToMinaBase58PrivateKey as hexToMinaBase58PrivateKey2 } from "@toon-protocol/core";
@@ -2257,6 +2272,20 @@ var TOKEN_NETWORK_ABI = [
2257
2272
  ],
2258
2273
  outputs: []
2259
2274
  },
2275
+ {
2276
+ name: "closeChannel",
2277
+ type: "function",
2278
+ stateMutability: "nonpayable",
2279
+ inputs: [{ name: "channelId", type: "bytes32" }],
2280
+ outputs: []
2281
+ },
2282
+ {
2283
+ name: "settleChannel",
2284
+ type: "function",
2285
+ stateMutability: "nonpayable",
2286
+ inputs: [{ name: "channelId", type: "bytes32" }],
2287
+ outputs: []
2288
+ },
2260
2289
  {
2261
2290
  name: "channels",
2262
2291
  type: "function",
@@ -2429,12 +2458,43 @@ var OnChainChannelClient = class {
2429
2458
  );
2430
2459
  }
2431
2460
  const chainPrefix = ctx.chain.split(":")[0];
2432
- if (chainPrefix === "solana" || chainPrefix === "mina") {
2461
+ if (chainPrefix === "solana") {
2462
+ return this.depositSolana(channelId, amount, opts.currentDeposit);
2463
+ }
2464
+ if (chainPrefix === "mina") {
2465
+ throw new Error("Deposit on mina is not yet supported (EVM + Solana today; Mina follow-up).");
2466
+ }
2467
+ return this.depositEvm(channelId, amount, opts.currentDeposit, ctx);
2468
+ }
2469
+ /**
2470
+ * Solana deposit: fire the standalone `deposit` instruction against the channel
2471
+ * vault. Incremental on-chain (the program adds `amount`), so the new total is
2472
+ * the caller-tracked current plus the delta. Requires the funded payer token
2473
+ * account (the funded ATA) from the Solana channel config.
2474
+ */
2475
+ async depositSolana(channelId, amount, currentDeposit) {
2476
+ if (!this.solanaConfig) {
2477
+ throw new Error("Solana channel config not set \u2014 cannot deposit.");
2478
+ }
2479
+ const cfg = this.solanaConfig;
2480
+ const payerTokenAccount = cfg.deposit?.payerTokenAccount;
2481
+ if (!payerTokenAccount) {
2433
2482
  throw new Error(
2434
- `Deposit on ${chainPrefix} is not yet supported (EVM only; follow-up PR adds it).`
2483
+ "Solana deposit requires solanaConfig.deposit.payerTokenAccount (the funded SPL token account)."
2435
2484
  );
2436
2485
  }
2437
- return this.depositEvm(channelId, amount, opts.currentDeposit, ctx);
2486
+ const payerSeed = cfg.keypair.slice(0, 32);
2487
+ const payerPubkey = base58Encode2(new Uint8Array(ed255192.getPublicKey(payerSeed)));
2488
+ const { depositTxSignature } = await depositSolanaChannel({
2489
+ rpcUrl: cfg.rpcUrl,
2490
+ programId: cfg.programId,
2491
+ channelPDA: channelId,
2492
+ payerSeed,
2493
+ payerPubkey,
2494
+ payerTokenAccount,
2495
+ amount
2496
+ });
2497
+ return { txHash: depositTxSignature, depositTotal: currentDeposit + amount };
2438
2498
  }
2439
2499
  /**
2440
2500
  * EVM deposit: approve the token-network for the delta if the allowance is
@@ -2474,6 +2534,103 @@ var OnChainChannelClient = class {
2474
2534
  await publicClient.waitForTransactionReceipt({ hash: depositHash });
2475
2535
  return { txHash: depositHash, depositTotal: newTotal };
2476
2536
  }
2537
+ /**
2538
+ * Close a channel to begin the settlement grace period. Dispatches by the
2539
+ * channel's cached chain context. EVM `closeChannel` is unilateral (channelId
2540
+ * only); after it confirms we read the `channels()` view for the AUTHORITATIVE
2541
+ * `closedAt` + `settlementTimeout` (block-timestamp seconds) and compute
2542
+ * `settleableAt = closedAt + settlementTimeout`. Solana/Mina are follow-ups.
2543
+ */
2544
+ async closeChannel(channelId) {
2545
+ const ctx = this.channelContext.get(channelId);
2546
+ if (!ctx) {
2547
+ throw new Error(
2548
+ `No on-chain context for channel "${channelId}" \u2014 it must be opened by this client first.`
2549
+ );
2550
+ }
2551
+ const chainPrefix = ctx.chain.split(":")[0];
2552
+ if (chainPrefix === "solana" || chainPrefix === "mina") {
2553
+ throw new Error(
2554
+ `Close on ${chainPrefix} is not yet supported (EVM today; Solana/Mina follow-up).`
2555
+ );
2556
+ }
2557
+ const { publicClient, walletClient } = this.createClients(ctx.chain);
2558
+ const tokenNetworkAddr = ctx.tokenNetworkAddress;
2559
+ const closeHash = await walletClient.writeContract({
2560
+ address: tokenNetworkAddr,
2561
+ abi: TOKEN_NETWORK_ABI,
2562
+ functionName: "closeChannel",
2563
+ args: [channelId]
2564
+ });
2565
+ await publicClient.waitForTransactionReceipt({ hash: closeHash });
2566
+ const info = await this.readEvmChannel(publicClient, tokenNetworkAddr, channelId);
2567
+ return {
2568
+ txHash: closeHash,
2569
+ closedAt: info.closedAt,
2570
+ settlementTimeout: info.settlementTimeout,
2571
+ settleableAt: info.closedAt + info.settlementTimeout
2572
+ };
2573
+ }
2574
+ /**
2575
+ * Settle a closed channel after its grace period to release collateral. EVM
2576
+ * `settleChannel` is unilateral (channelId only); the contract itself reverts
2577
+ * before `closedAt + settlementTimeout`, so an early call surfaces as a tx
2578
+ * revert here — but the caller (ToonClient/daemon) enforces the time guard
2579
+ * BEFORE spending gas. Solana/Mina are follow-ups.
2580
+ */
2581
+ async settleChannel(channelId) {
2582
+ const ctx = this.channelContext.get(channelId);
2583
+ if (!ctx) {
2584
+ throw new Error(
2585
+ `No on-chain context for channel "${channelId}" \u2014 it must be opened by this client first.`
2586
+ );
2587
+ }
2588
+ const chainPrefix = ctx.chain.split(":")[0];
2589
+ if (chainPrefix === "solana" || chainPrefix === "mina") {
2590
+ throw new Error(
2591
+ `Settle on ${chainPrefix} is not yet supported (EVM today; Solana/Mina follow-up).`
2592
+ );
2593
+ }
2594
+ const { publicClient, walletClient } = this.createClients(ctx.chain);
2595
+ const settleHash = await walletClient.writeContract({
2596
+ address: ctx.tokenNetworkAddress,
2597
+ abi: TOKEN_NETWORK_ABI,
2598
+ functionName: "settleChannel",
2599
+ args: [channelId]
2600
+ });
2601
+ await publicClient.waitForTransactionReceipt({ hash: settleHash });
2602
+ return { txHash: settleHash };
2603
+ }
2604
+ /**
2605
+ * Read the EVM channel's close-relevant fields so a restarted daemon can
2606
+ * recompute the grace timer from chain (chain is authoritative). EVM-only.
2607
+ */
2608
+ async getChannelCloseInfo(channelId) {
2609
+ const ctx = this.channelContext.get(channelId);
2610
+ if (!ctx) throw new Error(`No on-chain context for channel "${channelId}".`);
2611
+ const chainPrefix = ctx.chain.split(":")[0];
2612
+ if (chainPrefix === "solana" || chainPrefix === "mina") {
2613
+ throw new Error(`getChannelCloseInfo on ${chainPrefix} is not supported.`);
2614
+ }
2615
+ const { publicClient } = this.createClients(ctx.chain);
2616
+ const info = await this.readEvmChannel(publicClient, ctx.tokenNetworkAddress, channelId);
2617
+ return {
2618
+ status: STATE_MAP2[info.state] ?? "open",
2619
+ closedAt: info.closedAt,
2620
+ settlementTimeout: info.settlementTimeout,
2621
+ settleableAt: info.closedAt + info.settlementTimeout
2622
+ };
2623
+ }
2624
+ /** Read + destructure the EVM `channels(bytes32)` view. */
2625
+ async readEvmChannel(publicClient, tokenNetworkAddr, channelId) {
2626
+ const res = await publicClient.readContract({
2627
+ address: tokenNetworkAddr,
2628
+ abi: TOKEN_NETWORK_ABI,
2629
+ functionName: "channels",
2630
+ args: [channelId]
2631
+ });
2632
+ return { settlementTimeout: res[0], state: Number(res[1]), closedAt: res[2] };
2633
+ }
2477
2634
  /**
2478
2635
  * Opens a REAL on-chain Solana payment channel.
2479
2636
  *
@@ -3438,7 +3595,12 @@ var ChannelManager = class {
3438
3595
  tokenNetworkAddress: tnAddr,
3439
3596
  tokenAddress: chainContext?.tokenAddress,
3440
3597
  recipient: chainContext?.recipient,
3441
- depositTotal: chainContext?.depositTotal
3598
+ depositTotal: chainContext?.depositTotal,
3599
+ // Resume the withdraw-flow timers so a daemon restart mid-grace
3600
+ // doesn't strand funds (the gate can't be evaluated without them).
3601
+ ...persisted.closedAt !== void 0 ? { closedAt: persisted.closedAt } : {},
3602
+ ...persisted.settleableAt !== void 0 ? { settleableAt: persisted.settleableAt } : {},
3603
+ ...persisted.settledAt !== void 0 ? { settledAt: persisted.settledAt } : {}
3442
3604
  });
3443
3605
  return;
3444
3606
  }
@@ -3473,12 +3635,7 @@ var ChannelManager = class {
3473
3635
  }
3474
3636
  tracking.nonce += 1;
3475
3637
  tracking.cumulativeAmount += additionalAmount;
3476
- if (this.store) {
3477
- this.store.save(channelId, {
3478
- nonce: tracking.nonce,
3479
- cumulativeAmount: tracking.cumulativeAmount
3480
- });
3481
- }
3638
+ this.persist(channelId);
3482
3639
  const signer = this.chainSigners.get(tracking.chainType);
3483
3640
  if (signer && tracking.chainType !== "evm") {
3484
3641
  if (!tracking.recipient) {
@@ -3578,6 +3735,57 @@ var ChannelManager = class {
3578
3735
  }
3579
3736
  tracking.depositTotal = total;
3580
3737
  }
3738
+ /** Persist a channel's full nonce/amount + withdraw-timer state to the store. */
3739
+ persist(channelId) {
3740
+ if (!this.store) return;
3741
+ const t = this.channels.get(channelId);
3742
+ if (!t) return;
3743
+ this.store.save(channelId, {
3744
+ nonce: t.nonce,
3745
+ cumulativeAmount: t.cumulativeAmount,
3746
+ ...t.closedAt !== void 0 ? { closedAt: t.closedAt } : {},
3747
+ ...t.settleableAt !== void 0 ? { settleableAt: t.settleableAt } : {},
3748
+ ...t.settledAt !== void 0 ? { settledAt: t.settledAt } : {}
3749
+ });
3750
+ }
3751
+ /**
3752
+ * Record that a channel was closed (withdraw flow): stores `closedAt` +
3753
+ * `settleableAt` (unix SECONDS) so the grace timer survives a daemon restart.
3754
+ */
3755
+ setChannelClosed(channelId, closedAt, settleableAt) {
3756
+ const tracking = this.channels.get(channelId);
3757
+ if (!tracking) {
3758
+ throw new Error(`Channel "${channelId}" is not being tracked.`);
3759
+ }
3760
+ tracking.closedAt = closedAt;
3761
+ tracking.settleableAt = settleableAt;
3762
+ this.persist(channelId);
3763
+ }
3764
+ /** Record that a channel was settled (collateral released). */
3765
+ setChannelSettled(channelId, settledAt) {
3766
+ const tracking = this.channels.get(channelId);
3767
+ if (!tracking) {
3768
+ throw new Error(`Channel "${channelId}" is not being tracked.`);
3769
+ }
3770
+ tracking.settledAt = settledAt;
3771
+ this.persist(channelId);
3772
+ }
3773
+ /** The `settleableAt` timestamp (unix seconds) for a closed channel, if set. */
3774
+ getSettleableAt(channelId) {
3775
+ return this.channels.get(channelId)?.settleableAt;
3776
+ }
3777
+ /**
3778
+ * Where a channel sits in the withdraw journey, from the tracked timers:
3779
+ * `open` (never closed) → `closing` (closed, grace not elapsed) →
3780
+ * `settleable` (grace elapsed) → `settled`. `nowSec` is injectable for tests.
3781
+ */
3782
+ getChannelCloseState(channelId, nowSec = BigInt(Math.floor(Date.now() / 1e3))) {
3783
+ const t = this.channels.get(channelId);
3784
+ if (!t || t.closedAt === void 0) return "open";
3785
+ if (t.settledAt !== void 0) return "settled";
3786
+ if (t.settleableAt !== void 0 && nowSec >= t.settleableAt) return "settleable";
3787
+ return "closing";
3788
+ }
3581
3789
  /**
3582
3790
  * Gets all tracked channel IDs.
3583
3791
  */
@@ -3603,7 +3811,10 @@ var JsonFileChannelStore = class {
3603
3811
  const data = this.readFile();
3604
3812
  data[channelId] = {
3605
3813
  nonce: tracking.nonce,
3606
- cumulativeAmount: tracking.cumulativeAmount.toString()
3814
+ cumulativeAmount: tracking.cumulativeAmount.toString(),
3815
+ ...tracking.closedAt !== void 0 ? { closedAt: tracking.closedAt.toString() } : {},
3816
+ ...tracking.settleableAt !== void 0 ? { settleableAt: tracking.settleableAt.toString() } : {},
3817
+ ...tracking.settledAt !== void 0 ? { settledAt: tracking.settledAt.toString() } : {}
3607
3818
  };
3608
3819
  this.writeFile(data);
3609
3820
  }
@@ -3613,7 +3824,10 @@ var JsonFileChannelStore = class {
3613
3824
  if (!entry) return void 0;
3614
3825
  return {
3615
3826
  nonce: entry.nonce,
3616
- cumulativeAmount: BigInt(entry.cumulativeAmount)
3827
+ cumulativeAmount: BigInt(entry.cumulativeAmount),
3828
+ ...entry.closedAt !== void 0 ? { closedAt: BigInt(entry.closedAt) } : {},
3829
+ ...entry.settleableAt !== void 0 ? { settleableAt: BigInt(entry.settleableAt) } : {},
3830
+ ...entry.settledAt !== void 0 ? { settledAt: BigInt(entry.settledAt) } : {}
3617
3831
  };
3618
3832
  }
3619
3833
  list() {
@@ -4743,6 +4957,60 @@ var ToonClient = class {
4743
4957
  depositTotal: result.depositTotal.toString()
4744
4958
  };
4745
4959
  }
4960
+ /**
4961
+ * Close a channel to begin the settlement grace period (first half of
4962
+ * withdraw). Records `closedAt`/`settleableAt` (unix seconds) on the tracked
4963
+ * channel — persisted, so the grace timer survives a daemon restart. Spends
4964
+ * on-chain. EVM today; Solana/Mina are follow-ups.
4965
+ */
4966
+ async closeChannel(channelId) {
4967
+ if (!this.channelManager) throw new Error("ChannelManager not initialized");
4968
+ if (!this.onChainChannelClient) {
4969
+ throw new Error("On-chain channel client not configured (no chainRpcUrls).");
4970
+ }
4971
+ const r = await this.onChainChannelClient.closeChannel(channelId);
4972
+ this.channelManager.setChannelClosed(channelId, r.closedAt, r.settleableAt);
4973
+ return {
4974
+ channelId,
4975
+ ...r.txHash ? { txHash: r.txHash } : {},
4976
+ closedAt: r.closedAt.toString(),
4977
+ settleableAt: r.settleableAt.toString()
4978
+ };
4979
+ }
4980
+ /**
4981
+ * Settle a closed channel to release collateral (second half of withdraw).
4982
+ * THE time guard: never settle before `settleableAt`. A too-early call throws
4983
+ * a retryable error (carrying the remaining seconds) BEFORE spending gas — the
4984
+ * contract would revert anyway. Spends on-chain. EVM today.
4985
+ */
4986
+ async settleChannel(channelId) {
4987
+ if (!this.channelManager) throw new Error("ChannelManager not initialized");
4988
+ if (!this.onChainChannelClient) {
4989
+ throw new Error("On-chain channel client not configured (no chainRpcUrls).");
4990
+ }
4991
+ const settleableAt = this.channelManager.getSettleableAt(channelId);
4992
+ if (settleableAt === void 0) {
4993
+ throw new Error(`Channel "${channelId}" is not closed; call closeChannel first.`);
4994
+ }
4995
+ const nowSec = BigInt(Math.floor(Date.now() / 1e3));
4996
+ if (nowSec < settleableAt) {
4997
+ const remaining = settleableAt - nowSec;
4998
+ throw Object.assign(
4999
+ new Error(
5000
+ `Channel "${channelId}" is not settleable yet \u2014 ${remaining}s remain (settleable at ${settleableAt}).`
5001
+ ),
5002
+ { name: "SettleTooEarlyError", retryable: true, settleableAt: settleableAt.toString() }
5003
+ );
5004
+ }
5005
+ const r = await this.onChainChannelClient.settleChannel(channelId);
5006
+ this.channelManager.setChannelSettled(channelId, nowSec);
5007
+ return { channelId, ...r.txHash ? { txHash: r.txHash } : {} };
5008
+ }
5009
+ /** Where a tracked channel sits in the withdraw journey. */
5010
+ getChannelCloseState(channelId) {
5011
+ if (!this.channelManager) throw new Error("ChannelManager not initialized");
5012
+ return this.channelManager.getChannelCloseState(channelId);
5013
+ }
4746
5014
  /**
4747
5015
  * Read the on-chain settlement-token balance of this client's OWN wallet on
4748
5016
  * each configured chain (EVM token, Solana SPL, native MINA). A free read — no