aipou-mcp-server 0.2.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/README.md +70 -0
- package/dist/canonical.d.ts +2 -0
- package/dist/canonical.js +18 -0
- package/dist/claims.d.ts +2 -0
- package/dist/claims.js +150 -0
- package/dist/collector.d.ts +4 -0
- package/dist/collector.js +68 -0
- package/dist/contract.d.ts +17 -0
- package/dist/contract.js +107 -0
- package/dist/identity.d.ts +18 -0
- package/dist/identity.js +59 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +84 -0
- package/dist/lock.d.ts +1 -0
- package/dist/lock.js +42 -0
- package/dist/policy.d.ts +11 -0
- package/dist/policy.js +54 -0
- package/dist/protocol.test.d.ts +1 -0
- package/dist/protocol.test.js +165 -0
- package/dist/providers.d.ts +3 -0
- package/dist/providers.js +32 -0
- package/dist/receipts.d.ts +11 -0
- package/dist/receipts.js +53 -0
- package/dist/rewards.d.ts +7 -0
- package/dist/rewards.js +12 -0
- package/dist/store.d.ts +8 -0
- package/dist/store.js +132 -0
- package/dist/types.d.ts +73 -0
- package/dist/types.js +1 -0
- package/package.json +62 -0
- package/server.json +60 -0
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SkippedReceipt, UsageReceipt } from "./types.js";
|
|
2
|
+
export interface SettlementPolicy {
|
|
3
|
+
minTotalTokens: number;
|
|
4
|
+
maxDailyReceiptsPerWallet: number;
|
|
5
|
+
}
|
|
6
|
+
export declare const DAY_MS: number;
|
|
7
|
+
export declare function settlementPolicyFromEnv(): SettlementPolicy;
|
|
8
|
+
export declare function filterEligibleReceipts(candidates: UsageReceipt[], recentlySettled: UsageReceipt[], policy: SettlementPolicy, now?: number): {
|
|
9
|
+
eligible: UsageReceipt[];
|
|
10
|
+
skipped: SkippedReceipt[];
|
|
11
|
+
};
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
function positiveIntFromEnv(name, fallback) {
|
|
3
|
+
const raw = process.env[name];
|
|
4
|
+
if (!raw)
|
|
5
|
+
return fallback;
|
|
6
|
+
const value = Number.parseInt(raw, 10);
|
|
7
|
+
if (!Number.isInteger(value) || value < 0)
|
|
8
|
+
throw new Error(`${name} must be a non-negative integer`);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
// Both checks are opt-in: 0 (the default) disables them, keeping the original
|
|
12
|
+
// settlement behavior unless the operator explicitly sets the env vars.
|
|
13
|
+
export function settlementPolicyFromEnv() {
|
|
14
|
+
return {
|
|
15
|
+
minTotalTokens: positiveIntFromEnv("AIPOU_MIN_RECEIPT_TOKENS", 0),
|
|
16
|
+
maxDailyReceiptsPerWallet: positiveIntFromEnv("AIPOU_MAX_DAILY_RECEIPTS_PER_WALLET", 0)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function filterEligibleReceipts(candidates, recentlySettled, policy, now = Date.now()) {
|
|
20
|
+
const windowStart = now - DAY_MS;
|
|
21
|
+
const dailyCount = new Map();
|
|
22
|
+
for (const receipt of recentlySettled) {
|
|
23
|
+
if (Date.parse(receipt.recordedAt) >= windowStart) {
|
|
24
|
+
const wallet = receipt.wallet.toLowerCase();
|
|
25
|
+
dailyCount.set(wallet, (dailyCount.get(wallet) ?? 0) + 1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const eligible = [];
|
|
29
|
+
const skipped = [];
|
|
30
|
+
for (const receipt of candidates) {
|
|
31
|
+
const totalTokens = receipt.inputTokens + receipt.outputTokens;
|
|
32
|
+
if (policy.minTotalTokens > 0 && totalTokens < policy.minTotalTokens) {
|
|
33
|
+
skipped.push({
|
|
34
|
+
receiptId: receipt.receiptId,
|
|
35
|
+
wallet: receipt.wallet,
|
|
36
|
+
reason: `below minimum work floor (${totalTokens} < ${policy.minTotalTokens} tokens)`
|
|
37
|
+
});
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const wallet = receipt.wallet.toLowerCase();
|
|
41
|
+
const used = dailyCount.get(wallet) ?? 0;
|
|
42
|
+
if (policy.maxDailyReceiptsPerWallet > 0 && used >= policy.maxDailyReceiptsPerWallet) {
|
|
43
|
+
skipped.push({
|
|
44
|
+
receiptId: receipt.receiptId,
|
|
45
|
+
wallet: receipt.wallet,
|
|
46
|
+
reason: `daily receipt limit reached (${policy.maxDailyReceiptsPerWallet} per wallet per 24h)`
|
|
47
|
+
});
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
dailyCount.set(wallet, used + 1);
|
|
51
|
+
eligible.push(receipt);
|
|
52
|
+
}
|
|
53
|
+
return { eligible, skipped };
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { generateKeyPairSync, sign } from "node:crypto";
|
|
2
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { Wallet, keccak256, toUtf8Bytes } from "ethers";
|
|
8
|
+
import { canonicalJson } from "./canonical.js";
|
|
9
|
+
import { verifyCollectorPayload } from "./collector.js";
|
|
10
|
+
import { verifyTaskAuthorization } from "./identity.js";
|
|
11
|
+
import { filterEligibleReceipts } from "./policy.js";
|
|
12
|
+
import { providerAssertion } from "./providers.js";
|
|
13
|
+
import { beginTask, completeTask } from "./receipts.js";
|
|
14
|
+
import { exportStoredReceipts, markBatchSettled, unsettledReceipts } from "./store.js";
|
|
15
|
+
test("creates verifiable receipts and rejects nonce/evidence replay", async () => {
|
|
16
|
+
const dataDir = await mkdtemp(path.join(tmpdir(), "aipou-protocol-"));
|
|
17
|
+
process.env.AIPOU_DATA_DIR = dataDir;
|
|
18
|
+
process.env.AIPOU_AGENT_PRIVATE_KEY = Wallet.createRandom().privateKey;
|
|
19
|
+
const verifyingContract = Wallet.createRandom().address;
|
|
20
|
+
const taskHash = keccak256(toUtf8Bytes("build a useful artifact"));
|
|
21
|
+
const outputHash = keccak256(toUtf8Bytes("artifact-v1"));
|
|
22
|
+
const session = await beginTask({
|
|
23
|
+
provider: "openai",
|
|
24
|
+
model: "codex",
|
|
25
|
+
taskHash,
|
|
26
|
+
client: "test-client",
|
|
27
|
+
chainId: 8453,
|
|
28
|
+
verifyingContract
|
|
29
|
+
});
|
|
30
|
+
assert.equal(verifyTaskAuthorization(session), true);
|
|
31
|
+
const receipt = await completeTask({
|
|
32
|
+
nonce: session.nonce,
|
|
33
|
+
inputTokens: 1200,
|
|
34
|
+
outputTokens: 500,
|
|
35
|
+
durationSeconds: 180,
|
|
36
|
+
outputHash
|
|
37
|
+
});
|
|
38
|
+
assert.equal(receipt.trustTier, "client_signed");
|
|
39
|
+
const { collectorSignature, batchRoot, claimTransaction, ...signedPayload } = receipt;
|
|
40
|
+
assert.equal(verifyCollectorPayload(signedPayload, collectorSignature, receipt.collectorPublicKey), true);
|
|
41
|
+
await assert.rejects(completeTask({
|
|
42
|
+
nonce: session.nonce,
|
|
43
|
+
inputTokens: 1200,
|
|
44
|
+
outputTokens: 500,
|
|
45
|
+
durationSeconds: 180,
|
|
46
|
+
outputHash
|
|
47
|
+
}), /already been completed/);
|
|
48
|
+
const repeatedSession = await beginTask({
|
|
49
|
+
provider: "openai",
|
|
50
|
+
model: "codex",
|
|
51
|
+
taskHash,
|
|
52
|
+
client: "test-client",
|
|
53
|
+
chainId: 8453,
|
|
54
|
+
verifyingContract
|
|
55
|
+
});
|
|
56
|
+
await assert.rejects(completeTask({
|
|
57
|
+
nonce: repeatedSession.nonce,
|
|
58
|
+
inputTokens: 1200,
|
|
59
|
+
outputTokens: 500,
|
|
60
|
+
durationSeconds: 180,
|
|
61
|
+
outputHash
|
|
62
|
+
}), /Duplicate task\/output evidence/);
|
|
63
|
+
});
|
|
64
|
+
test("derives provider_signed only from a configured valid signature", async () => {
|
|
65
|
+
const dataDir = await mkdtemp(path.join(tmpdir(), "aipou-provider-"));
|
|
66
|
+
process.env.AIPOU_DATA_DIR = dataDir;
|
|
67
|
+
process.env.AIPOU_AGENT_PRIVATE_KEY = Wallet.createRandom().privateKey;
|
|
68
|
+
const pair = generateKeyPairSync("ed25519");
|
|
69
|
+
const publicKey = pair.publicKey.export({ format: "pem", type: "spki" }).toString();
|
|
70
|
+
const keyFile = path.join(dataDir, "provider-keys.json");
|
|
71
|
+
await writeFile(keyFile, JSON.stringify({ local: { "test-key": publicKey } }));
|
|
72
|
+
process.env.AIPOU_PROVIDER_KEYS_FILE = keyFile;
|
|
73
|
+
const session = await beginTask({
|
|
74
|
+
provider: "local",
|
|
75
|
+
model: "signed-model",
|
|
76
|
+
taskHash: keccak256(toUtf8Bytes("provider task")),
|
|
77
|
+
client: "test-client",
|
|
78
|
+
chainId: 8453,
|
|
79
|
+
verifyingContract: Wallet.createRandom().address
|
|
80
|
+
});
|
|
81
|
+
const completion = {
|
|
82
|
+
nonce: session.nonce,
|
|
83
|
+
inputTokens: 2000,
|
|
84
|
+
outputTokens: 750,
|
|
85
|
+
durationSeconds: 240,
|
|
86
|
+
outputHash: keccak256(toUtf8Bytes("provider artifact"))
|
|
87
|
+
};
|
|
88
|
+
const signature = sign(null, Buffer.from(canonicalJson(providerAssertion(session, completion))), pair.privateKey).toString("base64");
|
|
89
|
+
const receipt = await completeTask({
|
|
90
|
+
...completion,
|
|
91
|
+
providerEvidence: { keyId: "test-key", signature }
|
|
92
|
+
});
|
|
93
|
+
assert.equal(receipt.trustTier, "provider_signed");
|
|
94
|
+
});
|
|
95
|
+
function fakeReceipt(overrides) {
|
|
96
|
+
return {
|
|
97
|
+
receiptId: `0x${"0".repeat(63)}1`,
|
|
98
|
+
wallet: "0x1111111111111111111111111111111111111111",
|
|
99
|
+
inputTokens: 1000,
|
|
100
|
+
outputTokens: 500,
|
|
101
|
+
recordedAt: new Date().toISOString(),
|
|
102
|
+
...overrides
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
test("settlement policy skips tiny receipts and enforces the daily wallet limit", () => {
|
|
106
|
+
const policy = { minTotalTokens: 200, maxDailyReceiptsPerWallet: 2 };
|
|
107
|
+
const wallet = "0x1111111111111111111111111111111111111111";
|
|
108
|
+
const candidates = [
|
|
109
|
+
fakeReceipt({ receiptId: "0xaaa1", wallet }),
|
|
110
|
+
fakeReceipt({ receiptId: "0xaaa2", wallet, inputTokens: 50, outputTokens: 20 }),
|
|
111
|
+
fakeReceipt({ receiptId: "0xaaa3", wallet }),
|
|
112
|
+
fakeReceipt({ receiptId: "0xaaa4", wallet })
|
|
113
|
+
];
|
|
114
|
+
const alreadySettledToday = [fakeReceipt({ receiptId: "0xbbb1", wallet, claimTransaction: "0xtx" })];
|
|
115
|
+
const { eligible, skipped } = filterEligibleReceipts(candidates, alreadySettledToday, policy);
|
|
116
|
+
assert.deepEqual(eligible.map((item) => item.receiptId), ["0xaaa1"]);
|
|
117
|
+
assert.equal(skipped.length, 3);
|
|
118
|
+
assert.match(skipped[0].reason, /minimum work floor/);
|
|
119
|
+
assert.match(skipped[1].reason, /daily receipt limit/);
|
|
120
|
+
const staleSettled = [
|
|
121
|
+
fakeReceipt({
|
|
122
|
+
receiptId: "0xccc1",
|
|
123
|
+
wallet,
|
|
124
|
+
claimTransaction: "0xtx",
|
|
125
|
+
recordedAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString()
|
|
126
|
+
})
|
|
127
|
+
];
|
|
128
|
+
const fresh = filterEligibleReceipts([fakeReceipt({ receiptId: "0xddd1", wallet })], staleSettled, policy);
|
|
129
|
+
assert.equal(fresh.eligible.length, 1);
|
|
130
|
+
const disabled = filterEligibleReceipts(candidates, alreadySettledToday, { minTotalTokens: 0, maxDailyReceiptsPerWallet: 0 });
|
|
131
|
+
assert.equal(disabled.eligible.length, candidates.length);
|
|
132
|
+
assert.equal(disabled.skipped.length, 0);
|
|
133
|
+
});
|
|
134
|
+
test("settled receipts move to the archive and stay exportable", async () => {
|
|
135
|
+
const dataDir = await mkdtemp(path.join(tmpdir(), "aipou-archive-"));
|
|
136
|
+
process.env.AIPOU_DATA_DIR = dataDir;
|
|
137
|
+
process.env.AIPOU_AGENT_PRIVATE_KEY = Wallet.createRandom().privateKey;
|
|
138
|
+
const session = await beginTask({
|
|
139
|
+
provider: "local",
|
|
140
|
+
model: "archive-model",
|
|
141
|
+
taskHash: keccak256(toUtf8Bytes("archive task")),
|
|
142
|
+
client: "test-client",
|
|
143
|
+
chainId: 8453,
|
|
144
|
+
verifyingContract: Wallet.createRandom().address
|
|
145
|
+
});
|
|
146
|
+
const receipt = await completeTask({
|
|
147
|
+
nonce: session.nonce,
|
|
148
|
+
inputTokens: 900,
|
|
149
|
+
outputTokens: 300,
|
|
150
|
+
durationSeconds: 60,
|
|
151
|
+
outputHash: keccak256(toUtf8Bytes("archive output"))
|
|
152
|
+
});
|
|
153
|
+
await markBatchSettled({
|
|
154
|
+
root: `0x${"ab".repeat(32)}`,
|
|
155
|
+
receiptIds: [receipt.receiptId],
|
|
156
|
+
publishTransaction: "0xpublish",
|
|
157
|
+
claimTransaction: "0xclaim",
|
|
158
|
+
settledAt: new Date().toISOString()
|
|
159
|
+
});
|
|
160
|
+
assert.equal((await unsettledReceipts(10)).length, 0);
|
|
161
|
+
const exported = await exportStoredReceipts();
|
|
162
|
+
assert.equal(exported.length, 1);
|
|
163
|
+
assert.equal(exported[0].receiptId, receipt.receiptId);
|
|
164
|
+
assert.equal(exported[0].claimTransaction, "0xclaim");
|
|
165
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { CompleteTaskInput, ProviderEvidence, TaskSession, TrustTier } from "./types.js";
|
|
2
|
+
export declare function providerAssertion(session: TaskSession, input: CompleteTaskInput): Record<string, unknown>;
|
|
3
|
+
export declare function deriveTrustTier(session: TaskSession, input: CompleteTaskInput, evidence?: ProviderEvidence): Promise<TrustTier>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { verify } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { canonicalJson } from "./canonical.js";
|
|
5
|
+
async function configuredKeys() {
|
|
6
|
+
const file = process.env.AIPOU_PROVIDER_KEYS_FILE;
|
|
7
|
+
if (!file)
|
|
8
|
+
return {};
|
|
9
|
+
return JSON.parse(await readFile(path.resolve(file), "utf8"));
|
|
10
|
+
}
|
|
11
|
+
export function providerAssertion(session, input) {
|
|
12
|
+
return {
|
|
13
|
+
nonce: session.nonce,
|
|
14
|
+
provider: session.provider,
|
|
15
|
+
model: session.model,
|
|
16
|
+
inputTokens: input.inputTokens,
|
|
17
|
+
outputTokens: input.outputTokens,
|
|
18
|
+
durationSeconds: input.durationSeconds,
|
|
19
|
+
outputHash: input.outputHash
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export async function deriveTrustTier(session, input, evidence) {
|
|
23
|
+
if (!evidence)
|
|
24
|
+
return "client_signed";
|
|
25
|
+
const publicKey = (await configuredKeys())[session.provider]?.[evidence.keyId];
|
|
26
|
+
if (!publicKey)
|
|
27
|
+
throw new Error(`No trusted provider key for ${session.provider}/${evidence.keyId}`);
|
|
28
|
+
const valid = verify(null, Buffer.from(canonicalJson(providerAssertion(session, input))), publicKey, Buffer.from(evidence.signature, "base64"));
|
|
29
|
+
if (!valid)
|
|
30
|
+
throw new Error("Invalid provider signature");
|
|
31
|
+
return "provider_signed";
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CompleteTaskInput, TaskSession, UsageReceipt } from "./types.js";
|
|
2
|
+
export declare function beginTask(input: {
|
|
3
|
+
provider: string;
|
|
4
|
+
model: string;
|
|
5
|
+
taskHash: string;
|
|
6
|
+
client: string;
|
|
7
|
+
chainId: number;
|
|
8
|
+
verifyingContract: string;
|
|
9
|
+
}): Promise<TaskSession>;
|
|
10
|
+
export declare function completeTask(input: CompleteTaskInput): Promise<UsageReceipt>;
|
|
11
|
+
export declare function exportReceipts(wallet?: string): Promise<UsageReceipt[]>;
|
package/dist/receipts.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { canonicalJson, sha256Hex } from "./canonical.js";
|
|
2
|
+
import { getCollectorPublicKey, signCollectorPayload } from "./collector.js";
|
|
3
|
+
import { createTaskSession, verifyTaskAuthorization } from "./identity.js";
|
|
4
|
+
import { deriveTrustTier } from "./providers.js";
|
|
5
|
+
import { estimateReward } from "./rewards.js";
|
|
6
|
+
import { exportStoredReceipts, getSession, saveCompletedReceipt, saveSession } from "./store.js";
|
|
7
|
+
export async function beginTask(input) {
|
|
8
|
+
const session = await createTaskSession(input);
|
|
9
|
+
await saveSession(session);
|
|
10
|
+
return session;
|
|
11
|
+
}
|
|
12
|
+
export async function completeTask(input) {
|
|
13
|
+
const session = await getSession(input.nonce);
|
|
14
|
+
if (!session)
|
|
15
|
+
throw new Error("Unknown task nonce");
|
|
16
|
+
if (session.completedAt)
|
|
17
|
+
throw new Error("Task nonce has already been completed");
|
|
18
|
+
if (!verifyTaskAuthorization(session))
|
|
19
|
+
throw new Error("Invalid EIP-712 wallet authorization");
|
|
20
|
+
const trustTier = await deriveTrustTier(session, input, input.providerEvidence);
|
|
21
|
+
const recordedAt = new Date().toISOString();
|
|
22
|
+
const collectorPublicKey = await getCollectorPublicKey();
|
|
23
|
+
const unsigned = {
|
|
24
|
+
nonce: session.nonce,
|
|
25
|
+
wallet: session.wallet,
|
|
26
|
+
provider: session.provider,
|
|
27
|
+
model: session.model,
|
|
28
|
+
inputTokens: input.inputTokens,
|
|
29
|
+
outputTokens: input.outputTokens,
|
|
30
|
+
durationSeconds: input.durationSeconds,
|
|
31
|
+
taskHash: session.taskHash,
|
|
32
|
+
outputHash: input.outputHash,
|
|
33
|
+
client: session.client,
|
|
34
|
+
trustTier,
|
|
35
|
+
walletAuthorization: session.walletAuthorization,
|
|
36
|
+
authorizationIssuedAt: session.issuedAt,
|
|
37
|
+
authorizationChainId: session.chainId,
|
|
38
|
+
authorizationContract: session.verifyingContract,
|
|
39
|
+
providerEvidence: input.providerEvidence,
|
|
40
|
+
collectorPublicKey,
|
|
41
|
+
recordedAt,
|
|
42
|
+
estimatedReward: estimateReward({ ...input, trustTier })
|
|
43
|
+
};
|
|
44
|
+
const receiptId = sha256Hex(canonicalJson(unsigned));
|
|
45
|
+
const collectorSignature = await signCollectorPayload({ receiptId, ...unsigned });
|
|
46
|
+
const receipt = { receiptId, ...unsigned, collectorSignature };
|
|
47
|
+
const evidenceKey = sha256Hex(canonicalJson({ taskHash: session.taskHash, outputHash: input.outputHash }));
|
|
48
|
+
await saveCompletedReceipt(evidenceKey, receipt);
|
|
49
|
+
return receipt;
|
|
50
|
+
}
|
|
51
|
+
export async function exportReceipts(wallet) {
|
|
52
|
+
return exportStoredReceipts(wallet);
|
|
53
|
+
}
|
package/dist/rewards.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const tierMultiplier = {
|
|
2
|
+
client_signed: 0.75,
|
|
3
|
+
provider_signed: 1.5
|
|
4
|
+
};
|
|
5
|
+
export function estimateReward(input) {
|
|
6
|
+
const totalTokens = Math.max(0, input.inputTokens) + Math.max(0, input.outputTokens);
|
|
7
|
+
const tokenScore = Math.min(totalTokens / 1000, 30);
|
|
8
|
+
const durationScore = Math.min(Math.max(0, input.durationSeconds) / 300, 12);
|
|
9
|
+
const rawReward = (tokenScore + durationScore) * tierMultiplier[input.trustTier];
|
|
10
|
+
const cappedReward = Math.min(rawReward, 50);
|
|
11
|
+
return cappedReward.toFixed(4);
|
|
12
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ClaimBatch, TaskSession, UsageReceipt } from "./types.js";
|
|
2
|
+
export declare function saveSession(session: TaskSession): Promise<void>;
|
|
3
|
+
export declare function getSession(nonce: string): Promise<TaskSession | null>;
|
|
4
|
+
export declare function saveCompletedReceipt(evidenceKey: string, receipt: UsageReceipt): Promise<void>;
|
|
5
|
+
export declare function exportStoredReceipts(wallet?: string): Promise<UsageReceipt[]>;
|
|
6
|
+
export declare function unsettledReceipts(limit: number): Promise<UsageReceipt[]>;
|
|
7
|
+
export declare function settledReceiptsSince(sinceMs: number): Promise<UsageReceipt[]>;
|
|
8
|
+
export declare function markBatchSettled(batch: ClaimBatch): Promise<void>;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { withFileLock } from "./lock.js";
|
|
4
|
+
const emptyState = () => ({ sessions: {}, receipts: [], completedEvidence: {}, batches: [] });
|
|
5
|
+
let mutationQueue = Promise.resolve();
|
|
6
|
+
function dataDir() {
|
|
7
|
+
return path.resolve(process.env.AIPOU_DATA_DIR || ".aipou");
|
|
8
|
+
}
|
|
9
|
+
function statePath() {
|
|
10
|
+
return path.join(dataDir(), "state.json");
|
|
11
|
+
}
|
|
12
|
+
function lockPath() {
|
|
13
|
+
return `${statePath()}.lock`;
|
|
14
|
+
}
|
|
15
|
+
function archiveDir() {
|
|
16
|
+
return path.join(dataDir(), "settled");
|
|
17
|
+
}
|
|
18
|
+
async function readState() {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(await readFile(statePath(), "utf8"));
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === "ENOENT")
|
|
24
|
+
return emptyState();
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function writeState(state) {
|
|
29
|
+
const temporary = `${statePath()}.${process.pid}.tmp`;
|
|
30
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
31
|
+
await rename(temporary, statePath());
|
|
32
|
+
}
|
|
33
|
+
// The in-process queue orders local callers; the file lock orders the multiple
|
|
34
|
+
// MCP server processes (Claude Code, OpenClaw, Codex) that share one data dir.
|
|
35
|
+
// Plain reads stay lock-free: writeState replaces state.json atomically via
|
|
36
|
+
// rename, and every authoritative check re-reads inside mutate().
|
|
37
|
+
async function mutate(operation) {
|
|
38
|
+
const next = mutationQueue.then(async () => {
|
|
39
|
+
await mkdir(dataDir(), { recursive: true });
|
|
40
|
+
return withFileLock(lockPath(), async () => {
|
|
41
|
+
const state = await readState();
|
|
42
|
+
const result = await operation(state);
|
|
43
|
+
await writeState(state);
|
|
44
|
+
return result;
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
mutationQueue = next.catch(() => undefined);
|
|
48
|
+
return next;
|
|
49
|
+
}
|
|
50
|
+
export async function saveSession(session) {
|
|
51
|
+
await mutate((state) => {
|
|
52
|
+
if (state.sessions[session.nonce])
|
|
53
|
+
throw new Error("Task nonce already exists");
|
|
54
|
+
state.sessions[session.nonce] = session;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export async function getSession(nonce) {
|
|
58
|
+
return (await readState()).sessions[nonce] || null;
|
|
59
|
+
}
|
|
60
|
+
export async function saveCompletedReceipt(evidenceKey, receipt) {
|
|
61
|
+
await mutate((state) => {
|
|
62
|
+
const session = state.sessions[receipt.nonce];
|
|
63
|
+
if (!session)
|
|
64
|
+
throw new Error("Unknown task nonce");
|
|
65
|
+
if (session.completedAt)
|
|
66
|
+
throw new Error("Task nonce has already been completed");
|
|
67
|
+
if (state.completedEvidence[evidenceKey]) {
|
|
68
|
+
throw new Error(`Duplicate task/output evidence already recorded as ${state.completedEvidence[evidenceKey]}`);
|
|
69
|
+
}
|
|
70
|
+
if (state.receipts.some((item) => item.receiptId === receipt.receiptId)) {
|
|
71
|
+
throw new Error("Receipt id has already been recorded");
|
|
72
|
+
}
|
|
73
|
+
if ((state.settledReceiptIds ?? []).includes(receipt.receiptId)) {
|
|
74
|
+
throw new Error("Receipt id has already been settled and archived");
|
|
75
|
+
}
|
|
76
|
+
session.completedAt = receipt.recordedAt;
|
|
77
|
+
state.completedEvidence[evidenceKey] = receipt.receiptId;
|
|
78
|
+
state.receipts.push(receipt);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async function readArchivedReceipts() {
|
|
82
|
+
let files;
|
|
83
|
+
try {
|
|
84
|
+
files = await readdir(archiveDir());
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error.code === "ENOENT")
|
|
88
|
+
return [];
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
const receipts = [];
|
|
92
|
+
for (const file of files.filter((name) => name.endsWith(".json")).sort()) {
|
|
93
|
+
receipts.push(...JSON.parse(await readFile(path.join(archiveDir(), file), "utf8")));
|
|
94
|
+
}
|
|
95
|
+
return receipts;
|
|
96
|
+
}
|
|
97
|
+
export async function exportStoredReceipts(wallet) {
|
|
98
|
+
const receipts = [...(await readArchivedReceipts()), ...(await readState()).receipts];
|
|
99
|
+
if (!wallet)
|
|
100
|
+
return receipts;
|
|
101
|
+
return receipts.filter((receipt) => receipt.wallet.toLowerCase() === wallet.toLowerCase());
|
|
102
|
+
}
|
|
103
|
+
export async function unsettledReceipts(limit) {
|
|
104
|
+
return (await readState()).receipts.filter((receipt) => !receipt.claimTransaction).slice(0, limit);
|
|
105
|
+
}
|
|
106
|
+
export async function settledReceiptsSince(sinceMs) {
|
|
107
|
+
const receipts = [...(await readArchivedReceipts()), ...(await readState()).receipts];
|
|
108
|
+
return receipts.filter((receipt) => receipt.claimTransaction && Date.parse(receipt.recordedAt) >= sinceMs);
|
|
109
|
+
}
|
|
110
|
+
export async function markBatchSettled(batch) {
|
|
111
|
+
await mutate(async (state) => {
|
|
112
|
+
const included = new Set(batch.receiptIds);
|
|
113
|
+
const settled = [];
|
|
114
|
+
for (const receipt of state.receipts) {
|
|
115
|
+
if (included.has(receipt.receiptId)) {
|
|
116
|
+
if (receipt.claimTransaction)
|
|
117
|
+
throw new Error(`Receipt ${receipt.receiptId} is already settled`);
|
|
118
|
+
receipt.batchRoot = batch.root;
|
|
119
|
+
receipt.claimTransaction = batch.claimTransaction;
|
|
120
|
+
settled.push(receipt);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Settled receipts move to per-batch archive files so state.json stays
|
|
124
|
+
// small; their ids stay in state to keep the replay check O(state).
|
|
125
|
+
await mkdir(archiveDir(), { recursive: true });
|
|
126
|
+
const archivePath = path.join(archiveDir(), `batch-${batch.root.slice(2, 18)}.json`);
|
|
127
|
+
await writeFile(archivePath, `${JSON.stringify(settled, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
128
|
+
state.receipts = state.receipts.filter((receipt) => !included.has(receipt.receiptId));
|
|
129
|
+
state.settledReceiptIds = [...(state.settledReceiptIds ?? []), ...batch.receiptIds];
|
|
130
|
+
state.batches.push(batch);
|
|
131
|
+
});
|
|
132
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type TrustTier = "client_signed" | "provider_signed";
|
|
2
|
+
export interface TaskSession {
|
|
3
|
+
nonce: string;
|
|
4
|
+
wallet: string;
|
|
5
|
+
provider: string;
|
|
6
|
+
model: string;
|
|
7
|
+
taskHash: string;
|
|
8
|
+
client: string;
|
|
9
|
+
issuedAt: number;
|
|
10
|
+
chainId: number;
|
|
11
|
+
verifyingContract: string;
|
|
12
|
+
walletAuthorization: string;
|
|
13
|
+
completedAt?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ProviderEvidence {
|
|
16
|
+
keyId: string;
|
|
17
|
+
signature: string;
|
|
18
|
+
}
|
|
19
|
+
export interface CompleteTaskInput {
|
|
20
|
+
nonce: string;
|
|
21
|
+
inputTokens: number;
|
|
22
|
+
outputTokens: number;
|
|
23
|
+
durationSeconds: number;
|
|
24
|
+
outputHash: string;
|
|
25
|
+
providerEvidence?: ProviderEvidence;
|
|
26
|
+
}
|
|
27
|
+
export interface UsageReceipt {
|
|
28
|
+
receiptId: string;
|
|
29
|
+
nonce: string;
|
|
30
|
+
wallet: string;
|
|
31
|
+
provider: string;
|
|
32
|
+
model: string;
|
|
33
|
+
inputTokens: number;
|
|
34
|
+
outputTokens: number;
|
|
35
|
+
durationSeconds: number;
|
|
36
|
+
taskHash: string;
|
|
37
|
+
outputHash: string;
|
|
38
|
+
client: string;
|
|
39
|
+
trustTier: TrustTier;
|
|
40
|
+
walletAuthorization: string;
|
|
41
|
+
authorizationIssuedAt: number;
|
|
42
|
+
authorizationChainId: number;
|
|
43
|
+
authorizationContract: string;
|
|
44
|
+
providerEvidence?: ProviderEvidence;
|
|
45
|
+
collectorPublicKey: string;
|
|
46
|
+
collectorSignature: string;
|
|
47
|
+
recordedAt: string;
|
|
48
|
+
estimatedReward: string;
|
|
49
|
+
batchRoot?: string;
|
|
50
|
+
claimTransaction?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface ClaimBatch {
|
|
53
|
+
root: string;
|
|
54
|
+
receiptIds: string[];
|
|
55
|
+
publishTransaction: string;
|
|
56
|
+
claimTransaction: string;
|
|
57
|
+
settledAt: string;
|
|
58
|
+
}
|
|
59
|
+
export interface SkippedReceipt {
|
|
60
|
+
receiptId: string;
|
|
61
|
+
wallet: string;
|
|
62
|
+
reason: string;
|
|
63
|
+
}
|
|
64
|
+
export interface SettlementResult extends ClaimBatch {
|
|
65
|
+
skippedReceipts: SkippedReceipt[];
|
|
66
|
+
}
|
|
67
|
+
export interface ProtocolState {
|
|
68
|
+
sessions: Record<string, TaskSession>;
|
|
69
|
+
receipts: UsageReceipt[];
|
|
70
|
+
completedEvidence: Record<string, string>;
|
|
71
|
+
batches: ClaimBatch[];
|
|
72
|
+
settledReceiptIds?: string[];
|
|
73
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "aipou-mcp-server",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"mcpName": "io.github.0xddneto/ai-proof-of-us",
|
|
5
|
+
"description": "MCP server for private, signed AI task receipts and optional validated AIPOU claims.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"ai-agents",
|
|
10
|
+
"ai-receipts",
|
|
11
|
+
"provenance",
|
|
12
|
+
"base",
|
|
13
|
+
"aipou"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/0xddneto/AI-Proof-of-Us.git",
|
|
19
|
+
"directory": "mcp-server"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/0xddneto/AI-Proof-of-Us#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/0xddneto/AI-Proof-of-Us/issues"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "dist/index.js",
|
|
27
|
+
"types": "dist/index.d.ts",
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md",
|
|
31
|
+
"server.json"
|
|
32
|
+
],
|
|
33
|
+
"bin": {
|
|
34
|
+
"aipou-mcp": "dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"test": "npm run build && node --test dist/protocol.test.js",
|
|
39
|
+
"pack:check": "npm test && npm pack --dry-run",
|
|
40
|
+
"dev": "tsx src/index.ts",
|
|
41
|
+
"start": "node dist/index.js",
|
|
42
|
+
"prepublishOnly": "npm test"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.13.0",
|
|
52
|
+
"dotenv": "^16.6.1",
|
|
53
|
+
"ethers": "^6.17.0",
|
|
54
|
+
"merkletreejs": "^0.6.0",
|
|
55
|
+
"zod": "^3.23.8"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "^22.5.4",
|
|
59
|
+
"tsx": "^4.19.0",
|
|
60
|
+
"typescript": "^5.5.4"
|
|
61
|
+
}
|
|
62
|
+
}
|