Bridge your decentralized social identity (ATProto/Bluesky) with your Ethereum wallet address through cryptographically verifiable attestations.
ATProto-EVM Link allows users to create verifiable, cryptographic proofs linking their Bluesky (ATProto) identity to Ethereum wallet addresses. These attestations are:
- Self-sovereign - Stored in the user's own ATProto Personal Data Server (PDS)
- Cryptographically verifiable - Anyone can verify the EIP-712 signature
- Multi-chain - Works with Ethereum, Base, Optimism, and Arbitrum
- Wallet-agnostic - Supports EOAs, smart wallets (Coinbase), and multisigs (Safe)
| Feature | Description |
|---|---|
| ATProto OAuth | Secure authentication via Bluesky using OAuth 2.0 + DPoP |
| EIP-712 Signatures | Human-readable, typed data signing for wallet attestations |
| Multi-chain Support | Ethereum Mainnet, Base, Optimism, Arbitrum |
| Smart Wallet Support | EOA, Coinbase Smart Wallet (ERC-4337), Safe multisigs (ERC-1271) |
| Decentralized Storage | Attestations stored in user's ATProto PDS, not a central database |
| Public Verification | Anyone can verify attestations without authentication |
| Unlink Capability | Users can revoke attestations at any time |
- Node.js 18+
- npm or pnpm
# Clone the repository
git clone https://github.com/piss-beauty/atproto-evm-link.git
cd atproto-evm-link
# Install dependencies
npm install
# Copy environment variables
cp .env.local.example .env.local
# Start development server
npm run devVisit http://127.0.0.1:3333 to use the app.
Note: The dev server binds to
127.0.0.1(notlocalhost) as required by ATProto OAuth per RFC 8252.
| Variable | Required | Description |
|---|---|---|
COOKIE_SECRET |
Yes | 32+ character secret for session encryption |
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID |
No | WalletConnect Cloud project ID |
NEXT_PUBLIC_ALCHEMY_API_KEY |
No | Alchemy API key for better RPC |
PUBLIC_URL |
Prod | Production URL (e.g., https://link.piss.beauty) |
ATPROTO_JWK_PRIVATE |
Prod | Private JWK for confidential OAuth client |
Generate a JWK for production:
node scripts/generate-jwk.jsUser App Bluesky PDS
│ │ │
├─ Click "Sign In" ───►│ │
│ ├─ OAuth authorize ────────►│
│ │◄─ Authorization code ─────┤
│ ├─ Exchange for tokens ────►│
│ │◄─ Access + DPoP tokens ───┤
│◄─ Session created ───┤ │
User App Wallet
│ │ │
├─ Connect wallet ────►│ │
│ ├─ Request signature ───►│
│ │ (EIP-712 typed) │
│ │◄─ Signed message ──────┤
│◄─ Show confirmation ─┤ │
The signed attestation is stored as an ATProto record:
{
"$type": "org.impactindexer.link.attestation",
"address": "0x1234...5678",
"chainId": 1,
"signature": "0xabc...def",
"message": {
"did": "did:plc:abc123",
"evmAddress": "0x1234...5678",
"chainId": "1",
"timestamp": "1706745600",
"nonce": "1"
},
"signatureType": "eoa",
"createdAt": "2024-02-01T00:00:00.000Z"
}Anyone can verify an attestation by:
- Fetching the attestation from the user's PDS
- Reconstructing the EIP-712 message
- Recovering the signer address from the signature
- Comparing with the claimed address
For smart contract wallets (ERC-1271), the contract's isValidSignature method is called.
src/
├── app/
│ ├── api/
│ │ ├── attestations/ # CRUD for attestations
│ │ │ ├── route.ts # POST: Create attestation
│ │ │ ├── [did]/
│ │ │ │ ├── route.ts # GET: List attestations for DID
│ │ │ │ └── [rkey]/
│ │ │ │ └── route.ts # DELETE: Remove attestation
│ │ ├── login/route.ts # POST: Initiate OAuth
│ │ ├── logout/route.ts # POST: Clear session
│ │ ├── oauth/
│ │ │ ├── callback/ # OAuth redirect handler
│ │ │ ├── client-metadata.json/ # OAuth client config
│ │ │ └── jwks.json/ # Public keys for confidential client
│ │ ├── status/route.ts # GET: Check auth status
│ │ └── verify/[identifier]/ # GET: Verify attestations
│ ├── docs/page.tsx # Documentation page
│ ├── link/page.tsx # Linking wizard
│ ├── manage/page.tsx # Manage linked wallets
│ └── verify/[identifier]/ # Public verification page
├── components/
│ ├── steps/ # Wizard step components
│ │ ├── AtprotoAuthStep.tsx
│ │ ├── WalletConnectStep.tsx
│ │ ├── ReviewStep.tsx
│ │ └── SuccessStep.tsx
│ └── ... # Shared UI components
├── hooks/
│ ├── useAttestationSigning.ts # EIP-712 signing hook
│ └── useWalletType.ts # Detect EOA vs smart wallet
├── lib/
│ ├── auth/client.ts # ATProto OAuth client
│ ├── attestation.ts # EIP-712 types & helpers
│ ├── pds.ts # PDS storage operations
│ ├── verify.ts # Signature verification
│ └── wagmi.ts # Wallet configuration
└── providers/
└── WagmiProvider.tsx # Wallet provider wrapper
Initiate ATProto OAuth flow.
Request:
{
"handle": "alice.bsky.social",
"returnTo": "/link"
}Response:
{
"redirectUrl": "https://bsky.social/oauth/authorize?..."
}Check authentication status.
Response:
{
"authenticated": true,
"did": "did:plc:abc123",
"handle": "alice.bsky.social"
}Create a new attestation.
Request:
{
"address": "0x1234...5678",
"chainId": 1,
"signature": "0xabc...def",
"message": { ... },
"signatureType": "eoa"
}Fetch all attestations for a DID or handle.
Response:
{
"did": "did:plc:abc123",
"attestations": [...],
"count": 2
}Delete an attestation (authenticated, owner only).
Verify all attestations for a handle or DID.
Response:
{
"did": "did:plc:abc123",
"handle": "alice.bsky.social",
"attestations": [
{
"address": "0x1234...5678",
"chainId": 1,
"valid": true,
"signerType": "eoa"
}
]
}Attestations use the org.impactindexer.link.attestation lexicon:
- Repository: GainForest/lexicons
- Path:
lexicons/org/impactindexer/link/attestation.json
| Wallet | Type | Signature Standard |
|---|---|---|
| MetaMask | EOA | ECDSA (secp256k1) |
| Rabby | EOA | ECDSA (secp256k1) |
| Coinbase Wallet | EOA | ECDSA (secp256k1) |
| Coinbase Smart Wallet | Smart Contract | ERC-1271 |
| Safe | Multisig | ERC-1271 |
| WalletConnect | Various | Depends on connected wallet |
- Framework: Next.js 16 (App Router)
- Language: TypeScript (strict mode)
- Styling: Tailwind CSS v4
- Ethereum: wagmi v2 + viem
- ATProto: @atproto/api + @atproto/oauth-client-node
- Sessions: iron-session
-
Session Storage: OAuth sessions are stored in-memory only (not cookies) due to size constraints. For production, use Redis or a database.
-
Signature Replay: Each attestation includes a timestamp and nonce to prevent replay attacks.
-
Chain ID Binding: Attestations are bound to a specific chain ID to prevent cross-chain confusion.
-
DID Verification: The signed message includes the user's DID, ensuring the attestation is bound to their identity.
See CONTRIBUTING.md for guidelines.
MIT
- GainForest Lexicons - ATProto lexicon definitions
- Hypergoat - ATProto AppView for indexing
- Impact Indexer - Environmental impact data explorer