@anywalletkit/wallet-ton 0.4.0 → 0.7.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.d.cts CHANGED
@@ -65,6 +65,12 @@ interface TonConfig {
65
65
  simulate?: boolean;
66
66
  /** Seconds a signed external stays valid (default 60, max 600). */
67
67
  validUntilSeconds?: number;
68
+ /**
69
+ * Most milliseconds a send waits for this account's previous transfer to move the wallet's seqno
70
+ * before signing the next one (default 60 000). A transfer signed at the same seqno would never
71
+ * execute; past this wait the send throws instead.
72
+ */
73
+ pendingSeqnoTimeoutMs?: number;
68
74
  /** Nanotons attached to a jetton transfer on top of `forwardTonAmount` (default 0.05 TON). */
69
75
  jettonAttachedValue?: bigint | number | string;
70
76
  /**
@@ -267,7 +273,7 @@ declare function tonCenterEndpoint(endpoint: TonEndpoint, opts?: {
267
273
  fetch?: typeof fetch;
268
274
  timeoutMs?: number;
269
275
  }): TonRpc;
270
- /** An endpoint answered with a refusal (`ok: false`): nothing was taken. A thrown fetch, a timeout or a non-JSON body may have been. */
276
+ /** An endpoint answered with a node refusal (toncenter's `ok: false` envelope): nothing was taken. A thrown fetch, a timeout, a non-JSON body or a gateway error may have been. */
271
277
  declare function isAnsweredRefusal(e: unknown): boolean;
272
278
  /** One RPC, or a failover RPC walking the configured list (`retries` extra rounds after the first full pass). */
273
279
  declare function createTonRpc(config: TonConfig): TonRpc | undefined;
@@ -638,7 +644,7 @@ declare class WalletAccountReadOnlyTon implements IWalletAccountReadOnly {
638
644
  protected toMessages(tx: TonTransaction | TonTransaction[]): V5R1Message[];
639
645
  protected validUntil(): number;
640
646
  /** The cell to sign for `messages` at the CURRENT seqno (fetched fresh every time). */
641
- protected signingCell(messages: V5R1Message[]): Promise<{
647
+ protected signingCell(messages: V5R1Message[], knownSeqno?: number): Promise<{
642
648
  signing: Cell;
643
649
  seqno: number;
644
650
  validUntil: number;
@@ -749,9 +755,17 @@ declare class WalletAccountTon extends WalletAccountReadOnlyTon implements IWall
749
755
  readonly needsNetwork = true;
750
756
  /** Derivation path, `ton-mnemonic`, or `external`. */
751
757
  readonly path: string;
752
- /** The signer that holds the key. */
753
- readonly signer: Signer;
758
+ /** What holds the key: `seed`, `key` (one imported key) or `external`. */
759
+ readonly signerKind: Signer['kind'];
754
760
  private _readOnly;
761
+ /**
762
+ * The last external this account broadcast (accepted, or maybe sent) and when it expires. Until
763
+ * the wallet's seqno moves past it, a new message signed at the chain seqno would carry the same
764
+ * seqno and silently never execute, so the next send waits for it first.
765
+ */
766
+ private _inFlight;
767
+ /** Tail of this account's send queue: seqno → sign → broadcast runs one send at a time. */
768
+ private _sendTail;
755
769
  /** `publicKey` is the signer's 32-byte ed25519 key; the V5R1 address derives from it synchronously. */
756
770
  constructor(signer: Signer, publicKey: Uint8Array, path: string, config?: TonConfig);
757
771
  /** Back-compat: wrap a raw derived key in a {@link LocalSigner} (the account takes ownership; it cannot derive). */
package/dist/index.d.ts CHANGED
@@ -65,6 +65,12 @@ interface TonConfig {
65
65
  simulate?: boolean;
66
66
  /** Seconds a signed external stays valid (default 60, max 600). */
67
67
  validUntilSeconds?: number;
68
+ /**
69
+ * Most milliseconds a send waits for this account's previous transfer to move the wallet's seqno
70
+ * before signing the next one (default 60 000). A transfer signed at the same seqno would never
71
+ * execute; past this wait the send throws instead.
72
+ */
73
+ pendingSeqnoTimeoutMs?: number;
68
74
  /** Nanotons attached to a jetton transfer on top of `forwardTonAmount` (default 0.05 TON). */
69
75
  jettonAttachedValue?: bigint | number | string;
70
76
  /**
@@ -267,7 +273,7 @@ declare function tonCenterEndpoint(endpoint: TonEndpoint, opts?: {
267
273
  fetch?: typeof fetch;
268
274
  timeoutMs?: number;
269
275
  }): TonRpc;
270
- /** An endpoint answered with a refusal (`ok: false`): nothing was taken. A thrown fetch, a timeout or a non-JSON body may have been. */
276
+ /** An endpoint answered with a node refusal (toncenter's `ok: false` envelope): nothing was taken. A thrown fetch, a timeout, a non-JSON body or a gateway error may have been. */
271
277
  declare function isAnsweredRefusal(e: unknown): boolean;
272
278
  /** One RPC, or a failover RPC walking the configured list (`retries` extra rounds after the first full pass). */
273
279
  declare function createTonRpc(config: TonConfig): TonRpc | undefined;
@@ -638,7 +644,7 @@ declare class WalletAccountReadOnlyTon implements IWalletAccountReadOnly {
638
644
  protected toMessages(tx: TonTransaction | TonTransaction[]): V5R1Message[];
639
645
  protected validUntil(): number;
640
646
  /** The cell to sign for `messages` at the CURRENT seqno (fetched fresh every time). */
641
- protected signingCell(messages: V5R1Message[]): Promise<{
647
+ protected signingCell(messages: V5R1Message[], knownSeqno?: number): Promise<{
642
648
  signing: Cell;
643
649
  seqno: number;
644
650
  validUntil: number;
@@ -749,9 +755,17 @@ declare class WalletAccountTon extends WalletAccountReadOnlyTon implements IWall
749
755
  readonly needsNetwork = true;
750
756
  /** Derivation path, `ton-mnemonic`, or `external`. */
751
757
  readonly path: string;
752
- /** The signer that holds the key. */
753
- readonly signer: Signer;
758
+ /** What holds the key: `seed`, `key` (one imported key) or `external`. */
759
+ readonly signerKind: Signer['kind'];
754
760
  private _readOnly;
761
+ /**
762
+ * The last external this account broadcast (accepted, or maybe sent) and when it expires. Until
763
+ * the wallet's seqno moves past it, a new message signed at the chain seqno would carry the same
764
+ * seqno and silently never execute, so the next send waits for it first.
765
+ */
766
+ private _inFlight;
767
+ /** Tail of this account's send queue: seqno → sign → broadcast runs one send at a time. */
768
+ private _sendTail;
755
769
  /** `publicKey` is the signer's 32-byte ed25519 key; the V5R1 address derives from it synchronously. */
756
770
  constructor(signer: Signer, publicKey: Uint8Array, path: string, config?: TonConfig);
757
771
  /** Back-compat: wrap a raw derived key in a {@link LocalSigner} (the account takes ownership; it cannot derive). */
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  rawTonAddress,
11
11
  tonExplorerTxUrl,
12
12
  valueFlowTransfers
13
- } from "./chunk-ZFENRKRW.js";
13
+ } from "./chunk-WU4RNRQJ.js";
14
14
 
15
15
  // src/buffer-global.ts
16
16
  import { Buffer } from "buffer";
@@ -95,12 +95,19 @@ function tonCenterEndpoint(endpoint, opts = {}) {
95
95
  }
96
96
  if (!res.ok || json.ok !== true) {
97
97
  const detail = typeof json.error === "string" ? json.error : JSON.stringify(json.error ?? json);
98
- throw Object.assign(new NetworkError(`${method}: ${detail} (HTTP ${res.status})`, { provider: url }), { answered: true });
98
+ const err = new NetworkError(`${method}: ${detail} (HTTP ${res.status})`, { provider: url });
99
+ throw isNodeRefusal(res.status, json) ? Object.assign(err, { answered: true }) : err;
99
100
  }
100
101
  return json.result;
101
102
  }
102
103
  };
103
104
  }
105
+ var UNKNOWN_STATUS = /* @__PURE__ */ new Set([408, 502, 503, 504]);
106
+ function isNodeRefusal(status, json) {
107
+ if (UNKNOWN_STATUS.has(status)) return false;
108
+ if (json.ok !== false || typeof json.error !== "string" || json.error.trim() === "") return false;
109
+ return !/time[ds]? ?out|timed out|deadline/i.test(json.error);
110
+ }
104
111
  function isAnsweredRefusal(e) {
105
112
  return e?.answered === true;
106
113
  }
@@ -950,8 +957,8 @@ var WalletAccountReadOnlyTon = class {
950
957
  return Math.floor(Date.now() / 1e3) + seconds;
951
958
  }
952
959
  /** The cell to sign for `messages` at the CURRENT seqno (fetched fresh every time). */
953
- async signingCell(messages) {
954
- const seqno = await this.getSeqno();
960
+ async signingCell(messages, knownSeqno) {
961
+ const seqno = knownSeqno ?? await this.getSeqno();
955
962
  const validUntil = this.validUntil();
956
963
  return { signing: v5r1SigningMessage({ walletId: this._walletId, validUntil, seqno, messages }), seqno, validUntil };
957
964
  }
@@ -1112,6 +1119,8 @@ function tonSignDataDigest(address, request) {
1112
1119
  }
1113
1120
 
1114
1121
  // src/account.ts
1122
+ var PENDING_SEQNO_TIMEOUT_MS = 6e4;
1123
+ var PENDING_SEQNO_POLL_MS = 1500;
1115
1124
  var toHex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
1116
1125
  var carriesKey = (bytes, key) => {
1117
1126
  outer: for (let i = 0; i + key.length <= bytes.length; i++) {
@@ -1125,13 +1134,28 @@ var WalletAccountTon = class _WalletAccountTon extends WalletAccountReadOnlyTon
1125
1134
  needsNetwork = true;
1126
1135
  /** Derivation path, `ton-mnemonic`, or `external`. */
1127
1136
  path;
1128
- /** The signer that holds the key. */
1129
- signer;
1137
+ /**
1138
+ * The signer that holds the key, in a private field: not a property of the account, so no code
1139
+ * the account is handed to (a protocol module, a logger) can reach it and sign around the
1140
+ * account's checks (audit 2026-10-29 A1).
1141
+ */
1142
+ #signer;
1143
+ /** What holds the key: `seed`, `key` (one imported key) or `external`. */
1144
+ signerKind;
1130
1145
  _readOnly;
1146
+ /**
1147
+ * The last external this account broadcast (accepted, or maybe sent) and when it expires. Until
1148
+ * the wallet's seqno moves past it, a new message signed at the chain seqno would carry the same
1149
+ * seqno and silently never execute, so the next send waits for it first.
1150
+ */
1151
+ _inFlight;
1152
+ /** Tail of this account's send queue: seqno → sign → broadcast runs one send at a time. */
1153
+ _sendTail = Promise.resolve();
1131
1154
  /** `publicKey` is the signer's 32-byte ed25519 key; the V5R1 address derives from it synchronously. */
1132
1155
  constructor(signer, publicKey, path, config = {}) {
1133
1156
  super(publicKey, config);
1134
- this.signer = requireCurve(signer, "ed25519");
1157
+ this.#signer = requireCurve(signer, "ed25519");
1158
+ this.signerKind = this.#signer.kind;
1135
1159
  this.path = path;
1136
1160
  }
1137
1161
  /** Back-compat: wrap a raw derived key in a {@link LocalSigner} (the account takes ownership; it cannot derive). */
@@ -1154,7 +1178,7 @@ var WalletAccountTon = class _WalletAccountTon extends WalletAccountReadOnlyTon
1154
1178
  async sign(message) {
1155
1179
  const bytes = typeof message === "string" ? new TextEncoder().encode(message) : message;
1156
1180
  if (bytes.length === 32 || carriesKey(bytes, this.publicKey)) throw new MessageIsTransactionError();
1157
- return toHex(await this.signer.sign(bytes, { scheme: "ed25519", context: "ton-message" }));
1181
+ return toHex(await this.#signer.sign(bytes, { scheme: "ed25519", context: "ton-message" }));
1158
1182
  }
1159
1183
  /**
1160
1184
  * TON Connect `ton_proof` for this wallet's address: the `proof` object of the connect reply. The
@@ -1163,7 +1187,7 @@ var WalletAccountTon = class _WalletAccountTon extends WalletAccountReadOnlyTon
1163
1187
  */
1164
1188
  async signTonProof(request) {
1165
1189
  const digest = tonProofDigest(this._address, request);
1166
- const signature = await this.signer.sign(digest, { scheme: "ed25519", context: "ton-proof" });
1190
+ const signature = await this.#signer.sign(digest, { scheme: "ed25519", context: "ton-proof" });
1167
1191
  return {
1168
1192
  timestamp: request.timestamp,
1169
1193
  domain: { lengthBytes: new TextEncoder().encode(request.domain).length, value: request.domain },
@@ -1174,7 +1198,7 @@ var WalletAccountTon = class _WalletAccountTon extends WalletAccountReadOnlyTon
1174
1198
  /** TON Connect `signData` (text / binary) for this wallet's address: the reply the dApp gets. */
1175
1199
  async signTonConnectData(request) {
1176
1200
  const digest = tonSignDataDigest(this._address, request);
1177
- const signature = await this.signer.sign(digest, { scheme: "ed25519", context: "ton-sign-data" });
1201
+ const signature = await this.#signer.sign(digest, { scheme: "ed25519", context: "ton-sign-data" });
1178
1202
  return {
1179
1203
  signature: Buffer6.from(signature).toString("base64"),
1180
1204
  address: this._address.toRawString(),
@@ -1204,16 +1228,35 @@ var WalletAccountTon = class _WalletAccountTon extends WalletAccountReadOnlyTon
1204
1228
  bodyModes(body) {
1205
1229
  return parseV5R1Body(body)?.messages.map((m) => m.mode) ?? [];
1206
1230
  }
1207
- async #signMessages(messages, limit, op) {
1231
+ /**
1232
+ * The seqno to sign the next send at: the chain's, once it has moved past this account's last
1233
+ * broadcast. While that external can still land (before its valid_until) the seqno is polled, up
1234
+ * to `pendingSeqnoTimeoutMs`; an expired one that never landed is forgotten.
1235
+ */
1236
+ async #nextSendSeqno() {
1237
+ const timeout = this._config.pendingSeqnoTimeoutMs ?? PENDING_SEQNO_TIMEOUT_MS;
1238
+ const deadline = Date.now() + timeout;
1239
+ for (; ; ) {
1240
+ const chain = await this.getSeqno();
1241
+ const prev = this._inFlight;
1242
+ if (!prev || chain > prev.seqno || Date.now() / 1e3 > prev.validUntil) {
1243
+ this._inFlight = void 0;
1244
+ return chain;
1245
+ }
1246
+ if (Date.now() >= deadline) throw new Error(`The previous transfer is still pending (seqno ${prev.seqno} has not moved): wait for it to land, then send again.`);
1247
+ await new Promise((resolve) => setTimeout(resolve, Math.min(PENDING_SEQNO_POLL_MS, Math.max(0, deadline - Date.now()))));
1248
+ }
1249
+ }
1250
+ async #signMessages(messages, limit, op, forSend = false) {
1208
1251
  this.assertNoDrain(messages.map((m) => sendModeOf(m.mode)));
1209
1252
  const want = messages.reduce((sum, m) => sum + (m.message.info.type === "internal" ? m.message.info.value.coins : 0n), 0n);
1210
1253
  if (want > 0n) {
1211
1254
  const balance = await this.getBalance();
1212
1255
  if (balance < want) throw new UnsafeTransactionError("insufficient-balance", `Insufficient TON balance: the messages carry ${want} nanotons, the wallet holds ${balance} (fees come on top).`);
1213
1256
  }
1214
- const { signing } = await this.signingCell(messages);
1257
+ const { signing } = await this.signingCell(messages, forSend ? await this.#nextSendSeqno() : void 0);
1215
1258
  const fee = await this.preflight(v5r1PackUnsigned(signing), limit, op, want);
1216
- const signature = await this.signer.sign(new Uint8Array(signing.hash()), { scheme: "ed25519", context: "ton-external" });
1259
+ const signature = await this.#signer.sign(new Uint8Array(signing.hash()), { scheme: "ed25519", context: "ton-external" });
1217
1260
  return { body: v5r1PackSigned(signing, signature), fee };
1218
1261
  }
1219
1262
  async preflight(body, limit, op, expectedTonOut) {
@@ -1248,26 +1291,47 @@ var WalletAccountTon = class _WalletAccountTon extends WalletAccountReadOnlyTon
1248
1291
  const rpc = this.requireRpc("send transactions");
1249
1292
  const init = await this.initIfNeeded();
1250
1293
  const hash = body.hash().toString("hex");
1294
+ const parsed = parseV5R1Body(body);
1295
+ const sent = () => {
1296
+ if (parsed) this._inFlight = { seqno: parsed.seqno, validUntil: parsed.validUntil };
1297
+ };
1251
1298
  try {
1252
1299
  await rpc.sendBoc(v5r1ExternalMessage(this._address, body, init));
1253
1300
  } catch (e) {
1254
1301
  if (isAnsweredRefusal(e)) throw e;
1302
+ sent();
1255
1303
  throw new BroadcastUnknownError(hash, e);
1256
1304
  }
1305
+ sent();
1257
1306
  return hash;
1258
1307
  }
1308
+ /**
1309
+ * One send at a time per account: two sends started together would read the same seqno before
1310
+ * either is broadcast. A failed send does not block the next.
1311
+ */
1312
+ #serial(run) {
1313
+ const next = this._sendTail.then(run);
1314
+ this._sendTail = next.then(() => void 0, () => void 0);
1315
+ return next;
1316
+ }
1259
1317
  /** Sign and broadcast; `hash` is the body hash (hex) — resolve it with getTransactionReceipt. */
1260
- async sendTransaction(tx, options = {}) {
1318
+ sendTransaction(tx, options = {}) {
1319
+ return this.#serial(() => this.#sendTransactionNow(tx, options));
1320
+ }
1321
+ async #sendTransactionNow(tx, options) {
1261
1322
  this.requireRpc("send transactions");
1262
1323
  if (tx instanceof Cell5) this.assertNoDrain(this.bodyModes(tx));
1263
- const { body, fee } = tx instanceof Cell5 ? { body: tx, fee: await this.preflight(tx, feeLimit(this._config.transactionMaxFee, options), "transaction") } : await this.#signMessages(this.toMessages(tx), feeLimit(this._config.transactionMaxFee, options), "transaction");
1324
+ const { body, fee } = tx instanceof Cell5 ? { body: tx, fee: await this.preflight(tx, feeLimit(this._config.transactionMaxFee, options), "transaction") } : await this.#signMessages(this.toMessages(tx), feeLimit(this._config.transactionMaxFee, options), "transaction", true);
1264
1325
  return { hash: await this.broadcast(body), fee };
1265
1326
  }
1266
1327
  /** TON (no `token`) or jetton transfer. */
1267
- async transfer(o, writeOptions = {}) {
1328
+ transfer(o, writeOptions = {}) {
1329
+ return this.#serial(() => this.#transferNow(o, writeOptions));
1330
+ }
1331
+ async #transferNow(o, writeOptions) {
1268
1332
  this.requireRpc("transfer tokens");
1269
1333
  const message = o.token ? await this.jettonTransferMessage(o) : this.transactionMessage({ to: o.recipient, value: o.amount, ...o.memo !== void 0 ? { body: o.memo } : {} });
1270
- const { body, fee } = await this.#signMessages([message], feeLimit(this._config.transferMaxFee, writeOptions), "transfer");
1334
+ const { body, fee } = await this.#signMessages([message], feeLimit(this._config.transferMaxFee, writeOptions), "transfer", true);
1271
1335
  return { hash: await this.broadcast(body), fee };
1272
1336
  }
1273
1337
  /** The same wallet as a {@link WalletAccountReadOnlyTon}. */
@@ -1277,7 +1341,7 @@ var WalletAccountTon = class _WalletAccountTon extends WalletAccountReadOnlyTon
1277
1341
  }
1278
1342
  /** Disposes the signer: a local key is zeroized, an external one is released. */
1279
1343
  dispose() {
1280
- this.signer.dispose();
1344
+ this.#signer.dispose();
1281
1345
  }
1282
1346
  };
1283
1347