aipou-mcp-server 0.4.0 → 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/NOTICE +8 -0
- package/README.md +18 -0
- package/dist/bounded-input.d.ts +8 -0
- package/dist/bounded-input.js +21 -0
- package/dist/bounded-input.test.d.ts +1 -0
- package/dist/bounded-input.test.js +24 -0
- package/dist/claims.js +119 -31
- package/dist/doctor.d.ts +32 -0
- package/dist/doctor.js +112 -0
- package/dist/doctor.test.d.ts +1 -0
- package/dist/doctor.test.js +164 -0
- package/dist/evidence.d.ts +21 -0
- package/dist/evidence.js +14 -0
- package/dist/feedback.d.ts +17 -0
- package/dist/feedback.js +24 -0
- package/dist/identity.d.ts +3 -0
- package/dist/identity.js +25 -1
- package/dist/index.js +15121 -106
- package/dist/init.d.ts +8 -2
- package/dist/init.js +20 -7
- package/dist/init.test.js +18 -4
- package/dist/lock.js +47 -9
- package/dist/lock.test.d.ts +1 -0
- package/dist/lock.test.js +26 -0
- package/dist/observed-outcome-pilot.d.ts +16 -0
- package/dist/observed-outcome-pilot.js +59 -0
- package/dist/observed-outcome-pilot.test.d.ts +1 -0
- package/dist/observed-outcome-pilot.test.js +25 -0
- package/dist/paybox-link.d.ts +34 -0
- package/dist/paybox-link.js +52 -0
- package/dist/paybox-link.test.d.ts +1 -0
- package/dist/paybox-link.test.js +36 -0
- package/dist/private-json.d.ts +2 -0
- package/dist/private-json.js +39 -0
- package/dist/protocol.test.js +23 -0
- package/dist/receipt-metadata.d.ts +13 -0
- package/dist/receipt-metadata.js +28 -0
- package/dist/receipt-metadata.test.d.ts +1 -0
- package/dist/receipt-metadata.test.js +39 -0
- package/dist/receipt-verifier.d.ts +13 -0
- package/dist/receipt-verifier.js +125 -0
- package/dist/receipt-verifier.test.d.ts +1 -0
- package/dist/receipt-verifier.test.js +54 -0
- package/dist/receipts.js +11 -2
- package/dist/settlement-driver.d.ts +3 -0
- package/dist/settlement-driver.js +120 -0
- package/dist/settlement-driver.test.d.ts +1 -0
- package/dist/settlement-driver.test.js +78 -0
- package/dist/settlement-runner.d.ts +34 -0
- package/dist/settlement-runner.js +40 -0
- package/dist/settlement-runner.test.d.ts +1 -0
- package/dist/settlement-runner.test.js +128 -0
- package/dist/status.js +2 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +33 -9
- package/dist/technocore-link.d.ts +34 -0
- package/dist/technocore-link.js +49 -0
- package/dist/technocore-link.test.d.ts +1 -0
- package/dist/technocore-link.test.js +36 -0
- package/dist/tool-metadata.test.d.ts +1 -0
- package/dist/tool-metadata.test.js +86 -0
- package/dist/types.d.ts +6 -0
- package/dist/usage-recovery.test.d.ts +1 -0
- package/dist/usage-recovery.test.js +121 -0
- package/package.json +7 -5
- package/server.json +2 -2
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { canonicalJson, sha256Hex } from "./canonical.js";
|
|
2
|
+
import { receiptEvidenceReport } from "./evidence.js";
|
|
3
|
+
import { verifyCollectorPayload } from "./collector.js";
|
|
4
|
+
import { verifyTaskAuthorization } from "./identity.js";
|
|
5
|
+
function isBytes32(value) {
|
|
6
|
+
return typeof value === "string" && /^0x[0-9a-f]{64}$/i.test(value);
|
|
7
|
+
}
|
|
8
|
+
function assertReceiptShape(receipt) {
|
|
9
|
+
const errors = [];
|
|
10
|
+
const fields = new Set(["receiptId", "nonce", "wallet", "provider", "model", "inputTokens", "outputTokens", "durationSeconds", "taskHash", "outputHash", "client", "trustTier", "walletAuthorization", "authorizationIssuedAt", "authorizationChainId", "authorizationContract", "providerEvidence", "collectorPublicKey", "collectorSignature", "recordedAt", "estimatedReward", "batchRoot", "claimTransaction", "tokenUsageMeasurement"]);
|
|
11
|
+
if (Object.keys(receipt).some(key => !fields.has(key)))
|
|
12
|
+
return ["receipt contains unsupported fields"];
|
|
13
|
+
for (const [key, value] of Object.entries(receipt)) {
|
|
14
|
+
if (key === "providerEvidence") {
|
|
15
|
+
if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(k => !["keyId", "signature"].includes(k)) || typeof value.keyId !== "string" || typeof value.signature !== "string" || value.keyId.length > 128 || value.signature.length > 2048))
|
|
16
|
+
return ["providerEvidence must contain bounded keyId and signature strings"];
|
|
17
|
+
}
|
|
18
|
+
else if (value !== undefined && typeof value !== "string" && typeof value !== "number")
|
|
19
|
+
return ["receipt fields must be scalar values"];
|
|
20
|
+
if (typeof value === "string" && value.length > 8192)
|
|
21
|
+
return ["receipt field exceeds length limit"];
|
|
22
|
+
}
|
|
23
|
+
for (const field of ["receiptId", "nonce", "taskHash", "outputHash"]) {
|
|
24
|
+
if (!isBytes32(receipt[field]))
|
|
25
|
+
errors.push(`${field} must be a bytes32 hex value`);
|
|
26
|
+
}
|
|
27
|
+
if (typeof receipt.walletAuthorization !== "string" || !/^0x[0-9a-f]+$/i.test(receipt.walletAuthorization)) {
|
|
28
|
+
errors.push("walletAuthorization must be a hex EIP-712 signature");
|
|
29
|
+
}
|
|
30
|
+
if (typeof receipt.wallet !== "string" || !/^0x[0-9a-f]{40}$/i.test(receipt.wallet))
|
|
31
|
+
errors.push("wallet must be an address");
|
|
32
|
+
if (typeof receipt.authorizationContract !== "string" || !/^0x[0-9a-f]{40}$/i.test(receipt.authorizationContract)) {
|
|
33
|
+
errors.push("authorizationContract must be an address");
|
|
34
|
+
}
|
|
35
|
+
for (const field of ["provider", "model", "client", "collectorPublicKey", "collectorSignature", "recordedAt", "estimatedReward"]) {
|
|
36
|
+
if (typeof receipt[field] !== "string" || receipt[field].length === 0)
|
|
37
|
+
errors.push(`${field} must be a non-empty string`);
|
|
38
|
+
}
|
|
39
|
+
for (const field of ["inputTokens", "outputTokens", "durationSeconds", "authorizationIssuedAt", "authorizationChainId"]) {
|
|
40
|
+
if (!Number.isSafeInteger(receipt[field]) || Number(receipt[field]) < 0)
|
|
41
|
+
errors.push(`${field} must be a non-negative safe integer`);
|
|
42
|
+
}
|
|
43
|
+
if (receipt.trustTier !== "client_signed" && receipt.trustTier !== "provider_signed")
|
|
44
|
+
errors.push("trustTier is unsupported");
|
|
45
|
+
if (receipt.tokenUsageMeasurement !== undefined && !["measured", "unmeasured", "unspecified"].includes(receipt.tokenUsageMeasurement))
|
|
46
|
+
errors.push("tokenUsageMeasurement is unsupported");
|
|
47
|
+
if (receipt.tokenUsageMeasurement === "unmeasured" && (receipt.inputTokens !== 0 || receipt.outputTokens !== 0))
|
|
48
|
+
errors.push("unmeasured token usage must have zero counts");
|
|
49
|
+
if (Number.isNaN(Date.parse(String(receipt.recordedAt))))
|
|
50
|
+
errors.push("recordedAt must be an ISO date");
|
|
51
|
+
return errors;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Verifies only the self-contained receipt facts. It does not check a
|
|
55
|
+
* collector allowlist, an on-chain claim, delivery, or business acceptance.
|
|
56
|
+
*/
|
|
57
|
+
export function verifyAipouReceipt(candidate) {
|
|
58
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
errors: ["receipt must be an object"],
|
|
62
|
+
workReceiptId: null,
|
|
63
|
+
claimedWorkReceiptId: null,
|
|
64
|
+
evidence: receiptEvidenceReport({ receiptIntegrity: false, walletAuthorization: false, collectorSignature: false })
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const receipt = candidate;
|
|
68
|
+
const errors = assertReceiptShape(receipt);
|
|
69
|
+
if (errors.length > 0) {
|
|
70
|
+
return {
|
|
71
|
+
valid: false,
|
|
72
|
+
errors,
|
|
73
|
+
workReceiptId: null,
|
|
74
|
+
claimedWorkReceiptId: isBytes32(receipt.receiptId) ? receipt.receiptId.toLowerCase() : null,
|
|
75
|
+
evidence: receiptEvidenceReport({ receiptIntegrity: false, walletAuthorization: false, collectorSignature: false })
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const { receiptId, collectorSignature: signature, batchRoot: _batchRoot, claimTransaction: _claimTransaction, ...unsigned } = receipt;
|
|
79
|
+
const receiptIntegrity = sha256Hex(canonicalJson(unsigned)).toLowerCase() === receiptId.toLowerCase();
|
|
80
|
+
if (!receiptIntegrity)
|
|
81
|
+
errors.push("receiptId does not match canonical unsigned receipt payload");
|
|
82
|
+
let collectorSignatureValid = false;
|
|
83
|
+
try {
|
|
84
|
+
collectorSignatureValid = verifyCollectorPayload({ receiptId, ...unsigned }, signature, receipt.collectorPublicKey);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
collectorSignatureValid = false;
|
|
88
|
+
}
|
|
89
|
+
if (!collectorSignatureValid)
|
|
90
|
+
errors.push("collector signature is invalid");
|
|
91
|
+
const session = {
|
|
92
|
+
nonce: receipt.nonce,
|
|
93
|
+
wallet: receipt.wallet,
|
|
94
|
+
provider: receipt.provider,
|
|
95
|
+
model: receipt.model,
|
|
96
|
+
taskHash: receipt.taskHash,
|
|
97
|
+
client: receipt.client,
|
|
98
|
+
issuedAt: receipt.authorizationIssuedAt,
|
|
99
|
+
chainId: receipt.authorizationChainId,
|
|
100
|
+
verifyingContract: receipt.authorizationContract,
|
|
101
|
+
walletAuthorization: receipt.walletAuthorization,
|
|
102
|
+
completedAt: receipt.recordedAt
|
|
103
|
+
};
|
|
104
|
+
let walletAuthorization = false;
|
|
105
|
+
try {
|
|
106
|
+
walletAuthorization = verifyTaskAuthorization(session);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
walletAuthorization = false;
|
|
110
|
+
}
|
|
111
|
+
if (!walletAuthorization)
|
|
112
|
+
errors.push("wallet authorization is invalid");
|
|
113
|
+
return {
|
|
114
|
+
valid: errors.length === 0,
|
|
115
|
+
errors,
|
|
116
|
+
workReceiptId: errors.length === 0 ? receiptId.toLowerCase() : null,
|
|
117
|
+
claimedWorkReceiptId: errors.length === 0 ? null : receiptId.toLowerCase(),
|
|
118
|
+
evidence: receiptEvidenceReport({
|
|
119
|
+
receiptIntegrity,
|
|
120
|
+
walletAuthorization,
|
|
121
|
+
collectorSignature: collectorSignatureValid,
|
|
122
|
+
hostExecution: receiptIntegrity && collectorSignatureValid ? "OBSERVED" : "UNKNOWN"
|
|
123
|
+
})
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { Wallet, keccak256, toUtf8Bytes } from "ethers";
|
|
7
|
+
import { beginTask, completeTask } from "./receipts.js";
|
|
8
|
+
import { verifyAipouReceipt } from "./receipt-verifier.js";
|
|
9
|
+
test("independent receipt verifier separates cryptographic receipt facts from outcome claims", async () => {
|
|
10
|
+
process.env.AIPOU_DATA_DIR = await mkdtemp(path.join(tmpdir(), "aipou-verifier-"));
|
|
11
|
+
process.env.AIPOU_AGENT_PRIVATE_KEY = Wallet.createRandom().privateKey;
|
|
12
|
+
const session = await beginTask({
|
|
13
|
+
provider: "local",
|
|
14
|
+
model: "verifier-test",
|
|
15
|
+
client: "test-client",
|
|
16
|
+
taskHash: keccak256(toUtf8Bytes("verify this receipt")),
|
|
17
|
+
chainId: 8453,
|
|
18
|
+
verifyingContract: Wallet.createRandom().address
|
|
19
|
+
});
|
|
20
|
+
const receipt = await completeTask({
|
|
21
|
+
nonce: session.nonce,
|
|
22
|
+
inputTokens: 100,
|
|
23
|
+
outputTokens: 20,
|
|
24
|
+
durationSeconds: 2,
|
|
25
|
+
outputHash: keccak256(toUtf8Bytes("verifier output"))
|
|
26
|
+
});
|
|
27
|
+
const verified = verifyAipouReceipt(receipt);
|
|
28
|
+
assert.equal(verified.valid, true);
|
|
29
|
+
assert.equal(verified.workReceiptId, receipt.receiptId);
|
|
30
|
+
assert.equal(verified.claimedWorkReceiptId, null);
|
|
31
|
+
assert.deepEqual(verified.evidence, {
|
|
32
|
+
receiptIntegrity: "VERIFIED",
|
|
33
|
+
walletAuthorization: "VERIFIED",
|
|
34
|
+
collectorSignature: "VERIFIED",
|
|
35
|
+
hostExecution: "OBSERVED",
|
|
36
|
+
delivery: "UNKNOWN",
|
|
37
|
+
businessAcceptance: "ABSENT",
|
|
38
|
+
settlement: "ABSENT",
|
|
39
|
+
externalEffect: "UNKNOWN"
|
|
40
|
+
});
|
|
41
|
+
const tampered = verifyAipouReceipt({ ...receipt, outputHash: `0x${"ef".repeat(32)}` });
|
|
42
|
+
assert.equal(tampered.valid, false);
|
|
43
|
+
assert.equal(tampered.workReceiptId, null);
|
|
44
|
+
assert.equal(tampered.claimedWorkReceiptId, receipt.receiptId);
|
|
45
|
+
assert.ok(tampered.errors.some((error) => /receiptId/.test(error)));
|
|
46
|
+
assert.ok(tampered.errors.some((error) => /collector signature/.test(error)));
|
|
47
|
+
});
|
|
48
|
+
test("malformed, deeply nested and oversized receipts fail closed without crypto work", () => {
|
|
49
|
+
for (const candidate of [null, [], { receiptId: `0x${"ab".repeat(32)}`, rawPrompt: "private" }, { collectorPublicKey: "x".repeat(9000) }, { providerEvidence: { keyId: "x", signature: {} } }]) {
|
|
50
|
+
const result = verifyAipouReceipt(candidate);
|
|
51
|
+
assert.equal(result.valid, false);
|
|
52
|
+
assert.equal(result.workReceiptId, null);
|
|
53
|
+
}
|
|
54
|
+
});
|
package/dist/receipts.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { canonicalJson, sha256Hex } from "./canonical.js";
|
|
2
2
|
import { getCollectorPublicKey, signCollectorPayload } from "./collector.js";
|
|
3
|
-
import { createTaskSession, verifyTaskAuthorization } from "./identity.js";
|
|
3
|
+
import { createTaskSession, isTaskSessionExpired, verifyTaskAuthorization } from "./identity.js";
|
|
4
4
|
import { deriveTrustTier } from "./providers.js";
|
|
5
5
|
import { estimateReward } from "./rewards.js";
|
|
6
6
|
import { exportStoredReceipts, getSession, saveCompletedReceipt, saveSession } from "./store.js";
|
|
@@ -14,13 +14,22 @@ export async function completeTask(input) {
|
|
|
14
14
|
if (!session)
|
|
15
15
|
throw new Error("Unknown task nonce");
|
|
16
16
|
if (session.completedAt)
|
|
17
|
-
throw new Error("Task nonce has already been completed");
|
|
17
|
+
throw new Error("Task nonce has already been completed; use export_ai_receipts and find this nonce to recover its stored receipt. Do not rerun the work or begin a new task to recover a lost response.");
|
|
18
|
+
if (isTaskSessionExpired(session))
|
|
19
|
+
throw new Error("Task session has expired; begin a new task instead of completing stale authority");
|
|
18
20
|
if (!verifyTaskAuthorization(session))
|
|
19
21
|
throw new Error("Invalid EIP-712 wallet authorization");
|
|
22
|
+
if (input.tokenUsageMeasurement !== undefined && !["measured", "unmeasured", "unspecified"].includes(input.tokenUsageMeasurement)) {
|
|
23
|
+
throw new Error("Unsupported tokenUsageMeasurement");
|
|
24
|
+
}
|
|
25
|
+
if (input.tokenUsageMeasurement === "unmeasured" && (input.inputTokens !== 0 || input.outputTokens !== 0)) {
|
|
26
|
+
throw new Error("Unmeasured token usage must use zero counts");
|
|
27
|
+
}
|
|
20
28
|
const trustTier = await deriveTrustTier(session, input, input.providerEvidence);
|
|
21
29
|
const recordedAt = new Date().toISOString();
|
|
22
30
|
const collectorPublicKey = await getCollectorPublicKey();
|
|
23
31
|
const unsigned = {
|
|
32
|
+
tokenUsageMeasurement: input.tokenUsageMeasurement ?? "unspecified",
|
|
24
33
|
nonce: session.nonce,
|
|
25
34
|
wallet: session.wallet,
|
|
26
35
|
provider: session.provider,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Contract, Transaction } from "ethers";
|
|
2
|
+
const abi = [
|
|
3
|
+
"function approvedRoots(bytes32) view returns(bool)",
|
|
4
|
+
"function claimedReceipts(bytes32) view returns(bool)",
|
|
5
|
+
"function publishRoot(bytes32)",
|
|
6
|
+
"function claimBatch(bytes32,address[],uint256[],bytes32[],bytes32[][])",
|
|
7
|
+
];
|
|
8
|
+
export function createSettlementDriver(signer, contract) {
|
|
9
|
+
const provider = signer.provider;
|
|
10
|
+
const claims = new Contract(contract, abi, signer);
|
|
11
|
+
const data = (kind, plan) => kind === "publish"
|
|
12
|
+
? claims.interface.encodeFunctionData("publishRoot", [plan.root])
|
|
13
|
+
: claims.interface.encodeFunctionData("claimBatch", [
|
|
14
|
+
plan.root,
|
|
15
|
+
plan.accounts,
|
|
16
|
+
plan.amounts,
|
|
17
|
+
plan.receiptIds,
|
|
18
|
+
plan.proofs,
|
|
19
|
+
]);
|
|
20
|
+
const fees = async () => {
|
|
21
|
+
const fee = await provider.getFeeData();
|
|
22
|
+
const gasPrice = fee.maxFeePerGas ?? fee.gasPrice;
|
|
23
|
+
if (gasPrice === null || gasPrice <= 0n)
|
|
24
|
+
throw new Error("SETTLEMENT_FEE_UNAVAILABLE: RPC did not supply a gas price.");
|
|
25
|
+
return fee.maxFeePerGas !== null && fee.maxPriorityFeePerGas !== null
|
|
26
|
+
? {
|
|
27
|
+
maxFeePerGas: fee.maxFeePerGas,
|
|
28
|
+
maxPriorityFeePerGas: fee.maxPriorityFeePerGas,
|
|
29
|
+
}
|
|
30
|
+
: { gasPrice };
|
|
31
|
+
};
|
|
32
|
+
const budget = async (cost) => {
|
|
33
|
+
const cap = process.env.AIPOU_MAX_SETTLEMENT_FEE_WEI;
|
|
34
|
+
if (cap && (!/^\d+$/.test(cap) || cost > BigInt(cap)))
|
|
35
|
+
throw new Error("SETTLEMENT_FEE_CAP: required fee exceeds AIPOU_MAX_SETTLEMENT_FEE_WEI.");
|
|
36
|
+
if ((await provider.getBalance(signer.address)) < cost) {
|
|
37
|
+
throw new Error(`SETTLEMENT_INSUFFICIENT_GAS: validator needs up to ${cost} wei for this batch including a conservative Base data-fee allowance. Fund the validator on Base and retry; no automatic transfer is performed.`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
approved: (root) => claims.approvedRoots(root),
|
|
42
|
+
claimed: (id) => claims.claimedReceipts(id),
|
|
43
|
+
preflight: async (plan, publishNeeded) => {
|
|
44
|
+
const fee = await fees();
|
|
45
|
+
const price = "gasPrice" in fee ? fee.gasPrice : fee.maxFeePerGas;
|
|
46
|
+
// A claim cannot be estimated against an unpublished root without reverting.
|
|
47
|
+
// Reserve a conservative upper bound before any publication; after publication
|
|
48
|
+
// use the actual RPC estimate. This is a balance gate, not a spending promise.
|
|
49
|
+
const claimGas = publishNeeded
|
|
50
|
+
? 120000n + BigInt(plan.receiptIds.length) * 100000n
|
|
51
|
+
: await provider.estimateGas({
|
|
52
|
+
from: signer.address,
|
|
53
|
+
to: contract,
|
|
54
|
+
data: data("claim", plan),
|
|
55
|
+
});
|
|
56
|
+
const pubGas = publishNeeded
|
|
57
|
+
? await provider.estimateGas({
|
|
58
|
+
from: signer.address,
|
|
59
|
+
to: contract,
|
|
60
|
+
data: data("publish", plan),
|
|
61
|
+
})
|
|
62
|
+
: 0n;
|
|
63
|
+
await budget(((claimGas + pubGas) * price * 120n) / 100n + 20000000000000n);
|
|
64
|
+
},
|
|
65
|
+
prepare: async (kind, plan) => {
|
|
66
|
+
const request = {
|
|
67
|
+
to: contract,
|
|
68
|
+
data: data(kind, plan),
|
|
69
|
+
value: 0n,
|
|
70
|
+
...(await fees()),
|
|
71
|
+
};
|
|
72
|
+
const gasLimit = ((await provider.estimateGas({ ...request, from: signer.address })) *
|
|
73
|
+
120n) /
|
|
74
|
+
100n;
|
|
75
|
+
const price = "gasPrice" in request ? request.gasPrice : request.maxFeePerGas;
|
|
76
|
+
await budget(gasLimit * price + 20000000000000n);
|
|
77
|
+
const populated = await signer.populateTransaction({
|
|
78
|
+
...request,
|
|
79
|
+
gasLimit,
|
|
80
|
+
});
|
|
81
|
+
const raw = await signer.signTransaction(populated);
|
|
82
|
+
const hash = Transaction.from(raw).hash;
|
|
83
|
+
return { hash, raw };
|
|
84
|
+
},
|
|
85
|
+
receipt: async (hash) => {
|
|
86
|
+
const result = await provider.getTransactionReceipt(hash);
|
|
87
|
+
return result ? { hash: result.hash, status: result.status } : null;
|
|
88
|
+
},
|
|
89
|
+
broadcast: async (tx) => {
|
|
90
|
+
const decoded = Transaction.from(tx.raw);
|
|
91
|
+
if (decoded.hash !== tx.hash ||
|
|
92
|
+
decoded.from !== signer.address ||
|
|
93
|
+
decoded.to?.toLowerCase() !== contract.toLowerCase() ||
|
|
94
|
+
decoded.chainId !== 8453n ||
|
|
95
|
+
decoded.value !== 0n) {
|
|
96
|
+
throw new Error("SETTLEMENT_JOURNAL_INVALID: saved transaction identity does not match this validator and contract.");
|
|
97
|
+
}
|
|
98
|
+
// If an RPC already knows this exact transaction, wait rather than signing again.
|
|
99
|
+
if (await provider.getTransaction(tx.hash))
|
|
100
|
+
return;
|
|
101
|
+
try {
|
|
102
|
+
await provider.broadcastTransaction(tx.raw);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
if (await provider.getTransaction(tx.hash))
|
|
106
|
+
return;
|
|
107
|
+
throw new Error("SETTLEMENT_BROADCAST_UNCONFIRMED: saved transaction retained; retry to reconcile. No replacement transaction was signed.");
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
wait: async (hash) => {
|
|
111
|
+
try {
|
|
112
|
+
const result = await provider.waitForTransaction(hash, 1, 90_000);
|
|
113
|
+
return result ? { hash: result.hash, status: result.status } : null;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new Error("SETTLEMENT_CONFIRMATION_UNAVAILABLE: saved transaction retained; retry to reconcile.");
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Wallet, Transaction } from "ethers";
|
|
4
|
+
import { createSettlementDriver } from "./settlement-driver.js";
|
|
5
|
+
const plan = {
|
|
6
|
+
version: 1,
|
|
7
|
+
chainId: 8453,
|
|
8
|
+
contract: "0x1111111111111111111111111111111111111111",
|
|
9
|
+
root: "0x" + "ab".repeat(32),
|
|
10
|
+
accounts: ["0x2222222222222222222222222222222222222222"],
|
|
11
|
+
amounts: ["1"],
|
|
12
|
+
receiptIds: ["0x" + "cd".repeat(32)],
|
|
13
|
+
proofs: [[]],
|
|
14
|
+
};
|
|
15
|
+
function fixture(balance) {
|
|
16
|
+
const wallet = Wallet.createRandom();
|
|
17
|
+
let signed = 0;
|
|
18
|
+
const provider = {
|
|
19
|
+
getFeeData: async () => ({
|
|
20
|
+
maxFeePerGas: 100n,
|
|
21
|
+
maxPriorityFeePerGas: 1n,
|
|
22
|
+
gasPrice: 100n,
|
|
23
|
+
}),
|
|
24
|
+
getBalance: async () => balance,
|
|
25
|
+
estimateGas: async () => 50000n,
|
|
26
|
+
};
|
|
27
|
+
const signer = {
|
|
28
|
+
address: wallet.address,
|
|
29
|
+
provider,
|
|
30
|
+
populateTransaction: async (request) => ({
|
|
31
|
+
...request,
|
|
32
|
+
chainId: 8453,
|
|
33
|
+
nonce: 7,
|
|
34
|
+
}),
|
|
35
|
+
signTransaction: async (request) => {
|
|
36
|
+
signed++;
|
|
37
|
+
return wallet.signTransaction(request);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
driver: createSettlementDriver(signer, plan.contract),
|
|
42
|
+
signed: () => signed,
|
|
43
|
+
address: wallet.address,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
test("real settlement driver rejects a low balance before signing publication or claim", async () => {
|
|
47
|
+
const f = fixture(1n);
|
|
48
|
+
await assert.rejects(f.driver.preflight(plan, true, [0]), /INSUFFICIENT_GAS/);
|
|
49
|
+
await assert.rejects(f.driver.prepare("publish", plan, []), /INSUFFICIENT_GAS/);
|
|
50
|
+
assert.equal(f.signed(), 0);
|
|
51
|
+
});
|
|
52
|
+
test("real settlement driver applies fee cap before signing", async () => {
|
|
53
|
+
const previous = process.env.AIPOU_MAX_SETTLEMENT_FEE_WEI;
|
|
54
|
+
try {
|
|
55
|
+
process.env.AIPOU_MAX_SETTLEMENT_FEE_WEI = "1";
|
|
56
|
+
const f = fixture(10n ** 18n);
|
|
57
|
+
await assert.rejects(f.driver.prepare("claim", plan, [0]), /FEE_CAP/);
|
|
58
|
+
assert.equal(f.signed(), 0);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
if (previous === undefined)
|
|
62
|
+
delete process.env.AIPOU_MAX_SETTLEMENT_FEE_WEI;
|
|
63
|
+
else
|
|
64
|
+
process.env.AIPOU_MAX_SETTLEMENT_FEE_WEI = previous;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
test("real settlement driver signs only a zero-value Base call to the claims contract", async () => {
|
|
68
|
+
const f = fixture(10n ** 18n);
|
|
69
|
+
const saved = await f.driver.prepare("publish", plan, []);
|
|
70
|
+
const tx = Transaction.from(saved.raw);
|
|
71
|
+
assert.equal(tx.hash, saved.hash);
|
|
72
|
+
assert.equal(tx.from, f.address);
|
|
73
|
+
assert.equal(tx.to, plan.contract);
|
|
74
|
+
assert.equal(tx.value, 0n);
|
|
75
|
+
assert.equal(tx.chainId, 8453n);
|
|
76
|
+
assert.equal(tx.nonce, 7);
|
|
77
|
+
await assert.rejects(f.driver.broadcast({ ...saved, hash: "0x" + "ef".repeat(32) }), /JOURNAL_INVALID/);
|
|
78
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface SettlementPlan {
|
|
2
|
+
version: 1;
|
|
3
|
+
chainId: number;
|
|
4
|
+
contract: string;
|
|
5
|
+
root: string;
|
|
6
|
+
accounts: string[];
|
|
7
|
+
amounts: string[];
|
|
8
|
+
receiptIds: string[];
|
|
9
|
+
proofs: string[][];
|
|
10
|
+
publish?: SignedSettlementTransaction;
|
|
11
|
+
claim?: SignedSettlementTransaction;
|
|
12
|
+
}
|
|
13
|
+
export interface SignedSettlementTransaction {
|
|
14
|
+
hash: string;
|
|
15
|
+
raw: string;
|
|
16
|
+
}
|
|
17
|
+
export interface SettlementConfirmation {
|
|
18
|
+
hash: string;
|
|
19
|
+
status: number | null;
|
|
20
|
+
}
|
|
21
|
+
export interface SettlementDriver {
|
|
22
|
+
approved(root: string): Promise<boolean>;
|
|
23
|
+
claimed(id: string): Promise<boolean>;
|
|
24
|
+
preflight(plan: SettlementPlan, publishNeeded: boolean, indices: number[]): Promise<void>;
|
|
25
|
+
prepare(kind: "publish" | "claim", plan: SettlementPlan, indices: number[]): Promise<SignedSettlementTransaction>;
|
|
26
|
+
receipt(hash: string): Promise<SettlementConfirmation | null>;
|
|
27
|
+
broadcast(tx: SignedSettlementTransaction): Promise<void>;
|
|
28
|
+
wait(hash: string): Promise<SettlementConfirmation | null>;
|
|
29
|
+
}
|
|
30
|
+
/** Persist signed bytes before broadcast; recovery resends the exact same hash. */
|
|
31
|
+
export declare function executeSettlementPlan(plan: SettlementPlan, driver: SettlementDriver, persist: (plan: SettlementPlan) => Promise<void>): Promise<{
|
|
32
|
+
publishTransaction: string;
|
|
33
|
+
claimTransaction: string;
|
|
34
|
+
}>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Persist signed bytes before broadcast; recovery resends the exact same hash. */
|
|
2
|
+
export async function executeSettlementPlan(plan, driver, persist) {
|
|
3
|
+
const execute = async (kind, indices) => {
|
|
4
|
+
let tx = plan[kind];
|
|
5
|
+
if (!tx) {
|
|
6
|
+
tx = await driver.prepare(kind, plan, indices);
|
|
7
|
+
plan[kind] = tx;
|
|
8
|
+
await persist(plan);
|
|
9
|
+
}
|
|
10
|
+
let result = await driver.receipt(tx.hash);
|
|
11
|
+
if (!result) {
|
|
12
|
+
await driver.broadcast(tx);
|
|
13
|
+
result = await driver.wait(tx.hash);
|
|
14
|
+
}
|
|
15
|
+
if (!result)
|
|
16
|
+
throw new Error("SETTLEMENT_UNCONFIRMED: rerun to reconcile the saved transaction; do not replace it.");
|
|
17
|
+
if (result.hash.toLowerCase() !== tx.hash.toLowerCase() ||
|
|
18
|
+
result.status !== 1) {
|
|
19
|
+
throw new Error("SETTLEMENT_REVERTED: inspect the saved hash before an operator resets the journal.");
|
|
20
|
+
}
|
|
21
|
+
return result.hash;
|
|
22
|
+
};
|
|
23
|
+
if (plan.claim) {
|
|
24
|
+
const claimTransaction = await execute("claim", []);
|
|
25
|
+
return { publishTransaction: plan.publish?.hash ?? "", claimTransaction };
|
|
26
|
+
}
|
|
27
|
+
const claimed = await Promise.all(plan.receiptIds.map((id) => driver.claimed(id)));
|
|
28
|
+
if (claimed.some(Boolean))
|
|
29
|
+
throw new Error("SETTLEMENT_RECONCILIATION_REQUIRED: onchain claims exist without a saved transaction; inspect events before changing local state.");
|
|
30
|
+
const indices = plan.receiptIds.map((_, i) => i);
|
|
31
|
+
const rootApproved = await driver.approved(plan.root);
|
|
32
|
+
await driver.preflight(plan, !rootApproved, indices);
|
|
33
|
+
const publishTransaction = rootApproved
|
|
34
|
+
? (plan.publish?.hash ?? "")
|
|
35
|
+
: await execute("publish", []);
|
|
36
|
+
if (!(await driver.approved(plan.root)))
|
|
37
|
+
throw new Error("SETTLEMENT_ROOT_UNCONFIRMED: rerun to reconcile.");
|
|
38
|
+
const claimTransaction = await execute("claim", indices);
|
|
39
|
+
return { publishTransaction, claimTransaction };
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { executeSettlementPlan, } from "./settlement-runner.js";
|
|
4
|
+
function fixture(approved = false) {
|
|
5
|
+
const plan = {
|
|
6
|
+
version: 1,
|
|
7
|
+
chainId: 8453,
|
|
8
|
+
contract: "claims",
|
|
9
|
+
root: "root",
|
|
10
|
+
accounts: ["wallet"],
|
|
11
|
+
amounts: ["10"],
|
|
12
|
+
receiptIds: ["id"],
|
|
13
|
+
proofs: [[]],
|
|
14
|
+
};
|
|
15
|
+
const confirmed = new Map();
|
|
16
|
+
const prepared = [], sent = [], persisted = [];
|
|
17
|
+
let balance = true, dropReply = false;
|
|
18
|
+
const driver = {
|
|
19
|
+
approved: async () => approved,
|
|
20
|
+
claimed: async () => false,
|
|
21
|
+
preflight: async () => {
|
|
22
|
+
if (!balance)
|
|
23
|
+
throw new Error("SETTLEMENT_INSUFFICIENT_GAS");
|
|
24
|
+
},
|
|
25
|
+
prepare: async (kind) => {
|
|
26
|
+
prepared.push(kind);
|
|
27
|
+
return { hash: kind, raw: kind + "-signed" };
|
|
28
|
+
},
|
|
29
|
+
receipt: async (hash) => confirmed.get(hash) ?? null,
|
|
30
|
+
broadcast: async (tx) => {
|
|
31
|
+
sent.push(tx.hash);
|
|
32
|
+
confirmed.set(tx.hash, { hash: tx.hash, status: 1 });
|
|
33
|
+
if (tx.hash === "publish")
|
|
34
|
+
approved = true;
|
|
35
|
+
if (dropReply) {
|
|
36
|
+
dropReply = false;
|
|
37
|
+
throw new Error("network reply lost");
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
wait: async (hash) => confirmed.get(hash) ?? null,
|
|
41
|
+
};
|
|
42
|
+
const persist = async (p) => {
|
|
43
|
+
persisted.push(JSON.stringify(p));
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
plan,
|
|
47
|
+
driver,
|
|
48
|
+
persist,
|
|
49
|
+
prepared,
|
|
50
|
+
sent,
|
|
51
|
+
persisted,
|
|
52
|
+
confirmed,
|
|
53
|
+
noGas: () => {
|
|
54
|
+
balance = false;
|
|
55
|
+
},
|
|
56
|
+
loseNextReply: () => {
|
|
57
|
+
dropReply = true;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
test("preflight refuses insufficient gas before any transaction is signed or sent", async () => {
|
|
62
|
+
const f = fixture();
|
|
63
|
+
f.noGas();
|
|
64
|
+
await assert.rejects(executeSettlementPlan(f.plan, f.driver, f.persist), /INSUFFICIENT_GAS/);
|
|
65
|
+
assert.deepEqual(f.prepared, []);
|
|
66
|
+
assert.deepEqual(f.sent, []);
|
|
67
|
+
});
|
|
68
|
+
test("already approved root is not republished", async () => {
|
|
69
|
+
const f = fixture(true);
|
|
70
|
+
const result = await executeSettlementPlan(f.plan, f.driver, f.persist);
|
|
71
|
+
assert.deepEqual(f.prepared, ["claim"]);
|
|
72
|
+
assert.equal(result.publishTransaction, "");
|
|
73
|
+
});
|
|
74
|
+
test("lost publication response resumes saved hash and never signs another publication", async () => {
|
|
75
|
+
const f = fixture();
|
|
76
|
+
f.loseNextReply();
|
|
77
|
+
await assert.rejects(executeSettlementPlan(f.plan, f.driver, f.persist), /reply lost/);
|
|
78
|
+
const restored = JSON.parse(f.persisted.at(-1));
|
|
79
|
+
const result = await executeSettlementPlan(restored, f.driver, f.persist);
|
|
80
|
+
assert.equal(result.publishTransaction, "publish");
|
|
81
|
+
assert.deepEqual(f.prepared, ["publish", "claim"]);
|
|
82
|
+
});
|
|
83
|
+
test("crash after signing claim recovers the same bytes, including when broadcast was not attempted", async () => {
|
|
84
|
+
const f = fixture(true);
|
|
85
|
+
let signed;
|
|
86
|
+
f.driver.broadcast = async (tx) => {
|
|
87
|
+
signed = tx;
|
|
88
|
+
throw new Error("offline");
|
|
89
|
+
};
|
|
90
|
+
await assert.rejects(executeSettlementPlan(f.plan, f.driver, f.persist), /offline/);
|
|
91
|
+
const restored = JSON.parse(f.persisted.at(-1));
|
|
92
|
+
f.driver.broadcast = async (tx) => {
|
|
93
|
+
assert.deepEqual(tx, signed);
|
|
94
|
+
f.confirmed.set(tx.hash, { hash: tx.hash, status: 1 });
|
|
95
|
+
};
|
|
96
|
+
await executeSettlementPlan(restored, f.driver, f.persist);
|
|
97
|
+
assert.deepEqual(f.prepared, ["claim"]);
|
|
98
|
+
});
|
|
99
|
+
test("confirmed claim survives a lost response without repeat broadcast", async () => {
|
|
100
|
+
const f = fixture(true);
|
|
101
|
+
f.loseNextReply();
|
|
102
|
+
await assert.rejects(executeSettlementPlan(f.plan, f.driver, f.persist), /reply lost/);
|
|
103
|
+
const result = await executeSettlementPlan(f.plan, f.driver, f.persist);
|
|
104
|
+
assert.equal(result.claimTransaction, "claim");
|
|
105
|
+
assert.deepEqual(f.sent, ["claim"]);
|
|
106
|
+
});
|
|
107
|
+
test("unconfirmed and reverted transactions do not get replaced", async () => {
|
|
108
|
+
const f = fixture(true);
|
|
109
|
+
f.driver.wait = async () => null;
|
|
110
|
+
f.driver.broadcast = async () => { };
|
|
111
|
+
await assert.rejects(executeSettlementPlan(f.plan, f.driver, f.persist), /UNCONFIRMED/);
|
|
112
|
+
f.confirmed.set("claim", { hash: "claim", status: 0 });
|
|
113
|
+
await assert.rejects(executeSettlementPlan(f.plan, f.driver, f.persist), /REVERTED/);
|
|
114
|
+
assert.deepEqual(f.prepared, ["claim"]);
|
|
115
|
+
});
|
|
116
|
+
test("unexpected onchain claims require explicit reconciliation", async () => {
|
|
117
|
+
const f = fixture();
|
|
118
|
+
f.driver.claimed = async () => true;
|
|
119
|
+
await assert.rejects(executeSettlementPlan(f.plan, f.driver, f.persist), /RECONCILIATION_REQUIRED/);
|
|
120
|
+
assert.deepEqual(f.sent, []);
|
|
121
|
+
});
|
|
122
|
+
test("journal persistence failure happens before broadcast", async () => {
|
|
123
|
+
const f = fixture();
|
|
124
|
+
await assert.rejects(executeSettlementPlan(f.plan, f.driver, async () => {
|
|
125
|
+
throw new Error("disk full");
|
|
126
|
+
}), /disk full/);
|
|
127
|
+
assert.deepEqual(f.sent, []);
|
|
128
|
+
});
|
package/dist/status.js
CHANGED
|
@@ -52,6 +52,8 @@ export async function getAipouStatus() {
|
|
|
52
52
|
claimed: claimedReceipts.length,
|
|
53
53
|
pending: walletPendingReceipts.length,
|
|
54
54
|
incompleteSessions: counts.incompleteSessions,
|
|
55
|
+
activeSessions: counts.activeSessions,
|
|
56
|
+
expiredSessions: counts.expiredSessions,
|
|
55
57
|
batches: counts.batches
|
|
56
58
|
},
|
|
57
59
|
rewards: {
|
package/dist/store.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export declare function protocolStateCounts(): Promise<{
|
|
|
8
8
|
sessions: number;
|
|
9
9
|
completedSessions: number;
|
|
10
10
|
incompleteSessions: number;
|
|
11
|
+
activeSessions: number;
|
|
12
|
+
expiredSessions: number;
|
|
11
13
|
batches: number;
|
|
12
14
|
}>;
|
|
13
15
|
export declare function settledReceiptsSince(sinceMs: number): Promise<UsageReceipt[]>;
|