@prismnetwork/agent-sdk 0.6.1 → 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/prism.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // Prism Network agent SDK: headless GPU leasing for wallet-holding agents.
2
2
  // No browser, no Privy. Authenticate with a wallet signature, pay on-chain, run.
3
3
  import { execFileSync, spawn } from "node:child_process";
4
+ import { createHash } from "node:crypto";
4
5
  import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5
6
  import { tmpdir } from "node:os";
6
7
  import { join } from "node:path";
@@ -14,10 +15,14 @@ import {
14
15
  stringToBytes,
15
16
  } from "viem";
16
17
  import { privateKeyToAccount } from "viem/accounts";
18
+ import { appraiseWorkload, DEFAULT_CONFIDENTIAL_BASE, EXPECTED_WORKLOAD, verifyConfidential } from "./attest.mjs";
19
+ import { decryptResponse, encryptChatRequest } from "./e2ee.mjs";
17
20
  import { openRelayForwarder } from "./relay.mjs";
18
21
  import { PrismVault } from "./vault.mjs";
22
+ import { toHex, verifyComposeMeasurement, verifyQuote, verifyReportBinding } from "./vendor/aci-verifier/index.mjs";
19
23
  import { PrismWorkspace } from "./workspace.mjs";
20
24
 
25
+ export { DEFAULT_CONFIDENTIAL_BASE, EXPECTED_WORKLOAD, renderChecks, verifyConfidential } from "./attest.mjs";
21
26
  export { PrismVault, VaultError, DEFAULT_TRUST_FLOOR, VAULT_KEY_STATEMENT } from "./vault.mjs";
