Skip to content

Commit 65cdd6e

Browse files
Merge main into feat/signature-support and add signatureData to link.evm
- Merged latest main which includes app.certified.link.evm lexicon - Added signatureData property to the new link.evm lexicon - Installed viem dependency for link.evm tests
2 parents abd5667 + 81b1213 commit 65cdd6e

11 files changed

Lines changed: 693 additions & 141 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@hypercerts-org/lexicon": minor
3+
---
4+
5+
Add `app.certified.link.evm` lexicon for verifiable linking of EVM
6+
wallet addresses to ATProto identities via cryptographic signatures.
7+
Currently supports EOA wallets with EIP-712 typed data; the `proof`
8+
field is an open union to allow future signature methods (e.g.
9+
ERC-1271, ERC-6492).

AGENTS.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,52 @@ npm run lint
250250
npm run check
251251
```
252252

253+
### Writing Tests
254+
255+
Tests live in `tests/` with one file per lexicon, named
256+
`validate-<lexicon-slug>.test.ts` (e.g. `validate-link-evm.test.ts`,
257+
`validate-rights.test.ts`).
258+
259+
The generated code provides two ways to validate records:
260+
261+
- **`validate()` from `generated/lexicons.js`** — generic, untyped.
262+
The return type is `ValidationResult<{ $type?: string }>` so
263+
`result.value` has no lexicon-specific fields. Use this for negative
264+
tests (missing fields, bad types) where you don't need to inspect
265+
the returned value.
266+
267+
- **`validateMain()` from per-lexicon type modules** (e.g.
268+
`generated/types/app/certified/link/evm.js`) — returns a properly
269+
typed `ValidationResult` with all lexicon fields on `result.value`.
270+
Use this for positive tests where you assert on specific field
271+
values. Note: `validateMain` requires `$type` to be present on the
272+
input record.
273+
274+
Example:
275+
276+
```typescript
277+
import { validate, ids } from "../generated/lexicons.js";
278+
import * as EvmLink from "../generated/types/app/certified/link/evm.js";
279+
280+
// Positive test — typed result via validateMain
281+
const result = EvmLink.validateMain({
282+
$type: ids.AppCertifiedLinkEvm,
283+
...record,
284+
});
285+
if (result.success) {
286+
expect(result.value.address).toBe("0x..."); // ← type-safe
287+
}
288+
289+
// Negative test — generic validate (no $type needed)
290+
const bad = validate(
291+
{ address: "0x..." }, // missing required fields
292+
ids.AppCertifiedLinkEvm,
293+
"main",
294+
false,
295+
);
296+
expect(bad.success).toBe(false);
297+
```
298+
253299
## Important Notes
254300

255301
- Lexicon JSON files should follow ATProto lexicon schema v1

README.md

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,15 @@ await agent.api.com.atproto.repo.createRecord({
142142

143143
### Certified (`app.certified.*`)
144144

145-
| Lexicon | NSID | Description |
146-
| -------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
147-
| **Location** | `app.certified.location` | Geographic reference using the [Location Protocol](https://spec.decentralizedgeo.org) (coordinates, GeoJSON, H3, WKT, etc.). |
148-
| **Profile** | `app.certified.actor.profile` | User account profile with display name, bio, avatar, and banner. |
149-
| **Organization** | `app.certified.actor.organization` | Organization metadata: legal structure, URLs, location, founding date. |
150-
| **Badge Definition** | `app.certified.badge.definition` | Defines a badge type with title, icon, and optional issuer allowlist. |
151-
| **Badge Award** | `app.certified.badge.award` | Awards a badge to a user, project, or activity. |
152-
| **Badge Response** | `app.certified.badge.response` | Recipient accepts or rejects a badge award. |
145+
| Lexicon | NSID | Description |
146+
| -------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
147+
| **Location** | `app.certified.location` | Geographic reference using the [Location Protocol](https://spec.decentralizedgeo.org) (coordinates, GeoJSON, H3, WKT, etc.). |
148+
| **Profile** | `app.certified.actor.profile` | User account profile with display name, bio, avatar, and banner. |
149+
| **Organization** | `app.certified.actor.organization` | Organization metadata: legal structure, URLs, location, founding date. |
150+
| **Badge Definition** | `app.certified.badge.definition` | Defines a badge type with title, icon, and optional issuer allowlist. |
151+
| **Badge Award** | `app.certified.badge.award` | Awards a badge to a user, project, or activity. |
152+
| **Badge Response** | `app.certified.badge.response` | Recipient accepts or rejects a badge award. |
153+
| **EVM Link** | `app.certified.link.evm` | Verifiable ATProto DID ↔ EVM wallet link via EIP-712 signature. Extensible for future proof methods (e.g. ERC-1271, ERC-6492). |
153154

154155
### Signatures (`app.certified.signature.*`)
155156

@@ -461,6 +462,47 @@ npm run gen-schemas-md # Regenerate SCHEMAS.md
461462
npm run test # Run tests
462463
```
463464

