Skip to content

Relay Server Deployment ​

The relay server is an optional backend service that enables account creation, improves login UX, and manages key rotation. This guide covers deployment to both Node.js/Express and Cloudflare Workers.

Note: NEAR does require a relay server for registering new accounts (which simply pays gas for the registration transaction), but after registration the wallet is serverless and non-custodial (local signer). Relay servers can be setup by anyone.

When You Need a Relay Server ​

The relay server handles three main responsibilities:

1. Account Creation ​

Creates NEAR accounts atomically with passkey registration. Without a relay, users would need to:

  1. Create a NEAR account separately
  2. Fund it
  3. Then register their passkey

The relay combines these into a single operation with a better UX.

2. Shamir 3-Pass (Smooth Login) ​

Enables logging in without biometric prompts for returning users. The relay cooperates with the client to unlock the VRF key using Shamir 3-pass protocol, giving a session-like experience while keeping secrets client-side.

Without this, every login requires TouchID/FaceID even for recently active users.

3. Key Rotation ​

Publishes the current server key ID and maintains a grace period for old keys while clients migrate. This allows you to rotate Shamir keys without breaking existing sessions.

Can you skip the relay? Yes, if you:

  • Don't need atomic account creation (users create accounts separately)
  • Accept requiring biometric auth on every login
  • Don't use Shamir 3-pass at all

For most production deployments, the relay significantly improves UX.

Deployment Options ​

Choose based on your infrastructure:

PlatformBest ForKey Benefits
Express/Node.jsTraditional hosting, existing Node infrastructureFamiliar stack, filesystem access, easy local dev
Cloudflare WorkersServerless, global edge deploymentAuto-scaling, low latency, no server management

Both platforms expose the same API and work identically from the client's perspective.

Option 1: Express/Node.js ​

Project Setup ​

Location: examples/relay-server/

Structure:

relay-server/
├── src/
│   └── index.ts          # Express server entry point
├── .env                  # Configuration
└── package.json

Implementation ​

typescript
import express from 'express'
import { AuthService } from '@tatchi-xyz/sdk/server'
import { createRelayRouter } from '@tatchi-xyz/sdk/server/router/express'

const app = express()
app.use(express.json())

// Initialize the authentication service
const service = new AuthService({
  relayerAccountId: process.env.RELAYER_ACCOUNT_ID!,
  relayerPrivateKey: process.env.RELAYER_PRIVATE_KEY!,
  webAuthnContractId: process.env.WEBAUTHN_CONTRACT_ID || 'w3a-v1.testnet',
  // Optional overrides (SDK defaults are applied when omitted)
  nearRpcUrl: process.env.NEAR_RPC_URL,
  networkId: process.env.NETWORK_ID,
  accountInitialBalance: process.env.ACCOUNT_INITIAL_BALANCE, // default: 0.03 NEAR
  createAccountAndRegisterGas: process.env.CREATE_ACCOUNT_AND_REGISTER_GAS, // default: 85 TGas
  shamir: {
    // Env-shaped config is supported for ergonomic wiring
    SHAMIR_P_B64U: process.env.SHAMIR_P_B64U!,
    SHAMIR_E_S_B64U: process.env.SHAMIR_E_S_B64U!,
    SHAMIR_D_S_B64U: process.env.SHAMIR_D_S_B64U!,
    SHAMIR_GRACE_KEYS_FILE: process.env.SHAMIR_GRACE_KEYS_FILE, // optional (Node only)
  },
})

// Mount relay endpoints
app.use('/', createRelayRouter(service, {
  healthz: true,
  readyz: true,
  // Optional built-in CORS. If you prefer custom CORS middleware, omit this and wire your own.
  corsOrigins: [process.env.EXPECTED_ORIGIN, process.env.EXPECTED_WALLET_ORIGIN],
}))
app.listen(3000, () => {
  console.log('Relay server listening on port 3000')
})

Configuration ​

Create a .env file:

bash
# NEAR Configuration
RELAYER_ACCOUNT_ID=relayer.testnet
RELAYER_PRIVATE_KEY=ed25519:...
NEAR_RPC_URL=https://test.rpc.fastnear.com
NETWORK_ID=testnet
WEBAUTHN_CONTRACT_ID=w3a-v1.testnet
ACCOUNT_INITIAL_BALANCE=30000000000000000000000
CREATE_ACCOUNT_AND_REGISTER_GAS=85000000000000

# CORS
EXPECTED_ORIGIN=http://localhost:5173
EXPECTED_WALLET_ORIGIN=https://wallet.example.localhost

# Shamir 3-pass Keys
SHAMIR_P_B64U=...                    # Public modulus
SHAMIR_E_S_B64U=...                  # Server encryption key
SHAMIR_D_S_B64U=...                  # Server decryption key

# Optional: Persist grace keys between restarts
SHAMIR_GRACE_KEYS_FILE=./grace-keys.json

# Optional: Shamir key rotation
ENABLE_ROTATION=1
ROTATE_EVERY=60

Key Rotation ​

Add rotation logic with the built-in cron helper (Express). Rotation only applies when Shamir is configured:

typescript
import { startKeyRotationCronjob } from '@tatchi-xyz/sdk/server/router/express'

startKeyRotationCronjob(service, {
  enabled: true,
  intervalMinutes: 60,
  maxGraceKeys: 5,
  logger: console,
})

Running Locally ​

bash
cd examples/relay-server
pnpm install
pnpm dev

Option 2: Cloudflare Workers ​

Why Cloudflare Workers? ​

  • Global edge network: Serve requests from locations close to users
  • Auto-scaling: Handle traffic spikes without configuration
  • No servers: Pay only for requests, not idle time
  • WASM support: Run cryptographic operations at near-native speed

Project Setup ​

Location: examples/relay-cloudflare-worker/

Structure:

relay-cloudflare-worker/
├── src/
│   └── worker.ts         # Worker entry point
├── wrangler.toml         # Cloudflare configuration
└── package.json

Implementation ​

Cloudflare Workers can’t rely on Node-style file paths for WASM. Import the WASM modules and pass them via config.

Also: avoid caching AuthService across requests if you use Workers bindings (e.g. Durable Objects), because some bindings are request-scoped.

typescript
import { AuthService } from '@tatchi-xyz/sdk/server'
import {
  createCloudflareRouter,
  createCloudflareCron
} from '@tatchi-xyz/sdk/server/router/cloudflare'

// Import WASM modules directly (Workers can't use import.meta.url)
import signerWasmModule from '@tatchi-xyz/sdk/server/wasm/signer'
import shamirWasmModule from '@tatchi-xyz/sdk/server/wasm/vrf'

function buildService(env: any) {
  return new AuthService({
    relayerAccountId: env.RELAYER_ACCOUNT_ID,
    relayerPrivateKey: env.RELAYER_PRIVATE_KEY,
    webAuthnContractId: env.WEBAUTHN_CONTRACT_ID,
    nearRpcUrl: env.NEAR_RPC_URL,
    networkId: env.NETWORK_ID || 'testnet',
    accountInitialBalance: env.ACCOUNT_INITIAL_BALANCE,
    createAccountAndRegisterGas: env.CREATE_ACCOUNT_AND_REGISTER_GAS,
    signerWasm: { moduleOrPath: signerWasmModule }, // Pass WASM modules directly
    shamir: {
      moduleOrPath: shamirWasmModule,
      SHAMIR_P_B64U: env.SHAMIR_P_B64U,
      SHAMIR_E_S_B64U: env.SHAMIR_E_S_B64U,
      SHAMIR_D_S_B64U: env.SHAMIR_D_S_B64U,
      // Workers have no filesystem; keep grace keys in memory only (or persist externally)
      graceShamirKeysFile: '',
    },
  })
}

export default {
  async fetch(request, env, ctx) {
    const service = buildService(env)
    const router = createCloudflareRouter(service, {
      corsOrigins: [env.EXPECTED_ORIGIN, env.EXPECTED_WALLET_ORIGIN].filter(Boolean),
      healthz: true,
      readyz: true,
    })
    return router(request, env, ctx)
  },

  // Optional: Enable automatic key rotation via cron
  async scheduled(event, env, ctx) {
    if (env.ENABLE_ROTATION !== '1') return
    // Note: rotation in Workers is ephemeral unless you persist keys externally.
    const service = buildService(env)
    const cron = createCloudflareCron(service, { enabled: true, rotate: true })
    return cron(event, env, ctx)
  }
}

