@forum-labs/payfetch 1.0.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +467 -0
  3. package/dist/bin/payfetch-mcp.d.ts +2 -0
  4. package/dist/bin/payfetch-mcp.js +9 -0
  5. package/dist/bin/payfetch.d.ts +2 -0
  6. package/dist/bin/payfetch.js +126 -0
  7. package/dist/src/config.d.ts +35 -0
  8. package/dist/src/config.js +170 -0
  9. package/dist/src/core/budget.d.ts +62 -0
  10. package/dist/src/core/budget.js +236 -0
  11. package/dist/src/core/constants.d.ts +66 -0
  12. package/dist/src/core/constants.js +115 -0
  13. package/dist/src/core/fs.d.ts +14 -0
  14. package/dist/src/core/fs.js +104 -0
  15. package/dist/src/core/integrity.d.ts +23 -0
  16. package/dist/src/core/integrity.js +150 -0
  17. package/dist/src/core/ledger.d.ts +149 -0
  18. package/dist/src/core/ledger.js +357 -0
  19. package/dist/src/core/notes.d.ts +22 -0
  20. package/dist/src/core/notes.js +24 -0
  21. package/dist/src/core/pipeline.d.ts +143 -0
  22. package/dist/src/core/pipeline.js +795 -0
  23. package/dist/src/core/policy.d.ts +57 -0
  24. package/dist/src/core/policy.js +224 -0
  25. package/dist/src/core/transport.d.ts +93 -0
  26. package/dist/src/core/transport.js +364 -0
  27. package/dist/src/guards/index.d.ts +4 -0
  28. package/dist/src/guards/index.js +3 -0
  29. package/dist/src/guards/internal.d.ts +17 -0
  30. package/dist/src/guards/internal.js +74 -0
  31. package/dist/src/guards/safety.d.ts +2 -0
  32. package/dist/src/guards/safety.js +94 -0
  33. package/dist/src/guards/trust.d.ts +2 -0
  34. package/dist/src/guards/trust.js +79 -0
  35. package/dist/src/guards/types.d.ts +59 -0
  36. package/dist/src/guards/types.js +20 -0
  37. package/dist/src/index.d.ts +58 -0
  38. package/dist/src/index.js +151 -0
  39. package/dist/src/mcp/server.d.ts +6 -0
  40. package/dist/src/mcp/server.js +125 -0
  41. package/dist/src/mcp/tools.d.ts +46 -0
  42. package/dist/src/mcp/tools.js +321 -0
  43. package/dist/src/payer/mpp.d.ts +7 -0
  44. package/dist/src/payer/mpp.js +13 -0
  45. package/dist/src/payer/parse402.d.ts +6 -0
  46. package/dist/src/payer/parse402.js +169 -0
  47. package/dist/src/payer/signer_cdp.d.ts +14 -0
  48. package/dist/src/payer/signer_cdp.js +64 -0
  49. package/dist/src/payer/signer_local.d.ts +8 -0
  50. package/dist/src/payer/signer_local.js +14 -0
  51. package/dist/src/payer/types.d.ts +99 -0
  52. package/dist/src/payer/types.js +10 -0
  53. package/dist/src/payer/x402.d.ts +23 -0
  54. package/dist/src/payer/x402.js +165 -0
  55. package/dist/src/report/report.d.ts +162 -0
  56. package/dist/src/report/report.js +220 -0
  57. package/mcpb/manifest.json +104 -0
  58. package/package.json +72 -0