465+
### Linking ATProto Identity to EVM Wallets
466+
467+
The `app.certified.link.evm` record enables verifiable linking between
468+
an ATProto DID and an EVM wallet address. The link is proven via a
469+
cryptographic signature, allowing any verifier to confirm that the
470+
wallet owner authorized the binding. Currently supports EOA wallets
471+
via EIP-712 typed data signatures; the `proof` field is an open union
472+
to allow future signature methods (e.g. ERC-1271, ERC-6492).
473+
474+
```typescript
475+
import { LINK_EVM_NSID } from "@hypercerts-org/lexicon";
476+
477+
const evmLinkRecord = {
478+
$type: LINK_EVM_NSID,
479+
address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
480+
proof: {
481+
$type: "app.certified.link.evm#eip712Proof",
482+
signature: "0xabc123...", // truncated for readability; real signatures are 130-132 hex chars
483+
message: {
484+
$type: "app.certified.link.evm#eip712Message",
485+
did: "did:plc:alice",
486+
evmAddress: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
487+
chainId: "1",
488+
timestamp: "1709500000",
489+
nonce: "0",
490+
},
491+
},
492+
createdAt: new Date().toISOString(),
493+
};
494+
```
495+
496+
**Key fields:**
497+
498+
- `address` (required): 0x-prefixed EVM wallet address (EIP-55
499+
checksummed, 42 chars)
500+
- `proof` (required): Open union containing the cryptographic proof of
501+
wallet ownership. Each variant bundles its signature with the
502+
corresponding message format. Currently the only variant is
503+
`#eip712Proof` for EOA wallets.
504+
- `createdAt` (required): Timestamp when the record was created
505+
464506
### Adding or Modifying a Lexicon
465507

466508
1. Edit JSON files in `lexicons/` following the namespace structure

SCHEMAS.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,42 @@ Certified lexicons are common/shared lexicons that can be used across multiple p
455455

456456
---
457457

458+
### `app.certified.link.evm`
459+
460+
**Description:** A verifiable link between an ATProto DID and an EVM wallet address, proven via a cryptographic signature. Currently supports EOA wallets via EIP-712 typed data signatures; the proof field is an open union to allow future signature methods.
461+
462+
**Key:** `any`
463+
464+
#### Properties
465+
466+
| Property | Type | Required | Description | Comments |
467+
| --------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
468+
| `address` | `string` || EVM wallet address (0x-prefixed, with EIP-55 checksum recommended). | maxLength: 42 |
469+
| `proof` | `union` || Cryptographic proof of wallet ownership. The union is open to allow future proof methods (e.g. ERC-1271, ERC-6492). Each variant bundles its signature with the corresponding message format. | |
470+
| `createdAt` | `string` || Client-declared timestamp when this record was originally created. | |
471+
| `signatureData` | `ref` || Optional cryptographic signatures attesting to this record's content. | |
472+
473+
#### Defs
474+
475+
##### `app.certified.link.evm#eip712Proof`
476+
477+
| Property | Type | Required | Description |
478+
| ----------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
479+
| `signature` | `string` || ECDSA signature over the EIP-712 hash (hex-encoded with 0x prefix, 64 or 65 bytes). |
480+
| `message` | `ref` || The EIP-712 typed data message that was signed by the wallet. Contains the fields binding an ATProto DID to an EVM address on a specific chain. |
481+
482+
##### `app.certified.link.evm#eip712Message`
483+
484+
| Property | Type | Required | Description |
485+
| ------------ | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
486+
| `did` | `string` || The ATProto DID being linked to the EVM address. |
487+
| `evmAddress` | `string` || The EVM wallet address (must match the top-level address field). |
488+
| `chainId` | `string` || EVM chain ID as string (bigint serialized). Identifies which chain was used for signing; for EOA wallets the identity link applies across all EVM-compatible chains. |
489+
| `timestamp` | `string` || Unix timestamp when the attestation was created (bigint serialized). |
490+
| `nonce` | `string` || Replay-protection nonce (bigint serialized). |
491+
492+
---
493+
458494
### `app.certified.signature.inline`
459495

