Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/natureloved/DeadMan-Vault/llms.txt

Use this file to discover all available pages before exploring further.

Deadman Vault’s security architecture is built around a single governing principle: no sensitive key material or raw database rows ever reach the browser. Every vault creates a dedicated server-side Stacks keypair (the “keeper”), whose private key is encrypted at rest with AES-256-GCM and decrypted only inside server-side API routes. Supabase Row-Level Security locks all four database tables from public access, routing every read and write through the service-role key on the server. This layered approach prevents browser-side exposure of private keys and vault metadata alike — though it also means the server is custodial, a tradeoff that is explicitly acceptable for testnet and explicitly unacceptable for mainnet.

Custodial Keeper Design

The keeper is custodial — its private key can move real funds. This is a deliberate shortcut for testnet demo purposes only. Do not carry this design to mainnet. See Production-Grade Alternative below.

Keypair Generation

When a vault is created, the server calls generateKeeperAccount() from src/lib/keeper/account.ts, which uses @stacks/transactionsrandomPrivateKey() to produce a fresh, dedicated Stacks keypair:
import { randomPrivateKey, getAddressFromPrivateKey } from "@stacks/transactions";
import { FLOWVAULT_NETWORK } from "@/lib/constants";

export function generateKeeperAccount(): { privateKey: string; address: string } {
  const privateKey = randomPrivateKey();
  const address = getAddressFromPrivateKey(privateKey, FLOWVAULT_NETWORK);
  return { privateKey, address };
}
Each vault gets its own unique keypair — no key is ever reused across vaults.

Encryption at Rest

The private key is immediately encrypted before being written to the database. Encryption uses AES-256-GCM (src/lib/keeper/crypto.ts) with the KEEPER_ENCRYPTION_KEY environment variable as the symmetric key:
const ALGORITHM = "aes-256-gcm";

export function encrypt(plaintext: string): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv(ALGORITHM, getKey(), iv);
  const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
  const authTag = cipher.getAuthTag();
  return `${iv.toString("hex")}:${authTag.toString("hex")}:${ciphertext.toString("hex")}`;
}
The resulting ciphertext is stored in keeper_key_ciphertext as a self-contained iv:authTag:ciphertext string (all hex). All three components needed for decryption are bundled together in a single field — the GCM auth tag also authenticates the ciphertext, so any tampering is detected at decrypt time. KEEPER_ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes). Generate a new key with:
openssl rand -hex 32

Decryption is Server-Side Only

The keeper private key is only decrypted inside server-side API routes (and the keeper cron job). The decrypt() function is never called from client components. The call site in src/lib/keeper/settlement.ts is representative:
// Only executed inside a server-side API route or Vercel cron function
const keeperKey = decrypt(keeperKeyCiphertext);

keeper_key_ciphertext is Stripped Before the Browser

serializeVault() in src/lib/supabase/serialize.ts maps the raw Supabase row to the camelCase shape returned to the frontend. It explicitly omits keeper_key_ciphertext:
export function serializeVault(vault: Vault, extra?: { currentBlock?: number }) {
  return {
    id: vault.id,
    ownerAddress: vault.owner_address,
    ownerEmail: vault.owner_email,
    vaultName: vault.vault_name,
    status: vault.status,
    totalDeposited: vault.total_deposited,
    heartbeatIntervalDays: vault.heartbeat_interval_days,
    lastHeartbeatBlock: vault.last_heartbeat_block,
    heartbeatDeadlineBlock: vault.heartbeat_deadline_block,
    messageToBeneficiaries: vault.message_to_beneficiaries,
    txId: vault.tx_id,
    keeperAddress: vault.keeper_address,
    currentBlock: extra?.currentBlock,
    createdAt: vault.created_at,
    updatedAt: vault.updated_at,
    // keeper_key_ciphertext is deliberately absent — never sent to the client
  };
}
The browser never sees the ciphertext, and therefore cannot attempt decryption even if KEEPER_ENCRYPTION_KEY were somehow exposed separately.

What the Owner Signs

