@escro/sdk 0.3.0 → 0.4.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
@@ -3,7 +3,7 @@ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { en
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
4
 
5
5
  // src/client.ts
6
- import { Connection as Connection2, PublicKey as PublicKey2 } from "@solana/web3.js";
6
+ import { Connection as Connection2, PublicKey as PublicKey3 } from "@solana/web3.js";
7
7
 
8
8
  // ../shared/dist/index.js
9
9
  import { createHash } from "crypto";
@@ -22,20 +22,10 @@ var EscrowState = /* @__PURE__ */ ((EscrowState2) => {
22
22
  EscrowState2["REFUNDED"] = "REFUNDED";
23
23
  return EscrowState2;
24
24
  })(EscrowState || {});
25
- function canonicalize(value) {
26
- if (value === null) return "null";
27
- if (Array.isArray(value)) {
28
- return "[" + value.map((v) => v === void 0 ? "null" : canonicalize(v)).join(",") + "]";
29
- }
30
- if (typeof value === "object") {
31
- const obj = value;
32
- const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
33
- return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k])).join(",") + "}";
34
- }
35
- return JSON.stringify(value);
36
- }
37
25
  var PROGRAM_ID_DEVNET = "EjLpJ3obUYk1f2KDensU7UdGBZTxSdxufm7CSnMA3H5C";
38
26
  var PROGRAM_ID_MAINNET = "4CwLNRF4mFXutiBGjtMHQBxh8whW8T8EyvJXdRhY1rvr";
27
+ var CURRENT_ACCOUNT_VERSION = 1;
28
+ var MAX_DISPUTE_REASON_BYTES = 200;
39
29
  var ONCHAIN_REFUND_GRACE_SECONDS = 900;
40
30
  var ONCHAIN_TIMEOUT_SECONDS = 86400;
41
31
  var CLOCK_SKEW_SECONDS = 60;
@@ -55,6 +45,26 @@ function assertProgramIdNotPlaceholder(id, cluster) {
55
45
  );
56
46
  }
57
47
  }
48
+ function canonicalize(value) {
49
+ if (value === null) return "null";
50
+ if (Array.isArray(value)) {
51
+ return "[" + value.map((v) => v === void 0 ? "null" : canonicalize(v)).join(",") + "]";
52
+ }
53
+ if (typeof value === "object") {
54
+ const obj = value;
55
+ const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
56
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k])).join(",") + "}";
57
+ }
58
+ return JSON.stringify(value);
59
+ }
60
+ function disputeReasonError(reason) {
61
+ if (reason.length === 0) return "reason must not be empty";
62
+ const bytes = new TextEncoder().encode(reason).length;
63
+ if (bytes > MAX_DISPUTE_REASON_BYTES) {
64
+ return `reason is ${String(bytes)} bytes in UTF-8; the program accepts at most ${String(MAX_DISPUTE_REASON_BYTES)}`;
65
+ }
66
+ return null;
67
+ }
58
68
  function hashBody(rawBody) {
59
69
  return createHash("sha256").update(rawBody, "utf8").digest("hex");
60
70
  }
@@ -169,8 +179,12 @@ var EscrowTimeoutError = class extends EscroError {
169
179
  }
170
180
  };