@@ -0,0 +1,99 @@
1
+ import type { SupportedNetwork } from "../core/constants.js";
2
+ export type Rail = "x402" | "mpp";
3
+ export type Eip712TypedData = {
4
+ domain: {
5
+ name?: string;
6
+ version?: string;
7
+ chainId?: number;
8
+ verifyingContract?: `0x${string}`;
9
+ salt?: `0x${string}`;
10
+ };
11
+ types: Record<string, ReadonlyArray<{
12
+ name: string;
13
+ type: string;
14
+ }>>;
15
+ primaryType: string;
16
+ message: Record<string, unknown>;
17
+ };
18
+ export interface WalletSigner {
19
+ kind: "local_key" | "cdp_server_wallet";
20
+ address(): Promise<string>;
21
+ signTypedData(td: Eip712TypedData): Promise<`0x${string}`>;
22
+ }
23
+ export type X402Terms = {
24
+ scheme: string;
25
+ network: string;
26
+ asset: string | null;
27
+ amountAtomic: string | null;
28
+ amountUsd: number | null;
29
+ payTo: string | null;
30
+ maxTimeoutSeconds: number | null;
31
+ mimeType: string | null;
32
+ hasOutputSchema: boolean;
33
+ outputSchemaSha256: string | null;
34
+ networkAsDeclared: string;
35
+ resource: string | null;
36
+ rawAccepts: unknown;
37
+ };
38
+ export type ParsedChallenge = {
39
+ rail: Rail | null;
40
+ x402Version: number | null;
41
+ terms: X402Terms[];
42
+ termsHash: string | null;
43
+ malformed: boolean;
44
+ };
45
+ export type PaymentQuote = {
46
+ rail: Rail;
47
+ scheme: "exact";
48
+ network: SupportedNetwork;
49
+ asset: string;
50
+ amountAtomic: string;
51
+ amountUsd: number;
52
+ payTo: string;
53
+ maxTimeoutSeconds: number | null;
54
+ resource: string | null;
55
+ mimeType: string | null;
56
+ outputSchemaSha256: string | null;
57
+ rawAccepts: unknown;
58
+ x402Version: number;
59
+ networkAsDeclared: string;
60
+ };
61
+ export type PaymentProof = {
62
+ headers: Record<string, string>;
63
+ validBeforeTs: number;
64
+ nonce: string;
65
+ };
66
+ export interface PaymentPayer {
67
+ rail: Rail;
68
+ detects(challenge: ParsedChallenge): boolean;
69
+ quotes(challenge: ParsedChallenge): PaymentQuote[];
70
+ buildPayment(quote: PaymentQuote, signer: WalletSigner, deps: PayfetchDeps): Promise<PaymentProof>;
71
+ }
72
+ export type ElicitRequest = {
73
+ host: string;
74
+ resource: string | null;
75
+ amountUsd: number;
76
+ networkLabel: string;
77
+ assetLabel: string;
78
+ guards: unknown[];
79
+ remainingBudgets: unknown;
80
+ };
81
+ export type ElicitDecision = {
82
+ approved: boolean;
83
+ cancelled?: boolean;
84
+ };
85
+ export type ElicitFn = (request: ElicitRequest) => Promise<ElicitDecision>;
86
+ export type PayfetchDeps = {
87
+ fetch: typeof fetch;
88
+ signer: WalletSigner;
89
+ now: () => number;
90
+ random: () => Uint8Array;
91
+ dataDir: string;
92
+ log: (msg: string, fields?: Record<string, unknown>) => void;
93
+ elicit: ElicitFn | null;
94
+ };
95
+ export declare class UnsupportedRailError extends Error {
96
+ readonly rail: Rail;
97
+ readonly method: string;
98
+ constructor(rail: Rail, method: string);
99
+ }
@@ -0,0 +1,10 @@
1
+ export class UnsupportedRailError extends Error {
2
+ rail;
3
+ method;
4
+ constructor(rail, method) {
5
+ super(`Rail "${rail}" does not support ${method}() in v1 (SPEC §2 stub).`);
6
+ this.name = "UnsupportedRailError";
7
+ this.rail = rail;
8
+ this.method = method;
9
+ }
10
+ }
@@ -0,0 +1,23 @@
1
+ import type { Eip712TypedData, ParsedChallenge, PayfetchDeps, PaymentPayer, PaymentProof, PaymentQuote, WalletSigner } from "./types.js";
2
+ export declare const AUTHORIZATION_TYPES: Eip712TypedData["types"];
3
+ export declare const AUTHORIZATION_PRIMARY_TYPE = "TransferWithAuthorization";
4
+ export type QuoteRejections = Record<string, number>;
5
+ export declare function quoteWithRejections(challenge: ParsedChallenge): {
6
+ quotes: PaymentQuote[];
7
+ rejected: QuoteRejections;
8
+ };
9
+ export declare function selectQuote(quotes: readonly PaymentQuote[]): PaymentQuote | null;
10
+ export declare function buildX402Payment(quote: PaymentQuote, signer: WalletSigner, deps: PayfetchDeps): Promise<PaymentProof>;
11
+ export type SettlementResponse = {
12
+ success: boolean;
13
+ transaction?: string;
14
+ network?: string;
15
+ payer?: string;
16
+ };
17
+ export declare function parseSettlementResponse(headerValue: string): SettlementResponse | null;
18
+ export declare class X402Payer implements PaymentPayer {
19
+ readonly rail: "x402";
20
+ detects(challenge: ParsedChallenge): boolean;
21
+ quotes(challenge: ParsedChallenge): PaymentQuote[];
22
+ buildPayment(quote: PaymentQuote, signer: WalletSigner, deps: PayfetchDeps): Promise<PaymentProof>;
23
+ }
@@ -0,0 +1,165 @@
1
+ import { decodePaymentResponseHeader } from "@x402/core/http";
2
+ import { safeBase64Encode } from "@x402/core/utils";
3
+ import { getAddress, toHex } from "viem";
4
+ import { CLOCK_SKEW_S, NONCE_BYTES, PAYMENT_VALIDITY_DEFAULT_S, PAYMENT_VALIDITY_MAX_S, RAILS_ENABLED, SUPPORTED_NETWORKS, SUPPORTED_SCHEMES, X_PAYMENT_HEADER, chainIdForNetwork, deriveAmountUsd, isKnownAsset, isSupportedX402Version, knownAssetNetwork, } from "../core/constants.js";
5
+ export const AUTHORIZATION_TYPES = Object.freeze({
6
+ TransferWithAuthorization: [
7
+ { name: "from", type: "address" },
8
+ { name: "to", type: "address" },
9
+ { name: "value", type: "uint256" },
10
+ { name: "validAfter", type: "uint256" },
11
+ { name: "validBefore", type: "uint256" },
12
+ { name: "nonce", type: "bytes32" },
13
+ ],
14
+ });
15
+ export const AUTHORIZATION_PRIMARY_TYPE = "TransferWithAuthorization";
16
+ function rejectReason(term, x402Version) {
17
+ if (!isSupportedX402Version(x402Version))
18
+ return "unsupported_x402_version";
19
+ if (!RAILS_ENABLED.includes("x402"))
20
+ return "unsupported_rail_x402";
21
+ if (!SUPPORTED_SCHEMES.includes(term.scheme)) {
22
+ return `unsupported_scheme_${term.scheme}`;
23
+ }
24
+ if (!SUPPORTED_NETWORKS.includes(term.network))
25
+ return "unsupported_network";
26
+ if (!isKnownAsset(term.asset))
27
+ return "unknown_asset";
28
+ const assetNetwork = knownAssetNetwork(term.asset);
29
+ if (assetNetwork !== null && assetNetwork !== term.network)
30
+ return "asset_network_mismatch";
31
+ if (term.payTo == null || term.payTo.length === 0)
32
+ return "missing_pay_to";
33
+ if (term.amountAtomic == null || !/^\d+$/.test(term.amountAtomic))
34
+ return "non_integer_amount";
35
+ return null;
36
+ }
37
+ function toQuote(term, x402Version) {
38
+ const asset = term.asset.toLowerCase();
39
+ const amountAtomic = term.amountAtomic;
40
+ const amountUsd = term.amountUsd ?? deriveAmountUsd(amountAtomic, asset) ?? 0;
41
+ return {
42
+ rail: "x402",
43
+ scheme: "exact",
44
+ network: term.network,
45
+ asset,
46
+ amountAtomic,
47
+ amountUsd,
48
+ payTo: term.payTo,
49
+ maxTimeoutSeconds: term.maxTimeoutSeconds,
50
+ resource: term.resource,
51
+ mimeType: term.mimeType,
52
+ outputSchemaSha256: term.outputSchemaSha256,
53
+ rawAccepts: term.rawAccepts,
54
+ x402Version,
55
+ networkAsDeclared: term.networkAsDeclared,
56
+ };
57
+ }
58
+ export function quoteWithRejections(challenge) {
59
+ const quotes = [];
60
+ const rejected = {};
61
+ for (const term of challenge.terms) {
62
+ const reason = rejectReason(term, challenge.x402Version);
63
+ if (reason !== null) {
64
+ rejected[reason] = (rejected[reason] ?? 0) + 1;
65
+ continue;
66
+ }
67
+ quotes.push(toQuote(term, challenge.x402Version));
68
+ }
69
+ return { quotes, rejected };
70
+ }
71
+ export function selectQuote(quotes) {
72
+ let best = null;
73
+ for (const q of quotes) {
74
+ if (best === null || q.amountUsd < best.amountUsd)
75
+ best = q;
76
+ }
77
+ return best;
78
+ }
79
+ function extractExtra(rawAccepts) {
80
+ if (rawAccepts == null || typeof rawAccepts !== "object")
81
+ return {};
82
+ const extra = rawAccepts.extra;
83
+ if (extra == null || typeof extra !== "object")
84
+ return {};
85
+ const e = extra;
86
+ return {
87
+ name: typeof e.name === "string" ? e.name : undefined,
88
+ version: typeof e.version === "string" ? e.version : undefined,
89
+ };
90
+ }
91
+ export async function buildX402Payment(quote, signer, deps) {
92
+ const nowSec = Math.floor(deps.now() / 1000);
93
+ const validAfter = nowSec - CLOCK_SKEW_S;
94
+ const timeoutS = Math.min(quote.maxTimeoutSeconds ?? PAYMENT_VALIDITY_DEFAULT_S, PAYMENT_VALIDITY_MAX_S);
95
+ const validBefore = nowSec + timeoutS;
96
+ const nonceBytes = deps.random();
97
+ if (nonceBytes.length !== NONCE_BYTES) {
98
+ throw new Error(`deps.random() must return ${NONCE_BYTES} bytes for the EIP-3009 nonce; got ${nonceBytes.length}`);
99
+ }
100
+ const nonceHex = toHex(nonceBytes);
101
+ const from = getAddress(await signer.address());
102
+ const to = getAddress(quote.payTo);
103
+ const verifyingContract = getAddress(quote.asset);
104
+ const { name, version } = extractExtra(quote.rawAccepts);
105
+ const chainId = chainIdForNetwork(quote.network);
106
+ if (chainId === null) {
107
+ throw new Error(`no EVM chainId for network "${quote.network}"`);
108
+ }
109
+ const authorization = {
110
+ from,
111
+ to,
112
+ value: quote.amountAtomic,
113
+ validAfter: String(validAfter),
114
+ validBefore: String(validBefore),
115
+ nonce: nonceHex,
116
+ };
117
+ const typedData = {
118
+ types: AUTHORIZATION_TYPES,
119
+ domain: { name, version, chainId, verifyingContract },
120
+ primaryType: AUTHORIZATION_PRIMARY_TYPE,
121
+ message: { ...authorization },
122
+ };
123
+ const signature = await signer.signTypedData(typedData);
124
+ const payment = quote.x402Version === 1
125
+ ? {
126
+ x402Version: 1,
127
+ scheme: quote.scheme,
128
+ network: quote.networkAsDeclared,
129
+ payload: { signature, authorization },
130
+ }
131
+ : {
132
+ x402Version: quote.x402Version,
133
+ accepted: quote.rawAccepts,
134
+ payload: { signature, authorization },
135
+ };
136
+ const header = safeBase64Encode(JSON.stringify(payment));
137
+ return {
138
+ headers: { [X_PAYMENT_HEADER]: header },
139
+ validBeforeTs: validBefore,
140
+ nonce: nonceHex,
141
+ };
142
+ }
143
+ export function parseSettlementResponse(headerValue) {
144
+ try {
145
+ const decoded = decodePaymentResponseHeader(headerValue);
146
+ if (decoded == null || typeof decoded !== "object")
147
+ return null;
148
+ return decoded;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ export class X402Payer {
155
+ rail = "x402";
156
+ detects(challenge) {
157
+ return challenge.rail === "x402";
158
+ }
159
+ quotes(challenge) {
160
+ return quoteWithRejections(challenge).quotes;
161
+ }
162
+ buildPayment(quote, signer, deps) {
163
+ return buildX402Payment(quote, signer, deps);
164
+ }
165
+ }
@@ -0,0 +1,162 @@
1
+ import type { Eip712TypedData, WalletSigner } from "../payer/types.js";
2
+ import type { Receipt } from "../core/ledger.js";
3
+ export declare const OUTCOME_REPORT_SCHEMA_ID: "p3f.outcome-report.v1";
4
+ export declare const REPORT_CLASSES_V1: readonly ["paid_not_delivered", "paid_delivered"];
5
+ export type ReportClass = (typeof REPORT_CLASSES_V1)[number];
6
+ export declare const AMOUNT_BANDS: readonly ["lt_0.01", "0.01_0.10", "0.10_1", "gte_1"];
7
+ export type AmountBand = (typeof AMOUNT_BANDS)[number];
8
+ export declare const HTTP_STATUS_CLASSES: readonly ["2xx", "3xx", "4xx", "5xx"];
9
+ export type HttpStatusClass = (typeof HTTP_STATUS_CLASSES)[number];
10
+ export type ReportChecks = {
11
+ settlementConfirmed: boolean;
12
+ httpStatusClass: HttpStatusClass | null;
13
+ contentTypeOk: boolean | null;
14
+ nonEmpty: boolean;
15
+ };
16
+ export type OutcomeReport = {
17
+ schema: typeof OUTCOME_REPORT_SCHEMA_ID;
18
+ endpoint: {
19
+ method: string;
20
+ url: string;
21
+ };
22
+ outcome: ReportClass;
23
+ checks: ReportChecks;
24
+ termsHash: string;
25
+ payTo: string | null;
26
+ amountBand: AmountBand;
27
+ utcDay: string;
28
+ test: boolean;
29
+ clientVersion: string;
30
+ payer: string;
31
+ sig: string;
32
+ anchor?: {
33
+ kind: "tx";
34
+ txRef: string;
35
+ network: string;
36
+ };
37
+ };
38
+ export declare class NotReportableError extends Error {
39
+ readonly outcome: string;
40
+ constructor(outcome: string);
41
+ }
42
+ export declare function amountBandFor(usd: number): AmountBand;
43
+ export declare function httpStatusClassOf(status: number | null | undefined): HttpStatusClass | null;
44
+ export declare function stripQuery(rawUrl: string): string;
45
+ export declare function buildReportFromReceipt(receipt: Receipt): Omit<OutcomeReport, "payer" | "sig">;
46
+ export declare const OUTCOME_REPORT_DOMAIN: {
47
+ readonly name: "Forum Labs Outcome Report";
48
+ readonly version: "1";
49
+ };
50
+ export declare const OUTCOME_REPORT_TYPES: {
51
+ readonly OutcomeReport: readonly [{
52
+ readonly name: "schema";
53
+ readonly type: "string";
54
+ }, {
55
+ readonly name: "endpointMethod";
56
+ readonly type: "string";
57
+ }, {
58
+ readonly name: "endpointUrl";
59
+ readonly type: "string";
60
+ }, {
61
+ readonly name: "outcome";
62
+ readonly type: "string";
63
+ }, {
64
+ readonly name: "settlementConfirmed";
65
+ readonly type: "bool";
66
+ }, {
67
+ readonly name: "httpStatusClass";
68
+ readonly type: "string";
69
+ }, {
70
+ readonly name: "contentTypeOk";
71
+ readonly type: "string";
72
+ }, {
73
+ readonly name: "nonEmpty";
74
+ readonly type: "bool";
75
+ }, {
76
+ readonly name: "termsHash";
77
+ readonly type: "string";
78
+ }, {
79
+ readonly name: "payTo";
80
+ readonly type: "string";
81
+ }, {
82
+ readonly name: "amountBand";
83
+ readonly type: "string";
84
+ }, {
85
+ readonly name: "utcDay";
86
+ readonly type: "string";
87
+ }, {
88
+ readonly name: "test";
89
+ readonly type: "bool";
90
+ }, {
91
+ readonly name: "clientVersion";
92
+ readonly type: "string";
93
+ }, {
94
+ readonly name: "payer";
95
+ readonly type: "address";
96
+ }, {
97
+ readonly name: "anchorKind";
98
+ readonly type: "string";
99
+ }, {
100
+ readonly name: "anchorNetwork";
101
+ readonly type: "string";
102
+ }, {
103
+ readonly name: "anchorTxRef";
104
+ readonly type: "string";
105
+ }];
106
+ };
107
+ export type OutcomeReportSignedMessage = {
108
+ schema: string;
109
+ endpointMethod: string;
110
+ endpointUrl: string;
111
+ outcome: string;
112
+ settlementConfirmed: boolean;
113
+ httpStatusClass: string;
114
+ contentTypeOk: string;
115
+ nonEmpty: boolean;
116
+ termsHash: string;
117
+ payTo: string;
118
+ amountBand: string;
119
+ utcDay: string;
120
+ test: boolean;
121
+ clientVersion: string;
122
+ payer: `0x${string}`;
123
+ anchorKind: string;
124
+ anchorNetwork: string;
125
+ anchorTxRef: string;
126
+ };
127
+ export declare function outcomeReportMessage(report: Omit<OutcomeReport, "sig">): OutcomeReportSignedMessage;
128
+ export declare function outcomeReportTypedData(report: Omit<OutcomeReport, "sig">): Eip712TypedData;
129
+ export declare function signReport(unsigned: Omit<OutcomeReport, "payer" | "sig">, signer: WalletSigner): Promise<OutcomeReport>;
130
+ export declare const OUTCOME_REPORT_PATH = "/v1/outcomes/report";
131
+ export type SubmitResult = {
132
+ status: number;
133
+ ok: boolean;
134
+ body: unknown;
135
+ };
136
+ export declare function submitReport(report: OutcomeReport, baseUrl: string, fetchImpl: typeof fetch): Promise<SubmitResult>;
137
+ export type RunReportDeps = {
138
+ readReceipts: () => Receipt[];
139
+ receiptId: string;
140
+ signer: WalletSigner;
141
+ fetchImpl: typeof fetch;
142
+ baseUrl: string;
143
+ confirm: () => Promise<boolean>;
144
+ out: (line: string) => void;
145
+ err: (line: string) => void;
146
+ };
147
+ export type RunReportOutcome = {
148
+ kind: "not_found";
149
+ } | {
150
+ kind: "not_reportable";
151
+ outcome: string;
152
+ } | {
153
+ kind: "aborted";
154
+ } | {
155
+ kind: "submit_failed";
156
+ status: number;
157
+ } | {
158
+ kind: "submitted";
159
+ status: number;
160
+ report: OutcomeReport;
161
+ };
162
+ export declare function runReport(deps: RunReportDeps): Promise<RunReportOutcome>;
@@ -0,0 +1,220 @@
1
+ import { hashTerms } from "../payer/parse402.js";
2
+ export const OUTCOME_REPORT_SCHEMA_ID = "p3f.outcome-report.v1";
3
+ export const REPORT_CLASSES_V1 = ["paid_not_delivered", "paid_delivered"];
4
+ export const AMOUNT_BANDS = ["lt_0.01", "0.01_0.10", "0.10_1", "gte_1"];
5
+ export const HTTP_STATUS_CLASSES = ["2xx", "3xx", "4xx", "5xx"];
6
+ export class NotReportableError extends Error {
7
+ outcome;
8
+ constructor(outcome) {
9
+ super(`payfetch: receipt outcome "${outcome}" is not reportable — only settled payment ` +
10
+ `outcomes (paid_delivered / paid_not_delivered) can be reported in this version.`);
11
+ this.outcome = outcome;
12
+ this.name = "NotReportableError";
13
+ }
14
+ }
15
+ export function amountBandFor(usd) {
16
+ if (!(usd >= 0.01))
17
+ return "lt_0.01";
18
+ if (usd < 0.1)
19
+ return "0.01_0.10";
20
+ if (usd < 1)
21
+ return "0.10_1";
22
+ return "gte_1";
23
+ }
24
+ export function httpStatusClassOf(status) {
25
+ if (status == null || !Number.isFinite(status))
26
+ return null;
27
+ if (status >= 200 && status < 300)
28
+ return "2xx";
29
+ if (status >= 300 && status < 400)
30
+ return "3xx";
31
+ if (status >= 400 && status < 500)
32
+ return "4xx";
33
+ if (status >= 500 && status < 600)
34
+ return "5xx";
35
+ return null;
36
+ }
37
+ export function stripQuery(rawUrl) {
38
+ let s = rawUrl;
39
+ const hash = s.indexOf("#");
40
+ if (hash >= 0)
41
+ s = s.slice(0, hash);
42
+ const q = s.indexOf("?");
43
+ if (q >= 0)
44
+ s = s.slice(0, q);
45
+ return s;
46
+ }
47
+ function termsHashFromReceipt(receipt) {
48
+ const q = receipt.quote;
49
+ if (q === null)
50
+ return "";
51
+ const term = {
52
+ scheme: q.scheme,
53
+ network: q.network,
54
+ asset: q.asset,
55
+ amountAtomic: q.amountAtomic,
56
+ amountUsd: q.amountUsd,
57
+ payTo: q.payTo,
58
+ maxTimeoutSeconds: q.maxTimeoutSeconds,
59
+ mimeType: q.mimeType,
60
+ hasOutputSchema: q.outputSchemaSha256 !== null,
61
+ outputSchemaSha256: q.outputSchemaSha256,
62
+ networkAsDeclared: q.networkAsDeclared,
63
+ resource: q.resource,
64
+ rawAccepts: q.rawAccepts,
65
+ };
66
+ return hashTerms([term]);
67
+ }
68
+ function utcDayOf(ts) {
69
+ return new Date(ts).toISOString().slice(0, 10);
70
+ }
71
+ const REPORTABLE = new Set(REPORT_CLASSES_V1);
72
+ export function buildReportFromReceipt(receipt) {
73
+ if (!REPORTABLE.has(receipt.outcome))
74
+ throw new NotReportableError(receipt.outcome);
75
+ if (receipt.payment === null)
76
+ throw new NotReportableError(receipt.outcome);
77
+ const status = receipt.http?.status ?? null;
78
+ const advertisedMime = receipt.quote?.mimeType ?? null;
79
+ const gotType = receipt.http?.contentType ?? null;
80
+ const contentTypeOk = advertisedMime !== null && gotType !== null
81
+ ? gotType.toLowerCase().includes(advertisedMime.toLowerCase())
82
+ : null;
83
+ const amountUsd = receipt.payment.settledAmountUsd ?? receipt.quote?.amountUsd ?? 0;
84
+ const report = {
85
+ schema: OUTCOME_REPORT_SCHEMA_ID,
86
+ endpoint: { method: receipt.method.toUpperCase(), url: stripQuery(receipt.url) },
87
+ outcome: receipt.outcome,
88
+ checks: {
89
+ settlementConfirmed: receipt.payment.settlementConfirmed,
90
+ httpStatusClass: httpStatusClassOf(status),
91
+ contentTypeOk,
92
+ nonEmpty: (receipt.http?.bodyBytes ?? 0) > 0,
93
+ },
94
+ termsHash: termsHashFromReceipt(receipt),
95
+ payTo: receipt.quote?.payTo ?? null,
96
+ amountBand: amountBandFor(amountUsd),
97
+ utcDay: utcDayOf(receipt.ts),
98
+ test: receipt.test,
99
+ clientVersion: receipt.clientVersion,
100
+ };
101
+ if (receipt.payment.txRef !== null) {
102
+ report.anchor = { kind: "tx", txRef: receipt.payment.txRef, network: receipt.quote?.network ?? "base" };
103
+ }
104
+ return report;
105
+ }
106
+ export const OUTCOME_REPORT_DOMAIN = {
107
+ name: "Forum Labs Outcome Report",
108
+ version: "1",
109
+ };
110
+ export const OUTCOME_REPORT_TYPES = {
111
+ OutcomeReport: [
112
+ { name: "schema", type: "string" },
113
+ { name: "endpointMethod", type: "string" },
114
+ { name: "endpointUrl", type: "string" },
115
+ { name: "outcome", type: "string" },
116
+ { name: "settlementConfirmed", type: "bool" },
117
+ { name: "httpStatusClass", type: "string" },
118
+ { name: "contentTypeOk", type: "string" },
119
+ { name: "nonEmpty", type: "bool" },
120
+ { name: "termsHash", type: "string" },
121
+ { name: "payTo", type: "string" },
122
+ { name: "amountBand", type: "string" },
123
+ { name: "utcDay", type: "string" },
124
+ { name: "test", type: "bool" },
125
+ { name: "clientVersion", type: "string" },
126
+ { name: "payer", type: "address" },
127
+ { name: "anchorKind", type: "string" },
128
+ { name: "anchorNetwork", type: "string" },
129
+ { name: "anchorTxRef", type: "string" },
130
+ ],
131
+ };
132
+ function triBool(v) {
133
+ return v === null ? "" : v ? "true" : "false";
134
+ }
135
+ export function outcomeReportMessage(report) {
136
+ return {
137
+ schema: report.schema,
138
+ endpointMethod: report.endpoint.method.toUpperCase(),
139
+ endpointUrl: report.endpoint.url,
140
+ outcome: report.outcome,
141
+ settlementConfirmed: report.checks.settlementConfirmed,
142
+ httpStatusClass: report.checks.httpStatusClass ?? "",
143
+ contentTypeOk: triBool(report.checks.contentTypeOk),
144
+ nonEmpty: report.checks.nonEmpty,
145
+ termsHash: report.termsHash,
146
+ payTo: report.payTo ?? "",
147
+ amountBand: report.amountBand,
148
+ utcDay: report.utcDay,
149
+ test: report.test,
150
+ clientVersion: report.clientVersion,
151
+ payer: report.payer,
152
+ anchorKind: report.anchor?.kind ?? "",
153
+ anchorNetwork: report.anchor?.network ?? "",
154
+ anchorTxRef: report.anchor?.txRef ?? "",
155
+ };
156
+ }
157
+ export function outcomeReportTypedData(report) {
158
+ return {
159
+ domain: OUTCOME_REPORT_DOMAIN,
160
+ types: OUTCOME_REPORT_TYPES,
161
+ primaryType: "OutcomeReport",
162
+ message: outcomeReportMessage(report),
163
+ };
164
+ }
165
+ export async function signReport(unsigned, signer) {
166
+ const payer = (await signer.address()).toLowerCase();
167
+ const withPayer = { ...unsigned, payer };
168
+ const sig = await signer.signTypedData(outcomeReportTypedData(withPayer));
169
+ return { ...withPayer, sig };
170
+ }
171
+ export const OUTCOME_REPORT_PATH = "/v1/outcomes/report";
172
+ export async function submitReport(report, baseUrl, fetchImpl) {
173
+ const res = await fetchImpl(`${baseUrl}${OUTCOME_REPORT_PATH}`, {
174
+ method: "POST",
175
+ headers: { "content-type": "application/json" },
176
+ body: JSON.stringify(report),
177
+ });
178
+ let body = null;
179
+ try {
180
+ body = await res.json();
181
+ }
182
+ catch {
183
+ body = null;
184
+ }
185
+ return { status: res.status, ok: res.ok, body };
186
+ }
187
+ export async function runReport(deps) {
188
+ const receipt = deps.readReceipts().find((r) => r.receiptId === deps.receiptId);
189
+ if (receipt === undefined) {
190
+ deps.err(`payfetch: no receipt found with id ${deps.receiptId}.`);
191
+ return { kind: "not_found" };
192
+ }
193
+ let unsigned;
194
+ try {
195
+ unsigned = buildReportFromReceipt(receipt);
196
+ }
197
+ catch (e) {
198
+ if (e instanceof NotReportableError) {
199
+ deps.err(e.message);
200
+ return { kind: "not_reportable", outcome: e.outcome };
201
+ }
202
+ throw e;
203
+ }
204
+ const report = await signReport(unsigned, deps.signer);
205
+ deps.out("This is the wallet-signed outcome report that will be submitted:");
206
+ deps.out(JSON.stringify(report, null, 2));
207
+ deps.out("It contains NO query string, request/response body, exact amount, exact " +
208
+ "timestamp, or receiptId — and no install-id. Reporting is opt-in and manual.");
209
+ if (!(await deps.confirm())) {
210
+ deps.out("Aborted — nothing was submitted.");
211
+ return { kind: "aborted" };
212
+ }
213
+ const result = await submitReport(report, deps.baseUrl, deps.fetchImpl);
214
+ if (!result.ok) {
215
+ deps.err(`payfetch: report submission failed (HTTP ${result.status}): ${JSON.stringify(result.body)}`);
216
+ return { kind: "submit_failed", status: result.status };
217
+ }
218
+ deps.out(`Report accepted (HTTP ${result.status}): ${JSON.stringify(result.body)}`);
219
+ return { kind: "submitted", status: result.status, report };
220
+ }