@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/README.md CHANGED
@@ -15,8 +15,9 @@ import { Escro, EscrowState } from "@escro/sdk";
15
15
  import { Keypair } from "@solana/web3.js";
16
16
 
17
17
  const client = new Escro({
18
+ cluster: "devnet",
18
19
  rpcUrl: "https://api.devnet.solana.com",
19
- apiUrl: "https://api.escro.ai",
20
+ apiUrl: "https://api-devnet.escro.ai",
20
21
  wallet: Keypair.fromSecretKey(/* ... */),
21
22
  });
22
23
 
@@ -43,22 +44,13 @@ await client.cancelEscrow(escrowPda); // only if FUNDED, before worker claims
43
44
 
44
45
  ## API
45
46
 
46
- | Method | Role | Description |
47
- |---|---|---|
48
- | `createEscrow(params)` | Buyer | Create + fund escrow atomically |
49
- | `releasePayment(address)` | Buyer | Approve payment to worker |
50
- | `cancelEscrow(address)` | Buyer | Cancel funded escrow, full refund |
51
- | `claimTask(address)` | Worker | Claim an assigned task |
52
- | `submitDeliverable(address, params)` | Worker | Submit work for review |
53
- | `getMyTasks(state?, options?)` | Worker | List assigned tasks (paginated) |
54
- | `getEscrow(address, options?)` | Both | Fetch escrow by PDA address |
55
- | `raiseDispute(address, params)` | Both | Escalate to dispute |
47
+ Every method, its parameters and its errors are in the generated
48
+ [SDK reference](https://escro.ai/docs/sdk-reference).
56
49
 
57
50
  ## Docs
58
51
 
59
- - [Buyer Bot Guide](https://escro.ai/docs/buyer-bot)
60
- - [Worker Bot Guide](https://escro.ai/docs/worker-bot)
61
- - [SDK Reference](https://escro.ai/docs/sdk-reference)
52
+ - [Buyer Bot guide](https://escro.ai/docs/buyer-bot)
53
+ - [Worker Bot guide](https://escro.ai/docs/worker-bot)
62
54
 
63
55
  ## License
64
56
 
package/dist/index.cjs CHANGED
@@ -134,7 +134,7 @@ __export(index_exports, {
134
134
  module.exports = __toCommonJS(index_exports);
135
135
 
136
136
  // src/client.ts
137
- var import_web32 = require("@solana/web3.js");
137
+ var import_web33 = require("@solana/web3.js");
138
138
 
139
139
  // ../shared/dist/index.js
140
140
  var import_crypto = require("crypto");
@@ -153,20 +153,10 @@ var EscrowState = /* @__PURE__ */ ((EscrowState2) => {
153
153
  EscrowState2["REFUNDED"] = "REFUNDED";
154
154
  return EscrowState2;
155
155
  })(EscrowState || {});
156
- function canonicalize(value) {
157
- if (value === null) return "null";
158
- if (Array.isArray(value)) {
159
- return "[" + value.map((v) => v === void 0 ? "null" : canonicalize(v)).join(",") + "]";
160
- }
161
- if (typeof value === "object") {
162
- const obj = value;
163
- const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
164
- return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k])).join(",") + "}";
165
- }
166
- return JSON.stringify(value);
167
- }
168
156
  var PROGRAM_ID_DEVNET = "EjLpJ3obUYk1f2KDensU7UdGBZTxSdxufm7CSnMA3H5C";
169
157
  var PROGRAM_ID_MAINNET = "4CwLNRF4mFXutiBGjtMHQBxh8whW8T8EyvJXdRhY1rvr";
158
+ var CURRENT_ACCOUNT_VERSION = 1;
159
+ var MAX_DISPUTE_REASON_BYTES = 200;
170
160
  var ONCHAIN_REFUND_GRACE_SECONDS = 900;
171
161
  var ONCHAIN_TIMEOUT_SECONDS = 86400;
172
162
  var CLOCK_SKEW_SECONDS = 60;
@@ -186,6 +176,26 @@ function assertProgramIdNotPlaceholder(id, cluster) {
186
176
  );
187
177
  }
188
178
  }
179
+ function canonicalize(value) {
180
+ if (value === null) return "null";
181
+ if (Array.isArray(value)) {
182
+ return "[" + value.map((v) => v === void 0 ? "null" : canonicalize(v)).join(",") + "]";
183
+ }
184
+ if (typeof value === "object") {
185
+ const obj = value;
186
+ const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
187
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k])).join(",") + "}";
188
+ }
189
+ return JSON.stringify(value);
190
+ }
191
+ function disputeReasonError(reason) {
192
+ if (reason.length === 0) return "reason must not be empty";
193
+ const bytes = new TextEncoder().encode(reason).length;
194
+ if (bytes > MAX_DISPUTE_REASON_BYTES) {
195
+ return `reason is ${String(bytes)} bytes in UTF-8; the program accepts at most ${String(MAX_DISPUTE_REASON_BYTES)}`;
196
+ }
197
+ return null;
198
+ }
189
199
  function hashBody(rawBody) {
190
200
  return (0, import_crypto.createHash)("sha256").update(rawBody, "utf8").digest("hex");
191
201
  }