WASM Bundling Configuration ​

Cloudflare Workers require explicit WASM bundling. Update wrangler.toml:

toml
name = "w3a-relay-prod"
main = "src/worker.ts"
compatibility_date = "2024-09-24"
compatibility_flags = ["nodejs_compat"]

# Configure module rules for WASM imports
[[rules]]
type = "CompiledWasm"
globs = ["**/*.wasm"]
fallthrough = true

# Durable Object for threshold session persistence (Cloudflare-native; required for prod threshold signing).
[[durable_objects.bindings]]
name = "THRESHOLD_STORE"
class_name = "ThresholdEd25519StoreDurableObject"

[[migrations]]
tag = "v2"
# Cloudflare Free plan requires SQLite-backed Durable Objects.
new_sqlite_classes = ["ThresholdEd25519StoreDurableObject"]

[triggers]
crons = []

# Defaults (can be overridden per env)
[vars]
RELAYER_ACCOUNT_ID = "w3a-relayer.testnet"
NEAR_RPC_URL = "https://test.rpc.fastnear.com"
NETWORK_ID = "testnet"
WEBAUTHN_CONTRACT_ID = "w3a-v1.testnet"
ACCOUNT_INITIAL_BALANCE = "40000000000000000000000" # 0.04 NEAR
CREATE_ACCOUNT_AND_REGISTER_GAS = "85000000000000"  # 85 TGas
RELAYER_URL = "https://relay.example.com"
EXPECTED_ORIGIN = "https://app.example.com"
EXPECTED_WALLET_ORIGIN = "https://wallet.example.com"
THRESHOLD_PREFIX = "tatchi:prod:w3a"
THRESHOLD_ED25519_SHARE_MODE = "derived"

# Secrets are NOT defined here. Set them via Wrangler secrets:
#   wrangler secret put RELAYER_PRIVATE_KEY
#   wrangler secret put SHAMIR_P_B64U
#   wrangler secret put SHAMIR_E_S_B64U
#   wrangler secret put SHAMIR_D_S_B64U

[env.production]
name = "w3a-relay-prod"

[env.production.vars]
RELAYER_ACCOUNT_ID = "w3a-relayer.testnet"
NEAR_RPC_URL = "https://test.rpc.fastnear.com"
NETWORK_ID = "testnet"
WEBAUTHN_CONTRACT_ID = "w3a-v1.testnet"
ACCOUNT_INITIAL_BALANCE = "40000000000000000000000" # 0.04 NEAR
CREATE_ACCOUNT_AND_REGISTER_GAS = "85000000000000"  # 85 TGas
RELAYER_URL = "https://relay.example.com"
EXPECTED_ORIGIN = "https://app.example.com"
EXPECTED_WALLET_ORIGIN = "https://wallet.example.com"
THRESHOLD_PREFIX = "tatchi:prod:w3a"
THRESHOLD_ED25519_SHARE_MODE = "derived"

[[env.production.durable_objects.bindings]]
name = "THRESHOLD_STORE"
class_name = "ThresholdEd25519StoreDurableObject"

# Staging worker (separate Worker name + tighter CORS allowlist)
[env.staging]
name = "w3a-relay-staging"

[env.staging.vars]
RELAYER_ACCOUNT_ID = "w3a-relayer.testnet"
NEAR_RPC_URL = "https://test.rpc.fastnear.com"
NETWORK_ID = "testnet"
WEBAUTHN_CONTRACT_ID = "w3a-v1.testnet"
ACCOUNT_INITIAL_BALANCE = "40000000000000000000000" # 0.04 NEAR
CREATE_ACCOUNT_AND_REGISTER_GAS = "85000000000000"  # 85 TGas
RELAYER_URL = "https://relay-staging.example.com"
EXPECTED_ORIGIN = "https://staging.app.example.com"
EXPECTED_WALLET_ORIGIN = "https://wallet-staging.example.com"
THRESHOLD_PREFIX = "tatchi:staging:w3a"
THRESHOLD_ED25519_SHARE_MODE = "derived"