460496
**Description:** Inline attestation signature embedded directly in a record.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
{
2+
"lexicon": 1,
3+
"id": "app.certified.link.evm",
4+
"defs": {
5+
"main": {
6+
"type": "record",
7+
"description": "A verifiable link between an ATProto DID and an EVM wallet address, proven via a cryptographic signature. Currently supports EOA wallets via EIP-712 typed data signatures; the proof field is an open union to allow future signature methods.",
8+
"key": "any",
9+
"record": {
10+
"type": "object",
11+
"required": ["address", "proof", "createdAt"],
12+
"properties": {
13+
"address": {
14+
"type": "string",
15+
"description": "EVM wallet address (0x-prefixed, with EIP-55 checksum recommended).",
16+
"minLength": 42,
17+
"maxLength": 42
18+
},
19+
"proof": {
20+
"type": "union",
21+
"refs": ["#eip712Proof"],
22+
"description": "Cryptographic proof of wallet ownership. The union is open to allow future proof methods (e.g. ERC-1271, ERC-6492). Each variant bundles its signature with the corresponding message format."
23+
},
24+
"createdAt": {
25+
"type": "string",
26+
"format": "datetime",
27+
"description": "Client-declared timestamp when this record was originally created."
28+
},
29+
"signatureData": {
30+
"type": "ref",
31+
"ref": "app.certified.signature.list",
32+
"description": "Optional cryptographic signatures attesting to this record's content."
33+
}
34+
}
35+
}
36+
},
37+
"eip712Proof": {
38+
"type": "object",
39+
"description": "EOA wallet ownership proof via EIP-712 typed data signature. Contains both the structured message that was signed and the resulting signature.",
40+
"required": ["signature", "message"],
41+
"properties": {
42+
"signature": {
43+
"type": "string",
44+
"description": "ECDSA signature over the EIP-712 hash (hex-encoded with 0x prefix, 64 or 65 bytes).",
45+
"minLength": 130,
46+
"maxLength": 132
47+
},
48+
"message": {
49+
"type": "ref",
50+
"ref": "#eip712Message",
51+
"description": "The EIP-712 structured message fields that were signed."
52+
}
53+
}
54+
},
55+
"eip712Message": {
56+
"type": "object",
57+
"description": "The EIP-712 typed data message that was signed by the wallet. Contains the fields binding an ATProto DID to an EVM address on a specific chain.",
58+
"required": ["did", "evmAddress", "chainId", "timestamp", "nonce"],
59+
"properties": {
60+
"did": {
61+
"type": "string",
62+
"format": "did",
63+
"description": "The ATProto DID being linked to the EVM address.",
64+
"maxLength": 256
65+
},
66+
"evmAddress": {
67+
"type": "string",
68+
"description": "The EVM wallet address (must match the top-level address field).",
69+
"minLength": 42,
70+
"maxLength": 42
71+
},
72+
"chainId": {
73+
"type": "string",
74+
"description": "EVM chain ID as string (bigint serialized). Identifies which chain was used for signing; for EOA wallets the identity link applies across all EVM-compatible chains.",
75+
"minLength": 1,
76+
"maxLength": 78
77+
},
78+
"timestamp": {
79+
"type": "string",
80+
"description": "Unix timestamp when the attestation was created (bigint serialized).",
81+
"minLength": 1,
82+
"maxLength": 78
83+
},
84+
"nonce": {
85+
"type": "string",
86+
"description": "Replay-protection nonce (bigint serialized).",
87+
"minLength": 1,
88+
"maxLength": 78
89+
}
90+
}
91+
}
92+
}
93+
}

0 commit comments

Comments
 (0)