@@ -300,8 +310,12 @@ var EscrowTimeoutError = class extends EscroError {
300
310
  }
301
311
  };
302
312
  var EscroConfirmError = class extends EscroError {
303
- constructor(address, signature, code, message) {
304
- super(`Confirm failed for escrow ${address} (signature ${signature}): ${message}`, code);
313
+ constructor(address, signature, code, message, retryAfterMs2) {
314
+ super(
315
+ `Confirm failed for escrow ${address} (signature ${signature}): ${message}`,
316
+ code,
317
+ retryAfterMs2
318
+ );
305
319
  __publicField(this, "address", address);
306
320
  __publicField(this, "signature", signature);
307
321
  this.name = "EscroConfirmError";
@@ -491,8 +505,11 @@ var ApiClient = class {
491
505
  return res.json();
492
506
  }
493
507
  /**
494
- * POST /v1/escrows/:address/release — DB-only state update.
495
- * Requires auth (buyer). No on-chain tx returned.
508
+ * POST /v1/escrows/:address/release: records the landed release once the API's RPC shows
509
+ * the escrow closed. Requires auth (buyer). No on-chain tx returned. A 409
510
+ * `ESCROW_NOT_SETTLED`, a 5xx or a 429 throws an {@link EscroError} with the server's code,
511
+ * or `HTTP_<status>` when the answer carries none; {@link Escro.releasePaymentWithOutcome}
512
+ * decides which to retry. Other refusals throw as every route does.
496
513
  */
497
514
  async releasePaymentDb(address) {
498
515
  const path = `/v1/escrows/${address}/release`;
@@ -500,7 +517,19 @@ var ApiClient = class {
500
517
  method: "POST",
501
518
  headers: authHeaders(this.wallet, "POST", path)
502
519
  });
503
- if (!res.ok) await handleError(res, address);
520
+ if (res.ok) return;
521
+ if (res.status !== 409 && res.status !== 429 && res.status < 500) {
522
+ await handleError(res, address);
523
+ }
524
+ const body = await res.json().catch(() => ({}));
525
+ if (res.status === 409 && body.code !== "ESCROW_NOT_SETTLED") {
526
+ throw new EscrowStateError(body.error ?? `HTTP ${String(res.status)}`);
527
+ }
528
+ throw new EscroError(
529
+ body.error ?? `HTTP ${String(res.status)}`,
530
+ body.code ?? `HTTP_${String(res.status)}`,
531
+ retryAfterMs(res.headers.get("retry-after"))
532
+ );
504
533
  }
505
534
  /**
506
535
  * POST /v1/escrows/:address/cancel — returns unsigned tx.
@@ -607,8 +636,61 @@ async function pollUntil(address, fetcher, predicate, timeoutMs = MS_24_H) {
607
636
 
608
637
  // src/solana.ts
609
638
  var import_node_crypto = require("crypto");
610
- var import_web3 = require("@solana/web3.js");
639
+ var import_web32 = require("@solana/web3.js");
611
640
  var import_spl_token = require("@solana/spl-token");
641
+
642
+ // ../shared/dist/solana.js
643
+ var import_web3 = require("@solana/web3.js");
644
+ var import_bs583 = __toESM(require("bs58"), 1);
645
+ var utf8 = (s) => new TextEncoder().encode(s);
646
+ function findPlatformConfigPda(programId) {
647
+ return import_web3.PublicKey.findProgramAddressSync([utf8("config")], programId);
648
+ }
649
+ function derivePlatformConfigPda(programId) {
650
+ return findPlatformConfigPda(programId)[0];
651
+ }
652
+ var PLATFORM_CONFIG_LAYOUT = {
653
+ version: 8,
654
+ fee_bps: 9,
655
+ oracle_authority: 11,
656
+ usdc_mint: 43,
657
+ treasury: 75,
658
+ bump: 107,
659
+ length: 108
660
+ };
661
+ var PLATFORM_CONFIG_DISCRIMINATOR = [
662
+ 160,
663
+ 78,
664
+ 128,
665
+ 0,
666
+ 248,
667
+ 83,
668
+ 230,
669
+ 160
670
+ ];
671
+ function decodePlatformConfig(data) {
672
+ const L = PLATFORM_CONFIG_LAYOUT;
673
+ if (data.length !== L.length) {
674
+ throw new Error(
675
+ `PlatformConfig account is ${String(data.length)} bytes, expected ${String(L.length)}`
676
+ );
677
+ }
678
+ if (PLATFORM_CONFIG_DISCRIMINATOR.some((byte2, i) => data[i] !== byte2)) {
679
+ throw new Error("PlatformConfig discriminator mismatch: not a PlatformConfig account");
680
+ }
681
+ const byte = (at) => data[at] ?? 0;
682
+ const key = (at) => import_bs583.default.encode(data.subarray(at, at + 32));
683
+ return {
684
+ version: byte(L.version),
685
+ feeBps: byte(L.fee_bps) | byte(L.fee_bps + 1) << 8,
686
+ oracleAuthority: key(L.oracle_authority),
687
+ usdcMint: key(L.usdc_mint),
688
+ treasury: key(L.treasury),
689
+ bump: byte(L.bump)
690
+ };
691
+ }
692
+
693
+ // src/solana.ts
612
694
  function disc(name) {
613
695
  return Buffer.from((0, import_node_crypto.createHash)("sha256").update(`global:${name}`).digest()).subarray(0, 8);
614
696
  }
@@ -616,14 +698,14 @@ var DISC_RELEASE_PAYMENT = disc("release_payment");
616
698
  function resolveProgramId(cluster) {
617
699
  const id = getProgramId(cluster);
618
700
  assertProgramIdNotPlaceholder(id, cluster);
619
- return new import_web3.PublicKey(id);
701
+ return new import_web32.PublicKey(id);
620
702
  }
621
703
  function allowedProgramIds(programId) {
622
704
  return /* @__PURE__ */ new Set([
623
705
  programId.toBase58(),
624
706
  import_spl_token.TOKEN_PROGRAM_ID.toBase58(),
625
707
  import_spl_token.ASSOCIATED_TOKEN_PROGRAM_ID.toBase58(),
626
- import_web3.ComputeBudgetProgram.programId.toBase58()
708
+ import_web32.ComputeBudgetProgram.programId.toBase58()
627
709
  ]);
628
710
  }
629
711
  var MAX_INSTRUCTIONS = 6;
@@ -649,25 +731,17 @@ var FORBIDDEN_TOKEN_IX_TAGS = /* @__PURE__ */ new Set([
649
731
  15
650
732
  // BurnChecked
651
733
  ]);
652
- var EXPECTED_PLATFORM_CONFIG_VERSION = 1;
653
- var VERSION_OFFSET = 8;
654
- var MINT_OFFSET = 43;
655
- var TREASURY_OFFSET = 75;
656
734
  async function fetchPlatformConfig(connection, cluster) {
657
- const programId = resolveProgramId(cluster);
658
- const [pda] = import_web3.PublicKey.findProgramAddressSync([Buffer.from("config")], programId);
735
+ const pda = derivePlatformConfigPda(resolveProgramId(cluster));
659
736
  const info = await connection.getAccountInfo(pda);
660
737
  if (!info) throw new Error("PlatformConfig account not found on-chain");
661
- const version = info.data[VERSION_OFFSET];
662
- if (version !== EXPECTED_PLATFORM_CONFIG_VERSION) {
738
+ const config = decodePlatformConfig(info.data);
739
+ if (config.version !== CURRENT_ACCOUNT_VERSION) {
663
740
  throw new Error(
664
- `PlatformConfig version mismatch: expected ${String(EXPECTED_PLATFORM_CONFIG_VERSION)}, got ${String(version)}`
741
+ `PlatformConfig version mismatch: expected ${String(CURRENT_ACCOUNT_VERSION)}, got ${String(config.version)}`
665
742
  );
666
743
  }
667
- return {
668
- mint: new import_web3.PublicKey(info.data.subarray(MINT_OFFSET, MINT_OFFSET + 32)),
669
- treasury: new import_web3.PublicKey(info.data.subarray(TREASURY_OFFSET, TREASURY_OFFSET + 32))
670
- };
744
+ return { mint: new import_web32.PublicKey(config.usdcMint), treasury: new import_web32.PublicKey(config.treasury) };
671
745
  }
672
746
  function validateTransaction(tx, cluster) {
673
747
  const programId = resolveProgramId(cluster);
@@ -697,7 +771,7 @@ function validateTransaction(tx, cluster) {
697
771
  }
698
772
  async function signAndSend(base64Tx, wallet, connection, cluster) {
699
773
  const buf = Buffer.from(base64Tx, "base64");
700
- const tx = import_web3.Transaction.from(buf);
774
+ const tx = import_web32.Transaction.from(buf);
701
775
  validateTransaction(tx, cluster);
702
776
  tx.partialSign(wallet);
703
777
  const raw = tx.serialize();
@@ -705,13 +779,22 @@ async function signAndSend(base64Tx, wallet, connection, cluster) {
705
779
  const signature = await connection.sendRawTransaction(raw, {
706
780
  skipPreflight: false
707
781
  });
708
- await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, "confirmed");
782
+ const { value } = await connection.confirmTransaction(
783
+ { signature, blockhash, lastValidBlockHeight },
784
+ "confirmed"
785
+ );
786
+ if (value.err !== null) {
787
+ throw new EscroError(
788
+ `Transaction ${signature} failed at execution: ${JSON.stringify(value.err)}`,
789
+ "TX_FAILED"
790
+ );
791
+ }
709
792
  return signature;
710
793
  }
711
794
  async function buildReleasePaymentTx(params) {
712
795
  const { buyer, escrowPda, worker, mint, treasury, connection, cluster } = params;
713
796
  const programId = resolveProgramId(cluster);
714
- const [platformConfig] = import_web3.PublicKey.findProgramAddressSync([Buffer.from("config")], programId);
797
+ const platformConfig = derivePlatformConfigPda(programId);
715
798
  const vault = (0, import_spl_token.getAssociatedTokenAddressSync)(
716
799
  mint,
717
800
  escrowPda,
@@ -733,7 +816,7 @@ async function buildReleasePaymentTx(params) {
733
816
  import_spl_token.TOKEN_PROGRAM_ID,
734
817
  import_spl_token.ASSOCIATED_TOKEN_PROGRAM_ID
735
818
  );
736
- const ix = new import_web3.TransactionInstruction({
819
+ const ix = new import_web32.TransactionInstruction({
737
820
  programId,
738
821
  keys: [
739
822
  { pubkey: buyer, isSigner: true, isWritable: true },
@@ -751,7 +834,7 @@ async function buildReleasePaymentTx(params) {
751
834
  data: DISC_RELEASE_PAYMENT
752
835
  });
753
836
  const { blockhash } = await connection.getLatestBlockhash("confirmed");
754
- const tx = new import_web3.Transaction();
837
+ const tx = new import_web32.Transaction();
755
838
  tx.recentBlockhash = blockhash;
756
839
  tx.feePayer = buyer;
757
840
  tx.add((0, import_spl_token.createAssociatedTokenAccountIdempotentInstruction)(buyer, workerAta, worker, mint));
@@ -776,6 +859,9 @@ var RETRYABLE_CONFIRM_CODES = [
776
859
  function isRetryableConfirmCode(code) {
777
860
  return RETRYABLE_CONFIRM_CODES.includes(code);
778
861
  }
862
+ function isRetryableReleaseCode(code) {
863
+ return code === "ESCROW_NOT_SETTLED" || isRetryableConfirmCode(code);
864
+ }
779
865
  function isUrl(value) {
780
866
  try {
781
867
  new URL(value);
@@ -794,7 +880,7 @@ var Escro = class {
794
880
  __publicField(this, "router", new EscrowPaymentRouter());
795
881
  this.options = options;
796
882
  this.cluster = options.cluster;
797
- this.connection = new import_web32.Connection(options.rpcUrl, "confirmed");
883
+ this.connection = new import_web33.Connection(options.rpcUrl, "confirmed");
798
884
  this.api = new ApiClient(options.apiUrl, options.wallet);
799
885
  }
800
886
  // ── Buyer Bot ────────────────────────────────────────────────────────────────
@@ -813,12 +899,29 @@ var Escro = class {
813
899
  /**
814
900
  * Release payment to the worker. Buyer must be the wallet in the constructor.
815
901
  * Builds and submits the `release_payment` on-chain tx FIRST (the chain is the
816
- * source of truth), then confirms DB state. The DB confirmation is best-effort:
817
- * the oracle sweep reconciles it if it fails.
902
+ * source of truth), then has the API record it, retrying as
903
+ * {@link Escro.releasePaymentWithOutcome} does. Use that method to see whether the
904
+ * API recorded the release.
818
905
  *
819
906
  * @returns Solana transaction signature.
820
907
  */
821
908
  async releasePayment(address) {
909
+ const { signature } = await this.releasePaymentWithOutcome(address);
910
+ return signature;
911
+ }
912
+ /**
913
+ * {@link Escro.releasePayment}, reporting whether the API recorded the release.
914
+ * `ESCROW_NOT_SETTLED` (the API's RPC does not yet show the escrow closed), the codes
915
+ * {@link Escro.confirm} retries and a network failure are retried for about 30 s. A
916
+ * failed record never throws: the release already landed, so the signature is returned
917
+ * with `confirm.landed: false` and an `EscroConfirmError` (the API's code, or
918
+ * `NETWORK_ERROR` when the last call never reached it). A release that failed at
919
+ * execution on chain throws an {@link EscroError} with code `TX_FAILED`, and the API is
920
+ * never asked to record it.
921
+ *
922
+ * @returns `{ signature, confirm }`.
923
+ */
924
+ async releasePaymentWithOutcome(address) {
822
925
  const escrow = await this.api.getEscrow(address);
823
926
  if (!escrow.taker) {
824
927
  throw new EscrowNotFoundError(`Escrow ${address} has no assigned worker`);
@@ -826,8 +929,8 @@ var Escro = class {
826
929
  const { treasury, mint } = await fetchPlatformConfig(this.connection, this.cluster);
827
930
  const unsignedTx = await buildReleasePaymentTx({
828
931
  buyer: this.options.wallet.publicKey,
829
- escrowPda: new import_web32.PublicKey(address),
830
- worker: new import_web32.PublicKey(escrow.taker),
932
+ escrowPda: new import_web33.PublicKey(address),
933
+ worker: new import_web33.PublicKey(escrow.taker),
831
934
  mint,
832
935
  treasury,
833
936
  connection: this.connection,
@@ -840,10 +943,17 @@ var Escro = class {
840
943
  this.cluster
841
944
  );
842
945
  try {
843
- await this.api.releasePaymentDb(address);
844
- } catch {
946
+ await this.retryRecord(
947
+ address,
948
+ signature,
949
+ () => this.api.releasePaymentDb(address),
950
+ isRetryableReleaseCode
951
+ );
952
+ } catch (err) {
953
+ if (!(err instanceof EscroConfirmError)) throw err;
954
+ return { signature, confirm: { landed: false, error: err } };
845
955
  }
846
- return signature;
956
+ return { signature, confirm: { landed: true } };
847
957
  }
848
958
  /**
849
959
  * Raise a dispute for an escrow. Either the buyer or the assigned worker
@@ -852,6 +962,8 @@ var Escro = class {
852
962
  * @returns Solana transaction signature.
853
963
  */
854
964
  async raiseDispute(address, params) {
965
+ const reasonError = disputeReasonError(params.reason);
966
+ if (reasonError !== null) throw new EscroError(reasonError, "VALIDATION_ERROR");
855
967
  if (params.evidence !== void 0 && !isUrl(params.evidence)) {
856
968
  throw new EscroError(`evidence must be a URL: ${params.evidence}`, "VALIDATION_ERROR");
857
969
  }
@@ -1031,21 +1143,40 @@ var Escro = class {
1031
1143
  * transition succeeds).
1032
1144
  */
1033
1145
  async confirm(address, signature, opts = {}) {
1146
+ return this.retryRecord(
1147
+ address,
1148
+ signature,
1149
+ () => this.api.confirm(address, signature, opts.evidence),
1150
+ isRetryableConfirmCode
1151
+ );
1152
+ }
1153
+ // Calls `record` until the API records the landed `signature`: a code `retryable` accepts or
1154
+ // a network failure is retried for about 30 s (waiting at least a sent Retry-After), any
1155
+ // other refusal is not. Throws EscroConfirmError.
1156
+ async retryRecord(address, signature, record, retryable) {
1034
1157
  const deadline = Date.now() + CONFIRM_WINDOW_MS;
1035
1158
  let delay = CONFIRM_FIRST_DELAY_MS;
1036
1159
  for (; ; ) {
1037
1160
  let code;
1038
1161
  let message;
1162
+ let retryAfterMs2;
1039
1163
  let wait = delay;
1040
1164
  try {
1041
- return await this.api.confirm(address, signature, opts.evidence);
1165
+ return await record();
1042
1166
  } catch (err) {
1043
1167
  if (err instanceof EscroError) {
1044
- if (!isRetryableConfirmCode(err.code)) {
1045
- throw new EscroConfirmError(address, signature, err.code, err.message);
1168
+ if (!retryable(err.code)) {
1169
+ throw new EscroConfirmError(
1170
+ address,
1171
+ signature,
1172
+ err.code,
1173
+ err.message,
1174
+ err.retryAfterMs
1175
+ );
1046
1176
  }
1047
1177
  code = err.code;
1048
1178
  message = err.message;
1179
+ retryAfterMs2 = err.retryAfterMs;
1049
1180
  wait = Math.max(delay, err.retryAfterMs ?? 0);
1050
1181
  } else {
1051
1182
  code = "NETWORK_ERROR";
@@ -1053,7 +1184,7 @@ var Escro = class {
1053
1184
  }
1054
1185
  }
1055
1186
  if (Date.now() + wait > deadline) {
1056
- throw new EscroConfirmError(address, signature, code, message);
1187
+ throw new EscroConfirmError(address, signature, code, message, retryAfterMs2);
1057
1188
  }
1058
1189
  await sleep2(wait);
1059
1190
  delay = Math.min(delay * 2, CONFIRM_MAX_DELAY_MS);