mycomesh-consumer 0.1.53 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -135
- package/bin/mycomesh-consumer.mjs +5 -8
- package/networks/mycomesh-v11-sepolia.json +27 -0
- package/package.json +8 -21
- package/src/chain.mjs +52 -0
- package/src/cli.mjs +132 -643
- package/src/consumer.mjs +143 -478
- package/src/disputes.mjs +54 -0
- package/src/eip712.mjs +151 -0
- package/src/protocol.mjs +90 -0
- package/src/secure-envelope.mjs +324 -0
- package/src/sse.mjs +101 -0
- package/bin/mycomesh.mjs +0 -10
- package/networks/v10-controlled-test.json +0 -119
- package/networks/v10-dynamic-20260926.ca.crt +0 -25
- package/networks/v10-dynamic-20260926.json +0 -154
- package/src/consumer-discovery.mjs +0 -355
- package/src/consumer-history.mjs +0 -287
- package/src/consumer-request-journal.mjs +0 -189
- package/src/consumer-reserved.mjs +0 -112
- package/src/consumer-runtime.mjs +0 -4176
- package/src/consumer-sessions.mjs +0 -205
- package/src/release.mjs +0 -12
- /package/networks/{v10-controlled-test.ca.crt → mycomesh-testnet-ca.crt} +0 -0
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
-
import { closeSync, constants, existsSync, fchmodSync, fsyncSync, fstatSync, lstatSync, mkdirSync,
|
|
3
|
-
openSync, readFileSync, renameSync, unlinkSync, writeSync } from 'node:fs';
|
|
4
|
-
import { dirname, join, resolve } from 'node:path';
|
|
5
|
-
|
|
6
|
-
const SCHEMA = 'mycomesh.consumer.request-journal.v1';
|
|
7
|
-
const HASH = /^0x[0-9a-f]{64}$/;
|
|
8
|
-
const ADDRESS = /^0x[0-9a-f]{40}$/i;
|
|
9
|
-
const MAX_REQUEST_BYTES = 16 * 1024 * 1024;
|
|
10
|
-
const SAFE_HEADERS = new Set(['content-type', 'x-request-id', 'x-mycomesh-request-id', 'x-mycomesh-session-id',
|
|
11
|
-
'x-mycomesh-content-verification', 'x-mycomesh-history-status', 'x-should-retry']);
|
|
12
|
-
const PAYMENT_FIELDS = new Set(['payment-response', 'payment_response', 'payment_v7', 'payment_v8', 'payment_v9', 'payment_v10',
|
|
13
|
-
'mycomesh_v7_settlement', 'mycomesh_v8_settlement', 'mycomesh_v9_settlement', 'mycomesh_v10_settlement', 'settlement_v10',
|
|
14
|
-
'signed_receipt', 'relay_dispatch', 'key_signature', 'provider_signature', 'relay_signature', 'authorization',
|
|
15
|
-
'api_key', 'payment_key', 'private_key', 'signature']);
|
|
16
|
-
const SECRET_VALUE = /myco_(?:sk|local|share)_[a-z0-9_-]+|\bbearer\s+\S+/i;
|
|
17
|
-
const sha256 = value => createHash('sha256').update(value).digest('hex');
|
|
18
|
-
|
|
19
|
-
export class RequestJournalError extends Error {
|
|
20
|
-
constructor(code, message, requestId) {
|
|
21
|
-
super(message); this.name = 'RequestJournalError'; this.code = code; this.statusCode = 422;
|
|
22
|
-
this.requestId = requestId; this.retryable = false;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function canonical(value, ancestors = new Set()) {
|
|
27
|
-
if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value);
|
|
28
|
-
if (typeof value === 'number' && Number.isFinite(value)) return JSON.stringify(value);
|
|
29
|
-
if (!value || typeof value !== 'object' || ancestors.has(value)) throw new TypeError('journal input must be finite canonical JSON');
|
|
30
|
-
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new TypeError('journal input must be plain JSON');
|
|
31
|
-
ancestors.add(value);
|
|
32
|
-
try {
|
|
33
|
-
if (Array.isArray(value)) return '[' + Array.from(value, item => canonical(item, ancestors)).join(',') + ']';
|
|
34
|
-
return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonical(value[key], ancestors)).join(',') + '}';
|
|
35
|
-
} finally { ancestors.delete(value); }
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Hash only; the original request (including its prompt) is never persisted. */
|
|
39
|
-
export function requestPayloadHash(value) {
|
|
40
|
-
const encoded = canonical(value);
|
|
41
|
-
if (Buffer.byteLength(encoded) > MAX_REQUEST_BYTES) throw new RequestJournalError('request_too_large', 'Request exceeds the idempotency journal size bound');
|
|
42
|
-
return '0x' + sha256(encoded);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function syncDirectory(path) {
|
|
46
|
-
const fd = openSync(path, constants.O_RDONLY);
|
|
47
|
-
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
48
|
-
}
|
|
49
|
-
function privateDirectory(path) {
|
|
50
|
-
if (existsSync(path) && !lstatSync(path).isDirectory()) throw new Error('request journal must use regular directories');
|
|
51
|
-
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
52
|
-
if (!lstatSync(path).isDirectory()) throw new Error('request journal directory changed');
|
|
53
|
-
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
54
|
-
try { fchmodSync(fd, 0o700); } finally { closeSync(fd); }
|
|
55
|
-
}
|
|
56
|
-
function writeAll(fd, bytes) {
|
|
57
|
-
fchmodSync(fd, 0o600);
|
|
58
|
-
let offset = 0;
|
|
59
|
-
while (offset < bytes.length) {
|
|
60
|
-
const written = writeSync(fd, bytes, offset, bytes.length - offset);
|
|
61
|
-
if (!written) throw new Error('incomplete request journal write');
|
|
62
|
-
offset += written;
|
|
63
|
-
}
|
|
64
|
-
fsyncSync(fd);
|
|
65
|
-
}
|
|
66
|
-
function cleanValue(value) {
|
|
67
|
-
if (typeof value === 'string') {
|
|
68
|
-
if (SECRET_VALUE.test(value)) throw new RequestJournalError('unsafe_cached_response', 'Response contains credential-like text; it cannot be cached');
|
|
69
|
-
return value;
|
|
70
|
-
}
|
|
71
|
-
if (Array.isArray(value)) return value.map(cleanValue);
|
|
72
|
-
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
|
|
73
|
-
.filter(([key]) => !PAYMENT_FIELDS.has(key.toLowerCase())).map(([key, item]) => [key, cleanValue(item)]));
|
|
74
|
-
return value;
|
|
75
|
-
}
|
|
76
|
-
/** Cache API output only, excluding payment proofs and top-level echoed input. */
|
|
77
|
-
export function protectedCachedResult(result) {
|
|
78
|
-
if (!result || !Number.isInteger(result.status) || result.status < 100 || result.status > 599) throw new TypeError('invalid cached HTTP status');
|
|
79
|
-
canonical(result.payload); // Validate before traversing; rejects cycles/non-JSON.
|
|
80
|
-
const payload = cleanValue(result.payload);
|
|
81
|
-
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
82
|
-
for (const key of ['input', 'messages', 'prompt', 'instructions', 'system_prompt', 'metadata']) delete payload[key];
|
|
83
|
-
}
|
|
84
|
-
const headers = Object.fromEntries(Object.entries(result.headers || {}).flatMap(([key, value]) => {
|
|
85
|
-
const name = key.toLowerCase();
|
|
86
|
-
if (!SAFE_HEADERS.has(name)) return [];
|
|
87
|
-
if (typeof value !== 'string' || value.length > 2048 || /[\r\n]/.test(value) || SECRET_VALUE.test(value)) throw new TypeError('invalid cached response header');
|
|
88
|
-
return [[name, value]];
|
|
89
|
-
}));
|
|
90
|
-
return { status: result.status, headers, payload };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* A permanent per-key O_EXCL claim is the execution fence. No TTL or stale-lock
|
|
95
|
-
* recovery ever removes a claim. Missing/corrupt state fails closed. Filesystem
|
|
96
|
-
* loss is outside this guarantee: retain this directory across restarts.
|
|
97
|
-
*/
|
|
98
|
-
export class ConsumerRequestJournal {
|
|
99
|
-
constructor({ directory, chainId, contract, paymentKeyAddress, maxResponseBytes = 8 * 1024 * 1024 }) {
|
|
100
|
-
if (!directory || !Number.isSafeInteger(chainId) || chainId <= 0 || !ADDRESS.test(contract || '') || !ADDRESS.test(paymentKeyAddress || '')) throw new TypeError('invalid request journal scope');
|
|
101
|
-
if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > 16 * 1024 * 1024) throw new TypeError('invalid response size bound');
|
|
102
|
-
this.scopeHash = sha256(canonical({ chainId, contract: contract.toLowerCase(), paymentKeyAddress: paymentKeyAddress.toLowerCase() }));
|
|
103
|
-
this.root = resolve(directory); this.directory = join(this.root, this.scopeHash); this.maxResponseBytes = maxResponseBytes;
|
|
104
|
-
privateDirectory(this.root); privateDirectory(this.directory); syncDirectory(this.root); syncDirectory(dirname(this.root));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
_path(entryKey) {
|
|
108
|
-
if (typeof entryKey !== 'string' || !/^[0-9a-f]{64}$/.test(entryKey)) throw new TypeError('invalid journal entry key');
|
|
109
|
-
return join(this.directory, entryKey + '.json');
|
|
110
|
-
}
|
|
111
|
-
_read(entryKey) {
|
|
112
|
-
let fd;
|
|
113
|
-
try {
|
|
114
|
-
fd = openSync(this._path(entryKey), constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
115
|
-
const info = fstatSync(fd);
|
|
116
|
-
if (!info.isFile() || info.size > this.maxResponseBytes + 16384 || (info.mode & 0o077)) throw new Error('invalid journal file');
|
|
117
|
-
const value = JSON.parse(readFileSync(fd, 'utf8'));
|
|
118
|
-
if (value.schema !== SCHEMA || value.scope_hash !== this.scopeHash || value.key_hash !== entryKey
|
|
119
|
-
|| !HASH.test(value.request_id || '') || !HASH.test(value.payload_hash || '') || !/^[0-9a-f]{64}$/.test(value.owner_hash || '')
|
|
120
|
-
|| !['in_progress', 'complete', 'outcome_unknown'].includes(value.phase)) throw new Error('invalid journal record');
|
|
121
|
-
if (value.phase === 'complete' && (!value.result || value.result_hash !== sha256(canonical(value.result)))) throw new Error('invalid cached result');
|
|
122
|
-
return value;
|
|
123
|
-
} catch {
|
|
124
|
-
throw new RequestJournalError('idempotency_state_unknown', 'Existing idempotency state is unavailable or incomplete; do not execute again');
|
|
125
|
-
} finally { if (fd !== undefined) closeSync(fd); }
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
claim({ idempotencyKey, payloadHash }) {
|
|
129
|
-
if (typeof idempotencyKey !== 'string' || !idempotencyKey.trim() || Buffer.byteLength(idempotencyKey) > 1024 || /[\u0000-\u001f\u007f]/.test(idempotencyKey)) throw new RequestJournalError('invalid_idempotency_key', 'Idempotency-Key must be nonempty and at most 1024 bytes');
|
|
130
|
-
if (!HASH.test(payloadHash || '')) throw new TypeError('payloadHash must be a canonical SHA-256 value');
|
|
131
|
-
const entryKey = sha256(idempotencyKey), path = this._path(entryKey);
|
|
132
|
-
const requestId = '0x' + randomBytes(32).toString('hex'), claimToken = randomBytes(32).toString('hex');
|
|
133
|
-
const now = Math.floor(Date.now() / 1000);
|
|
134
|
-
const record = { schema: SCHEMA, scope_hash: this.scopeHash, key_hash: entryKey, payload_hash: payloadHash,
|
|
135
|
-
request_id: requestId, owner_hash: sha256(claimToken), phase: 'in_progress', created_at: now, updated_at: now };
|
|
136
|
-
let fd;
|
|
137
|
-
try {
|
|
138
|
-
fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
139
|
-
} catch (error) {
|
|
140
|
-
if (error.code !== 'EEXIST') throw error;
|
|
141
|
-
const existing = this._read(entryKey);
|
|
142
|
-
if (existing.payload_hash !== payloadHash) throw new RequestJournalError('idempotency_conflict', 'Idempotency-Key was already bound to different request content', existing.request_id);
|
|
143
|
-
if (existing.phase === 'complete') return { action: 'replay', requestId: existing.request_id, result: existing.result };
|
|
144
|
-
throw new RequestJournalError('idempotency_outcome_unknown', 'This request is in progress or has an unknown outcome; automatic execution is forbidden', existing.request_id);
|
|
145
|
-
}
|
|
146
|
-
try {
|
|
147
|
-
writeAll(fd, Buffer.from(canonical(record))); syncDirectory(this.directory);
|
|
148
|
-
} finally { closeSync(fd); } // Never remove an incomplete exclusive claim.
|
|
149
|
-
return { action: 'execute', requestId, claimToken, entryKey };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
_update(claim, update) {
|
|
153
|
-
if (!claim || !/^[0-9a-f]{64}$/.test(claim.claimToken || '') || !HASH.test(claim.requestId || '')) throw new TypeError('invalid execution claim');
|
|
154
|
-
const path = this._path(claim.entryKey), lockPath = path + '.update-lock';
|
|
155
|
-
let lock;
|
|
156
|
-
try { lock = openSync(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600); }
|
|
157
|
-
catch { throw new RequestJournalError('idempotency_update_locked', 'Request journal update is unavailable; retain the original execution fence', claim.requestId); }
|
|
158
|
-
let temporary;
|
|
159
|
-
try {
|
|
160
|
-
const record = this._read(claim.entryKey);
|
|
161
|
-
if (record.request_id !== claim.requestId || !timingSafeEqual(Buffer.from(record.owner_hash, 'hex'), Buffer.from(sha256(claim.claimToken), 'hex'))) throw new RequestJournalError('idempotency_claim_mismatch', 'This process does not own the execution claim', record.request_id);
|
|
162
|
-
const next = update(record);
|
|
163
|
-
if (next === record) return;
|
|
164
|
-
temporary = path + '.' + randomBytes(12).toString('hex') + '.tmp';
|
|
165
|
-
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
166
|
-
try { writeAll(fd, Buffer.from(canonical(next))); } finally { closeSync(fd); }
|
|
167
|
-
renameSync(temporary, path); temporary = null; syncDirectory(this.directory);
|
|
168
|
-
} finally {
|
|
169
|
-
if (temporary) { try { unlinkSync(temporary); } catch {} }
|
|
170
|
-
closeSync(lock); unlinkSync(lockPath); syncDirectory(this.directory);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
complete(claim, result) {
|
|
175
|
-
const cleaned = protectedCachedResult(result), encoded = canonical(cleaned);
|
|
176
|
-
if (Buffer.byteLength(encoded) > this.maxResponseBytes) throw new RequestJournalError('cached_response_too_large', 'Response exceeds the protected cache bound; the execution remains fenced', claim?.requestId);
|
|
177
|
-
this._update(claim, record => {
|
|
178
|
-
if (record.phase === 'complete') {
|
|
179
|
-
if (record.result_hash !== sha256(encoded)) throw new RequestJournalError('idempotency_completion_conflict', 'A completed response cannot be replaced', record.request_id);
|
|
180
|
-
return record;
|
|
181
|
-
}
|
|
182
|
-
return { ...record, phase: 'complete', updated_at: Math.floor(Date.now() / 1000), result: cleaned, result_hash: sha256(encoded) };
|
|
183
|
-
});
|
|
184
|
-
return cleaned;
|
|
185
|
-
}
|
|
186
|
-
markOutcomeUnknown(claim) {
|
|
187
|
-
this._update(claim, record => record.phase === 'complete' ? record : { ...record, phase: 'outcome_unknown', updated_at: Math.floor(Date.now() / 1000) });
|
|
188
|
-
}
|
|
189
|
-
}
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
2
|
-
import { keccak_256 } from '@noble/hashes/sha3.js';
|
|
3
|
-
|
|
4
|
-
// These types are shared with MycoSettlementV10.sol and gateway/chain_v10.py.
|
|
5
|
-
export const RESERVED_AUTH_SCHEMA = 'mycomesh.x402.myco-credit-v4';
|
|
6
|
-
export const RESERVED_SIGNED_SCHEMA = 'mycomesh.settlement.v10.signed.v1';
|
|
7
|
-
const AUTH_TYPE = 'ReservedPaymentAuthorization(bytes32 channelId,bytes32 requestId,bytes32 requestHash,address key,uint256 maxFee,uint64 issuedAt,uint64 executeBy,uint64 deadline)';
|
|
8
|
-
const RECEIPT_TYPE = 'ReservedUsageReceipt(bytes32 channelId,bytes32 authorizationHash,bytes32 dispatchHash,bytes32 responseHash,uint256 inputTokens,uint256 outputTokens,uint256 actualFee)';
|
|
9
|
-
const DISPATCH_TYPE = 'RelayDispatch(bytes32 authorizationHash,bytes32 channelId)';
|
|
10
|
-
const OPEN_TYPE = 'OpenCapacityChannel(address consumerOwner,address consumerKey,address providerOwner,address providerSigner,address relay,address relaySigner,address pool,bytes32 channel,uint64 pricingVersion,bytes32 pricingHash,uint256 capacity,uint256 maxFeePerRequest,uint64 validFrom,uint64 admitUntil,uint64 claimUntil,uint256 consumerNonce,uint256 providerNonce,uint64 permitDeadline)';
|
|
11
|
-
const AUTH_FIELDS = [['channel_id','b'],['request_id','b'],['request_hash','b'],['key','a'],['max_fee',256],['issued_at',64],['execute_by',64],['deadline',64]];
|
|
12
|
-
const RECEIPT_FIELDS = [['channel_id','b'],['authorization_hash','b'],['dispatch_hash','b'],['response_hash','b'],['input_tokens',256],['output_tokens',256],['actual_fee',256]];
|
|
13
|
-
export const CHANNEL_FIELDS = [['consumer_owner','a'],['consumer_key','a'],['provider_owner','a'],['provider_signer','a'],['relay','a'],['relay_signer','a'],['pool','a'],['channel','b'],['pricing_version',64],['pricing_hash','b'],['capacity',256],['max_fee_per_request',256],['valid_from',64],['admit_until',64],['claim_until',64],['consumer_nonce',256],['provider_nonce',256],['permit_deadline',64]];
|
|
14
|
-
const hash = bytes => '0x' + Buffer.from(keccak_256(bytes)).toString('hex');
|
|
15
|
-
const bytes = value => Buffer.from(value.slice(2), 'hex');
|
|
16
|
-
const textHash = value => bytes(hash(Buffer.from(value)));
|
|
17
|
-
const integer = value => value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString();
|
|
18
|
-
function record(raw, fields) {
|
|
19
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('missing V10 record');
|
|
20
|
-
return Object.fromEntries(fields.map(([name, kind]) => {
|
|
21
|
-
let value = raw[name];
|
|
22
|
-
if (kind === 'a' || kind === 'b') {
|
|
23
|
-
if (typeof value !== 'string' || !(kind === 'a' ? /^0x[\da-f]{40}$/i : /^0x[\da-f]{64}$/i).test(value)
|
|
24
|
-
|| (name !== 'pool' && /^0x0+$/.test(value))) throw new Error(`invalid V10 ${name}`);
|
|
25
|
-
value = value.toLowerCase();
|
|
26
|
-
} else {
|
|
27
|
-
if (!(typeof value === 'number' && Number.isSafeInteger(value)) && !(typeof value === 'string' && /^\d+$/.test(value))) throw new Error(`invalid V10 ${name}`);
|
|
28
|
-
const n = BigInt(value);
|
|
29
|
-
if (n < 0n || n >= (1n << BigInt(kind))) throw new Error(`invalid V10 ${name}`);
|
|
30
|
-
value = integer(n);
|
|
31
|
-
}
|
|
32
|
-
return [name, value];
|
|
33
|
-
}));
|
|
34
|
-
}
|
|
35
|
-
const word = value => Buffer.from((typeof value === 'string' && value.startsWith('0x') ? value.slice(2) : BigInt(value).toString(16)).padStart(64, '0'), 'hex');
|
|
36
|
-
const structHash = (type, raw, fields) => hash(Buffer.concat([textHash(type), ...Object.values(record(raw, fields)).map(word)]));
|
|
37
|
-
function digest(struct, chainId, contract) {
|
|
38
|
-
const c = record({key:contract}, [['key','a']]).key;
|
|
39
|
-
if (!Number.isSafeInteger(Number(chainId)) || BigInt(chainId) <= 0n) throw new Error('invalid V10 chain ID');
|
|
40
|
-
const domain = hash(Buffer.concat([textHash('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'), textHash('MycoMesh Settlement'), textHash('10'), word(chainId), word(c)]));
|
|
41
|
-
return bytes(hash(Buffer.concat([Buffer.from([25,1]), bytes(domain), bytes(struct)])));
|
|
42
|
-
}
|
|
43
|
-
function privateBytes(key) {
|
|
44
|
-
return String(key).startsWith('myco_sk_') ? Buffer.from(key.slice(8), 'base64url') : Buffer.from(key.replace(/^0x/, ''), 'hex');
|
|
45
|
-
}
|
|
46
|
-
function recover(d, sig) {
|
|
47
|
-
if (typeof sig !== 'string' || !/^0x[\da-f]{130}$/i.test(sig)) throw new Error('invalid V10 signature');
|
|
48
|
-
const raw = bytes(sig), recovery = raw[64] >= 27 ? raw[64]-27 : raw[64];
|
|
49
|
-
if (recovery > 1) throw new Error('invalid V10 signature recovery');
|
|
50
|
-
const s = secp256k1.Signature.fromCompact(raw.subarray(0,64));
|
|
51
|
-
if (s.hasHighS()) throw new Error('noncanonical V10 signature');
|
|
52
|
-
return '0x'+hash(s.addRecoveryBit(recovery).recoverPublicKey(d).toRawBytes(false).slice(1)).slice(-40);
|
|
53
|
-
}
|
|
54
|
-
export function capacityChannelId(config, chainId, contract) { return '0x' + digest(structHash(OPEN_TYPE, config, CHANNEL_FIELDS), chainId, contract).toString('hex'); }
|
|
55
|
-
export function reservedSettlementKey(channelId, requestId) {
|
|
56
|
-
const normalized = record({channel_id:channelId,request_id:requestId},AUTH_FIELDS.slice(0,2));
|
|
57
|
-
return hash(Buffer.concat(Object.values(normalized).map(word)));
|
|
58
|
-
}
|
|
59
|
-
export function decodeCapacityChannel(output, channelId, chainId, contract) {
|
|
60
|
-
if (typeof output !== 'string' || !/^0x[\da-f]{1408}$/i.test(output)) throw new Error('invalid channelInfo response');
|
|
61
|
-
const words = output.slice(2).match(/.{64}/g);
|
|
62
|
-
const raw = Object.fromEntries(CHANNEL_FIELDS.map(([name,kind],i) => {
|
|
63
|
-
if (kind === 'a' && !/^0{24}/.test(words[i])) throw new Error('noncanonical channel address');
|
|
64
|
-
return [name,kind === 'a' ? '0x'+words[i].slice(24) : kind === 'b' ? '0x'+words[i] : BigInt('0x'+words[i]).toString()];
|
|
65
|
-
}));
|
|
66
|
-
const config = record(raw, CHANNEL_FIELDS);
|
|
67
|
-
const [settled,credit,stake,closed] = words.slice(18).map(w=>BigInt('0x'+w));
|
|
68
|
-
if (closed > 1n || BigInt(config.capacity) === 0n || [settled,credit,stake].some(n=>n>BigInt(config.capacity))) throw new Error('invalid channel accounting');
|
|
69
|
-
if (capacityChannelId(config,chainId,contract) !== channelId.toLowerCase()) throw new Error('channel identity mismatch');
|
|
70
|
-
return {...config,channel_id:channelId.toLowerCase(),settled_max_fee:integer(settled),credit_remaining:integer(credit),stake_remaining:integer(stake),closed:closed===1n};
|
|
71
|
-
}
|
|
72
|
-
export function buildReservedAuthorization({paymentKey,chainId,settlementContract,channelId,requestId,requestHash,maxFee,issuedAt,executeBy,deadline}) {
|
|
73
|
-
const keyBytes=privateBytes(paymentKey);
|
|
74
|
-
const key='0x'+hash(secp256k1.getPublicKey(keyBytes,false).slice(1)).slice(-40);
|
|
75
|
-
const authorization=record({channel_id:channelId,request_id:requestId,request_hash:requestHash,key,max_fee:maxFee,issued_at:issuedAt,execute_by:executeBy,deadline},AUTH_FIELDS);
|
|
76
|
-
const h=structHash(AUTH_TYPE,authorization,AUTH_FIELDS), d=digest(h,chainId,settlementContract);
|
|
77
|
-
const sig=secp256k1.sign(d,keyBytes,{lowS:true,prehash:false});
|
|
78
|
-
const envelope={schema:RESERVED_AUTH_SCHEMA,protocol_version:10,chain_id:Number(chainId),settlement_contract:settlementContract.toLowerCase(),authorization,authorization_hash:h,authorization_digest:'0x'+d.toString('hex'),key_signature:'0x'+Buffer.concat([Buffer.from(sig.toCompactRawBytes()),Buffer.from([27+sig.recovery])]).toString('hex')};
|
|
79
|
-
verifyReservedAuthorization(envelope,{now:issuedAt});
|
|
80
|
-
return envelope;
|
|
81
|
-
}
|
|
82
|
-
export function verifyReservedAuthorization(value, expected={}) {
|
|
83
|
-
if (value?.schema!==RESERVED_AUTH_SCHEMA || value.protocol_version!==10) throw new Error('unsupported reserved authorization');
|
|
84
|
-
if (expected.protocolVersion!==undefined && expected.protocolVersion!==10) throw new Error('settlement protocol version mismatch');
|
|
85
|
-
const a=record(value.authorization,AUTH_FIELDS), now=BigInt(expected.now ?? Math.floor(Date.now()/1000));
|
|
86
|
-
if (!(BigInt(a.max_fee)>0n && BigInt(a.issued_at)<=now && now<=BigInt(a.deadline) && BigInt(a.issued_at)<=BigInt(a.execute_by) && BigInt(a.execute_by)<BigInt(a.deadline) && BigInt(a.deadline)-BigInt(a.issued_at)<=10800n)) throw new Error('V10 authorization time/fee invalid');
|
|
87
|
-
for (const [wanted,actual] of [[expected.chainId,value.chain_id],[expected.contract,value.settlement_contract],[expected.channelId,a.channel_id],[expected.requestId,a.request_id],[expected.requestHash,a.request_hash]]) {
|
|
88
|
-
if (wanted!==undefined && String(wanted).toLowerCase()!==String(actual).toLowerCase()) throw new Error('V10 authorization scope mismatch');
|
|
89
|
-
}
|
|
90
|
-
const h=structHash(AUTH_TYPE,a,AUTH_FIELDS),d=digest(h,value.chain_id,value.settlement_contract);
|
|
91
|
-
if (value.authorization_hash!==h || value.authorization_digest!=='0x'+d.toString('hex') || recover(d,value.key_signature)!==a.key) throw new Error('V10 authorization signature/hash mismatch');
|
|
92
|
-
return {...value,authorization:a};
|
|
93
|
-
}
|
|
94
|
-
export function verifyReservedReceipt(value, expected={}) {
|
|
95
|
-
if (value?.schema!==RESERVED_SIGNED_SCHEMA || value.protocol_version!==10) throw new Error('unsupported reserved receipt');
|
|
96
|
-
const a=verifyReservedAuthorization(value.authorization,expected),c=expected.channel;
|
|
97
|
-
// Role identities must come from a pinned on-chain channel, never the Relay's envelope.
|
|
98
|
-
if (!c || capacityChannelId(c,a.chain_id,a.settlement_contract)!==a.authorization.channel_id || c.closed!==false || c.consumer_key!==a.authorization.key) throw new Error('verified capacity channel required');
|
|
99
|
-
const auth=a.authorization;
|
|
100
|
-
if (!(BigInt(c.valid_from)<=BigInt(auth.issued_at) && BigInt(auth.execute_by)<=BigInt(c.admit_until) && BigInt(auth.deadline)<=BigInt(c.claim_until) && BigInt(auth.max_fee)<=BigInt(c.max_fee_per_request))) throw new Error('authorization exceeds channel terms');
|
|
101
|
-
const d=value.dispatch;
|
|
102
|
-
if (d?.schema!=='mycomesh.settlement.v10.dispatch.v1' || d.protocol_version!==10 || d.authorization?.authorization_hash!==a.authorization_hash || d.authorization?.key_signature!==a.key_signature) throw new Error('invalid Relay dispatch');
|
|
103
|
-
verifyReservedAuthorization(d.authorization,expected);
|
|
104
|
-
for (const envelope of [value,d]) if (envelope.chain_id!==a.chain_id || envelope.settlement_contract!==a.settlement_contract) throw new Error('V10 receipt deployment mismatch');
|
|
105
|
-
const dh=hash(Buffer.concat([textHash(DISPATCH_TYPE),word(a.authorization_hash),word(auth.channel_id)]));
|
|
106
|
-
const dd=digest(dh,a.chain_id,a.settlement_contract);
|
|
107
|
-
if (d.dispatch_hash!==dh || d.dispatch_digest!=='0x'+dd.toString('hex') || d.relay_signer!==c.relay_signer || recover(dd,d.relay_signature)!==c.relay_signer || value.relay_signature!==d.relay_signature) throw new Error('V10 Relay dispatch signature mismatch');
|
|
108
|
-
const r=record(value.receipt,RECEIPT_FIELDS);
|
|
109
|
-
if (r.channel_id!==auth.channel_id || r.authorization_hash!==a.authorization_hash || r.dispatch_hash!==dh || BigInt(r.actual_fee)<=0n || BigInt(r.actual_fee)>BigInt(auth.max_fee) || value.key_signature!==a.key_signature || value.provider_signer!==c.provider_signer) throw new Error('V10 receipt binding mismatch');
|
|
110
|
-
if (recover(digest(structHash(RECEIPT_TYPE,r,RECEIPT_FIELDS),a.chain_id,a.settlement_contract),value.provider_signature)!==c.provider_signer) throw new Error('V10 Provider signature mismatch');
|
|
111
|
-
return {authorization:a,receipt:{...r,provider:c.provider_owner,provider_signer:c.provider_signer,relay:c.relay,pool:c.pool}};
|
|
112
|
-
}
|