171
181
  var EscroConfirmError = class extends EscroError {
172
- constructor(address, signature, code, message) {
173
- super(`Confirm failed for escrow ${address} (signature ${signature}): ${message}`, code);
182
+ constructor(address, signature, code, message, retryAfterMs2) {
183
+ super(
184
+ `Confirm failed for escrow ${address} (signature ${signature}): ${message}`,
185
+ code,
186
+ retryAfterMs2
187
+ );
174
188
  __publicField(this, "address", address);
175
189
  __publicField(this, "signature", signature);
176
190
  this.name = "EscroConfirmError";
@@ -360,8 +374,11 @@ var ApiClient = class {
360
374
  return res.json();
361
375
  }
362
376
  /**
363
- * POST /v1/escrows/:address/release — DB-only state update.
364
- * Requires auth (buyer). No on-chain tx returned.
377
+ * POST /v1/escrows/:address/release: records the landed release once the API's RPC shows
378
+ * the escrow closed. Requires auth (buyer). No on-chain tx returned. A 409
379
+ * `ESCROW_NOT_SETTLED`, a 5xx or a 429 throws an {@link EscroError} with the server's code,
380
+ * or `HTTP_<status>` when the answer carries none; {@link Escro.releasePaymentWithOutcome}
381
+ * decides which to retry. Other refusals throw as every route does.
365
382
  */
366
383
  async releasePaymentDb(address) {
367
384
  const path = `/v1/escrows/${address}/release`;
@@ -369,7 +386,19 @@ var ApiClient = class {
369
386
  method: "POST",
370
387
  headers: authHeaders(this.wallet, "POST", path)
371
388
  });
372
- if (!res.ok) await handleError(res, address);
389
+ if (res.ok) return;
390
+ if (res.status !== 409 && res.status !== 429 && res.status < 500) {
391
+ await handleError(res, address);
392
+ }
393
+ const body = await res.json().catch(() => ({}));
394
+ if (res.status === 409 && body.code !== "ESCROW_NOT_SETTLED") {
395
+ throw new EscrowStateError(body.error ?? `HTTP ${String(res.status)}`);
396
+ }
397
+ throw new EscroError(
398
+ body.error ?? `HTTP ${String(res.status)}`,
399
+ body.code ?? `HTTP_${String(res.status)}`,
400
+ retryAfterMs(res.headers.get("retry-after"))
401
+ );
373
402
  }
374
403
  /**
375
404
  * POST /v1/escrows/:address/cancel — returns unsigned tx.
@@ -478,7 +507,7 @@ async function pollUntil(address, fetcher, predicate, timeoutMs = MS_24_H) {
478
507
  import { createHash as createHash2 } from "crypto";
479
508
  import {
480
509
  ComputeBudgetProgram,
481
- PublicKey,
510
+ PublicKey as PublicKey2,
482
511
  Transaction,
483
512
  TransactionInstruction
484
513
  } from "@solana/web3.js";
@@ -488,6 +517,59 @@ import {
488
517
  getAssociatedTokenAddressSync,
489
518
  createAssociatedTokenAccountIdempotentInstruction
490
519
  } from "@solana/spl-token";
520
+
521
+ // ../shared/dist/solana.js
522
+ import { PublicKey } from "@solana/web3.js";
523
+ import bs583 from "bs58";
524
+ var utf8 = (s) => new TextEncoder().encode(s);
525
+ function findPlatformConfigPda(programId) {
526
+ return PublicKey.findProgramAddressSync([utf8("config")], programId);
527
+ }
528
+ function derivePlatformConfigPda(programId) {
529
+ return findPlatformConfigPda(programId)[0];
530
+ }
531
+ var PLATFORM_CONFIG_LAYOUT = {
532
+ version: 8,
533
+ fee_bps: 9,
534
+ oracle_authority: 11,
535
+ usdc_mint: 43,
536
+ treasury: 75,
537
+ bump: 107,
538
+ length: 108
539
+ };
540
+ var PLATFORM_CONFIG_DISCRIMINATOR = [
541
+ 160,
542
+ 78,
543
+ 128,
544
+ 0,
545
+ 248,
546
+ 83,
547
+ 230,
548
+ 160
549
+ ];
550
+ function decodePlatformConfig(data) {
551
+ const L = PLATFORM_CONFIG_LAYOUT;
552
+ if (data.length !== L.length) {
553
+ throw new Error(
554
+ `PlatformConfig account is ${String(data.length)} bytes, expected ${String(L.length)}`
555
+ );
556
+ }
557
+ if (PLATFORM_CONFIG_DISCRIMINATOR.some((byte2, i) => data[i] !== byte2)) {
558
+ throw new Error("PlatformConfig discriminator mismatch: not a PlatformConfig account");
559
+ }
560
+ const byte = (at) => data[at] ?? 0;
561
+ const key = (at) => bs583.encode(data.subarray(at, at + 32));
562
+ return {
563
+ version: byte(L.version),
564
+ feeBps: byte(L.fee_bps) | byte(L.fee_bps + 1) << 8,
565
+ oracleAuthority: key(L.oracle_authority),
566
+ usdcMint: key(L.usdc_mint),
567
+ treasury: key(L.treasury),
568
+ bump: byte(L.bump)
569
+ };
570
+ }
571
+
572
+ // src/solana.ts
491
573
  function disc(name) {
492
574
  return Buffer.from(createHash2("sha256").update(`global:${name}`).digest()).subarray(0, 8);
493
575
  }
@@ -495,7 +577,7 @@ var DISC_RELEASE_PAYMENT = disc("release_payment");
495
577
  function resolveProgramId(cluster) {
496
578
  const id = getProgramId(cluster);
497
579
  assertProgramIdNotPlaceholder(id, cluster);
498
- return new PublicKey(id);
580
+ return new PublicKey2(id);
499
581
  }
500
582
  function allowedProgramIds(programId) {
501
583
  return /* @__PURE__ */ new Set([
@@ -528,25 +610,17 @@ var FORBIDDEN_TOKEN_IX_TAGS = /* @__PURE__ */ new Set([
528
610
  15
529
611
  // BurnChecked
530
612
  ]);
531
- var EXPECTED_PLATFORM_CONFIG_VERSION = 1;
532
- var VERSION_OFFSET = 8;
533
- var MINT_OFFSET = 43;
534
- var TREASURY_OFFSET = 75;
535
613
  async function fetchPlatformConfig(connection, cluster) {
536
- const programId = resolveProgramId(cluster);
537
- const [pda] = PublicKey.findProgramAddressSync([Buffer.from("config")], programId);
614
+ const pda = derivePlatformConfigPda(resolveProgramId(cluster));
538
615
  const info = await connection.getAccountInfo(pda);
539
616
  if (!info) throw new Error("PlatformConfig account not found on-chain");
540
- const version = info.data[VERSION_OFFSET];
541
- if (version !== EXPECTED_PLATFORM_CONFIG_VERSION) {
617
+ const config = decodePlatformConfig(info.data);
618
+ if (config.version !== CURRENT_ACCOUNT_VERSION) {
542
619
  throw new Error(
543
- `PlatformConfig version mismatch: expected ${String(EXPECTED_PLATFORM_CONFIG_VERSION)}, got ${String(version)}`
620
+ `PlatformConfig version mismatch: expected ${String(CURRENT_ACCOUNT_VERSION)}, got ${String(config.version)}`
544
621
  );
545
622
  }
546
- return {
547
- mint: new PublicKey(info.data.subarray(MINT_OFFSET, MINT_OFFSET + 32)),
548
- treasury: new PublicKey(info.data.subarray(TREASURY_OFFSET, TREASURY_OFFSET + 32))
549
- };
623
+ return { mint: new PublicKey2(config.usdcMint), treasury: new PublicKey2(config.treasury) };
550
624
  }
551
625
  function validateTransaction(tx, cluster) {
552
626
  const programId = resolveProgramId(cluster);
@@ -590,7 +664,7 @@ async function signAndSend(base64Tx, wallet, connection, cluster) {
590
664
  async function buildReleasePaymentTx(params) {
591
665
  const { buyer, escrowPda, worker, mint, treasury, connection, cluster } = params;
592
666
  const programId = resolveProgramId(cluster);
593
- const [platformConfig] = PublicKey.findProgramAddressSync([Buffer.from("config")], programId);
667
+ const platformConfig = derivePlatformConfigPda(programId);
594
668
  const vault = getAssociatedTokenAddressSync(
595
669
  mint,
596
670
  escrowPda,
@@ -655,6 +729,9 @@ var RETRYABLE_CONFIRM_CODES = [
655
729
  function isRetryableConfirmCode(code) {
656
730
  return RETRYABLE_CONFIRM_CODES.includes(code);
657
731
  }
732
+ function isRetryableReleaseCode(code) {
733
+ return code === "ESCROW_NOT_SETTLED" || isRetryableConfirmCode(code);
734
+ }
658
735
  function isUrl(value) {
659
736
  try {
660
737
  new URL(value);
@@ -692,12 +769,27 @@ var Escro = class {
692
769
  /**
693
770
  * Release payment to the worker. Buyer must be the wallet in the constructor.
694
771
  * Builds and submits the `release_payment` on-chain tx FIRST (the chain is the
695
- * source of truth), then confirms DB state. The DB confirmation is best-effort:
696
- * the oracle sweep reconciles it if it fails.
772
+ * source of truth), then has the API record it, retrying as
773
+ * {@link Escro.releasePaymentWithOutcome} does. Use that method to see whether the
774
+ * API recorded the release.
697
775
  *
698
776
  * @returns Solana transaction signature.
699
777
  */
700
778
  async releasePayment(address) {
779
+ const { signature } = await this.releasePaymentWithOutcome(address);
780
+ return signature;
781
+ }
782
+ /**
783
+ * {@link Escro.releasePayment}, reporting whether the API recorded the release.
784
+ * `ESCROW_NOT_SETTLED` (the API's RPC does not yet show the escrow closed), the codes
785
+ * {@link Escro.confirm} retries and a network failure are retried for about 30 s. A
786
+ * failure never throws: the release already landed, so the signature is returned with
787
+ * `confirm.landed: false` and an `EscroConfirmError` (the API's code, or
788
+ * `NETWORK_ERROR` when the last call never reached it).
789
+ *
790
+ * @returns `{ signature, confirm }`.
791
+ */
792
+ async releasePaymentWithOutcome(address) {
701
793
  const escrow = await this.api.getEscrow(address);
702
794
  if (!escrow.taker) {
703
795
  throw new EscrowNotFoundError(`Escrow ${address} has no assigned worker`);
@@ -705,8 +797,8 @@ var Escro = class {
705
797
  const { treasury, mint } = await fetchPlatformConfig(this.connection, this.cluster);
706
798
  const unsignedTx = await buildReleasePaymentTx({
707
799
  buyer: this.options.wallet.publicKey,
708
- escrowPda: new PublicKey2(address),
709
- worker: new PublicKey2(escrow.taker),
800
+ escrowPda: new PublicKey3(address),
801
+ worker: new PublicKey3(escrow.taker),
710
802
  mint,
711
803
  treasury,
712
804
  connection: this.connection,
@@ -719,10 +811,17 @@ var Escro = class {
719
811
  this.cluster
720
812
  );
721
813
  try {
722
- await this.api.releasePaymentDb(address);
723
- } catch {
814
+ await this.retryRecord(
815
+ address,
816
+ signature,
817
+ () => this.api.releasePaymentDb(address),
818
+ isRetryableReleaseCode
819
+ );
820
+ } catch (err) {
821
+ if (!(err instanceof EscroConfirmError)) throw err;
822
+ return { signature, confirm: { landed: false, error: err } };
724
823
  }
725
- return signature;
824
+ return { signature, confirm: { landed: true } };
726
825
  }
727
826
  /**
728
827
  * Raise a dispute for an escrow. Either the buyer or the assigned worker
@@ -731,6 +830,8 @@ var Escro = class {
731
830
  * @returns Solana transaction signature.
732
831
  */
733
832
  async raiseDispute(address, params) {
833
+ const reasonError = disputeReasonError(params.reason);
834
+ if (reasonError !== null) throw new EscroError(reasonError, "VALIDATION_ERROR");
734
835
  if (params.evidence !== void 0 && !isUrl(params.evidence)) {
735
836
  throw new EscroError(`evidence must be a URL: ${params.evidence}`, "VALIDATION_ERROR");
736
837
  }
@@ -910,21 +1011,40 @@ var Escro = class {
910
1011
  * transition succeeds).
911
1012
  */
912
1013
  async confirm(address, signature, opts = {}) {
1014
+ return this.retryRecord(
1015
+ address,
1016
+ signature,
1017
+ () => this.api.confirm(address, signature, opts.evidence),
1018
+ isRetryableConfirmCode
1019
+ );
1020
+ }
1021
+ // Calls `record` until the API records the landed `signature`: a code `retryable` accepts or
1022
+ // a network failure is retried for about 30 s (waiting at least a sent Retry-After), any
1023
+ // other refusal is not. Throws EscroConfirmError.
1024
+ async retryRecord(address, signature, record, retryable) {
913
1025
  const deadline = Date.now() + CONFIRM_WINDOW_MS;
914
1026
  let delay = CONFIRM_FIRST_DELAY_MS;
915
1027
  for (; ; ) {
916
1028
  let code;
917
1029
  let message;
1030
+ let retryAfterMs2;
918
1031
  let wait = delay;
919
1032
  try {
920
- return await this.api.confirm(address, signature, opts.evidence);
1033
+ return await record();
921
1034
  } catch (err) {
922
1035
  if (err instanceof EscroError) {
923
- if (!isRetryableConfirmCode(err.code)) {
924
- throw new EscroConfirmError(address, signature, err.code, err.message);
1036
+ if (!retryable(err.code)) {
1037
+ throw new EscroConfirmError(
1038
+ address,
1039
+ signature,
1040
+ err.code,
1041
+ err.message,
1042
+ err.retryAfterMs
1043
+ );
925
1044
  }
926
1045
  code = err.code;
927
1046
  message = err.message;
1047
+ retryAfterMs2 = err.retryAfterMs;
928
1048
  wait = Math.max(delay, err.retryAfterMs ?? 0);
929
1049
  } else {
930
1050
  code = "NETWORK_ERROR";
@@ -932,7 +1052,7 @@ var Escro = class {
932
1052
  }
933
1053
  }
934
1054
  if (Date.now() + wait > deadline) {
935
- throw new EscroConfirmError(address, signature, code, message);
1055
+ throw new EscroConfirmError(address, signature, code, message, retryAfterMs2);
936
1056
  }
937
1057
  await sleep2(wait);
938
1058
  delay = Math.min(delay * 2, CONFIRM_MAX_DELAY_MS);