tersign 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +53 -3
- package/dist/adapter/x402.d.ts +29 -0
- package/dist/adapter/x402.js +105 -0
- package/dist/assure.d.ts +47 -0
- package/dist/assure.js +68 -0
- package/dist/canonical.d.ts +4 -0
- package/dist/canonical.js +23 -0
- package/dist/compliance/record.d.ts +45 -0
- package/dist/compliance/record.js +94 -0
- package/dist/compliance/types.d.ts +6 -0
- package/dist/compliance/types.js +1 -0
- package/dist/dispute/sign.d.ts +80 -0
- package/dist/dispute/sign.js +212 -0
- package/dist/dispute/types.d.ts +98 -0
- package/dist/dispute/types.js +1 -0
- package/dist/envelope/serialize.d.ts +30 -0
- package/dist/envelope/serialize.js +73 -0
- package/dist/envelope/types.d.ts +49 -0
- package/dist/envelope/types.js +15 -0
- package/dist/evidence/action.d.ts +93 -0
- package/dist/evidence/action.js +70 -0
- package/dist/idempotency/d1.d.ts +32 -0
- package/dist/idempotency/d1.js +46 -0
- package/dist/idempotency/middleware.d.ts +63 -0
- package/dist/idempotency/middleware.js +0 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/ledgerClient.d.ts +34 -0
- package/dist/ledgerClient.js +58 -0
- package/dist/mcp/bin.d.ts +2 -0
- package/dist/mcp/bin.js +5 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +131 -0
- package/dist/mcp/tools.d.ts +57 -0
- package/dist/mcp/tools.js +85 -0
- package/dist/receipt/eip712.d.ts +72 -0
- package/dist/receipt/eip712.js +95 -0
- package/dist/types.d.ts +126 -0
- package/dist/types.js +5 -0
- package/dist/verify-bin.d.ts +2 -0
- package/dist/verify-bin.js +64 -0
- package/package.json +57 -4
- package/index.js +0 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { recoverTypedDataAddress } from 'viem';
|
|
2
|
+
import { digestOf } from '../canonical.js';
|
|
3
|
+
export const DISPUTE_DOMAIN = { name: 'tersign dispute', version: '1', chainId: 1n };
|
|
4
|
+
export const EVIDENCE_DOMAIN = { name: 'tersign evidence', version: '1', chainId: 1n };
|
|
5
|
+
export const CRITERIA_DOMAIN = { name: 'tersign acceptance-criteria', version: '1', chainId: 1n };
|
|
6
|
+
export const DISPUTE_TYPES = {
|
|
7
|
+
DisputeAttestation: [
|
|
8
|
+
{ name: 'version', type: 'uint256' },
|
|
9
|
+
{ name: 'disputeDigest', type: 'bytes32' },
|
|
10
|
+
{ name: 'receiptDigest', type: 'bytes32' },
|
|
11
|
+
{ name: 'openedAt', type: 'uint256' },
|
|
12
|
+
],
|
|
13
|
+
};
|
|
14
|
+
export const EVIDENCE_TYPES = {
|
|
15
|
+
EvidenceAttestation: [
|
|
16
|
+
{ name: 'version', type: 'uint256' },
|
|
17
|
+
{ name: 'evidenceDigest', type: 'bytes32' },
|
|
18
|
+
{ name: 'disputeDigest', type: 'bytes32' },
|
|
19
|
+
{ name: 'submittedAt', type: 'uint256' },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
export const CRITERIA_TYPES = {
|
|
23
|
+
CriteriaAttestation: [
|
|
24
|
+
{ name: 'version', type: 'uint256' },
|
|
25
|
+
{ name: 'criteriaDigest', type: 'bytes32' },
|
|
26
|
+
{ name: 'issuedAt', type: 'uint256' },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
/** Pinned digest of the dispute-layer EIP-712 material. The ledger re-declares these
|
|
30
|
+
* constants (Workers bundle, no shared package yet) and pins the SAME vector — if either
|
|
31
|
+
* side edits a domain or type, the cross-impl test breaks before signatures do. */
|
|
32
|
+
export const DISPUTE_WIRE_VECTOR = digestOf({
|
|
33
|
+
domains: {
|
|
34
|
+
dispute: { ...DISPUTE_DOMAIN, chainId: 1 },
|
|
35
|
+
evidence: { ...EVIDENCE_DOMAIN, chainId: 1 },
|
|
36
|
+
criteria: { ...CRITERIA_DOMAIN, chainId: 1 },
|
|
37
|
+
},
|
|
38
|
+
types: { ...DISPUTE_TYPES, ...EVIDENCE_TYPES, ...CRITERIA_TYPES },
|
|
39
|
+
});
|
|
40
|
+
export function disputeDigest(dispute) {
|
|
41
|
+
return digestOf(dispute);
|
|
42
|
+
}
|
|
43
|
+
export function evidenceDigest(evidence) {
|
|
44
|
+
return digestOf(evidence);
|
|
45
|
+
}
|
|
46
|
+
export function criteriaDigest(criteria) {
|
|
47
|
+
return digestOf(criteria);
|
|
48
|
+
}
|
|
49
|
+
/** Sign a dispute as the CLAIMANT. The ledger only accepts disputes whose recovered
|
|
50
|
+
* signer equals the disputed receipt's `payer` — possession of the paying key IS the
|
|
51
|
+
* standing to dispute. */
|
|
52
|
+
export async function signDispute(dispute, account) {
|
|
53
|
+
if (!account.signTypedData)
|
|
54
|
+
throw new Error('account cannot sign typed data');
|
|
55
|
+
const payload = {
|
|
56
|
+
version: 1,
|
|
57
|
+
disputeDigest: disputeDigest(dispute),
|
|
58
|
+
receiptDigest: dispute.receiptDigest,
|
|
59
|
+
openedAt: dispute.openedAt,
|
|
60
|
+
};
|
|
61
|
+
const signature = await account.signTypedData({
|
|
62
|
+
domain: DISPUTE_DOMAIN,
|
|
63
|
+
types: DISPUTE_TYPES,
|
|
64
|
+
primaryType: 'DisputeAttestation',
|
|
65
|
+
message: {
|
|
66
|
+
version: BigInt(payload.version),
|
|
67
|
+
disputeDigest: payload.disputeDigest,
|
|
68
|
+
receiptDigest: payload.receiptDigest,
|
|
69
|
+
openedAt: BigInt(payload.openedAt),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
return { dispute, attestation: { format: 'eip712', payload, signature } };
|
|
73
|
+
}
|
|
74
|
+
export async function verifyDispute(signed, expectedSigner) {
|
|
75
|
+
const { dispute, attestation } = signed;
|
|
76
|
+
if (attestation.format !== 'eip712')
|
|
77
|
+
return { valid: false, reason: 'jws not implemented in v0' };
|
|
78
|
+
if (attestation.payload.disputeDigest !== disputeDigest(dispute)) {
|
|
79
|
+
return { valid: false, reason: 'dispute digest mismatch — dispute was altered after signing' };
|
|
80
|
+
}
|
|
81
|
+
if (attestation.payload.receiptDigest !== dispute.receiptDigest) {
|
|
82
|
+
return { valid: false, reason: 'attestation/receipt digest mismatch' };
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const signer = await recoverTypedDataAddress({
|
|
86
|
+
domain: DISPUTE_DOMAIN,
|
|
87
|
+
types: DISPUTE_TYPES,
|
|
88
|
+
primaryType: 'DisputeAttestation',
|
|
89
|
+
message: {
|
|
90
|
+
version: BigInt(attestation.payload.version),
|
|
91
|
+
disputeDigest: attestation.payload.disputeDigest,
|
|
92
|
+
receiptDigest: attestation.payload.receiptDigest,
|
|
93
|
+
openedAt: BigInt(attestation.payload.openedAt),
|
|
94
|
+
},
|
|
95
|
+
signature: attestation.signature,
|
|
96
|
+
});
|
|
97
|
+
if (expectedSigner && signer.toLowerCase() !== expectedSigner.toLowerCase()) {
|
|
98
|
+
return { valid: false, signer, reason: 'signer is not the receipt payer' };
|
|
99
|
+
}
|
|
100
|
+
return { valid: true, signer };
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
return { valid: false, reason: e instanceof Error ? e.message : 'signature recovery failed' };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Sign evidence as either party. Role/identity binding is enforced by the ledger:
|
|
107
|
+
* claimant evidence must recover to the receipt payer, respondent evidence to the
|
|
108
|
+
* seller's payTo key. */
|
|
109
|
+
export async function signEvidence(evidence, account) {
|
|
110
|
+
if (!account.signTypedData)
|
|
111
|
+
throw new Error('account cannot sign typed data');
|
|
112
|
+
const payload = {
|
|
113
|
+
version: 1,
|
|
114
|
+
evidenceDigest: evidenceDigest(evidence),
|
|
115
|
+
disputeDigest: evidence.disputeDigest,
|
|
116
|
+
submittedAt: evidence.submittedAt,
|
|
117
|
+
};
|
|
118
|
+
const signature = await account.signTypedData({
|
|
119
|
+
domain: EVIDENCE_DOMAIN,
|
|
120
|
+
types: EVIDENCE_TYPES,
|
|
121
|
+
primaryType: 'EvidenceAttestation',
|
|
122
|
+
message: {
|
|
123
|
+
version: BigInt(payload.version),
|
|
124
|
+
evidenceDigest: payload.evidenceDigest,
|
|
125
|
+
disputeDigest: payload.disputeDigest,
|
|
126
|
+
submittedAt: BigInt(payload.submittedAt),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
return { evidence, attestation: { format: 'eip712', payload, signature } };
|
|
130
|
+
}
|
|
131
|
+
export async function verifyEvidence(signed, expectedSigner) {
|
|
132
|
+
const { evidence, attestation } = signed;
|
|
133
|
+
if (attestation.format !== 'eip712')
|
|
134
|
+
return { valid: false, reason: 'jws not implemented in v0' };
|
|
135
|
+
if (attestation.payload.evidenceDigest !== evidenceDigest(evidence)) {
|
|
136
|
+
return { valid: false, reason: 'evidence digest mismatch — evidence was altered after signing' };
|
|
137
|
+
}
|
|
138
|
+
if (attestation.payload.disputeDigest !== evidence.disputeDigest) {
|
|
139
|
+
return { valid: false, reason: 'attestation/dispute digest mismatch' };
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const signer = await recoverTypedDataAddress({
|
|
143
|
+
domain: EVIDENCE_DOMAIN,
|
|
144
|
+
types: EVIDENCE_TYPES,
|
|
145
|
+
primaryType: 'EvidenceAttestation',
|
|
146
|
+
message: {
|
|
147
|
+
version: BigInt(attestation.payload.version),
|
|
148
|
+
evidenceDigest: attestation.payload.evidenceDigest,
|
|
149
|
+
disputeDigest: attestation.payload.disputeDigest,
|
|
150
|
+
submittedAt: BigInt(attestation.payload.submittedAt),
|
|
151
|
+
},
|
|
152
|
+
signature: attestation.signature,
|
|
153
|
+
});
|
|
154
|
+
if (expectedSigner && signer.toLowerCase() !== expectedSigner.toLowerCase()) {
|
|
155
|
+
return { valid: false, signer, reason: 'unexpected signer for this evidence role' };
|
|
156
|
+
}
|
|
157
|
+
return { valid: true, signer };
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
return { valid: false, reason: e instanceof Error ? e.message : 'signature recovery failed' };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Sign acceptance criteria as the SELLER, pre-committing the machine-checkable bar the
|
|
164
|
+
* arbiter will hold the delivery to. Attach the returned digest to offers/records. */
|
|
165
|
+
export async function signCriteria(criteria, account) {
|
|
166
|
+
if (!account.signTypedData)
|
|
167
|
+
throw new Error('account cannot sign typed data');
|
|
168
|
+
const payload = {
|
|
169
|
+
version: 1,
|
|
170
|
+
criteriaDigest: criteriaDigest(criteria),
|
|
171
|
+
issuedAt: criteria.issuedAt,
|
|
172
|
+
};
|
|
173
|
+
const signature = await account.signTypedData({
|
|
174
|
+
domain: CRITERIA_DOMAIN,
|
|
175
|
+
types: CRITERIA_TYPES,
|
|
176
|
+
primaryType: 'CriteriaAttestation',
|
|
177
|
+
message: {
|
|
178
|
+
version: BigInt(payload.version),
|
|
179
|
+
criteriaDigest: payload.criteriaDigest,
|
|
180
|
+
issuedAt: BigInt(payload.issuedAt),
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
return { criteria, attestation: { format: 'eip712', payload, signature } };
|
|
184
|
+
}
|
|
185
|
+
export async function verifyCriteria(signed, expectedSigner) {
|
|
186
|
+
const { criteria, attestation } = signed;
|
|
187
|
+
if (attestation.format !== 'eip712')
|
|
188
|
+
return { valid: false, reason: 'jws not implemented in v0' };
|
|
189
|
+
if (attestation.payload.criteriaDigest !== criteriaDigest(criteria)) {
|
|
190
|
+
return { valid: false, reason: 'criteria digest mismatch — criteria were altered after signing' };
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const signer = await recoverTypedDataAddress({
|
|
194
|
+
domain: CRITERIA_DOMAIN,
|
|
195
|
+
types: CRITERIA_TYPES,
|
|
196
|
+
primaryType: 'CriteriaAttestation',
|
|
197
|
+
message: {
|
|
198
|
+
version: BigInt(attestation.payload.version),
|
|
199
|
+
criteriaDigest: attestation.payload.criteriaDigest,
|
|
200
|
+
issuedAt: BigInt(attestation.payload.issuedAt),
|
|
201
|
+
},
|
|
202
|
+
signature: attestation.signature,
|
|
203
|
+
});
|
|
204
|
+
if (expectedSigner && signer.toLowerCase() !== expectedSigner.toLowerCase()) {
|
|
205
|
+
return { valid: false, signer, reason: 'unexpected signer' };
|
|
206
|
+
}
|
|
207
|
+
return { valid: true, signer };
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
return { valid: false, reason: e instanceof Error ? e.message : 'signature recovery failed' };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { SignedArtifact } from '../types.js';
|
|
2
|
+
export type { VerifyLike } from '../compliance/types.js';
|
|
3
|
+
/** Tersign dispute layer v0 — the genuinely unowned layer of agent commerce (x402 ships
|
|
4
|
+
* receipts + idempotency natively; disputes are explicitly out of scope upstream).
|
|
5
|
+
*
|
|
6
|
+
* Design constraints (do not relax):
|
|
7
|
+
* - OBJECTIVE disputes only in v1: reasons limited to machine-decidable or
|
|
8
|
+
* default-judgment-decidable claims; anything contested and non-mechanical ESCALATES.
|
|
9
|
+
* - SIGNED-EVIDENCE-ONLY: the adjudicator reads signed artifacts + ledger records; free-text
|
|
10
|
+
* from either party is never an input channel (prompt-injection neutralization).
|
|
11
|
+
* - Pre-committed outcomes: verdict ∈ {refund, uphold}; the arbiter can never invent
|
|
12
|
+
* a third destination for value.
|
|
13
|
+
* Everything is content-addressed (keccak digests of canonical JSON) and signed via
|
|
14
|
+
* digest-bound EIP-712 attestations, so payload schemas can evolve without breaking
|
|
15
|
+
* signatures — same pattern as compliance records. */
|
|
16
|
+
/** v1 objective reason codes. `quality`-style subjective claims are deliberately absent. */
|
|
17
|
+
export type DisputeReason = 'not_delivered' | 'wrong_content' | 'duplicate_charge';
|
|
18
|
+
export type DisputeVerdict = 'refund' | 'uphold';
|
|
19
|
+
export type DisputeStatus = 'open' | 'adjudicated' | 'escalated' | 'closed';
|
|
20
|
+
export interface DisputePayloadV1 {
|
|
21
|
+
version: 1;
|
|
22
|
+
/** digest of the receipt artifact being disputed */
|
|
23
|
+
receiptDigest: `0x${string}`;
|
|
24
|
+
reason: DisputeReason;
|
|
25
|
+
/** claimed refund, decimal string in the settlement asset/currency of the receipt */
|
|
26
|
+
claimAmount: string;
|
|
27
|
+
/** digest of the seller's pre-committed acceptance criteria, when the offer carried one */
|
|
28
|
+
criteriaDigest?: `0x${string}`;
|
|
29
|
+
/** free text for HUMANS reviewing the record — never an adjudication input */
|
|
30
|
+
statement?: string;
|
|
31
|
+
/** unix seconds */
|
|
32
|
+
openedAt: number;
|
|
33
|
+
}
|
|
34
|
+
/** Machine-checkable acceptance criteria a seller pre-commits at offer time. The
|
|
35
|
+
* pre-commitment is the contract surface: the arbiter selects between outcomes by
|
|
36
|
+
* evaluating THESE, not by judging quality after the fact. */
|
|
37
|
+
export interface CriterionV1 {
|
|
38
|
+
id: string;
|
|
39
|
+
kind: 'content-digest' | 'delivered-by' | 'no-duplicate-charge';
|
|
40
|
+
/** kind-specific parameters, all string-valued (e.g. { expected: "0x…" } for
|
|
41
|
+
* content-digest, { withinSeconds: "300" } for delivered-by) */
|
|
42
|
+
params: Record<string, string>;
|
|
43
|
+
description: string;
|
|
44
|
+
}
|
|
45
|
+
export interface AcceptanceCriteriaV1 {
|
|
46
|
+
version: 1;
|
|
47
|
+
resourceUrl: string;
|
|
48
|
+
criteria: CriterionV1[];
|
|
49
|
+
/** unix seconds */
|
|
50
|
+
issuedAt: number;
|
|
51
|
+
}
|
|
52
|
+
export interface EvidenceArtifactRef {
|
|
53
|
+
kind: 'content-digest' | 'delivery-attestation' | 'payment-proof' | 'transcript';
|
|
54
|
+
/** digest of the underlying artifact (content, attestation JSON, tx proof, transcript) */
|
|
55
|
+
digest: `0x${string}`;
|
|
56
|
+
/** unix seconds the referenced event happened, when applicable */
|
|
57
|
+
at?: number;
|
|
58
|
+
note?: string;
|
|
59
|
+
}
|
|
60
|
+
export interface EvidencePayloadV1 {
|
|
61
|
+
version: 1;
|
|
62
|
+
/** content address of the dispute this evidence answers */
|
|
63
|
+
disputeDigest: `0x${string}`;
|
|
64
|
+
role: 'claimant' | 'respondent';
|
|
65
|
+
artifacts: EvidenceArtifactRef[];
|
|
66
|
+
/** unix seconds */
|
|
67
|
+
submittedAt: number;
|
|
68
|
+
}
|
|
69
|
+
/** Digest-bound attestation payloads (the only EIP-712-signed shapes — fixed forever). */
|
|
70
|
+
export interface DisputeAttestationPayload {
|
|
71
|
+
version: 1;
|
|
72
|
+
disputeDigest: `0x${string}`;
|
|
73
|
+
receiptDigest: `0x${string}`;
|
|
74
|
+
openedAt: number;
|
|
75
|
+
}
|
|
76
|
+
export interface EvidenceAttestationPayload {
|
|
77
|
+
version: 1;
|
|
78
|
+
evidenceDigest: `0x${string}`;
|
|
79
|
+
disputeDigest: `0x${string}`;
|
|
80
|
+
submittedAt: number;
|
|
81
|
+
}
|
|
82
|
+
export interface CriteriaAttestationPayload {
|
|
83
|
+
version: 1;
|
|
84
|
+
criteriaDigest: `0x${string}`;
|
|
85
|
+
issuedAt: number;
|
|
86
|
+
}
|
|
87
|
+
export type SignedDispute = {
|
|
88
|
+
dispute: DisputePayloadV1;
|
|
89
|
+
attestation: SignedArtifact<DisputeAttestationPayload>;
|
|
90
|
+
};
|
|
91
|
+
export type SignedEvidence = {
|
|
92
|
+
evidence: EvidencePayloadV1;
|
|
93
|
+
attestation: SignedArtifact<EvidenceAttestationPayload>;
|
|
94
|
+
};
|
|
95
|
+
export type SignedCriteria = {
|
|
96
|
+
criteria: AcceptanceCriteriaV1;
|
|
97
|
+
attestation: SignedArtifact<CriteriaAttestationPayload>;
|
|
98
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Venue serializers for EvidenceEnvelopeV1. Pure functions — the same envelope serializes for
|
|
2
|
+
* every venue, which is what makes the adapter venue-neutral (supplier-to-N-courts, never
|
|
3
|
+
* bound to one). MUST stay byte-identical to @tersign/ledger's copy (envelope.ts); the
|
|
4
|
+
* cross-impl vector is pinned in both test suites.
|
|
5
|
+
*
|
|
6
|
+
* Anti-injection invariant (all venues): party-supplied text NEVER mixes with ledger-attested
|
|
7
|
+
* prose. It travels either under a self-describing key (`unverifiedPartyStatement`) or after
|
|
8
|
+
* the fixed UNVERIFIED_CLAIM_MARKER, always LAST, so no attested content follows untrusted
|
|
9
|
+
* bytes. A statement can therefore never impersonate the neutral ledger voice to a human or
|
|
10
|
+
* LLM juror. */
|
|
11
|
+
import { type EvidenceEnvelopeV1 } from './types.js';
|
|
12
|
+
export declare function assertEnvelopeCaps(env: EvidenceEnvelopeV1): void;
|
|
13
|
+
/** Internet Court submission: a self-describing JSON string that fits the evidenceDefs slot
|
|
14
|
+
* (≤5,000 chars). Carries digests + verifyUrl, never raw evidence. */
|
|
15
|
+
export declare function toInternetCourtSubmission(env: EvidenceEnvelopeV1): string;
|
|
16
|
+
/** Kleros ERC-1497 evidence JSON. fileURI resolves to the public verify endpoint; fileHash is
|
|
17
|
+
* the artifact digest. The party statement travels in its own self-describing extension field,
|
|
18
|
+
* never inside the attested description (ERC-1497 JSON tolerates extra fields). */
|
|
19
|
+
export interface Kleros1497Evidence {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
fileURI: string;
|
|
23
|
+
fileHash: string;
|
|
24
|
+
fileTypeExtension: string;
|
|
25
|
+
unverifiedPartyStatement?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function toKlerosEvidence(env: EvidenceEnvelopeV1): Kleros1497Evidence;
|
|
28
|
+
/** UMA-style claim: a compact assertion string a verifier can check mechanically. The party
|
|
29
|
+
* statement, when present, is appended LAST behind the fixed untrusted-text marker. */
|
|
30
|
+
export declare function toUMAClaim(env: EvidenceEnvelopeV1): string;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Venue serializers for EvidenceEnvelopeV1. Pure functions — the same envelope serializes for
|
|
2
|
+
* every venue, which is what makes the adapter venue-neutral (supplier-to-N-courts, never
|
|
3
|
+
* bound to one). MUST stay byte-identical to @tersign/ledger's copy (envelope.ts); the
|
|
4
|
+
* cross-impl vector is pinned in both test suites.
|
|
5
|
+
*
|
|
6
|
+
* Anti-injection invariant (all venues): party-supplied text NEVER mixes with ledger-attested
|
|
7
|
+
* prose. It travels either under a self-describing key (`unverifiedPartyStatement`) or after
|
|
8
|
+
* the fixed UNVERIFIED_CLAIM_MARKER, always LAST, so no attested content follows untrusted
|
|
9
|
+
* bytes. A statement can therefore never impersonate the neutral ledger voice to a human or
|
|
10
|
+
* LLM juror. */
|
|
11
|
+
import { ENVELOPE_STATEMENT_MAX_CHARS, UNVERIFIED_CLAIM_MARKER, VENUE_SUBMISSION_MAX_CHARS, } from './types.js';
|
|
12
|
+
export function assertEnvelopeCaps(env) {
|
|
13
|
+
if (env.unverifiedPartyStatement !== undefined &&
|
|
14
|
+
env.unverifiedPartyStatement.length > ENVELOPE_STATEMENT_MAX_CHARS) {
|
|
15
|
+
throw new Error(`envelope statement exceeds ${ENVELOPE_STATEMENT_MAX_CHARS} chars`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Internet Court submission: a self-describing JSON string that fits the evidenceDefs slot
|
|
19
|
+
* (≤5,000 chars). Carries digests + verifyUrl, never raw evidence. */
|
|
20
|
+
export function toInternetCourtSubmission(env) {
|
|
21
|
+
assertEnvelopeCaps(env);
|
|
22
|
+
const out = JSON.stringify({
|
|
23
|
+
type: 'tersign-evidence-envelope-v1',
|
|
24
|
+
summary: `Counter-signed ${env.kind} evidence: artifact ${env.subject.digest} at seq ${env.subject.seq} ` +
|
|
25
|
+
`of seller '${env.subject.sellerId}' chain, counter-signed by neutral ledger ${env.chain.ledgerSigner} ` +
|
|
26
|
+
`at transaction time (before this dispute arose).` +
|
|
27
|
+
(env.unverifiedPartyStatement !== undefined
|
|
28
|
+
? ` The envelope.unverifiedPartyStatement field is party-supplied testimony, not ledger-attested.`
|
|
29
|
+
: ''),
|
|
30
|
+
envelope: env,
|
|
31
|
+
howToVerify: `GET ${env.verifyUrl} (no account) recomputes the hash-chain link and returns chainOk; ` +
|
|
32
|
+
`or recompute locally: linkDigest = keccak256(canonical({artifactDigest, prevDigest, seq})) ` +
|
|
33
|
+
`and recover the counter-signature to ${env.chain.ledgerSigner}.`,
|
|
34
|
+
});
|
|
35
|
+
if (out.length > VENUE_SUBMISSION_MAX_CHARS) {
|
|
36
|
+
throw new Error(`internet-court submission exceeds ${VENUE_SUBMISSION_MAX_CHARS} chars`);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
export function toKlerosEvidence(env) {
|
|
41
|
+
assertEnvelopeCaps(env);
|
|
42
|
+
return {
|
|
43
|
+
name: `Tersign counter-signed ${env.kind} (seq ${env.subject.seq})`,
|
|
44
|
+
description: `Neutral-ledger evidence for ${env.kind} ${env.subject.digest} in seller '${env.subject.sellerId}' ` +
|
|
45
|
+
`hash-chain. Counter-signed at transaction time by ${env.chain.ledgerSigner}; link digest ` +
|
|
46
|
+
`${env.chain.linkDigest}. Verify without trusting any party at the fileURI.` +
|
|
47
|
+
(env.unverifiedPartyStatement !== undefined
|
|
48
|
+
? ` A party-supplied claim accompanies this evidence in the unverifiedPartyStatement field (not ledger-attested).`
|
|
49
|
+
: ''),
|
|
50
|
+
fileURI: env.verifyUrl,
|
|
51
|
+
fileHash: env.subject.digest,
|
|
52
|
+
fileTypeExtension: 'json',
|
|
53
|
+
...(env.unverifiedPartyStatement !== undefined
|
|
54
|
+
? { unverifiedPartyStatement: env.unverifiedPartyStatement }
|
|
55
|
+
: {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** UMA-style claim: a compact assertion string a verifier can check mechanically. The party
|
|
59
|
+
* statement, when present, is appended LAST behind the fixed untrusted-text marker. */
|
|
60
|
+
export function toUMAClaim(env) {
|
|
61
|
+
assertEnvelopeCaps(env);
|
|
62
|
+
const claim = `Tersign evidence envelope v1: ${env.kind} ${env.subject.digest} is entry seq ${env.subject.seq} ` +
|
|
63
|
+
`(prev ${env.chain.prevDigest ?? 'genesis'}) of seller '${env.subject.sellerId}' hash-chain, ` +
|
|
64
|
+
`counter-signed by ${env.chain.ledgerSigner} (link ${env.chain.linkDigest}). ` +
|
|
65
|
+
`Verifiable at ${env.verifyUrl}.` +
|
|
66
|
+
(env.unverifiedPartyStatement !== undefined
|
|
67
|
+
? ` ${UNVERIFIED_CLAIM_MARKER} ${env.unverifiedPartyStatement}`
|
|
68
|
+
: '');
|
|
69
|
+
if (claim.length > VENUE_SUBMISSION_MAX_CHARS) {
|
|
70
|
+
throw new Error(`uma claim exceeds ${VENUE_SUBMISSION_MAX_CHARS} chars`);
|
|
71
|
+
}
|
|
72
|
+
return claim;
|
|
73
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Evidence envelope v1 — the venue-neutral core of the evidence adapter.
|
|
2
|
+
*
|
|
3
|
+
* An envelope carries DIGESTS + a public verification URL, never raw evidence: the receiving
|
|
4
|
+
* venue (Internet Court, Kleros, UMA, or any future court) resolves `verifyUrl` to recompute
|
|
5
|
+
* the hash-chain link and recover the ledger counter-signature itself. Self-submitted evidence
|
|
6
|
+
* is testimony; a counter-signed contemporaneous record is an exhibit — the envelope is how an
|
|
7
|
+
* exhibit travels into a venue whose submission slot is a bounded text/JSON blob. */
|
|
8
|
+
/** 'refund' is deliberately absent: refunds live in their own table and are not yet servable
|
|
9
|
+
* as envelopes — the type must never advertise an evidence class the endpoint can't produce. */
|
|
10
|
+
export type EnvelopeSubjectKind = 'receipt' | 'action';
|
|
11
|
+
export interface EvidenceEnvelopeV1 {
|
|
12
|
+
version: 1;
|
|
13
|
+
schema: 'tersign-evidence-envelope-v1';
|
|
14
|
+
/** what the chained artifact is */
|
|
15
|
+
kind: EnvelopeSubjectKind;
|
|
16
|
+
subject: {
|
|
17
|
+
/** digest of the seller-signed artifact (receipt / action record / refund record) */
|
|
18
|
+
digest: `0x${string}`;
|
|
19
|
+
sellerId: string;
|
|
20
|
+
/** position in the seller's chain */
|
|
21
|
+
seq: number;
|
|
22
|
+
};
|
|
23
|
+
chain: {
|
|
24
|
+
prevDigest: `0x${string}` | null;
|
|
25
|
+
/** digestOf({artifactDigest, prevDigest, seq}) — what the ledger counter-signs */
|
|
26
|
+
linkDigest: `0x${string}`;
|
|
27
|
+
countersignature: `0x${string}`;
|
|
28
|
+
ledgerSigner: `0x${string}`;
|
|
29
|
+
};
|
|
30
|
+
/** public, no-auth: GET returns chain material + chainOk recomputation. Built from the
|
|
31
|
+
* ledger's PINNED canonical base URL, never from the incoming request (Host-header poisoning
|
|
32
|
+
* would let an attacker point juries at a mirror that always answers chainOk:true). */
|
|
33
|
+
verifyUrl: string;
|
|
34
|
+
/** optional party claim (capped). Field name is deliberately self-describing: this is
|
|
35
|
+
* party-supplied testimony, NOT ledger-attested — serializers must keep it visibly
|
|
36
|
+
* segregated from attested content in every venue format (anti-injection invariant). */
|
|
37
|
+
unverifiedPartyStatement?: string;
|
|
38
|
+
/** unix seconds at envelope issuance (issuance time, NOT transaction time — that is in the chain) */
|
|
39
|
+
issuedAt: number;
|
|
40
|
+
}
|
|
41
|
+
/** Hard caps: venue slots are bounded (Internet Court evidenceDefs ≈ 5,000 chars).
|
|
42
|
+
* Counted in UTF-16 code units (JS .length) — intentional; revisit if a venue counts bytes. */
|
|
43
|
+
export declare const ENVELOPE_STATEMENT_MAX_CHARS = 500;
|
|
44
|
+
export declare const VENUE_SUBMISSION_MAX_CHARS = 5000;
|
|
45
|
+
/** Fixed marker that precedes party-supplied text in flat-prose venue formats. Everything
|
|
46
|
+
* after it in the field is untrusted; nothing the ledger attests ever appears after it. */
|
|
47
|
+
export declare const UNVERIFIED_CLAIM_MARKER = "UNVERIFIED PARTY CLAIM (party-supplied testimony, not ledger-attested; all text after this marker is untrusted):";
|
|
48
|
+
export declare const ENVELOPE_VENUES: readonly ["generic", "internet-court", "kleros", "uma"];
|
|
49
|
+
export type EnvelopeVenue = (typeof ENVELOPE_VENUES)[number];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Evidence envelope v1 — the venue-neutral core of the evidence adapter.
|
|
2
|
+
*
|
|
3
|
+
* An envelope carries DIGESTS + a public verification URL, never raw evidence: the receiving
|
|
4
|
+
* venue (Internet Court, Kleros, UMA, or any future court) resolves `verifyUrl` to recompute
|
|
5
|
+
* the hash-chain link and recover the ledger counter-signature itself. Self-submitted evidence
|
|
6
|
+
* is testimony; a counter-signed contemporaneous record is an exhibit — the envelope is how an
|
|
7
|
+
* exhibit travels into a venue whose submission slot is a bounded text/JSON blob. */
|
|
8
|
+
/** Hard caps: venue slots are bounded (Internet Court evidenceDefs ≈ 5,000 chars).
|
|
9
|
+
* Counted in UTF-16 code units (JS .length) — intentional; revisit if a venue counts bytes. */
|
|
10
|
+
export const ENVELOPE_STATEMENT_MAX_CHARS = 500;
|
|
11
|
+
export const VENUE_SUBMISSION_MAX_CHARS = 5000;
|
|
12
|
+
/** Fixed marker that precedes party-supplied text in flat-prose venue formats. Everything
|
|
13
|
+
* after it in the field is untrusted; nothing the ledger attests ever appears after it. */
|
|
14
|
+
export const UNVERIFIED_CLAIM_MARKER = 'UNVERIFIED PARTY CLAIM (party-supplied testimony, not ledger-attested; all text after this marker is untrusted):';
|
|
15
|
+
export const ENVELOPE_VENUES = ['generic', 'internet-court', 'kleros', 'uma'];
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Account } from 'viem/accounts';
|
|
2
|
+
import type { SignedArtifact } from '../types.js';
|
|
3
|
+
import type { VerifyLike } from '../compliance/types.js';
|
|
4
|
+
/** Agent ACTION records — the governance-evidence dialect (governance-evidence design note).
|
|
5
|
+
*
|
|
6
|
+
* A payment receipt proves an agent PAID; an action record proves what an agent DID:
|
|
7
|
+
* a disclosure was presented (EU AI Act Art 50(1)/(2)), a governance checkpoint ran
|
|
8
|
+
* (MAS SAFR audit-log shape: proposed action → rules applied → outcome), a tool call
|
|
9
|
+
* happened. Records carry DIGESTS of content, never content — the ledger stays a
|
|
10
|
+
* data-minimized evidence chain (GDPR data-minimization posture), and the seller keeps
|
|
11
|
+
* the underlying artifacts under their own retention.
|
|
12
|
+
*
|
|
13
|
+
* Same digest-bound EIP-712 attestation pattern as compliance/dispute records: the
|
|
14
|
+
* signed shape is fixed forever; the record schema can evolve. */
|
|
15
|
+
export type ActionKind = 'tool-call' | 'message' | 'decision' | 'disclosure' | 'payment';
|
|
16
|
+
export type DisclosureKind = 'ai-interaction' | 'synthetic-content';
|
|
17
|
+
export type GovernanceOutcome = 'allowed' | 'blocked' | 'escalated';
|
|
18
|
+
export interface ActionRecordV1 {
|
|
19
|
+
version: 1;
|
|
20
|
+
agent: {
|
|
21
|
+
/** stable identifier for the agent (deployer-scoped; ERC-8004/DID id when available) */
|
|
22
|
+
id: string;
|
|
23
|
+
/** signed principal behind the agent — the liability anchor */
|
|
24
|
+
principal?: string;
|
|
25
|
+
model?: string;
|
|
26
|
+
framework?: string;
|
|
27
|
+
};
|
|
28
|
+
action: {
|
|
29
|
+
kind: ActionKind;
|
|
30
|
+
/** tool/function/route name when applicable */
|
|
31
|
+
name?: string;
|
|
32
|
+
inputDigest?: `0x${string}`;
|
|
33
|
+
outputDigest?: `0x${string}`;
|
|
34
|
+
};
|
|
35
|
+
/** Art 50 disclosure evidence: 'ai-interaction' → Art 50(1) (inform natural persons
|
|
36
|
+
* they interact with an AI system); 'synthetic-content' → Art 50(2)/(4) marking and
|
|
37
|
+
* deep-fake/text disclosure. textDigest = digest of the disclosure actually shown. */
|
|
38
|
+
disclosure?: {
|
|
39
|
+
kind: DisclosureKind;
|
|
40
|
+
/** unix seconds the disclosure was presented */
|
|
41
|
+
presentedAt: number;
|
|
42
|
+
/** channel: 'chat' | 'api' | 'voice' | 'ui' … free-form */
|
|
43
|
+
medium?: string;
|
|
44
|
+
textDigest?: `0x${string}`;
|
|
45
|
+
};
|
|
46
|
+
/** SAFR-shaped governance checkpoint evidence (audit-log component: proposed action,
|
|
47
|
+
* rules applied, outcome). SAFR is a MAS industry white paper (2026-07-03), not a
|
|
48
|
+
* mandate — position as alignment, never as statutory compliance. */
|
|
49
|
+
governance?: {
|
|
50
|
+
policyId?: string;
|
|
51
|
+
rulesApplied?: string[];
|
|
52
|
+
outcome: GovernanceOutcome;
|
|
53
|
+
/** digest of the controls-repository snapshot consulted */
|
|
54
|
+
controlsDigest?: `0x${string}`;
|
|
55
|
+
};
|
|
56
|
+
/** opaque per-deployer subject reference — never PII (data-minimization default) */
|
|
57
|
+
subjectRef?: string;
|
|
58
|
+
resourceUrl?: string;
|
|
59
|
+
/** unix seconds the action occurred */
|
|
60
|
+
occurredAt: number;
|
|
61
|
+
}
|
|
62
|
+
export interface ActionAttestationPayload {
|
|
63
|
+
version: 1;
|
|
64
|
+
actionDigest: `0x${string}`;
|
|
65
|
+
occurredAt: number;
|
|
66
|
+
}
|
|
67
|
+
export type SignedActionRecord = {
|
|
68
|
+
record: ActionRecordV1;
|
|
69
|
+
attestation: SignedArtifact<ActionAttestationPayload>;
|
|
70
|
+
};
|
|
71
|
+
export declare const ACTION_DOMAIN: {
|
|
72
|
+
readonly name: "tersign action-record";
|
|
73
|
+
readonly version: "1";
|
|
74
|
+
readonly chainId: 1n;
|
|
75
|
+
};
|
|
76
|
+
export declare const ACTION_TYPES: {
|
|
77
|
+
readonly ActionAttestation: readonly [{
|
|
78
|
+
readonly name: "version";
|
|
79
|
+
readonly type: "uint256";
|
|
80
|
+
}, {
|
|
81
|
+
readonly name: "actionDigest";
|
|
82
|
+
readonly type: "bytes32";
|
|
83
|
+
}, {
|
|
84
|
+
readonly name: "occurredAt";
|
|
85
|
+
readonly type: "uint256";
|
|
86
|
+
}];
|
|
87
|
+
};
|
|
88
|
+
/** Pinned digest of the action-record EIP-712 material — the ledger re-declares these
|
|
89
|
+
* constants and pins the SAME vector (cross-impl drift tripwire, like DISPUTE_WIRE_VECTOR). */
|
|
90
|
+
export declare const ACTION_WIRE_VECTOR: `0x${string}`;
|
|
91
|
+
export declare function actionDigest(record: ActionRecordV1): `0x${string}`;
|
|
92
|
+
export declare function signActionRecord(record: ActionRecordV1, account: Account): Promise<SignedActionRecord>;
|
|
93
|
+
export declare function verifyActionRecord(signed: SignedActionRecord, expectedSigner?: string): Promise<VerifyLike>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { recoverTypedDataAddress } from 'viem';
|
|
2
|
+
import { digestOf } from '../canonical.js';
|
|
3
|
+
export const ACTION_DOMAIN = { name: 'tersign action-record', version: '1', chainId: 1n };
|
|
4
|
+
export const ACTION_TYPES = {
|
|
5
|
+
ActionAttestation: [
|
|
6
|
+
{ name: 'version', type: 'uint256' },
|
|
7
|
+
{ name: 'actionDigest', type: 'bytes32' },
|
|
8
|
+
{ name: 'occurredAt', type: 'uint256' },
|
|
9
|
+
],
|
|
10
|
+
};
|
|
11
|
+
/** Pinned digest of the action-record EIP-712 material — the ledger re-declares these
|
|
12
|
+
* constants and pins the SAME vector (cross-impl drift tripwire, like DISPUTE_WIRE_VECTOR). */
|
|
13
|
+
export const ACTION_WIRE_VECTOR = digestOf({
|
|
14
|
+
domain: { ...ACTION_DOMAIN, chainId: 1 },
|
|
15
|
+
types: ACTION_TYPES,
|
|
16
|
+
});
|
|
17
|
+
export function actionDigest(record) {
|
|
18
|
+
return digestOf(record);
|
|
19
|
+
}
|
|
20
|
+
export async function signActionRecord(record, account) {
|
|
21
|
+
if (!account.signTypedData)
|
|
22
|
+
throw new Error('account cannot sign typed data');
|
|
23
|
+
const payload = {
|
|
24
|
+
version: 1,
|
|
25
|
+
actionDigest: actionDigest(record),
|
|
26
|
+
occurredAt: record.occurredAt,
|
|
27
|
+
};
|
|
28
|
+
const signature = await account.signTypedData({
|
|
29
|
+
domain: ACTION_DOMAIN,
|
|
30
|
+
types: ACTION_TYPES,
|
|
31
|
+
primaryType: 'ActionAttestation',
|
|
32
|
+
message: {
|
|
33
|
+
version: BigInt(payload.version),
|
|
34
|
+
actionDigest: payload.actionDigest,
|
|
35
|
+
occurredAt: BigInt(payload.occurredAt),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return { record, attestation: { format: 'eip712', payload, signature } };
|
|
39
|
+
}
|
|
40
|
+
export async function verifyActionRecord(signed, expectedSigner) {
|
|
41
|
+
const { record, attestation } = signed;
|
|
42
|
+
if (attestation.format !== 'eip712')
|
|
43
|
+
return { valid: false, reason: 'jws not implemented in v0' };
|
|
44
|
+
if (attestation.payload.actionDigest !== actionDigest(record)) {
|
|
45
|
+
return { valid: false, reason: 'action digest mismatch — record was altered after signing' };
|
|
46
|
+
}
|
|
47
|
+
if (attestation.payload.occurredAt !== record.occurredAt) {
|
|
48
|
+
return { valid: false, reason: 'attestation/occurredAt mismatch' };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const signer = await recoverTypedDataAddress({
|
|
52
|
+
domain: ACTION_DOMAIN,
|
|
53
|
+
types: ACTION_TYPES,
|
|
54
|
+
primaryType: 'ActionAttestation',
|
|
55
|
+
message: {
|
|
56
|
+
version: BigInt(attestation.payload.version),
|
|
57
|
+
actionDigest: attestation.payload.actionDigest,
|
|
58
|
+
occurredAt: BigInt(attestation.payload.occurredAt),
|
|
59
|
+
},
|
|
60
|
+
signature: attestation.signature,
|
|
61
|
+
});
|
|
62
|
+
if (expectedSigner && signer.toLowerCase() !== expectedSigner.toLowerCase()) {
|
|
63
|
+
return { valid: false, signer, reason: 'unexpected signer' };
|
|
64
|
+
}
|
|
65
|
+
return { valid: true, signer };
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
return { valid: false, reason: e instanceof Error ? e.message : 'signature recovery failed' };
|
|
69
|
+
}
|
|
70
|
+
}
|