···8080 pf.String(flagSmtpName, "", "SMTP from name")
8181 pf.String(flagIpfsNodeUrl, "http://127.0.0.1:5001", "Base URL of the Kubo RPC API (e.g. http://127.0.0.1:5001 or http://ipfs:5001 in Docker). All repo blocks and blobs are stored via this node")
8282 pf.String(flagIpfsGatewayUrl, "", "Public IPFS gateway URL for blob redirects (e.g. http://localhost:8080). When set, sync.getBlob redirects to the gateway instead of proxying through vow")
8383- pf.String(flagX402PinURL, "", "x402-gated remote pinning endpoint (e.g. https://402.pinata.cloud/v1/pin/public). When set, accounts with x402 pinning enabled will have blobs pinned here after local storage, with payment signed by the user's Ethereum wallet")
8484- pf.String(flagX402Network, "eip155:8453", "CAIP-2 chain identifier required by the x402 pinning service (e.g. eip155:8453 for Base Mainnet)")
8585-8683 pf.String(flagSessionSecret, "", "Session secret")
8784 pf.String(flagSessionCookieKey, "session", "Session cookie key name")
8885···190187 IPFSConfig: &server.IPFSConfig{
191188 NodeURL: v.GetString(flagIpfsNodeUrl),
192189 GatewayURL: v.GetString(flagIpfsGatewayUrl),
193193- X402: func() *server.X402Config {
194194- if u := v.GetString(flagX402PinURL); u != "" {
195195- return &server.X402Config{
196196- PinURL: u,
197197- Network: v.GetString(flagX402Network),
198198- }
199199- }
200200- return nil
201201- }(),
202190 },
203191 SessionSecret: v.GetString(flagSessionSecret),
204192 SessionCookieKey: v.GetString(flagSessionCookieKey),
-5
docker-compose.yaml
···8282 VOW_IPFS_NODE_URL: ${VOW_IPFS_NODE_URL:-http://ipfs:5001}
8383 # Optional public gateway for sync.getBlob redirects.
8484 VOW_IPFS_GATEWAY_URL: ${VOW_IPFS_GATEWAY_URL:-}
8585- # Optional x402-gated remote pinning service.
8686- VOW_X402_PIN_URL: ${VOW_X402_PIN_URL:-}
8787- # CAIP-2 chain ID for x402 pinning.
8888- VOW_X402_NETWORK: ${VOW_X402_NETWORK:-eip155:8453}
8989-9085 # Optional fallback for proxied ATProto requests.
9186 # Format: did#service-id, for example did:plc:xxx#atproto_labeler
9287 VOW_FALLBACK_PROXY: ${VOW_FALLBACK_PROXY:-}
···2929 Root []byte
3030 Preferences []byte
3131 Deactivated bool
3232- // X402PinningEnabled controls whether blobs and repo blocks are
3333- // additionally pinned to a remote x402-gated pinning service after being
3434- // written to the local Kubo node. When false (the default) content lives
3535- // only on the co-located node.
3636- X402PinningEnabled bool `gorm:"default:false"`
3732}
38333934// EthereumAddress returns the Ethereum address for PublicKey.
···2233import (
44 "bytes"
55- "context"
65 "fmt"
76 "io"
87 "mime/multipart"
···7069 logger.Error("error adding blob to ipfs", "error", err)
7170 helpers.ServerError(w, nil)
7271 return
7373- }
7474-7575- // If the account has opted into x402 remote pinning and the signer
7676- // is connected, kick off the payment+pin flow in the
7777- // background. The blob is already safe on the local Kubo node so this
7878- // is best-effort — a failure here does not affect the ATProto response.
7979- if urepo.X402PinningEnabled && s.ipfsConfig.X402 != nil {
8080- walletAddr := urepo.EthereumAddress()
8181- if walletAddr == "" {
8282- logger.Warn("x402 pinning enabled but no public key registered; skipping", "cid", c.String())
8383- } else if !s.signerHub.IsConnected(urepo.Repo.Did) {
8484- logger.Warn("x402 pinning enabled but signer not connected; skipping", "cid", c.String())
8585- } else {
8686- cidStr := c.String()
8787- blobSize := read
8888- go func() {
8989- pinCtx, cancel := context.WithTimeout(context.Background(), 2*signerRequestTimeout)
9090- defer cancel()
9191- if err := s.pinBlobWithX402(pinCtx, urepo.Repo.Did, walletAddr, cidStr, blobSize); err != nil {
9292- logger.Warn("x402 remote pin failed", "cid", cidStr, "error", err)
9393- }
9494- }()
9595- }
9672 }
97739874 // Persist a metadata row so we can list blobs by DID, resolve ownership,
+1-34
server/handle_signer_connect.go
···2233import (
44 "encoding/base64"
55- "encoding/hex"
65 "encoding/json"
76 "net/http"
87 "strings"
···4241}
43424443// wsIncoming is used for initial type-sniffing before full decode.
4545-// It covers both commit signing (sign_response / sign_reject) and
4646-// x402 payment signing (pay_response / pay_reject).
4744type wsIncoming struct {
4845 Type string `json:"type"`
4946 RequestID string `json:"requestId"`
5050- // sign_response: base64url-encoded EIP-191 signature bytes.
4747+ // sign_response: base64url-encoded signature bytes.
5148 Signature string `json:"signature,omitempty"`
5252- // pay_response: 0x-prefixed hex-encoded EIP-712 signature returned by
5353- // eth_signTypedData_v4. The PDS passes these bytes directly to the x402
5454- // SDK to assemble the final PaymentPayload.
5555- // Note: the field is also named "signature" in the pay_response JSON so
5656- // that the signer can use a single builder function; we distinguish the
5757- // two cases by message type.
5849}
59506051// handleSignerConnect upgrades the connection to a WebSocket and registers it
···215206 case in := <-inbound:
216207 switch in.Type {
217208 case "sign_response":
218218- // signature is base64url-encoded EIP-191 bytes.
219209 if in.Signature == "" {
220210 logger.Warn("signer: sign_response missing signature", "did", did)
221211 continue
···232222 case "sign_reject":
233223 if !s.signerHub.DeliverRejection(did, in.RequestID) {
234224 logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID)
235235- }
236236-237237- case "pay_response":
238238- // signature is the 0x-prefixed hex-encoded EIP-712 signature
239239- // returned by eth_signTypedData_v4. The x402 SDK receives these
240240- // raw bytes and assembles the PaymentPayload itself.
241241- if in.Signature == "" {
242242- logger.Warn("signer: pay_response missing signature", "did", did)
243243- continue
244244- }
245245- hexStr := strings.TrimPrefix(in.Signature, "0x")
246246- sigBytes, err := hex.DecodeString(hexStr)
247247- if err != nil {
248248- logger.Warn("signer: pay_response bad hex", "did", did, "error", err)
249249- continue
250250- }
251251- if !s.signerHub.DeliverSignature(did, in.RequestID, sigBytes) {
252252- logger.Warn("signer: pay_response for unknown requestId", "did", did, "requestId", in.RequestID)
253253- }
254254-255255- case "pay_reject":
256256- if !s.signerHub.DeliverRejection(did, in.RequestID) {
257257- logger.Warn("signer: pay_reject for unknown requestId", "did", did, "requestId", in.RequestID)
258225 }
259226260227 default:
-264
server/ipfs.go
···11package server
2233import (
44- "bytes"
54 "context"
65 "encoding/json"
77- "fmt"
86 "io"
97 "net/http"
1010- "time"
1111-1212- x402 "github.com/coinbase/x402/go"
1313- x402http "github.com/coinbase/x402/go/http"
1414- x402evm "github.com/coinbase/x402/go/mechanisms/evm"
1515- evmexactclient "github.com/coinbase/x402/go/mechanisms/evm/exact/client"
1616- "github.com/google/uuid"
178)
1891910// readJSON decodes a single JSON value from r into dst.
···2112 return json.NewDecoder(r).Decode(dst)
2213}
23142424-// ── signerHubEvmSigner ────────────────────────────────────────────────────────
2525-2626-// signerHubEvmSigner implements x402evm.ClientEvmSigner by delegating
2727-// SignTypedData to the user's Ethereum wallet via the signer WebSocket.
2828-// This means the PDS never holds a private key — the EIP-712 payload is
2929-// forwarded to the signer as a pay_request message and the resulting
3030-// 65-byte signature is returned through the SignerHub.
3131-type signerHubEvmSigner struct {
3232- hub *SignerHub
3333- did string
3434- address string // EIP-55 checksummed Ethereum address
3535-}
3636-3737-// Address returns the EIP-55 checksummed Ethereum address of the signer,
3838-// derived from the account's stored secp256k1 public key.
3939-func (s *signerHubEvmSigner) Address() string {
4040- return s.address
4141-}
4242-4343-// SignTypedData sends the EIP-712 typed data to the signer as a pay_request
4444-// WebSocket message and blocks until the signer returns the 65-byte signature
4545-// via pay_response, or until the context is cancelled.
4646-//
4747-// The typed data is serialised to JSON and forwarded verbatim so the signer
4848-// can pass it directly to eth_signTypedData_v4 without any transformation.
4949-func (s *signerHubEvmSigner) SignTypedData(
5050- ctx context.Context,
5151- domain x402evm.TypedDataDomain,
5252- types map[string][]x402evm.TypedDataField,
5353- primaryType string,
5454- message map[string]any,
5555-) ([]byte, error) {
5656- // Serialise the full EIP-712 object so the signer can call
5757- // eth_signTypedData_v4(walletAddress, JSON.stringify(typedData)).
5858- typedDataPayload := map[string]any{
5959- "domain": domain,
6060- "types": types,
6161- "primaryType": primaryType,
6262- "message": message,
6363- }
6464- typedDataJSON, err := json.Marshal(typedDataPayload)
6565- if err != nil {
6666- return nil, fmt.Errorf("x402: marshalling typed data: %w", err)
6767- }
6868-6969- requestID := uuid.NewString()
7070- expiresAt := time.Now().Add(signerRequestTimeout)
7171-7272- // Build a human-readable description from the typed data message fields
7373- // so the signer can show it in the notification before prompting.
7474- description := buildPayDescription(domain, message)
7575-7676- msg, err := json.Marshal(wsPayRequest{
7777- Type: "pay_request",
7878- RequestID: requestID,
7979- Did: s.did,
8080- TypedData: typedDataJSON,
8181- WalletAddress: s.address,
8282- Description: description,
8383- ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
8484- })
8585- if err != nil {
8686- return nil, fmt.Errorf("x402: encoding pay_request: %w", err)
8787- }
8888-8989- signCtx, cancel := context.WithDeadline(ctx, expiresAt)
9090- defer cancel()
9191-9292- // RequestSignature blocks until the signer returns the signature bytes
9393- // (delivered via pay_response) or the context times out / user rejects.
9494- return s.hub.RequestSignature(signCtx, s.did, requestID, msg)
9595-}
9696-9797-// ReadContract is not implemented — the PDS has no RPC connection and the
9898-// x402 EIP-3009 flow does not require on-chain reads on the client side.
9999-func (s *signerHubEvmSigner) ReadContract(
100100- _ context.Context,
101101- _ string,
102102- _ []byte,
103103- _ string,
104104- _ ...any,
105105-) (any, error) {
106106- return nil, fmt.Errorf("x402: ReadContract not supported on keyless PDS")
107107-}
108108-109109-// ── wsPayRequest ──────────────────────────────────────────────────────────────
110110-111111-// wsPayRequest is the WebSocket message the PDS sends to the signer when it
112112-// needs the user's wallet to sign an EIP-712 typed-data payload for an x402
113113-// EIP-3009 payment authorisation.
114114-type wsPayRequest struct {
115115- Type string `json:"type"` // always "pay_request"
116116- // RequestID is a UUID echoed back in the pay_response so the SignerHub
117117- // can route the reply to the correct waiting goroutine.
118118- RequestID string `json:"requestId"`
119119- Did string `json:"did"`
120120- // TypedData is the full EIP-712 object (domain, types, primaryType,
121121- // message) as produced by the x402 SDK. The signer passes it verbatim
122122- // to eth_signTypedData_v4(walletAddress, JSON.stringify(typedData)).
123123- TypedData json.RawMessage `json:"typedData"`
124124- // WalletAddress is the EIP-55 checksummed Ethereum address of the payer,
125125- // derived from the account's stored public key. Passed to the wallet as
126126- // the first argument of eth_signTypedData_v4.
127127- WalletAddress string `json:"walletAddress"`
128128- // Description is a human-readable summary shown in the signer's
129129- // notification before the wallet prompt appears, e.g.
130130- // "Pin blob bafyrei… (12 KB) via x402 on eip155:8453".
131131- Description string `json:"description,omitempty"`
132132- ExpiresAt string `json:"expiresAt"` // RFC3339
133133-}
134134-135135-// ── x402 HTTP client factory ──────────────────────────────────────────────────
136136-137137-// newX402HTTPClient builds an *http.Client that transparently handles the x402
138138-// 402-payment handshake for the given account. On a 402 response it:
139139-//
140140-// 1. Parses the payment requirements using the x402 SDK.
141141-// 2. Calls signerHubEvmSigner.SignTypedData, which sends a pay_request over
142142-// the account's signer WebSocket and waits for the wallet signature.
143143-// 3. Encodes the signed PaymentPayload and retries the original request with
144144-// the X-PAYMENT / PAYMENT-SIGNATURE header set.
145145-func (s *Server) newX402HTTPClient(did, walletAddress string) *http.Client {
146146- signer := &signerHubEvmSigner{
147147- hub: s.signerHub,
148148- did: did,
149149- address: walletAddress,
150150- }
151151-152152- x402Client := x402.Newx402Client().
153153- Register(
154154- x402.Network(s.ipfsConfig.X402.Network),
155155- evmexactclient.NewExactEvmScheme(signer),
156156- )
157157-158158- return x402http.WrapHTTPClientWithPayment(
159159- &http.Client{Timeout: 2 * signerRequestTimeout},
160160- x402http.Newx402HTTPClient(x402Client),
161161- )
162162-}
163163-164164-// ── request / response types ──────────────────────────────────────────────────
165165-166166-// x402PinRequest is the JSON body posted to the x402 pinning endpoint.
167167-// Pinata's server requires the file size upfront so it can compute a dynamic
168168-// price before the file is transferred.
169169-type x402PinRequest struct {
170170- FileSize int `json:"fileSize"`
171171-}
172172-173173-// x402PinResponse is the success body returned by the pinning endpoint.
174174-// Pinata returns a presigned upload URL the caller can use to push content
175175-// directly without an API key.
176176-type x402PinResponse struct {
177177- URL string `json:"url"`
178178-}
179179-180180-// ── pinBlobWithX402 ───────────────────────────────────────────────────────────
181181-182182-// pinBlobWithX402 pins cidStr to the configured x402 pinning endpoint on
183183-// behalf of the account identified by did / walletAddress. The end-to-end flow:
184184-//
185185-// 1. POST the file size to cfg.PinURL.
186186-// 2. The x402 HTTP transport intercepts the 402 response, selects a payment
187187-// requirement, and calls signerHubEvmSigner.SignTypedData.
188188-// 3. SignTypedData sends a pay_request WebSocket message to the signer and
189189-// blocks until the wallet returns the EIP-712 signature.
190190-// 4. The transport re-encodes the signed PaymentPayload and retries the POST
191191-// with the appropriate payment header.
192192-// 5. On success the presigned URL (if returned) is logged.
193193-//
194194-// The call is intentionally non-fatal: the blob is already safe on the local
195195-// Kubo node. Callers should log any returned error but not propagate it as an
196196-// ATProto failure.
197197-func (s *Server) pinBlobWithX402(ctx context.Context, did, walletAddress, cidStr string, fileSize int) error {
198198- cfg := s.ipfsConfig.X402
199199- if cfg == nil || cfg.PinURL == "" {
200200- return fmt.Errorf("x402 pinning not configured")
201201- }
202202-203203- logger := s.logger.With("op", "x402Pin", "did", did, "cid", cidStr)
204204-205205- body, err := json.Marshal(x402PinRequest{FileSize: fileSize})
206206- if err != nil {
207207- return fmt.Errorf("x402: encoding pin request: %w", err)
208208- }
209209-210210- req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.PinURL, bytes.NewReader(body))
211211- if err != nil {
212212- return fmt.Errorf("x402: building pin request: %w", err)
213213- }
214214- req.Header.Set("Content-Type", "application/json")
215215- // Provide GetBody so the x402 transport can replay the body on the
216216- // payment-retry request without consuming the original reader twice.
217217- req.GetBody = func() (io.ReadCloser, error) {
218218- return io.NopCloser(bytes.NewReader(body)), nil
219219- }
220220-221221- httpClient := s.newX402HTTPClient(did, walletAddress)
222222-223223- resp, err := httpClient.Do(req)
224224- if err != nil {
225225- return fmt.Errorf("x402: pin request failed: %w", err)
226226- }
227227- defer func() { _ = resp.Body.Close() }()
228228-229229- if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
230230- msg, _ := io.ReadAll(resp.Body)
231231- return fmt.Errorf("x402: pin rejected (status %d): %s", resp.StatusCode, string(msg))
232232- }
233233-234234- var pinResp x402PinResponse
235235- if err := readJSON(resp.Body, &pinResp); err == nil && pinResp.URL != "" {
236236- logger.Info("x402 pin accepted", "presignedURL", pinResp.URL)
237237- } else {
238238- logger.Info("x402 pin accepted")
239239- }
240240-241241- return nil
242242-}
243243-244244-// ── unpinFromIPFS ─────────────────────────────────────────────────────────────
245245-24615// unpinFromIPFS asks the local Kubo node to remove the recursive pin for the
24716// given CID so the content becomes eligible for garbage collection.
24817// It is intentionally best-effort: errors are logged but not propagated.
···2764527746 s.logger.Info("ipfs unpin: blob unpinned", "cid", cidStr)
27847}
279279-280280-// ── compile-time interface assertions ─────────────────────────────────────────
281281-282282-// Ensure signerHubEvmSigner satisfies the x402 ClientEvmSigner interface so
283283-// that build failures surface here rather than deep in the x402 SDK internals.
284284-var _ x402evm.ClientEvmSigner = (*signerHubEvmSigner)(nil)
285285-286286-// buildPayDescription creates a short human-readable description of the
287287-// payment for use in the signer notification. It extracts the token value
288288-// and recipient from the EIP-712 message fields, falling back to a generic
289289-// string if the fields are missing or unparseable.
290290-func buildPayDescription(domain x402evm.TypedDataDomain, message map[string]any) string {
291291- value, _ := message["value"].(string)
292292- to, _ := message["to"].(string)
293293-294294- chainID := ""
295295- if domain.ChainID != nil {
296296- chainID = fmt.Sprintf("eip155:%s", domain.ChainID.String())
297297- }
298298-299299- if value != "" && to != "" && chainID != "" {
300300- // Truncate the recipient address for display.
301301- toShort := to
302302- if len(to) > 10 {
303303- toShort = to[:6] + "…" + to[len(to)-4:]
304304- }
305305- return fmt.Sprintf("x402 payment: %s units → %s on %s", value, toShort, chainID)
306306- }
307307- if value != "" && chainID != "" {
308308- return fmt.Sprintf("x402 payment: %s units on %s", value, chainID)
309309- }
310310- return "Authorise an x402 payment?"
311311-}
-21
server/server.go
···5151// alongside. All repo blocks and blob data are stored on and retrieved from
5252// the co-located Kubo node — SQLite is used only for relational metadata
5353// (accounts, sessions, records index, etc.), not for content.
5454-// X402Config holds the configuration for the optional x402-gated remote
5555-// pinning service. When set, accounts that have opted in will have their
5656-// blobs pinned there after being written to the local Kubo node, with the
5757-// payment authorised by the user's Ethereum wallet via the browser-based signer.
5858-type X402Config struct {
5959- // PinURL is the base URL of the x402-gated pinning endpoint,
6060- // e.g. "https://402.pinata.cloud/v1/pin/public". The PDS POSTs to this
6161- // URL with the blob size, receives a 402 with payment requirements, asks
6262- // the signer to sign the EIP-3009 payment authorisation, then retries
6363- // the request with the X-PAYMENT header.
6464- PinURL string
6565-6666- // Network is the CAIP-2 chain identifier required by the pinning service,
6767- // e.g. "eip155:8453" for Base Mainnet.
6868- Network string
6969-}
7070-7154type IPFSConfig struct {
7255 // NodeURL is the base URL of the Kubo RPC API, e.g. "http://ipfs:5001"
7356 // in Docker or "http://127.0.0.1:5001" locally.
···7760 // "http://ipfs:8080" or "https://ipfs.io". When set, sync.getBlob
7861 // redirects clients to the gateway instead of proxying through vow.
7962 GatewayURL string
8080-8181- // X402 is optional. When non-nil, accounts with X402PinningEnabled=true
8282- // will have their content additionally pinned via the x402 protocol.
8383- X402 *X402Config
8463}
85648665type Server struct {