@agenttrust-sdk/mcp 0.2.1 → 0.2.3
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/embedded-data/devnet-attestor-trace.json +32 -0
- package/dist/embedded-data/devnet-chained-validation.json +52 -0
- package/dist/embedded-data/devnet-counterparties.json +53 -0
- package/dist/embedded-data/devnet-demo-policies.json +46 -0
- package/dist/embedded-data/devnet-namespaces.json +107 -0
- package/dist/embedded-data/devnet-smoke.json +24 -0
- package/dist/embedded-docs/getting-started/architecture-overview.mdx +85 -0
- package/dist/embedded-docs/getting-started/quickstart.mdx +100 -0
- package/dist/embedded-docs/index.mdx +64 -0
- package/dist/embedded-docs/integration-guides/capability-namespaces.mdx +15 -0
- package/dist/embedded-docs/integration-guides/custom-attestor.mdx +15 -0
- package/dist/embedded-docs/integration-guides/facilitator-adapters.mdx +85 -0
- package/dist/embedded-docs/integration-guides/pay-sh-adapter.mdx +110 -0
- package/dist/embedded-docs/integration-guides/x402-facilitator.mdx +79 -0
- package/dist/embedded-docs/programs/policy-vault/counterparty-tier-policy.mdx +15 -0
- package/dist/embedded-docs/programs/policy-vault/index.mdx +68 -0
- package/dist/embedded-docs/programs/policy-vault/kill-switch-policy.mdx +15 -0
- package/dist/embedded-docs/programs/policy-vault/require-validation-policy.mdx +15 -0
- package/dist/embedded-docs/programs/policy-vault/spending-policy.mdx +15 -0
- package/dist/embedded-docs/programs/policy-vault/velocity-policy.mdx +15 -0
- package/dist/embedded-docs/programs/trustgate.mdx +53 -0
- package/dist/embedded-docs/programs/validation-registry.mdx +49 -0
- package/dist/embedded-docs/reference/byte-offset-reference.mdx +20 -0
- package/dist/embedded-docs/reference/changelog.mdx +19 -0
- package/dist/embedded-docs/reference/devnet-program-ids.mdx +24 -0
- package/dist/embedded-docs/reference/discriminator-constants.mdx +16 -0
- package/dist/embedded-docs/reference/formal-verification.mdx +19 -0
- package/dist/embedded-docs/reference/mainnet-program-ids.mdx +16 -0
- package/dist/embedded-docs/reference/quantu-agent-registry.mdx +15 -0
- package/dist/embedded-docs/sdk/atomic-tx-invariant.mdx +37 -0
- package/dist/embedded-docs/sdk/gate-payment.mdx +22 -0
- package/dist/embedded-docs/sdk/index.mdx +73 -0
- package/dist/embedded-docs/sdk/mount-trustgate.mdx +15 -0
- package/dist/embedded-examples/attestor-demo/README.md +100 -0
- package/dist/embedded-examples/pay-sh-demo/README.md +136 -0
- package/dist/embedded-examples/pay-sh-demo/src/deps-real.ts +150 -0
- package/dist/embedded-examples/pay-sh-demo/src/deps.ts +150 -0
- package/dist/embedded-examples/pay-sh-demo/src/index.ts +471 -0
- package/dist/embedded-examples/pay-sh-demo/src/middleware.ts +198 -0
- package/dist/embedded-examples/pay-sh-demo/src/policy.ts +247 -0
- package/dist/embedded-examples/pay-sh-demo/src/x402.ts +140 -0
- package/dist/index.js +73 -18
- package/dist/index.js.map +1 -1
- package/dist/resources/docs.js +69 -46
- package/dist/resources/docs.js.map +1 -1
- package/dist/server.js +6 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/discovery/docs.js +7 -0
- package/dist/tools/discovery/docs.js.map +1 -1
- package/dist/tools/discovery/facilitator-walkthrough.js +44 -18
- package/dist/tools/discovery/facilitator-walkthrough.js.map +1 -1
- package/dist/tools/read/demo-state.js +7 -4
- package/dist/tools/read/demo-state.js.map +1 -1
- package/dist/trustgate/server/src/facilitators/README.md +241 -0
- package/package.json +2 -2
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paymentMiddleware` — Express middleware that gates a route behind
|
|
3
|
+
* Pay.sh / x402 + AgentTrust.
|
|
4
|
+
*
|
|
5
|
+
* Flow:
|
|
6
|
+
* 1. No `PAYMENT-SIGNATURE` / `X-PAYMENT` header → emit 402 with the x402
|
|
7
|
+
* v2 envelope (base64) advertising the SERVICE's PaymentRequirements
|
|
8
|
+
* 2. Header present → parse the proof, run AgentTrust pipeline:
|
|
9
|
+
* adapter.parseRequest → decide(ctx) → adapter.formatChallenge
|
|
10
|
+
* (Deny path) OR adapter.validatePaymentProof + adapter.emitFeedback
|
|
11
|
+
* (Allow path)
|
|
12
|
+
* 3. Allow → forward to the wrapped handler with `X-Payment-Receipt`
|
|
13
|
+
* header set to the feedback CPI signature
|
|
14
|
+
*
|
|
15
|
+
* The middleware is the demo's bridge from x402 wire to AgentTrust's
|
|
16
|
+
* adapter pipeline. In production with a real `simulateGatePayment`-backed
|
|
17
|
+
* `decide`, this same shape works for any AgentTrust-aware service.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { NextFunction, Request, RequestHandler, Response } from "express";
|
|
21
|
+
import { PublicKey } from "@solana/web3.js";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
ConfirmedSettlement,
|
|
25
|
+
GateDecision,
|
|
26
|
+
PaySh,
|
|
27
|
+
VerifyContext,
|
|
28
|
+
} from "@agenttrust/trustgate-server";
|
|
29
|
+
|
|
30
|
+
import { DemoOnChainStub, buildConfirmedSettlement } from "./deps";
|
|
31
|
+
import {
|
|
32
|
+
PaymentRequirementsBuilder,
|
|
33
|
+
buildPaymentRequirements,
|
|
34
|
+
encodeChallengeEnvelope,
|
|
35
|
+
} from "./x402";
|
|
36
|
+
|
|
37
|
+
export type DecideFn = (ctx: VerifyContext) => Promise<GateDecision>;
|
|
38
|
+
|
|
39
|
+
export interface PaymentMiddlewareOptions {
|
|
40
|
+
readonly adapter: PaySh;
|
|
41
|
+
readonly chainStub: DemoOnChainStub;
|
|
42
|
+
readonly decide: DecideFn;
|
|
43
|
+
readonly buildPaymentRequirements: PaymentRequirementsBuilder;
|
|
44
|
+
readonly facilitator: PublicKey;
|
|
45
|
+
readonly network: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PAY_SIG_HEADERS = ["payment-signature", "x-payment"];
|
|
49
|
+
|
|
50
|
+
export function paymentMiddleware(opts: PaymentMiddlewareOptions): RequestHandler {
|
|
51
|
+
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
52
|
+
const requirements = opts.buildPaymentRequirements(req);
|
|
53
|
+
|
|
54
|
+
const proof = readProofHeader(req);
|
|
55
|
+
if (!proof) {
|
|
56
|
+
sendChallenge(res, requirements);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let paymentPayload: unknown;
|
|
61
|
+
try {
|
|
62
|
+
paymentPayload = decodeProofHeader(proof);
|
|
63
|
+
} catch {
|
|
64
|
+
res.status(400).json({ error: "malformed_payment_proof" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ctx = await opts.adapter.parseRequest(synthesizeRequest({
|
|
69
|
+
paymentRequirements: requirements,
|
|
70
|
+
paymentPayload,
|
|
71
|
+
}));
|
|
72
|
+
if (!ctx) {
|
|
73
|
+
res.status(400).json({ error: "facilitator could not parse request" });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const decision = await opts.decide(ctx);
|
|
78
|
+
|
|
79
|
+
if (decision.kind !== "Allow") {
|
|
80
|
+
const challenge = opts.adapter.formatChallenge(decision, ctx);
|
|
81
|
+
applyHeaders(res, challenge.headers);
|
|
82
|
+
res.status(challenge.status).json(challenge.body ?? { decision });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Allow path — validate proof + emit feedback.
|
|
87
|
+
opts.chainStub.setHint({
|
|
88
|
+
payer: new PublicKey(extractPayerPubkey(paymentPayload) ?? ctx.payerAgent),
|
|
89
|
+
transferredAmount: ctx.amount,
|
|
90
|
+
transferredMint: ctx.mint,
|
|
91
|
+
transferRecipient: opts.adapter
|
|
92
|
+
? new PublicKey(requirements.extra.agentTrust.payeeRecipient)
|
|
93
|
+
: ctx.payeeAgent,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const validation = await opts.adapter.validatePaymentProof(paymentPayload, ctx);
|
|
97
|
+
if (!validation.valid) {
|
|
98
|
+
res.status(402).json({
|
|
99
|
+
error: validation.reason,
|
|
100
|
+
detail: validation.detail,
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const settlement: ConfirmedSettlement = buildConfirmedSettlement(
|
|
106
|
+
ctx,
|
|
107
|
+
validation.txSignature,
|
|
108
|
+
validation.payer,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
let feedbackSig: string;
|
|
112
|
+
try {
|
|
113
|
+
const fb = await opts.adapter.emitFeedback(ctx, settlement);
|
|
114
|
+
feedbackSig = fb.feedbackTxSignature;
|
|
115
|
+
} catch (e) {
|
|
116
|
+
res.status(500).json({
|
|
117
|
+
error: "feedback_emission_failed",
|
|
118
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const settleResp = opts.adapter.formatSettlement(ctx);
|
|
124
|
+
res.setHeader("X-Payment-Receipt", feedbackSig);
|
|
125
|
+
res.setHeader("X-Payment-Network", opts.network);
|
|
126
|
+
Object.entries(settleResp.facilitatorMeta).forEach(([k, v]) => {
|
|
127
|
+
if (typeof v === "string") res.setHeader(k, v);
|
|
128
|
+
});
|
|
129
|
+
next();
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// helpers
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function readProofHeader(req: Request): string | null {
|
|
138
|
+
for (const name of PAY_SIG_HEADERS) {
|
|
139
|
+
const v = req.header(name);
|
|
140
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function decodeProofHeader(value: string): unknown {
|
|
146
|
+
// Pay.sh's PAYMENT-SIGNATURE header is the base64-JSON PaymentPayload.
|
|
147
|
+
// X-PAYMENT (v1) is JSON. Try base64-decode first; fall back to plain JSON.
|
|
148
|
+
try {
|
|
149
|
+
const decoded = Buffer.from(value, "base64").toString("utf-8");
|
|
150
|
+
const json = JSON.parse(decoded);
|
|
151
|
+
if (json && typeof json === "object") return json;
|
|
152
|
+
} catch { /* fall through */ }
|
|
153
|
+
return JSON.parse(value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function extractPayerPubkey(paymentPayload: unknown): PublicKey | undefined {
|
|
157
|
+
if (!paymentPayload || typeof paymentPayload !== "object") return undefined;
|
|
158
|
+
const inner = (paymentPayload as Record<string, unknown>).payload;
|
|
159
|
+
if (!inner || typeof inner !== "object") return undefined;
|
|
160
|
+
const auth = (inner as Record<string, unknown>).authorization;
|
|
161
|
+
if (auth && typeof auth === "object") {
|
|
162
|
+
const from = (auth as Record<string, unknown>).from;
|
|
163
|
+
if (typeof from === "string") {
|
|
164
|
+
try { return new PublicKey(from); } catch { /* skip */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sendChallenge(res: Response, requirements: ReturnType<PaymentRequirementsBuilder>): void {
|
|
171
|
+
const envelope = { x402Version: 2 as const, accepts: [requirements] };
|
|
172
|
+
const b64 = encodeChallengeEnvelope(envelope);
|
|
173
|
+
res.setHeader("PAYMENT-REQUIRED", b64);
|
|
174
|
+
res.setHeader("X-Payment-Required", b64);
|
|
175
|
+
res.setHeader(
|
|
176
|
+
"Access-Control-Expose-Headers",
|
|
177
|
+
"PAYMENT-REQUIRED, X-Payment-Required, X-Payment-Receipt",
|
|
178
|
+
);
|
|
179
|
+
res.status(402).json({
|
|
180
|
+
error: "payment_required",
|
|
181
|
+
accepts: [requirements],
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function applyHeaders(
|
|
186
|
+
res: Response,
|
|
187
|
+
headers: Readonly<Record<string, string | readonly string[]>>,
|
|
188
|
+
): void {
|
|
189
|
+
Object.entries(headers).forEach(([k, v]) => {
|
|
190
|
+
res.setHeader(k, v as string | string[]);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function synthesizeRequest(body: unknown): Request {
|
|
195
|
+
return { body, header: () => undefined } as unknown as Request;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export { buildPaymentRequirements };
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo policy gate. Implements the `decide` function used by the
|
|
3
|
+
* payment middleware in lieu of an on-chain `gate_payment` simulation.
|
|
4
|
+
*
|
|
5
|
+
* Three counterparties at tiers 0 / 1 / 3 give us the three branches the
|
|
6
|
+
* brief's smoke test exercises:
|
|
7
|
+
*
|
|
8
|
+
* - tier 0 → Deny (CounterpartyTierBelowMin)
|
|
9
|
+
* - tier 1 → Deny (CounterpartyTierBelowMin)
|
|
10
|
+
* - tier 3 → Allow
|
|
11
|
+
*
|
|
12
|
+
* Two factories:
|
|
13
|
+
*
|
|
14
|
+
* - `makeTierDecide(table, minTier)` — static lookup against an in-
|
|
15
|
+
* memory table. Used by the mock-chain demo + CI tests.
|
|
16
|
+
* - `makeLiveTierDecide({ connection, resolveAtomStats, minTier, ... })` —
|
|
17
|
+
* Phase J4. Reads the live `tier_immediate` byte off Quantu's
|
|
18
|
+
* `AtomStats` PDA on every gate, with a 60s in-process cache so RPC
|
|
19
|
+
* load stays bounded under burst traffic. Falls back to the static
|
|
20
|
+
* `fallbackTable` if the on-chain read fails / account not found.
|
|
21
|
+
* Wired into `createRealDemoApp` (real-chain demo path).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Connection, PublicKey } from "@solana/web3.js";
|
|
25
|
+
|
|
26
|
+
import { GateDecision, VerifyContext } from "@agenttrust/trustgate-server";
|
|
27
|
+
|
|
28
|
+
export const DEMO_POLICY_MIN_TIER = 2;
|
|
29
|
+
|
|
30
|
+
export interface CounterpartyEntry {
|
|
31
|
+
readonly tier: number;
|
|
32
|
+
readonly label: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type CounterpartyTable = ReadonlyMap<string, CounterpartyEntry>;
|
|
36
|
+
|
|
37
|
+
const REASON_COUNTERPARTY_TIER_BELOW_MIN = {
|
|
38
|
+
code: 6,
|
|
39
|
+
name: "CounterpartyTierBelowMin",
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a `decide` function that resolves payerAgent → tier from a static
|
|
44
|
+
* map, compares against `minTier`, and returns Allow / Deny accordingly.
|
|
45
|
+
*
|
|
46
|
+
* Unknown payers map to "tier 0" (treated as Deny when minTier > 0). This
|
|
47
|
+
* matches the on-chain `default_unrated_treatment = UNRATED_DENY` behavior
|
|
48
|
+
* for the demo policy. Switch to an alternative resolver to change this.
|
|
49
|
+
*/
|
|
50
|
+
export function makeTierDecide(
|
|
51
|
+
table: CounterpartyTable,
|
|
52
|
+
minTier: number,
|
|
53
|
+
): (ctx: VerifyContext) => Promise<GateDecision> {
|
|
54
|
+
return async (ctx) => {
|
|
55
|
+
const payerB58 = ctx.payerAgent.toBase58();
|
|
56
|
+
const entry = table.get(payerB58);
|
|
57
|
+
const tier = entry?.tier ?? 0;
|
|
58
|
+
|
|
59
|
+
if (tier >= minTier) {
|
|
60
|
+
return { kind: "Allow" };
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
kind: "Deny",
|
|
64
|
+
reasonCode: REASON_COUNTERPARTY_TIER_BELOW_MIN.code,
|
|
65
|
+
reasonName: REASON_COUNTERPARTY_TIER_BELOW_MIN.name,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function lookupCounterparty(
|
|
71
|
+
table: CounterpartyTable,
|
|
72
|
+
pubkey: PublicKey,
|
|
73
|
+
): CounterpartyEntry | undefined {
|
|
74
|
+
return table.get(pubkey.toBase58());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ===========================================================================
|
|
78
|
+
// Phase J4 — live-tier sync against Quantu AtomStats
|
|
79
|
+
// ===========================================================================
|
|
80
|
+
|
|
81
|
+
// Pinned to the same commit `programs/policy-vault/src/ext/atom_engine.rs`
|
|
82
|
+
// reads on chain. Schema-version canary + tier-range checks prevent silent
|
|
83
|
+
// drift if Quantu re-shapes the account.
|
|
84
|
+
export const ATOM_STATS_SIZE = 561;
|
|
85
|
+
export const ATOM_STATS_TRUST_TIER_OFFSET = 551;
|
|
86
|
+
export const ATOM_STATS_SCHEMA_VERSION_OFFSET = 560;
|
|
87
|
+
export const ATOM_STATS_SCHEMA_VERSION_EXPECTED = 1;
|
|
88
|
+
export const ATOM_TIER_MAX = 4;
|
|
89
|
+
|
|
90
|
+
/** Default cache TTL — 60 seconds matches the brief and matches the
|
|
91
|
+
* facilitator's typical inter-payment interval; long enough to amortise
|
|
92
|
+
* RPC reads across a burst, short enough that a tier upgrade lands
|
|
93
|
+
* before it stales. */
|
|
94
|
+
export const DEFAULT_LIVE_TIER_TTL_MS = 60_000;
|
|
95
|
+
|
|
96
|
+
export interface CachedTier {
|
|
97
|
+
readonly tier: number;
|
|
98
|
+
readonly source: "atom-stats" | "fallback-table" | "unrated";
|
|
99
|
+
readonly fetchedAtMs: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface LiveTierCache {
|
|
103
|
+
get(payerB58: string): CachedTier | undefined;
|
|
104
|
+
set(payerB58: string, entry: CachedTier): void;
|
|
105
|
+
clear(): void;
|
|
106
|
+
size(): number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Default in-process Map-backed cache. Drop-in replaceable via the
|
|
110
|
+
* `cache` arg of `makeLiveTierDecide` for distributed setups. */
|
|
111
|
+
export function makeInProcessLiveTierCache(): LiveTierCache {
|
|
112
|
+
const m = new Map<string, CachedTier>();
|
|
113
|
+
return {
|
|
114
|
+
get: (k) => m.get(k),
|
|
115
|
+
set: (k, v) => { m.set(k, v); },
|
|
116
|
+
clear: () => { m.clear(); },
|
|
117
|
+
size: () => m.size,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface MakeLiveTierDecideArgs {
|
|
122
|
+
readonly connection: Connection;
|
|
123
|
+
/** Map a payer agent_account pubkey to its `atom_stats` PDA. Returning
|
|
124
|
+
* `null` short-circuits to the fallback table. The demo wires this to
|
|
125
|
+
* the bundled `devnet-counterparties.json` lookup; real facilitators
|
|
126
|
+
* derive via `deriveAtomStatsPda` from the SDK. */
|
|
127
|
+
readonly resolveAtomStats: (payerAgent: PublicKey) => PublicKey | null;
|
|
128
|
+
readonly atomEngineId: PublicKey;
|
|
129
|
+
readonly minTier: number;
|
|
130
|
+
readonly ttlMs?: number;
|
|
131
|
+
readonly fallbackTable?: CounterpartyTable;
|
|
132
|
+
readonly cache?: LiveTierCache;
|
|
133
|
+
/** Lets tests pin the clock without monkey-patching `Date.now`. */
|
|
134
|
+
readonly nowMs?: () => number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Live-tier `decide` factory.
|
|
139
|
+
*
|
|
140
|
+
* 1. Resolve the payer's atom_stats PDA. If the resolver returns null,
|
|
141
|
+
* the fallback static table (if any) is consulted; otherwise tier 0.
|
|
142
|
+
* 2. If the cache has a non-expired entry, return it.
|
|
143
|
+
* 3. Else `getAccountInfo(atom_stats)`, validate owner + size + schema
|
|
144
|
+
* version, then read `tier_immediate` at byte 551.
|
|
145
|
+
* 4. On any failure (account missing, owner mismatch, schema drift),
|
|
146
|
+
* consult the fallback table; if that's also empty, treat as tier 0.
|
|
147
|
+
* 5. Cache the result with `expiresAt = now + ttlMs`.
|
|
148
|
+
*
|
|
149
|
+
* Returns Allow when tier >= minTier; otherwise Deny with reasonCode
|
|
150
|
+
* `CounterpartyTierBelowMin = 6` (matches the chain enum).
|
|
151
|
+
*/
|
|
152
|
+
export function makeLiveTierDecide(
|
|
153
|
+
args: MakeLiveTierDecideArgs,
|
|
154
|
+
): (ctx: VerifyContext) => Promise<GateDecision> {
|
|
155
|
+
const ttlMs = args.ttlMs ?? DEFAULT_LIVE_TIER_TTL_MS;
|
|
156
|
+
const cache = args.cache ?? makeInProcessLiveTierCache();
|
|
157
|
+
const nowMs = args.nowMs ?? (() => Date.now());
|
|
158
|
+
|
|
159
|
+
return async (ctx) => {
|
|
160
|
+
const tier = await resolveLiveTier({
|
|
161
|
+
connection: args.connection,
|
|
162
|
+
atomEngineId: args.atomEngineId,
|
|
163
|
+
resolveAtomStats: args.resolveAtomStats,
|
|
164
|
+
fallbackTable: args.fallbackTable,
|
|
165
|
+
cache,
|
|
166
|
+
ttlMs,
|
|
167
|
+
nowMs,
|
|
168
|
+
payerAgent: ctx.payerAgent,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (tier >= args.minTier) {
|
|
172
|
+
return { kind: "Allow" };
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
kind: "Deny",
|
|
176
|
+
reasonCode: REASON_COUNTERPARTY_TIER_BELOW_MIN.code,
|
|
177
|
+
reasonName: REASON_COUNTERPARTY_TIER_BELOW_MIN.name,
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface ResolveLiveTierArgs {
|
|
183
|
+
connection: Connection;
|
|
184
|
+
atomEngineId: PublicKey;
|
|
185
|
+
resolveAtomStats: (payerAgent: PublicKey) => PublicKey | null;
|
|
186
|
+
fallbackTable?: CounterpartyTable;
|
|
187
|
+
cache: LiveTierCache;
|
|
188
|
+
ttlMs: number;
|
|
189
|
+
nowMs: () => number;
|
|
190
|
+
payerAgent: PublicKey;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function resolveLiveTier(a: ResolveLiveTierArgs): Promise<number> {
|
|
194
|
+
const payerB58 = a.payerAgent.toBase58();
|
|
195
|
+
|
|
196
|
+
const cached = a.cache.get(payerB58);
|
|
197
|
+
if (cached && a.nowMs() - cached.fetchedAtMs < a.ttlMs) {
|
|
198
|
+
return cached.tier;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const atomStats = a.resolveAtomStats(a.payerAgent);
|
|
202
|
+
if (atomStats) {
|
|
203
|
+
try {
|
|
204
|
+
const info = await a.connection.getAccountInfo(atomStats, "confirmed");
|
|
205
|
+
const tier = info ? readTierImmediateFromAtomStats(info, a.atomEngineId) : null;
|
|
206
|
+
if (tier !== null) {
|
|
207
|
+
a.cache.set(payerB58, { tier, source: "atom-stats", fetchedAtMs: a.nowMs() });
|
|
208
|
+
return tier;
|
|
209
|
+
}
|
|
210
|
+
} catch (_) {
|
|
211
|
+
// Network errors fall through to fallback; we don't want a transient
|
|
212
|
+
// RPC blip to flip every payer to Deny.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const fb = a.fallbackTable?.get(payerB58)?.tier ?? 0;
|
|
217
|
+
a.cache.set(payerB58, {
|
|
218
|
+
tier: fb,
|
|
219
|
+
source: a.fallbackTable?.has(payerB58) ? "fallback-table" : "unrated",
|
|
220
|
+
fetchedAtMs: a.nowMs(),
|
|
221
|
+
});
|
|
222
|
+
return fb;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Decode `tier_immediate` from an AtomStats account. Returns `null` when
|
|
227
|
+
* the account is uninitialised, has the wrong owner, or has drifted schema.
|
|
228
|
+
*
|
|
229
|
+
* Mirrors `programs/policy-vault/src/ext/atom_engine.rs::parse_atom_stats_bytes`
|
|
230
|
+
* verbatim (size + schema version + tier-range checks). Importantly the
|
|
231
|
+
* v1 demo reads `tier_immediate` (byte 551) — `tier_confirmed` (byte 555)
|
|
232
|
+
* is the post-vesting production value tracked separately.
|
|
233
|
+
*/
|
|
234
|
+
export function readTierImmediateFromAtomStats(
|
|
235
|
+
info: { data: Buffer | Uint8Array; owner: PublicKey },
|
|
236
|
+
atomEngineId: PublicKey,
|
|
237
|
+
): number | null {
|
|
238
|
+
if (!info.owner.equals(atomEngineId)) return null;
|
|
239
|
+
|
|
240
|
+
const data = info.data instanceof Buffer ? info.data : Buffer.from(info.data);
|
|
241
|
+
if (data.length < ATOM_STATS_SIZE) return null;
|
|
242
|
+
if (data[ATOM_STATS_SCHEMA_VERSION_OFFSET] !== ATOM_STATS_SCHEMA_VERSION_EXPECTED) return null;
|
|
243
|
+
|
|
244
|
+
const tier = data[ATOM_STATS_TRUST_TIER_OFFSET];
|
|
245
|
+
if (tier > ATOM_TIER_MAX) return null;
|
|
246
|
+
return tier;
|
|
247
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 v2 wire-format helpers — challenge envelope encoder + the
|
|
3
|
+
* `PaymentRequirements` builder shape the demo middleware uses.
|
|
4
|
+
*
|
|
5
|
+
* Implements the spec from `docs/plan/research/05-trustgate-x402-class.md`
|
|
6
|
+
* §A.4 (PaymentRequirements) + §A.5 (PaymentPayload). The demo emits v2;
|
|
7
|
+
* the `X-Payment-Required` header alias is set for v1 fallback compat.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Request } from "express";
|
|
11
|
+
|
|
12
|
+
export interface AgentTrustExtraInput {
|
|
13
|
+
readonly payerAgentAsset: string;
|
|
14
|
+
readonly payeeAgentAsset: string;
|
|
15
|
+
readonly payeeRecipient: string;
|
|
16
|
+
readonly policyId: number;
|
|
17
|
+
readonly issuedAt: number;
|
|
18
|
+
readonly serviceSignature: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PaymentRequirementsExtraInput {
|
|
22
|
+
readonly feePayer: string;
|
|
23
|
+
readonly memo: string;
|
|
24
|
+
readonly agentTrust: AgentTrustExtraInput;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PaymentRequirementsInput {
|
|
28
|
+
readonly scheme: "exact";
|
|
29
|
+
readonly network: string;
|
|
30
|
+
readonly maxAmountRequired: string;
|
|
31
|
+
readonly asset: string;
|
|
32
|
+
readonly payTo: string;
|
|
33
|
+
readonly resource: string;
|
|
34
|
+
readonly description?: string;
|
|
35
|
+
readonly maxTimeoutSeconds: number;
|
|
36
|
+
readonly extra: PaymentRequirementsExtraInput;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ChallengeEnvelope {
|
|
40
|
+
readonly x402Version: 2;
|
|
41
|
+
readonly accepts: ReadonlyArray<PaymentRequirementsInput>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type PaymentRequirementsBuilder = (req: Request) => PaymentRequirementsInput;
|
|
45
|
+
|
|
46
|
+
/** Per-challenge ed25519 signer the demo wires to the facilitator
|
|
47
|
+
* keypair. Signs canonical envelope bytes (B5). */
|
|
48
|
+
export type SignChallengeFn = (canonicalBytes: Uint8Array) => Uint8Array;
|
|
49
|
+
|
|
50
|
+
/** Per-request canonical-bytes builder — produced and used inside
|
|
51
|
+
* `buildPaymentRequirements` only. Exposed for the test path that
|
|
52
|
+
* builds requirements from outside the middleware. */
|
|
53
|
+
export interface AgentTrustHints {
|
|
54
|
+
readonly payerAgentAsset: string;
|
|
55
|
+
readonly payeeAgentAsset: string;
|
|
56
|
+
readonly payeeRecipient: string;
|
|
57
|
+
readonly policyId: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a PaymentRequirements builder. Captures static fields once and
|
|
62
|
+
* supplies per-request `memo` + `agentTrust` hints (so the signature
|
|
63
|
+
* binds to the correct payerAgentAsset for THIS request).
|
|
64
|
+
*
|
|
65
|
+
* `signChallenge` is invoked on every emission to bind the requirements
|
|
66
|
+
* to the facilitator's identity (B5).
|
|
67
|
+
* `canonicalBytesOf` derives the signing payload — pass the same fn the
|
|
68
|
+
* AgentTrust adapter uses to keep both sides byte-compatible.
|
|
69
|
+
*/
|
|
70
|
+
export function buildPaymentRequirements(args: {
|
|
71
|
+
scheme: "exact";
|
|
72
|
+
network: string;
|
|
73
|
+
amount: bigint;
|
|
74
|
+
asset: string;
|
|
75
|
+
payTo: string;
|
|
76
|
+
resource: string;
|
|
77
|
+
description?: string;
|
|
78
|
+
maxTimeoutSeconds: number;
|
|
79
|
+
feePayer: string;
|
|
80
|
+
agentTrustFor: (req: Request) => AgentTrustHints;
|
|
81
|
+
memoFor: (req: Request) => string;
|
|
82
|
+
signChallenge: SignChallengeFn;
|
|
83
|
+
canonicalBytesOf: (input: {
|
|
84
|
+
issuedAt: number;
|
|
85
|
+
network: string;
|
|
86
|
+
amount: bigint;
|
|
87
|
+
asset: string;
|
|
88
|
+
payTo: string;
|
|
89
|
+
payerAgentAsset: string;
|
|
90
|
+
payeeAgentAsset: string;
|
|
91
|
+
payeeRecipient: string;
|
|
92
|
+
policyId: number;
|
|
93
|
+
paymentIdHashHex: string;
|
|
94
|
+
}) => Uint8Array;
|
|
95
|
+
bytesToHex: (bytes: Uint8Array) => string;
|
|
96
|
+
paymentIdHashHexFor: (memo: string) => string;
|
|
97
|
+
}): PaymentRequirementsBuilder {
|
|
98
|
+
return (req) => {
|
|
99
|
+
const memo = args.memoFor(req);
|
|
100
|
+
const agentTrust = args.agentTrustFor(req);
|
|
101
|
+
const issuedAt = Date.now();
|
|
102
|
+
const paymentIdHashHex = args.paymentIdHashHexFor(memo);
|
|
103
|
+
const canonical = args.canonicalBytesOf({
|
|
104
|
+
issuedAt,
|
|
105
|
+
network: args.network,
|
|
106
|
+
amount: args.amount,
|
|
107
|
+
asset: args.asset,
|
|
108
|
+
payTo: args.payTo,
|
|
109
|
+
payerAgentAsset: agentTrust.payerAgentAsset,
|
|
110
|
+
payeeAgentAsset: agentTrust.payeeAgentAsset,
|
|
111
|
+
payeeRecipient: agentTrust.payeeRecipient,
|
|
112
|
+
policyId: agentTrust.policyId,
|
|
113
|
+
paymentIdHashHex,
|
|
114
|
+
});
|
|
115
|
+
const sig = args.signChallenge(canonical);
|
|
116
|
+
return {
|
|
117
|
+
scheme: args.scheme,
|
|
118
|
+
network: args.network,
|
|
119
|
+
maxAmountRequired: args.amount.toString(),
|
|
120
|
+
asset: args.asset,
|
|
121
|
+
payTo: args.payTo,
|
|
122
|
+
resource: args.resource,
|
|
123
|
+
description: args.description,
|
|
124
|
+
maxTimeoutSeconds: args.maxTimeoutSeconds,
|
|
125
|
+
extra: {
|
|
126
|
+
feePayer: args.feePayer,
|
|
127
|
+
memo,
|
|
128
|
+
agentTrust: {
|
|
129
|
+
...agentTrust,
|
|
130
|
+
issuedAt,
|
|
131
|
+
serviceSignature: args.bytesToHex(sig),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function encodeChallengeEnvelope(envelope: ChallengeEnvelope): string {
|
|
139
|
+
return Buffer.from(JSON.stringify(envelope), "utf-8").toString("base64");
|
|
140
|
+
}
|