[[env.staging.durable_objects.bindings]]
name = "THRESHOLD_STORE"
class_name = "ThresholdEd25519StoreDurableObject"

Managing Secrets ​

Secrets are never committed to wrangler.toml. Use the CLI:

bash
# Authenticate once
wrangler login

# Set secrets for production
wrangler secret put RELAYER_PRIVATE_KEY --env production
wrangler secret put SHAMIR_P_B64U --env production
wrangler secret put SHAMIR_E_S_B64U --env production
wrangler secret put SHAMIR_D_S_B64U --env production
# Optional (threshold signing)
wrangler secret put THRESHOLD_ED25519_MASTER_SECRET_B64U --env production

# Set secrets for staging
wrangler secret put RELAYER_PRIVATE_KEY --env staging
wrangler secret put SHAMIR_P_B64U --env staging
wrangler secret put SHAMIR_E_S_B64U --env staging
wrangler secret put SHAMIR_D_S_B64U --env staging
# Optional (threshold signing)
wrangler secret put THRESHOLD_ED25519_MASTER_SECRET_B64U --env staging

Deployment ​

bash
cd examples/relay-cloudflare-worker
pnpm install
wrangler deploy --env staging
wrangler deploy --env production

Your relay is now live at https://your-worker.your-subdomain.workers.dev.

Local Development ​

Test against the real Cloudflare runtime:

bash
# Run against Cloudflare's edge (uses real WASM runtime)
wrangler dev --remote

# Watch logs
wrangler tail

Why --remote? The local emulator doesn't perfectly match Cloudflare's WASM environment. Testing remotely catches issues early.

API Reference ​

Both platforms expose identical HTTP endpoints. Most applications should not call these directly — the client SDK handles them. Payloads are considered SDK-internal and may change; if you need to integrate at this layer, use the TypeScript types from @tatchi-xyz/sdk/server (see sdk/src/server/core/types.ts).

Core endpoints ​

  • POST /create_account_and_register_user — atomic account creation + registration (used during onboarding)
  • POST /verify-authentication-response — VRF + WebAuthn verification (VIEW call); optionally mints a session token/cookie when session support is configured
  • POST /recover-email — encrypted email recovery (TEE/DKIM by default; zk-email when enabled)
  • GET /healthz / GET /readyz — health/readiness checks (optional; enabled via router options)
  • GET /session/auth and POST /session/logout — session helpers (optional; enabled when a session adapter is provided)

Shamir 3-Pass Operations ​

Apply Server Lock ​

POST /vrf/apply-server-lock

Client sends a client-locked KEK; server adds its lock and returns the double-locked value.

Request:

json
{
  "kek_c_b64u": "client-locked-kek-base64url"
}

Response:

json
{
  "kek_cs_b64u": "client-then-server-locked-base64url",
  "keyId": "sha256-hash-of-server-public-key"
}

Remove Server Lock ​

POST /vrf/remove-server-lock

Client sends the client+server locked value and the keyId used; server removes its lock and returns a client-locked value.

Request:

json
{
  "kek_cs_b64u": "client-then-server-locked-base64url",
  "keyId": "sha256-hash-of-server-public-key"
}

Response:

json
{
  "kek_c_b64u": "client-locked-kek-base64url"
}

Error 400: Unknown/expired keyId (not in current or grace list).

Get Key Info ​

GET /shamir/key-info

Returns the current server key ID and grace list for client migration.

Response:

json
{
  "currentKeyId": "sha256-of-current-e_s",
  "p_b64u": "public-modulus-base64url",
  "graceKeyIds": ["sha256-of-old-key-1", "sha256-of-old-key-2"]
}

Health Check ​

GET /healthz (optional, enabled via router config)

Returns 200 OK if the service is healthy.

cURL Examples ​

Test your relay manually:

bash
# Check health
curl https://relay.example.com/healthz

