@escro/sdk 0.3.0 → 0.4.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.
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);
@@ -584,13 +658,22 @@ async function signAndSend(base64Tx, wallet, connection, cluster) {
584
658
  const signature = await connection.sendRawTransaction(raw, {
585
659
  skipPreflight: false
586
660
  });
587
- await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, "confirmed");
661
+ const { value } = await connection.confirmTransaction(
662
+ { signature, blockhash, lastValidBlockHeight },
663
+ "confirmed"
664
+ );
665
+ if (value.err !== null) {
666
+ throw new EscroError(
667
+ `Transaction ${signature} failed at execution: ${JSON.stringify(value.err)}`,
668
+ "TX_FAILED"
669
+ );
670
+ }
588
671
  return signature;
589
672
  }
590
673
  async function buildReleasePaymentTx(params) {
591
674
  const { buyer, escrowPda, worker, mint, treasury, connection, cluster } = params;
592
675
  const programId = resolveProgramId(cluster);
593
- const [platformConfig] = PublicKey.findProgramAddressSync([Buffer.from("config")], programId);
676
+ const platformConfig = derivePlatformConfigPda(programId);
594
677
  const vault = getAssociatedTokenAddressSync(
595
678
  mint,
596
679
  escrowPda,
@@ -655,6 +738,9 @@ var RETRYABLE_CONFIRM_CODES = [
655
738
  function isRetryableConfirmCode(code) {
656
739
  return RETRYABLE_CONFIRM_CODES.includes(code);
657
740
  }
741
+ function isRetryableReleaseCode(code) {
742
+ return code === "ESCROW_NOT_SETTLED" || isRetryableConfirmCode(code);
743
+ }
658
744
  function isUrl(value) {
659
745
  try {
660
746
  new URL(value);
@@ -692,12 +778,29 @@ var Escro = class {
692
778
  /**
693
779
  * Release payment to the worker. Buyer must be the wallet in the constructor.
694
780
  * 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.
781
+ * source of truth), then has the API record it, retrying as
782
+ * {@link Escro.releasePaymentWithOutcome} does. Use that method to see whether the
783
+ * API recorded the release.
697
784
  *
698
785
  * @returns Solana transaction signature.
699
786
  */
700
787
  async releasePayment(address) {
788
+ const { signature } = await this.releasePaymentWithOutcome(address);
789
+ return signature;
790
+ }
791
+ /**
792
+ * {@link Escro.releasePayment}, reporting whether the API recorded the release.
793
+ * `ESCROW_NOT_SETTLED` (the API's RPC does not yet show the escrow closed), the codes
794
+ * {@link Escro.confirm} retries and a network failure are retried for about 30 s. A
795
+ * failed record never throws: the release already landed, so the signature is returned
796
+ * with `confirm.landed: false` and an `EscroConfirmError` (the API's code, or
797
+ * `NETWORK_ERROR` when the last call never reached it). A release that failed at
798
+ * execution on chain throws an {@link EscroError} with code `TX_FAILED`, and the API is
799
+ * never asked to record it.
800
+ *
801
+ * @returns `{ signature, confirm }`.
802
+ */
803
+ async releasePaymentWithOutcome(address) {
701
804
  const escrow = await this.api.getEscrow(address);
702
805
  if (!escrow.taker) {
703
806
  throw new EscrowNotFoundError(`Escrow ${address} has no assigned worker`);
@@ -705,8 +808,8 @@ var Escro = class {
705
808
  const { treasury, mint } = await fetchPlatformConfig(this.connection, this.cluster);
706
809
  const unsignedTx = await buildReleasePaymentTx({
707
810
  buyer: this.options.wallet.publicKey,
708
- escrowPda: new PublicKey2(address),
709
- worker: new PublicKey2(escrow.taker),
811
+ escrowPda: new PublicKey3(address),
812
+ worker: new PublicKey3(escrow.taker),
710
813
  mint,
711
814
  treasury,
712
815
  connection: this.connection,
@@ -719,10 +822,17 @@ var Escro = class {
719
822
  this.cluster
720
823
  );
721
824
  try {
722
- await this.api.releasePaymentDb(address);
723
- } catch {
825
+ await this.retryRecord(
826
+ address,
827
+ signature,
828
+ () => this.api.releasePaymentDb(address),
829
+ isRetryableReleaseCode
830
+ );
831
+ } catch (err) {
832
+ if (!(err instanceof EscroConfirmError)) throw err;
833
+ return { signature, confirm: { landed: false, error: err } };
724
834
  }
725
- return signature;
835
+ return { signature, confirm: { landed: true } };
726
836
  }
727
837
  /**
728
838
  * Raise a dispute for an escrow. Either the buyer or the assigned worker
@@ -731,6 +841,8 @@ var Escro = class {
731
841
  * @returns Solana transaction signature.
732
842
  */
733
843
  async raiseDispute(address, params) {
844
+ const reasonError = disputeReasonError(params.reason);
845
+ if (reasonError !== null) throw new EscroError(reasonError, "VALIDATION_ERROR");
734
846
  if (params.evidence !== void 0 && !isUrl(params.evidence)) {
735
847
  throw new EscroError(`evidence must be a URL: ${params.evidence}`, "VALIDATION_ERROR");
736
848
  }
@@ -910,21 +1022,40 @@ var Escro = class {
910
1022
  * transition succeeds).
911
1023
  */
912
1024
  async confirm(address, signature, opts = {}) {
1025
+ return this.retryRecord(
1026
+ address,
1027
+ signature,
1028
+ () => this.api.confirm(address, signature, opts.evidence),
1029
+ isRetryableConfirmCode
1030
+ );
1031
+ }
1032
+ // Calls `record` until the API records the landed `signature`: a code `retryable` accepts or
1033
+ // a network failure is retried for about 30 s (waiting at least a sent Retry-After), any
1034
+ // other refusal is not. Throws EscroConfirmError.
1035
+ async retryRecord(address, signature, record, retryable) {
913
1036
  const deadline = Date.now() + CONFIRM_WINDOW_MS;
914
1037
  let delay = CONFIRM_FIRST_DELAY_MS;
915
1038
  for (; ; ) {
916
1039
  let code;
917
1040
  let message;
1041
+ let retryAfterMs2;
918
1042
  let wait = delay;
919
1043
  try {
920
- return await this.api.confirm(address, signature, opts.evidence);
1044
+ return await record();
921
1045
  } catch (err) {
922
1046
  if (err instanceof EscroError) {
923
- if (!isRetryableConfirmCode(err.code)) {
924
- throw new EscroConfirmError(address, signature, err.code, err.message);
1047
+ if (!retryable(err.code)) {
1048
+ throw new EscroConfirmError(
1049
+ address,
1050
+ signature,
1051
+ err.code,
1052
+ err.message,
1053
+ err.retryAfterMs
1054
+ );
925
1055
  }
926
1056
  code = err.code;
927
1057
  message = err.message;
1058
+ retryAfterMs2 = err.retryAfterMs;
928
1059
  wait = Math.max(delay, err.retryAfterMs ?? 0);
929
1060
  } else {
930
1061
  code = "NETWORK_ERROR";
@@ -932,7 +1063,7 @@ var Escro = class {
932
1063
  }
933
1064
  }
934
1065
  if (Date.now() + wait > deadline) {
935
- throw new EscroConfirmError(address, signature, code, message);
1066
+ throw new EscroConfirmError(address, signature, code, message, retryAfterMs2);
936
1067
  }
937
1068
  await sleep2(wait);
938
1069
  delay = Math.min(delay * 2, CONFIRM_MAX_DELAY_MS);