402mob-merchant-sdk 0.4.2 → 0.6.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/CHANGELOG.md +24 -1
- package/README.md +53 -2
- package/dist/index.d.ts +264 -1
- package/dist/index.js +239 -0
- package/dist/protected-inspection-schemas.d.ts +2 -0
- package/dist/protected-inspection-schemas.js +3117 -0
- package/dist/protected-inspection-types.d.ts +623 -0
- package/dist/protected-inspection-types.js +1 -0
- package/dist/protected-inspection.d.ts +42 -0
- package/dist/protected-inspection.js +197 -0
- package/dist/protected-work-schemas.d.ts +2 -0
- package/dist/protected-work-schemas.js +2558 -0
- package/dist/protected-work-types.d.ts +524 -0
- package/dist/protected-work-types.js +1 -0
- package/dist/protected-work.d.ts +40 -0
- package/dist/protected-work.js +187 -0
- package/examples/protected-inspection-service.ts +31 -0
- package/examples/protected-work.ts +21 -0
- package/examples/respond-to-work-request.ts +21 -0
- package/package.json +1 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { protectedSchemas } from './protected-work-schemas.js';
|
|
2
|
+
export * from './protected-work-types.js';
|
|
3
|
+
const USDC = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913';
|
|
4
|
+
const hash = /^0x[0-9a-f]{64}$/;
|
|
5
|
+
const address = /^0x(?!0{40}$)[0-9a-fA-F]{40}$/;
|
|
6
|
+
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
7
|
+
const canonical = (v) => JSON.stringify(v, (_key, item) => record(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : item);
|
|
8
|
+
function matches(s, v, depth = 0) {
|
|
9
|
+
if (depth > 128)
|
|
10
|
+
return false;
|
|
11
|
+
if ('const' in s && canonical(s.const) !== canonical(v))
|
|
12
|
+
return false;
|
|
13
|
+
if (s.enum && !s.enum.includes(v))
|
|
14
|
+
return false;
|
|
15
|
+
if (s.anyOf && !s.anyOf.some(c => matches(c, v, depth + 1)))
|
|
16
|
+
return false;
|
|
17
|
+
if (s.oneOf && s.oneOf.filter(c => matches(c, v, depth + 1)).length !== 1)
|
|
18
|
+
return false;
|
|
19
|
+
if (Array.isArray(s.type))
|
|
20
|
+
return s.type.some(type => matches({ ...s, type }, v, depth + 1));
|
|
21
|
+
switch (s.type) {
|
|
22
|
+
case 'null': return v === null;
|
|
23
|
+
case 'boolean': return typeof v === 'boolean';
|
|
24
|
+
case 'number':
|
|
25
|
+
case 'integer': return typeof v === 'number' && Number.isFinite(v) && (s.type !== 'integer' || Number.isSafeInteger(v)) && (s.minimum === undefined || v >= s.minimum) && (s.maximum === undefined || v <= s.maximum) && (s.exclusiveMinimum === undefined || v > s.exclusiveMinimum);
|
|
26
|
+
case 'string': return typeof v === 'string' && (s.minLength === undefined || [...v].length >= s.minLength) && (s.maxLength === undefined || [...v].length <= s.maxLength) && (!s.pattern || new RegExp(s.pattern).test(v)) && (!s.format || s.format === 'date-time' && /^\d{4}-\d\d-\d\dT/.test(v) && Number.isFinite(Date.parse(v)));
|
|
27
|
+
case 'array': return Array.isArray(v) && (s.minItems === undefined || v.length >= s.minItems) && (s.maxItems === undefined || v.length <= s.maxItems) && v.every((item, i) => { const child = s.prefixItems?.[i] ?? s.items ?? {}; return child !== false && (child === true || matches(child, item, depth + 1)); });
|
|
28
|
+
case 'object': return record(v) && (s.required ?? []).every(k => Object.hasOwn(v, k)) && Object.entries(v).every(([k, item]) => s.properties && Object.hasOwn(s.properties, k) ? matches(s.properties[k], item, depth + 1) : s.additionalProperties !== false);
|
|
29
|
+
default: return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class ProtectedWorkApiError extends Error {
|
|
33
|
+
status;
|
|
34
|
+
code;
|
|
35
|
+
constructor(status, code, message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.status = status;
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.name = 'ProtectedWorkApiError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** HTTP transport only. No keys, merchant bearer tokens, wallet signing or transaction broadcasting. */
|
|
43
|
+
export class ProtectedWorkClient {
|
|
44
|
+
origin;
|
|
45
|
+
contract;
|
|
46
|
+
verifierHash;
|
|
47
|
+
fetcher;
|
|
48
|
+
constructor(options = {}) {
|
|
49
|
+
const url = new URL(options.baseUrl ?? 'https://evidence.contact');
|
|
50
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || !(url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
|
|
51
|
+
throw new TypeError('Provide an exact HTTPS origin, or HTTP loopback for local tests.');
|
|
52
|
+
if (options.contract !== undefined && !address.test(options.contract))
|
|
53
|
+
throw new TypeError('Invalid escrow contract address.');
|
|
54
|
+
if (options.verifierHash !== undefined && !hash.test(options.verifierHash))
|
|
55
|
+
throw new TypeError('Invalid pinned verifier hash.');
|
|
56
|
+
this.origin = url.origin;
|
|
57
|
+
this.contract = options.contract?.toLowerCase();
|
|
58
|
+
this.verifierHash = options.verifierHash;
|
|
59
|
+
this.fetcher = options.fetch ?? globalThis.fetch;
|
|
60
|
+
}
|
|
61
|
+
getCapabilities() { return this.call('', 'GET', undefined, undefined, 'ProtectedWorkCapability'); }
|
|
62
|
+
prepare(input) { return this.call('/prepare', 'POST', input, 'ProtectedWorkPrepareRequest', 'ProtectedWorkPrepareResponse'); }
|
|
63
|
+
prepareFunding(input) { return this.call('/funding', 'POST', input, 'ProtectedWorkFundingRequest', 'ProtectedWorkFundingResponse'); }
|
|
64
|
+
getJob(id) { return this.job(id, '', 'GET', undefined, undefined, 'ProtectedWorkStatus'); }
|
|
65
|
+
createReadChallenge(id, input) { return this.job(id, '/challenges', 'POST', input, 'ProtectedWorkChallengeRequest', 'ProtectedWorkChallenge'); }
|
|
66
|
+
createCandidateSigning(id, input) { return this.job(id, '/candidate-signing', 'POST', input, 'ProtectedWorkCandidateRequest', 'ProtectedWorkCandidateSigning'); }
|
|
67
|
+
submitCandidate(id, input) { return this.job(id, '/submissions', 'POST', input, 'ProtectedWorkSubmissionRequest', 'ProtectedWorkSubmissionResponse'); }
|
|
68
|
+
attachReplicas(id, input) { return this.job(id, '/replicas', 'POST', input, 'ProtectedWorkReplicasRequest', 'ProtectedWorkReplicasResponse'); }
|
|
69
|
+
evaluate(id) { return this.job(id, '/evaluation', 'POST', {}, 'ProtectedWorkEvaluationRequest', 'ProtectedWorkEvaluationResponse'); }
|
|
70
|
+
readResult(id, input) { return this.job(id, '/result', 'POST', input, 'ProtectedWorkResultRequest', 'ProtectedWorkResult'); }
|
|
71
|
+
/** With no signature, requests a draft. A signed action may be relayed by a configured server. */
|
|
72
|
+
requestAction(id, input) { return this.job(id, '/actions', 'POST', input, 'ProtectedWorkActionRequest', 'ProtectedWorkActionResponse'); }
|
|
73
|
+
submitAppeal(id, input) { return this.job(id, '/appeals', 'POST', input, 'ProtectedWorkAppealRequest', 'ProtectedWorkAppealResponse'); }
|
|
74
|
+
job(id, suffix, method, input, inputSchema, outputSchema) {
|
|
75
|
+
if (!hash.test(id))
|
|
76
|
+
throw new TypeError('Protected job IDs are lowercase 32-byte hashes, not operation UUIDs.');
|
|
77
|
+
return this.call('/' + id + suffix, method, input, inputSchema, outputSchema, id);
|
|
78
|
+
}
|
|
79
|
+
invalid() { throw new ProtectedWorkApiError(200, 'invalid_response', 'Protected-work response does not match the pinned request. Preserve the job and signed transaction; do not repeat funding.'); }
|
|
80
|
+
transaction(value, allowApproval = false, amount) {
|
|
81
|
+
if (!record(value) || value.chainId !== 8453 || value.value !== '0' || typeof value.to !== 'string' || typeof value.data !== 'string' || !/^0x(?:[a-fA-F0-9]{2})+$/.test(value.data))
|
|
82
|
+
this.invalid();
|
|
83
|
+
if (allowApproval) {
|
|
84
|
+
if (value.to.toLowerCase() !== USDC || !amount || value.data.toLowerCase() !== '0x095ea7b3' + this.contract.slice(2).padStart(64, '0') + BigInt(amount).toString(16).padStart(64, '0'))
|
|
85
|
+
this.invalid();
|
|
86
|
+
}
|
|
87
|
+
else if (value.to.toLowerCase() !== this.contract)
|
|
88
|
+
this.invalid();
|
|
89
|
+
}
|
|
90
|
+
async call(suffix, method, input, inputSchema, outputSchema, id) {
|
|
91
|
+
if (suffix && (!this.contract || !this.verifierHash))
|
|
92
|
+
throw new TypeError('Pin contract and verifierHash before using protected-work methods.');
|
|
93
|
+
const body = input === undefined ? undefined : JSON.stringify(input);
|
|
94
|
+
if (body !== undefined && (new TextEncoder().encode(body).length > 65536 || inputSchema && !matches(protectedSchemas[inputSchema], JSON.parse(body))))
|
|
95
|
+
throw new TypeError('Protected-work input does not match its schema or exceeds 64 KiB.');
|
|
96
|
+
if (record(input) && record(input.terms) && input.terms.verifierHash !== undefined && input.terms.verifierHash !== this.verifierHash)
|
|
97
|
+
throw new TypeError('Funding verifier differs from the pinned verifier.');
|
|
98
|
+
const response = await this.fetcher(this.origin + '/v1/protected-work' + suffix, { method, redirect: 'error', credentials: 'omit', headers: { Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, ...(body === undefined ? {} : { body }) });
|
|
99
|
+
let payload;
|
|
100
|
+
try {
|
|
101
|
+
if (!response.body)
|
|
102
|
+
throw new Error();
|
|
103
|
+
const reader = response.body.getReader(), chunks = [];
|
|
104
|
+
let size = 0;
|
|
105
|
+
try {
|
|
106
|
+
while (true) {
|
|
107
|
+
const item = await reader.read();
|
|
108
|
+
if (item.done)
|
|
109
|
+
break;
|
|
110
|
+
size += item.value.length;
|
|
111
|
+
if (size > 65536) {
|
|
112
|
+
await reader.cancel();
|
|
113
|
+
throw new Error();
|
|
114
|
+
}
|
|
115
|
+
chunks.push(item.value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
reader.releaseLock();
|
|
120
|
+
}
|
|
121
|
+
const bytes = new Uint8Array(size);
|
|
122
|
+
let offset = 0;
|
|
123
|
+
for (const chunk of chunks) {
|
|
124
|
+
bytes.set(chunk, offset);
|
|
125
|
+
offset += chunk.length;
|
|
126
|
+
}
|
|
127
|
+
payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new ProtectedWorkApiError(response.status, response.ok ? 'invalid_response' : 'http_error', 'Unreadable or oversized response; preserve recovery data.');
|
|
131
|
+
}
|
|
132
|
+
if (!response.ok)
|
|
133
|
+
throw new ProtectedWorkApiError(response.status, record(payload) && typeof payload.error === 'string' ? payload.error : 'http_error', 'Protected-work request was rejected. Inspect status/code and the existing job before retrying.');
|
|
134
|
+
if (!matches(protectedSchemas[outputSchema], payload) || !record(payload))
|
|
135
|
+
this.invalid();
|
|
136
|
+
if (id && 'jobId' in payload && payload.jobId !== id)
|
|
137
|
+
this.invalid();
|
|
138
|
+
if (record(payload.relay) && payload.relay.queued !== (payload.relay.id !== null))
|
|
139
|
+
this.invalid();
|
|
140
|
+
if (suffix === '') {
|
|
141
|
+
if (payload.protocol !== '402mob.protected-json-work.v3' || payload.network !== 'eip155:8453' || (typeof payload.asset !== 'string' || payload.asset.toLowerCase() !== USDC) || this.verifierHash && payload.verifierHash !== this.verifierHash)
|
|
142
|
+
this.invalid();
|
|
143
|
+
}
|
|
144
|
+
if (record(payload.terms) && payload.terms.verifierHash !== this.verifierHash)
|
|
145
|
+
this.invalid();
|
|
146
|
+
for (const key of ['transaction', 'submissionTransaction', 'evaluationTransaction', 'refundTransaction', 'finalizeTransaction'])
|
|
147
|
+
if (payload[key] !== undefined && payload[key] !== null)
|
|
148
|
+
this.transaction(payload[key]);
|
|
149
|
+
if (Array.isArray(payload.transactions)) {
|
|
150
|
+
const amount = record(input) && record(input.terms) ? String(input.terms.amount) : undefined;
|
|
151
|
+
payload.transactions.forEach((tx, i) => this.transaction(tx, i === 0, amount));
|
|
152
|
+
}
|
|
153
|
+
for (const key of ['signing', 'sellerSigning'])
|
|
154
|
+
if (payload[key] !== undefined && payload[key] !== null) {
|
|
155
|
+
const signing = payload[key];
|
|
156
|
+
if (!record(signing) || !record(signing.domain) || !record(signing.message))
|
|
157
|
+
this.invalid();
|
|
158
|
+
const d = signing.domain;
|
|
159
|
+
if (d.name !== '402Mob Protected Work' || d.version !== '3' || d.chainId !== 8453 || typeof d.verifyingContract !== 'string' || d.verifyingContract.toLowerCase() !== this.contract)
|
|
160
|
+
this.invalid();
|
|
161
|
+
if (id && signing.message.jobId !== id)
|
|
162
|
+
this.invalid();
|
|
163
|
+
if (suffix.endsWith('/candidate-signing') && record(input) && (signing.primaryType !== 'Candidate' || signing.message.attempt !== input.attempt || signing.message.resultHash !== input.resultHash))
|
|
164
|
+
this.invalid();
|
|
165
|
+
if (suffix.endsWith('/actions') && record(input) && signing.primaryType !== { receipt: 'Receipt', accept: 'Accept', challenge: 'Challenge' }[String(input.action)])
|
|
166
|
+
this.invalid();
|
|
167
|
+
}
|
|
168
|
+
if (suffix.endsWith('/actions') && record(input)) {
|
|
169
|
+
const draft = input.signature === undefined && ['receipt', 'accept', 'challenge'].includes(String(input.action));
|
|
170
|
+
if (draft ? (payload.signing === null || payload.transaction !== null) : (payload.signing !== null || payload.transaction === null))
|
|
171
|
+
this.invalid();
|
|
172
|
+
}
|
|
173
|
+
if (suffix === '/prepare') {
|
|
174
|
+
if (!record(input) || !record(input.terms) || !record(payload.sellerSigning) || canonical(payload.terms) !== canonical({ ...input.terms, workHash: payload.workHash, verifierHash: this.verifierHash }) || canonical(payload.sellerSigning.message) !== canonical(payload.terms))
|
|
175
|
+
this.invalid();
|
|
176
|
+
}
|
|
177
|
+
if (suffix.endsWith('/challenges')) {
|
|
178
|
+
if (!record(input) || typeof input.wallet !== 'string' || typeof payload.expiresAt !== 'number' || payload.wallet !== input.wallet || payload.action !== 'read')
|
|
179
|
+
this.invalid();
|
|
180
|
+
const now = Math.floor(Date.now() / 1000);
|
|
181
|
+
const expected = '402Mob protected JSON work read proof\n' + canonical({ protocol: '402mob.protected-json-work.v3', origin: this.origin, contract: this.contract, chainId: 8453, jobId: id, action: 'read', wallet: input.wallet.toLowerCase(), expiresAt: payload.expiresAt });
|
|
182
|
+
if (payload.message !== expected || payload.expiresAt <= now || payload.expiresAt > now + 120)
|
|
183
|
+
this.invalid();
|
|
184
|
+
}
|
|
185
|
+
return payload;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {MerchantClient,type WorkerProfileInput,type FreshWorkProposalInput,type EvmAddress} from '../dist/index.js';
|
|
2
|
+
|
|
3
|
+
// Source SDK 0.6.0 example. No side effects on import and no signing or payment.
|
|
4
|
+
export const inspectionCapabilityId='payment-readiness-inspection';
|
|
5
|
+
export const inspectionWorkerProfile:WorkerProfileInput={
|
|
6
|
+
description:'Inspect buyer-signed endpoint observations and verify claimed Base USDC test payments under the protected inspection policy.',
|
|
7
|
+
availability:'accepting',discoverable:true,
|
|
8
|
+
capabilities:[{id:inspectionCapabilityId,title:'Payment-readiness evidence inspection',description:'Classify one bounded buyer probe. Negative and unresolved findings can be valid delivery. No independent HTTP truth or general audit certification.',inputDescription:'Selected protected_inspection proposal, confirmed service-fee escrow and retained buyer-signed observation. The buyer runs and funds any endpoint test.',resultDescription:'inspection-result-v1 JSON: observation, buyerSignature and fixed classification. Passing evidence is subject to escrow review and appeal.',resultContentTypes:['application/json']}]
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Review real capacity and discovery consent first. Profile PUT fully replaces
|
|
12
|
+
// existing capabilities; merge this declaration with any you want to preserve.
|
|
13
|
+
export function advertiseInspection(merchant:MerchantClient,reviewedProfile:WorkerProfileInput){
|
|
14
|
+
return merchant.setWorkerProfile(reviewedProfile);
|
|
15
|
+
}
|
|
16
|
+
export function findInspectionSellers(buyer:MerchantClient){
|
|
17
|
+
return buyer.searchWorkers({capability:inspectionCapabilityId,contentType:'application/json',limit:20});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Check GET /v1/protected-inspections and the deployment record first. Inputs
|
|
21
|
+
// come from actual authorized demand, not from this example. Prices are chosen
|
|
22
|
+
// by the seller; current pilot capacity is at most 1 USDC unsettled in total.
|
|
23
|
+
export function proposeInspection(merchant:MerchantClient,input:{requestId:string;targetUrl:string;paymentRecipient:EvmAddress;deliveryDeadline:string;servicePriceUSDC:string;testBudgetUSDC:string;testBudgetAtomic:string}){
|
|
24
|
+
const proposal:FreshWorkProposalInput={capability:inspectionCapabilityId,deliverable:'Verify the buyer-signed observation and any claimed Base USDC test transfer; return the fixed classification.',priceUSDC:input.servicePriceUSDC,deliveryDeadline:input.deliveryDeadline,testPurchaseBudgetUSDC:input.testBudgetUSDC,testPurchasePayer:'buyer',settlementMode:'protected_inspection',inspection:{version:'buyer-probe-v1',targetUrl:input.targetUrl,method:'GET',paymentRecipient:input.paymentRecipient,maxTestAmountAtomic:input.testBudgetAtomic,maxPaidAttempts:1},note:'A negative or unresolved finding may earn the service fee. The external test payment is separate and not refunded by escrow.'};
|
|
25
|
+
return merchant.proposeWorkRequest(input.requestId,proposal);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Selection returns protectedWorkHandoff. Use ProtectedInspectionClient for
|
|
29
|
+
// escrow preparation. Exchange the job ID through your agreed channel; there
|
|
30
|
+
// is no hosted inspection inbox or automatic notification. Require confirmed
|
|
31
|
+
// funding and signed evidence via /start before beginning the paid inspection.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {ProtectedWorkClient, type ProtectedWorkClientOptions, type ProtectedWorkPrepareRequest} from '../dist/index.js';
|
|
2
|
+
|
|
3
|
+
// Supply a reviewed origin, contract and verifier hash; never copy a test contract into production.
|
|
4
|
+
export async function prepareJsonWork(options: ProtectedWorkClientOptions, input: ProtectedWorkPrepareRequest) {
|
|
5
|
+
const client = new ProtectedWorkClient(options);
|
|
6
|
+
const capability = await client.getCapabilities();
|
|
7
|
+
if (!capability.configured) throw new Error('Protected work is disabled on this server.');
|
|
8
|
+
// The caller reviews the returned terms and EIP-712 data before asking its seller wallet to sign.
|
|
9
|
+
return client.prepare(input);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function readCandidate(options: ProtectedWorkClientOptions, jobId: string, wallet: string,
|
|
13
|
+
signMessage: (exactMessage: string) => Promise<string>) {
|
|
14
|
+
const client = new ProtectedWorkClient(options);
|
|
15
|
+
const challenge = await client.createReadChallenge(jobId, {action: 'read', wallet});
|
|
16
|
+
// The client has checked the challenge's origin, contract, wallet, job and expiry.
|
|
17
|
+
const signature = await signMessage(challenge.message);
|
|
18
|
+
return client.readResult(jobId, {proof: {wallet, expiresAt: challenge.expiresAt, signature}});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A read or receipt is not acceptance. No example broadcasts, funds, signs acceptance or retries.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {MerchantClient} from '../dist/index.js';
|
|
2
|
+
|
|
3
|
+
// Supply an enrolled merchant client from your runtime's secret manager.
|
|
4
|
+
// Register an accepting x402-endpoint-audit worker capability first.
|
|
5
|
+
// Decide feasibility and authorize any test spending in your own runtime.
|
|
6
|
+
export function proposeEndpointAudit(client:MerchantClient,requestId:string,deliveryDeadline:string){
|
|
7
|
+
return client.proposeWorkRequest(requestId,{
|
|
8
|
+
capability:'x402-endpoint-audit',
|
|
9
|
+
deliverable:'JSON findings with reproductions and transaction references.',
|
|
10
|
+
priceUSDC:'5',deliveryDeadline,
|
|
11
|
+
testPurchaseBudgetUSDC:'0.02',testPurchasePayer:'seller',
|
|
12
|
+
note:'Audit fee excludes seller-funded test purchases; scope follows the public request.'
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function declineEndpointAudit(client:MerchantClient,requestId:string){
|
|
17
|
+
return client.declineWorkRequest(requestId,{
|
|
18
|
+
reason:'endpoint_unavailable',
|
|
19
|
+
note:'The authorized feasibility check failed; no audit fee requested.'
|
|
20
|
+
});
|
|
21
|
+
}
|