# Get current key info
curl https://relay.example.com/shamir/key-info

# Apply server lock
curl -X POST https://relay.example.com/vrf/apply-server-lock \
  -H 'Content-Type: application/json' \
  -d '{"kek_c_b64u":"<client-locked-kek>"}'

# Remove server lock
curl -X POST https://relay.example.com/vrf/remove-server-lock \
  -H 'Content-Type: application/json' \
  -d '{
    "kek_cs_b64u":"<client-then-server-locked>",
    "keyId":"<key-id-from-apply-response>"
  }'

Security Considerations ​

Key ID Enforcement ​

The keyId parameter in /vrf/remove-server-lock is required and validated. This ensures:

  • Clients explicitly choose which server key to use
  • The server can't be tricked into using an old, compromised key
  • Key rotation is transparent and auditable

Grace Period Best Practices ​

Grace keys let old keys work temporarily while clients migrate:

  1. Keep the window short: 24-48 hours is usually sufficient
  2. Limit the list: Store at most 5 grace keys
  3. Monitor usage: Log which keys are being used
  4. Prune aggressively: Remove unused keys after the grace period
typescript
// Good: Short grace period
await service.rotateShamirServerKeypair({
  keepCurrentInGrace: true,
  maxGraceKeys: 3  // Keep only last 3 keys
})

// Bad: Indefinite grace period
// Never do this - old keys accumulate forever

CORS Configuration ​

Be explicit about allowed origins. If you use the built-in router CORS, configure corsOrigins:

ts
createRelayRouter(service, {
  corsOrigins: ['https://app.example.com', 'https://wallet.example.com'],
})

Avoid corsOrigins: '*' when using cookie sessions (credentials cannot be used with wildcard CORS).

Secret Management ​

Express:

  • Use .env files (never commit them!)
  • For production: Use secret managers (AWS Secrets Manager, HashiCorp Vault)
  • Rotate RELAYER_PRIVATE_KEY periodically

Cloudflare:

  • Always use wrangler secret put for sensitive values
  • Secrets are encrypted at rest and in transit
  • Use separate Workers for staging/production

Troubleshooting ​

Common Issues ​

"Invalid URL string" (Cloudflare only) ​

Cause: Worker tried to use import.meta.url to load WASM, which doesn't work in the Workers runtime.

Fix:

  1. Import WASM modules directly: import signerWasmModule from '@tatchi-xyz/sdk/server/wasm/signer'
  2. Pass via config: signerWasm: { moduleOrPath: signerWasmModule }
  3. Ensure wrangler.toml has the WASM bundling rule

CORS Errors ​

Symptoms: Browser shows "blocked by CORS policy" in console.

Fix:

  • Express: Verify cors() middleware includes the client origin
  • Cloudflare: Set EXPECTED_ORIGIN and EXPECTED_WALLET_ORIGIN in wrangler.toml
  • Check browser DevTools → Network → Response Headers for Access-Control-Allow-Origin

Account Creation Fails with "insufficient balance" ​

Cause: Relay account doesn't have enough NEAR to fund new accounts.

Fix:

  1. Check relayer balance: near state relayer.testnet
  2. Fund it: near send your-account.testnet relayer.testnet 10
  3. Verify accountInitialBalance in config is reasonable (0.03 NEAR is typical)

Shamir Unlock Returns 400 ​

Cause: Client is using an unknown/expired keyId not in the grace list.

Fix:

  1. Client should call /shamir/key-info to get the current keyId
  2. If keyId changed, client must re-wrap with the new key
  3. Check server logs for which keyId was requested vs. what's available

Debugging Tips ​

Express:

typescript
// Add request logging
app.use((req, res, next) => {
  console.log(`${req.method} ${req.path}`, req.body)
  next()
})

Cloudflare:

bash
# Live tail logs
wrangler tail

# Filtered logs
wrangler tail --status error

Next Steps ​

  • Configure the SDK: Point your client at the relay URL
  • Set up monitoring: Track account creation rate, error rates, key rotation events
  • Configure CI/CD: See Cloudflare + GitHub Actions for automated deployments
  • Review security: Read the Security Model to understand the full architecture