22
27
  export {
23
28
  PrismWorkspace,
@@ -46,6 +51,12 @@ export const TRUST_CLASSES = ["open", "isolated", "attested", "confidential"];
46
51
 
47
52
  const CONFIRMATIONS = 12;
48
53
  const FETCH_TIMEOUT_MS = 30_000;
54
+ // A generation can wait on a cold box, so the paid call gets its own budget and
55
+ // keeps the payment across the wait rather than paying twice.
56
+ const PAID_CALL_TIMEOUT_MS = 620_000;
57
+ const PAID_CALL_DEADLINE_MS = 600_000;
58
+ const PAID_CALL_RETRY_MS = 15_000;
59
+ const DEFAULT_MAX_TOKENS = 512;
49
60
 
50
61
  const erc20Abi = parseAbi([
51
62
  "function approve(address spender, uint256 value) returns (bool)",
@@ -105,6 +116,32 @@ function isSshWarmup(res) {
105
116
 
106
117
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
107
118
 
119
+ const asBytes = (body) =>
120
+ typeof body === "string" || body instanceof Uint8Array ? body : Buffer.from(JSON.stringify(body), "utf8");
121
+
122
+ /// A sealed answer, or an error that names the reason it did not open. The
123
+ /// service marks an encrypted answer with `x-e2ee-applied`, and a plaintext one
124
+ /// fails the AEAD for a reason that has nothing to do with the key.
125
+ function decryptAnswer(bytes, clientKey, headers, receiptId) {
126
+ const applied = headers.get("x-e2ee-applied");
127
+ const paidFor = `the generation is paid for, and receipt ${receiptId} still verifies what the workload served`;
128
+ if (applied !== null && applied.toLowerCase() !== "true") {
129
+ throw new PrismError(502, "e2ee_not_applied", {
130
+ cause: `the endpoint answered with x-e2ee-applied: ${applied}`,
131
+ hint: `the enclave returned the answer unencrypted; ${paidFor}`,
132
+ });
133
+ }
134
+ try {
135
+ return decryptResponse(bytes, clientKey);
136
+ } catch (err) {
137
+ if (applied !== null) throw err;
138
+ throw new PrismError(502, "e2ee_not_applied", {
139
+ cause: err?.message ?? String(err),
140
+ hint: `the endpoint marked no answer as encrypted and this one did not open under this call's key; ${paidFor}`,
141
+ });
142
+ }
143
+ }
144
+
108
145
  export class PrismAgent {
109
146
  constructor({ privateKey, apiBase = "https://prismnetwork.tech", escrow, rpcUrl }) {
110
147
  if (!escrow) throw new Error("escrow address is required");
@@ -175,12 +212,14 @@ export class PrismAgent {
175
212
 
176
213
  async transferUsdg(to, amountMicros) {
177
214
  try {
178
- const hash = await this.walletClient.writeContract({
179
- address: USDG,
180
- abi: erc20Abi,
181
- functionName: "transfer",
182
- args: [to, BigInt(amountMicros)],
183
- });
215
+ const hash = await this.#submit(() =>
216
+ this.walletClient.writeContract({
217
+ address: USDG,
218
+ abi: erc20Abi,
219
+ functionName: "transfer",
220
+ args: [to, BigInt(amountMicros)],
221
+ }),
222
+ );
184
223
  const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
185
224
  if (receipt.status !== "success") throw new PrismError(502, "transfer_reverted", { hash });
186
225
  return hash;
@@ -213,6 +252,10 @@ export class PrismAgent {
213
252
  // Approve USDG and create the on-chain lease bound to the quote. The escrow
214
253
  // binds funding to keccak256(quote_id), so reproduce it exactly or confirm rejects.
215
254
  async fund(quote) {
255
+ return this.#submit(() => this.#fundNow(quote));
256
+ }
257
+
258
+ async #fundNow(quote) {
216
259
  if (typeof quote?.quote_id !== "string" || typeof quote?.node_id !== "string") {
217
260
  throw new PrismError(400, "invalid_quote");
218
261
  }
@@ -220,28 +263,37 @@ export class PrismAgent {
220
263
  const duration = parseDuration(quote.duration_seconds);
221
264
  const clientReference = keccak256(stringToBytes(quote.quote_id));
222
265
  try {
223
- const allowance = await this.publicClient.readContract({
224
- address: USDG,
225
- abi: erc20Abi,
226
- functionName: "allowance",
227
- args: [this.address, this.escrow],
228
- });
229
- if (allowance < deposit) {
230
- const approveHash = await this.walletClient.writeContract({
266
+ // Approving and spending are one indivisible step. The approval covers
267
+ // exactly this deposit, so a second lease that read the allowance before
268
+ // this one spent it would find it gone by the time the chain ran it.
269
+ const hash = await (async () => {
270
+ const allowance = await this.publicClient.readContract({
231
271
  address: USDG,
232
272
  abi: erc20Abi,
233
- functionName: "approve",
234
- args: [this.escrow, deposit],
273
+ functionName: "allowance",
274
+ args: [this.address, this.escrow],
235
275
  });
236
- const approved = await this.publicClient.waitForTransactionReceipt({ hash: approveHash });
237
- if (approved.status !== "success") throw new PrismError(402, "approve_reverted", { hash: approveHash });
238
- }
239
- const hash = await this.walletClient.writeContract({
240
- address: this.escrow,
241
- abi: escrowAbi,
242
- functionName: "createLease",
243
- args: [quote.node_id, duration, clientReference],
244
- });
276
+ if (allowance < deposit) {
277
+ const approveHash = await this.walletClient.writeContract({
278
+ address: USDG,
279
+ abi: erc20Abi,
280
+ functionName: "approve",
281
+ args: [this.escrow, deposit],
282
+ });
283
+ const approved = await this.publicClient.waitForTransactionReceipt({ hash: approveHash });
284
+ if (approved.status !== "success") throw new PrismError(402, "approve_reverted", { hash: approveHash });
285
+ }
286
+ const funding = await this.walletClient.writeContract({
287
+ address: this.escrow,
288
+ abi: escrowAbi,
289
+ functionName: "createLease",
290
+ args: [quote.node_id, duration, clientReference],
291
+ });
292
+ // One confirmation here, not for the control-plane's benefit but so the
293
+ // allowance and the nonce are settled before the next lease reads them.
294
+ await this.publicClient.waitForTransactionReceipt({ hash: funding });
295
+ return funding;
296
+ })();
245
297
  // 12 confirmations: the control-plane rejects funding until the tx is final.
246
298
  const receipt = await this.publicClient.waitForTransactionReceipt({ hash, confirmations: CONFIRMATIONS });
247
299
  if (receipt.status !== "success") throw new PrismError(402, "lease_funding_reverted", { hash });
@@ -358,26 +410,35 @@ export class PrismAgent {
358
410
  hint: "the wallet needs USDG for the deposit and native ETH for gas on Robinhood Chain (id 4663) before it can lease",
359
411
  });
360
412
  }
361
- const quote = await this.quote({
362
- image,
363
- durationSeconds,
364
- minVramMib,
365
- preferredNodeId,
366
- minTrustClass,
367
- command,
368
- });
369
- if (maxDeposit != null && parseBaseUnits(quote.maximum_escrow, "maximum_escrow") > BigInt(maxDeposit)) {
370
- throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
371
- }
372
413
  const key = this.#generateSshKey();
414
+ let quote = null;
373
415
  let funded = null;
374
416
  let leaseId = null;
375
417
  try {
376
- funded = await this.fund(quote);
377
- const record = await this.confirm({
378
- quoteId: quote.quote_id,
379
- transactionHash: funded.hash,
380
- sshAuthorizedKey: key.publicKey,
418
+ // One renter takes one machine at a time, up to the point the chain knows
419
+ // it is taken. Asking for a quote releases this renter's other open
420
+ // quotes, so two quotes held at once can name the same machine and the
421
+ // second lease reverts against a node that is no longer free. Only the
422
+ // claim is serialised: provisioning, which is the part that takes
423
+ // minutes, still runs in parallel.
424
+ const record = await this.#submit(async () => {
425
+ quote = await this.quote({
426
+ image,
427
+ durationSeconds,
428
+ minVramMib,
429
+ preferredNodeId,
430
+ minTrustClass,
431
+ command,
432
+ });
433
+ if (maxDeposit != null && parseBaseUnits(quote.maximum_escrow, "maximum_escrow") > BigInt(maxDeposit)) {
434
+ throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
435
+ }
436
+ funded = await this.#fundNow(quote);
437
+ return this.confirm({
438
+ quoteId: quote.quote_id,
439
+ transactionHash: funded.hash,
440
+ sshAuthorizedKey: key.publicKey,
441
+ });
381
442
  });
382
443
  if (!Number.isInteger(record?.lease_id)) {
383
444
  throw new PrismError(502, "malformed_lease_record", { funding_hash: funded.hash });
@@ -496,6 +557,361 @@ export class PrismAgent {
496
557
  }
497
558
  }
498
559
 
560
+ // Unconsumed inference payments, keyed by the endpoint, the price and the
561
+ // request they paid for, so a generation that never happened is retried with
562
+ // the payment already made instead of paying for it twice, and a different
563
+ // prompt never inherits it.
564
+ #pendingPayments = new Map();
565
+
566
+ /// Pay for one call to a metered endpoint and keep the payment until the
567
+ /// endpoint actually serves. A 503 from an upstream that is merely
568
+ /// unavailable and a 402 for a payment that is only too young both heal by
569
+ /// themselves, so both retry with the same payment; everything else is final
570
+ /// and the payment stays cached for the next attempt at the same request.
571
+ ///
572
+ /// `body` is sent verbatim when it is already bytes, which is what a signed
573
+ /// receipt over the request needs: nothing between the caller and the workload
574
+ /// re-serializes it. Pass `seal` instead for a request that has to be built
575
+ /// fresh per attempt, such as an end-to-end encrypted one whose timestamp the
576
+ /// service only accepts inside a five-minute window, and pass `fingerprint`
577
+ /// so the cache still recognises two attempts as the same request.
578
+ async payAndPost({
579
+ base,
580
+ path,
581
+ price,
582
+ payTo,
583
+ body = null,
584
+ headers = {},
585
+ seal = null,
586
+ fingerprint = null,
587
+ retryDelayMs = PAID_CALL_RETRY_MS,
588
+ caller = "call",
589
+ }) {
590
+ let sent = seal ? seal() : { bytes: asBytes(body), headers };
591
+ const identity = createHash("sha256").update(fingerprint ?? sent.bytes).digest("hex");
592
+ const key = `${base}${path}:${price}:${identity}`;
593
+ let pending = this.#pendingPayments.get(key);
594
+ if (!pending) {
595
+ const tx = await this.transferUsdg(payTo, price);
596
+ const signature = await this.account.signMessage({ message: tx });
597
+ pending = { tx, header: Buffer.from(JSON.stringify({ txHash: tx, signature })).toString("base64") };
598
+ this.#pendingPayments.set(key, pending);
599
+ }
600
+ // The transfer is on-chain and irreversible from here. The signed header is
601
+ // the only thing that redeems it, and it lives in this process.
602
+ const kept = {
603
+ payment_tx: pending.tx,
604
+ payment_header: pending.header,
605
+ hint:
606
+ `the payment (tx ${pending.tx}) settled on-chain and the endpoint did not serve. While this process lives, ` +
607
+ `the next ${caller} for this same request redeems it without paying again. payment_header is what redeems ` +
608
+ "it, so keep it to do that from anywhere else.",
609
+ };
610
+ const deadline = Date.now() + PAID_CALL_DEADLINE_MS;
611
+ for (;;) {
612
+ let res;
613
+ let bytes;
614
+ try {
615
+ res = await fetch(`${base}${path}`, {
616
+ method: "POST",
617
+ headers: { "content-type": "application/json", "x-payment": pending.header, ...sent.headers },
618
+ body: sent.bytes,
619
+ signal: AbortSignal.timeout(PAID_CALL_TIMEOUT_MS),
620
+ });
621
+ bytes = Buffer.from(await res.arrayBuffer());
622
+ } catch (err) {
623
+ throw new PrismError(504, "endpoint_unreachable", { cause: err?.message ?? String(err), ...kept });
624
+ }
625
+ if (res.status === 200) {
626
+ this.#pendingPayments.delete(key);
627
+ // The endpoint replays a stored answer when it sees a payment it has
628
+ // already consumed. That is an answer to an earlier call, so it is not
629
+ // this one's, whatever the status line says.
630
+ if (String(res.headers.get("x-prism-replayed") ?? "").toLowerCase() === "true") {
631
+ throw new PrismError(409, "payment_replayed", {
632
+ cause: `the endpoint replayed an earlier answer for tx ${pending.tx}`,
633
+ hint: "this payment was already consumed by another call; pay again to have this request served",
634
+ });
635
+ }
636
+ return { status: 200, headers: res.headers, bytes, tx: pending.tx, sent };
637
+ }
638
+ const answered = (() => {
639
+ try {
640
+ return JSON.parse(bytes.toString("utf8"));
641
+ } catch {
642
+ return null;
643
+ }
644
+ })();
645
+ // A payment the endpoint has already consumed will never serve anything
646
+ // again, so it stops being something to retry with.
647
+ if (answered?.error === "payment_reused") this.#pendingPayments.delete(key);
648
+ const retryAfter = Number(res.headers.get("retry-after") ?? 0);
649
+ const retryable =
650
+ (res.status === 503 && answered?.error === "upstream_unavailable" && !(retryAfter > 120)) ||
651
+ (res.status === 402 && ["insufficient_confirmations", "tx_not_found"].includes(answered?.error));
652
+ if (!retryable || Date.now() > deadline) {
653
+ const said = [answered?.detail, answered?.retry].filter(Boolean).join("; ");
654
+ throw new PrismError(res.status, answered?.error ?? "generation_failed", {
655
+ cause: said || answered?.error || `status ${res.status}`,
656
+ ...(this.#pendingPayments.has(key) ? kept : { payment_tx: pending.tx }),
657
+ });
658
+ }
659
+ await sleep(retryDelayMs);
660
+ if (seal) sent = seal();
661
+ }
662
+ }
663
+
664
+ /// Buy one generation from the confidential tier: an OpenAI-shaped chat
665
+ /// request served by a model running in a GPU TEE, answered with a signed
666
+ /// receipt over the exact bytes of the exchange.
667
+ ///
668
+ /// With `e2ee` on (the default) the message contents are encrypted to a key
669
+ /// the enclave's own attestation quote commits to, established here before
670
+ /// anything is sent or paid, so the relay in between carries ciphertext. The
671
+ /// returned handle keeps those bytes and `verify()` checks the whole chain
672
+ /// against them.
673
+ ///
674
+ /// `expectedWorkload` is the code that enclave must be running, defaulting to
675
+ /// the deployment this SDK ships pinned. Passing `null` skips that appraisal
676
+ /// and leaves the prompt protected only by "some TDX enclave holds the key".
677
+ async confidentialInfer({
678
+ prompt = null,
679
+ messages = null,
680
+ model = null,
681
+ maxUsdg = 0.25,
682
+ maxTokens = DEFAULT_MAX_TOKENS,
683
+ e2ee = true,
684
+ expectedWorkload = EXPECTED_WORKLOAD,
685
+ endpoint = DEFAULT_CONFIDENTIAL_BASE,
686
+ } = {}) {
687
+ const chat = messages ?? (typeof prompt === "string" && prompt.trim() !== "" ? [{ role: "user", content: prompt }] : null);
688
+ if (!Array.isArray(chat) || chat.length === 0) {
689
+ throw new PrismError(400, "prompt_required", { hint: "pass a prompt string or a messages array" });
690
+ }
691
+ if (!Number.isInteger(maxTokens) || maxTokens <= 0) throw new PrismError(400, "invalid_max_tokens");
692
+ const base = String(endpoint).replace(/\/$/, "");
693
+ const chosen = await this.#confidentialModel(base, model, maxTokens);
694
+
695
+ // Everything that protects the prompt happens before it is sent: the key it
696
+ // is encrypted to has to be one the hardware quote commits to and the code
697
+ // behind that quote has to be the code this SDK pins, not whatever the
698
+ // relay offered.
699
+ const body = { model: chosen.model, messages: chat, max_tokens: maxTokens };
700
+ const plaintext = Buffer.from(JSON.stringify(body), "utf8");
701
+ let keysetDigest = null;
702
+ let seal = null;
703
+ if (e2ee) {
704
+ const established = await this.#establishKeyset(base, expectedWorkload);
705
+ keysetDigest = established.digest;
706
+ // The service rejects a request whose timestamp is more than five minutes
707
+ // old, and the retry budget is longer than that, so each attempt seals
708
+ // its own envelope with a fresh nonce and clock.
709
+ seal = () => encryptChatRequest(body, established.keyset);
710
+ }
711
+
712
+ // Encryption roughly doubles the body, and the relay refuses an oversized
713
+ // one after the payment has been made, so one envelope is built here purely
714
+ // to measure and the attempts seal their own.
715
+ const sized = seal ? seal().bytes : plaintext;
716
+ if (Number.isInteger(chosen.card.max_body_bytes) && sized.length > chosen.card.max_body_bytes) {
717
+ throw new PrismError(413, "request_too_large", {
718
+ required: String(sized.length),
719
+ max: String(chosen.card.max_body_bytes),
720
+ });
721
+ }
722
+
723
+ const quote = await this.#confidentialQuote(base, chosen, maxTokens);
724
+ const cap = BigInt(Math.round(maxUsdg * 1e6));
725
+ if (quote.price <= 0n || quote.price > cap) {
726
+ throw new PrismError(402, "cost_exceeds_max", { required: quote.price.toString(), max: cap.toString() });
727
+ }
728
+
729
+ const served = await this.payAndPost({
730
+ base,
731
+ path: "/v1/chat/completions",
732
+ price: quote.price,
733
+ payTo: quote.payTo,
734
+ ...(seal ? { seal, fingerprint: plaintext } : { body: plaintext }),
735
+ caller: "confidentialInfer",
736
+ });
737
+ const receiptId = served.headers.get("x-receipt-id");
738
+ const sent = served.sent;
739
+ const answer = seal
740
+ ? decryptAnswer(served.bytes, sent.clientKey, served.headers, receiptId)
741
+ : JSON.parse(served.bytes.toString("utf8"));
742
+
743
+ // The workload keeps receipts in memory only, so this one is fetched now
744
+ // and kept, whether or not the caller ever verifies it.
745
+ const receipt = receiptId ? await this.#confidentialReceipt(base, receiptId) : null;
746
+
747
+ return {
748
+ model: chosen.model,
749
+ content: answer?.choices?.[0]?.message?.content ?? null,
750
+ usage: answer?.usage ?? null,
751
+ receiptId,
752
+ receipt,
753
+ keysetDigest,
754
+ e2ee: Boolean(seal),
755
+ priceMicros: quote.price.toString(),
756
+ priceUsdg: (Number(quote.price) / 1e6).toFixed(6),
757
+ tx: served.tx,
758
+ bytes: {
759
+ request: sent.bytes,
760
+ response: served.bytes,
761
+ ...(seal ? { restoredRequest: sent.restored } : {}),
762
+ },
763
+ verify: (options = {}) =>
764
+ verifyConfidential({
765
+ base,
766
+ model: chosen.model,
767
+ receiptId,
768
+ receipt,
769
+ requestBytes: sent.bytes,
770
+ responseBytes: served.bytes,
771
+ restoredRequestBytes: seal ? sent.restored : null,
772
+ e2ee: Boolean(seal),
773
+ expectedWorkload,
774
+ expectedKeysetDigest: keysetDigest,
775
+ ...options,
776
+ }),
777
+ };
778
+ }
779
+
780
+ /// The confidential half of the endpoint's rate card, and the model this call
781
+ /// should use. The card also states the caps the endpoint enforces, so a
782
+ /// request it would refuse is refused here instead, before it is paid for.
783
+ async #confidentialModel(base, requested, maxTokens) {
784
+ const offer = await this.#publicJson(`${base}/v1/models`, "inference_endpoint_unavailable");
785
+ const card = offer.confidential;
786
+ const models = Object.keys(card?.models ?? {});
787
+ if (models.length === 0) {
788
+ throw new PrismError(503, "no_confidential_model", { hint: `${base} offers no confidential model right now` });
789
+ }
790
+ const model = requested ?? models[0];
791
+ if (!models.includes(model)) {
792
+ throw new PrismError(400, "unknown_model", { hint: `confidential models: ${models.join(", ")}` });
793
+ }
794
+ if (Number.isInteger(card.max_tokens) && maxTokens > card.max_tokens) {
795
+ throw new PrismError(400, "invalid_max_tokens", { hint: `the endpoint caps max_tokens at ${card.max_tokens}` });
796
+ }
797
+ return { model, card, payTo: offer.pay_to ?? null };
798
+ }
799
+
800
+ /// The keyset the enclave's quote commits to, and the code behind that quote.
801
+ /// Only the checks that protect the prompt run here, which is every check that
802
+ /// says who can read it: the quote verifies to Intel's root and commits to
803
+ /// this key set and this nonce, the boot log replays to the measurement that
804
+ /// quote states, and the measured compose runs the pinned launcher and source.
805
+ /// The rest of the transcript, receipt included, runs after the answer comes
806
+ /// back. Anything short of all of that refuses to hand over a prompt.
807
+ async #establishKeyset(base, expectedWorkload = EXPECTED_WORKLOAD) {
808
+ const nonce = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(32))).toString("hex");
809
+ const report = await this.#publicJson(`${base}/v1/attestation?nonce=${nonce}`, "attestation_unavailable");
810
+ const binding = await verifyReportBinding(report, nonce);
811
+ if (!binding.ok) {
812
+ const bad = binding.checks.find((c) => !c.ok);
813
+ throw new PrismError(502, "attestation_unverified", { cause: bad?.detail ?? bad?.name });
814
+ }
815
+ // Which code the report describes is appraised before its quote is, so a
816
+ // report naming the wrong workload is refused whatever hardware signed it.
817
+ let measurement;
818
+ try {
819
+ measurement = await verifyComposeMeasurement(report);
820
+ } catch (err) {
821
+ throw new PrismError(502, "attestation_unverified", {
822
+ cause: `the report's boot evidence could not be read: ${err?.message ?? err}`,
823
+ });
824
+ }
825
+ const composeHash = measurement.checks.find((c) => c.name === "compose_hash");
826
+ if (!composeHash.ok) throw new PrismError(502, "attestation_unverified", { cause: composeHash.detail });
827
+ const identity = await appraiseWorkload(report, measurement, expectedWorkload);
828
+ if (!identity.ok) {
829
+ throw new PrismError(502, "attestation_unverified", {
830
+ cause: identity.detail,
831
+ hint: "the enclave quoting this key set is not running the code this SDK pins, so no prompt was sent to it",
832
+ });
833
+ }
834
+
835
+ const quote = await verifyQuote(report);
836
+ if (!quote.ok) throw new PrismError(502, "quote_unverified", { cause: quote.detail });
837
+ if (quote.status !== "UpToDate") {
838
+ throw new PrismError(502, "quote_unverified", { cause: `the platform TCB is ${quote.status}` });
839
+ }
840
+ // What makes the measurement above authentic: the log replays to the RTMR3
841
+ // the verified quote itself states.
842
+ if (toHex(quote.report.rtMr3) !== toHex(measurement.rtmr3)) {
843
+ throw new PrismError(502, "attestation_unverified", {
844
+ cause: "the boot event log does not replay to the RTMR3 the verified quote states",
845
+ });
846
+ }
847
+ return { digest: binding.workloadKeysetDigest, keyset: binding.keyset, provenance: identity.provenance ?? null };
848
+ }
849
+
850
+ /// The endpoint prices each request itself, so the figure comes from an
851
+ /// unpaid request rather than from arithmetic on the rate card.
852
+ async #confidentialQuote(base, chosen, maxTokens) {
853
+ let res;
854
+ try {
855
+ res = await fetch(`${base}/v1/chat/completions`, {
856
+ method: "POST",
857
+ headers: { "content-type": "application/json", accept: "application/json" },
858
+ body: JSON.stringify({ model: chosen.model, max_tokens: maxTokens }),
859
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
860
+ });
861
+ } catch (err) {
862
+ throw new PrismError(504, "inference_endpoint_unavailable", { cause: err?.message ?? String(err) });
863
+ }
864
+ if (res.status !== 402) {
865
+ throw new PrismError(res.status, "no_quote", { hint: "the endpoint did not answer an unpaid request with a price" });
866
+ }
867
+ const body = await res.json().catch(() => null);
868
+ const accepted = (body?.accepts ?? []).find((a) => a.network === "eip155:4663" || a.network === "robinhood");
869
+ const micros = body?.quote?.price_micros ?? accepted?.amount ?? accepted?.maxAmountRequired;
870
+ const payTo = accepted?.payTo ?? chosen.payTo;
871
+ if (micros == null || !payTo) throw new PrismError(502, "no_quote", { cause: "the 402 named no USDG price to pay" });
872
+ return { price: BigInt(micros), payTo };
873
+ }
874
+
875
+ async #confidentialReceipt(base, receiptId) {
876
+ try {
877
+ return await this.#publicJson(`${base}/v1/receipts/${encodeURIComponent(receiptId)}`, "receipt_unavailable");
878
+ } catch {
879
+ // The generation is paid for and delivered; a receipt that cannot be
880
+ // fetched right now is a verification the caller loses, not a failure of
881
+ // the call. verify() says so plainly when it runs.
882
+ return null;
883
+ }
884
+ }
885
+
886
+ async #publicJson(url, code) {
887
+ let res;
888
+ try {
889
+ res = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
890
+ } catch (err) {
891
+ throw new PrismError(504, code, { cause: err?.message ?? String(err) });
892
+ }
893
+ if (!res.ok) throw new PrismError(res.status, code, { cause: `${url} answered ${res.status}` });
894
+ const body = await res.json().catch(() => null);
895
+ if (body === null) throw new PrismError(502, code, { cause: `${url} answered with something that is not JSON` });
896
+ return body;
897
+ }
898
+
899
+ // A wallet has one nonce, so two transactions prepared at the same moment are
900
+ // handed the same one and the chain refuses the second. Everything that
901
+ // submits from this wallet queues here, which is what lets one wallet fund
902
+ // several leases at once: they provision in parallel, they just do not sign
903
+ // at the same instant.
904
+ #submitting = Promise.resolve();
905
+
906
+ #submit(send) {
907
+ const done = this.#submitting.then(send, send);
908
+ this.#submitting = done.then(
909
+ () => {},
910
+ () => {},
911
+ );
912
+ return done;
913
+ }
914
+
499
915
  #generateSshKey() {
500
916
  const dir = mkdtempSync(join(tmpdir(), "prism-ssh-"));
501
917
  try {
@@ -0,0 +1,100 @@
1
+ // Ported from Dstack-TEE/private-ai-gateway clients/verifier-ts @ b6b5c1b, Apache-2.0.
2
+ //
3
+ // Every primitive goes through the Web Crypto API on `globalThis.crypto`, so
4
+ // the same code runs in a browser and in Node 20+ with no dependencies. ACI's
5
+ // only signature algorithm is Ed25519 and its only hash is SHA-256 (spec
6
+ // Appendix B); both are in Web Crypto, so nothing needs injecting.
7
+ import { AciFormatError } from "./errors.mjs";
8
+
9
+ const subtle = globalThis.crypto.subtle;
10
+
11
+ export function toHex(bytes) {
12
+ let out = "";
13
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
14
+ return out;
15
+ }
16
+
17
+ export function fromHex(hex) {
18
+ const h = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex;
19
+ if (h.length % 2 !== 0) throw new AciFormatError(`hex string has odd length: ${hex.length} chars`);
20
+ const out = new Uint8Array(h.length / 2);
21
+ for (let i = 0; i < out.length; i++) {
22
+ const byte = Number.parseInt(h.slice(i * 2, i * 2 + 2), 16);
23
+ if (Number.isNaN(byte)) {
24
+ throw new AciFormatError(`invalid hex at offset ${i * 2}: "${h.slice(i * 2, i * 2 + 2)}"`);
25
+ }
26
+ out[i] = byte;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /// Standard base64 with padding (RFC 4648 §4), the `_b64` field form (Appendix A).
32
+ export function toBase64(bytes) {
33
+ let bin = "";
34
+ for (const b of bytes) bin += String.fromCharCode(b);
35
+ return btoa(bin);
36
+ }
37
+
38
+ export function fromBase64(b64) {
39
+ let bin;
40
+ try {
41
+ bin = atob(b64);
42
+ } catch {
43
+ throw new AciFormatError("invalid base64");
44
+ }
45
+ const out = new Uint8Array(bin.length);
46
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
47
+ return out;
48
+ }
49
+
50
+ /// JCS (RFC 8785) bytes of a parsed JSON value under the ACI artifact
51
+ /// constraints (ASCII member names, integer numbers): compact serialization
52
+ /// with sorted member names (§7.2, §8).
53
+ export function jcsBytes(value) {
54
+ return new TextEncoder().encode(JSON.stringify(sortedValue(value)));
55
+ }
56
+
57
+ function sortedValue(value) {
58
+ if (Array.isArray(value)) return value.map(sortedValue);
59
+ if (value !== null && typeof value === "object") {
60
+ const out = {};
61
+ for (const key of Object.keys(value).sort()) out[key] = sortedValue(value[key]);
62
+ return out;
63
+ }
64
+ return value;
65
+ }
66
+
67
+ export async function sha256(bytes) {
68
+ return new Uint8Array(await subtle.digest("SHA-256", bytes));
69
+ }
70
+
71
+ /// The dstack RTMR replay hash (§9.1 policy).
72
+ export async function sha384(bytes) {
73
+ return new Uint8Array(await subtle.digest("SHA-384", bytes));
74
+ }
75
+
76
+ export async function sha256Hex(bytes) {
77
+ return toHex(await sha256(bytes));
78
+ }
79
+
80
+ /// `sha256:<lowercase-hex>`, the ACI digest form (Appendix A) used for keyset
81
+ /// digests, body hashes and session ids.
82
+ export async function sha256Prefixed(bytes) {
83
+ return `sha256:${await sha256Hex(bytes)}`;
84
+ }
85
+
86
+ /// RFC 8032 over `message`. `publicKeyRaw` is the 32-byte raw key, `signature`
87
+ /// the 64-byte value. A bad signature or a malformed key is false, never a throw.
88
+ export async function verifyEd25519(publicKeyRaw, signature, message) {
89
+ let key;
90
+ try {
91
+ key = await subtle.importKey("raw", publicKeyRaw, { name: "Ed25519" }, false, ["verify"]);
92
+ } catch {
93
+ return false;
94
+ }
95
+ try {
96
+ return await subtle.verify({ name: "Ed25519" }, key, signature, message);
97
+ } catch {
98
+ return false;
99
+ }
100
+ }