The vault owner never signs FlowVault contract calls directly. The only on-chain action the owner ever signs is a plain SIP-010 USDCx transfer to the keeper address — at vault creation and at each heartbeat. The keeper then performs all FlowVault operations (set-routing-rules, deposit, withdraw) using its server-held key.
This is why the custodial pattern exists in the first place: flowvault-v2 keys all vault state off tx-sender with no vault-id argument. Only the address that called deposit can ever call withdraw. The keeper bridges this gap on testnet.

What the Keeper CAN Do

The keeper’s capabilities are intentionally broad and represent the core custodial risk:
  • Withdraw the locked USDCx balance from FlowVault
  • Transfer USDCx to beneficiary addresses
  • Call set-routing-rules and deposit on the FlowVault contract
  • Any other Stacks transaction signed with its key

Database Security

All four Supabase tables are protected by Row-Level Security. Migration 003_enable_rls.sql enables RLS with zero public policies:
-- No policies are added on purpose — nothing should read/write these
-- tables directly from the browser using the public anon key.
ALTER TABLE vaults ENABLE ROW LEVEL SECURITY;
ALTER TABLE beneficiaries ENABLE ROW LEVEL SECURITY;
ALTER TABLE heartbeats ENABLE ROW LEVEL SECURITY;
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
All database operations — reads and writes — go through server-side API routes using SUPABASE_SERVICE_ROLE_KEY, which bypasses RLS by design. The browser-exposed NEXT_PUBLIC_SUPABASE_ANON_KEY is configured in the Supabase project but has zero table access in Deadman Vault.

Security Properties Summary

What is protectedMechanism
Keeper private key (plaintext)AES-256-GCM encryption; never leaves server memory
Keeper private key (ciphertext)Stripped from all client-facing responses by serializeVault()
Vault records and beneficiary dataRLS with no public policies; accessed only via service-role key
Heartbeat and notification logsSame RLS lockdown as vaults
FlowVault contract callsSigned exclusively by the keeper server-side
Owner walletOnly signs a USDCx transfer; never signs contract calls

Production-Grade Alternative

The README is explicit: the custodial keeper is a testnet-only shortcut. The production-grade alternative, as described in the src/lib/keeper/ source comments, is:
A non-custodial Clarity wrapper contract with a permissionless claim function, so no server ever holds a key that can move real funds.
In this design:
  • The owner deposits directly into a custom Clarity contract (not via a keeper intermediary)
  • The contract enforces the heartbeat deadline and beneficiary split on-chain
  • Any caller can invoke claim once the deadline passes — no server key is needed
  • The keeper cron would still monitor deadlines and send notifications, but would never hold signing authority over funds
Do not carry the custodial keeper pattern to mainnet. If KEEPER_ENCRYPTION_KEY is compromised, every vault’s keeper private key is recoverable. If SUPABASE_SERVICE_ROLE_KEY is compromised, all vault metadata is readable. The non-custodial Clarity contract approach eliminates the key-custody risk entirely.

Attack Surface Summary

KEEPER_ENCRYPTION_KEY Compromise

If the encryption key is leaked, all stored keeper key ciphertexts become decryptable. Every vault’s keeper private key would be compromised. Rotate immediately by re-encrypting all rows — but note that key rotation in production requires a coordinated migration.

SUPABASE_SERVICE_ROLE_KEY Exposure

The service-role key bypasses RLS and has full table access. Exposure would allow reading all vault records, beneficiary data, and notification logs. It would not expose keeper private keys (those are AES-encrypted), but would expose all metadata.

Server-Side Code Injection

Because decryption happens server-side, any code execution vulnerability in the Next.js API layer could reach the decrypted keeper keys in memory. Keep dependencies audited and Next.js patched.

Keeper Address Underfunded

If the keeper account runs out of STX gas, it cannot broadcast transactions. The vault enters a degraded state. KEEPER_FUNDING_PRIVATE_KEY provides an automated STX drip; manual testnet-faucet funding is the fallback.

Build docs developers (and LLMs) love