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.

The keeper is the autonomous enforcement layer of Deadman Vault. Without it, a missed heartbeat is just a database record — with it, a missed heartbeat becomes an irreversible on-chain event that settles funds directly to beneficiaries. Running as a Vercel Cron Job, the keeper continuously sweeps all live vaults, evaluates their deadline status against the current Stacks block height, sends escalating notifications to owners, and — once the grace period expires — locks in the trigger, schedules vesting, and distributes USDCx proportionally to every eligible beneficiary. It is the difference between an intent and a guarantee.

How the Keeper is Invoked

The keeper runs as a Vercel Cron Job that calls GET /api/keeper. The handler fetches the current Stacks block height and passes it to runKeeper:
import { runKeeper } from "@/lib/deadman-vault/keeper";

// Inside GET /api/keeper
const currentBlock = await getCurrentBlockHeight();
const result = await runKeeper(currentBlock);
// result: { checked: number; actions: string[] }
runKeeper returns a summary of every vault it evaluated and every action it took during that sweep.

What runKeeper Does

runKeeper(currentBlock) loads all vaults in sweepable states (active, warning, grace_period, triggered) via getVaultsForKeeperSweep, then evaluates each one in order. For each vault, it performs up to four distinct actions:
1

Retry settlement for already-triggered vaults

If a vault is already in triggered status and has at least one unclaimed beneficiary (including those whose vesting cliff has matured since the last sweep), settleVaultPayout is called again. This handles transient RPC failures, network timeouts, and beneficiaries whose cliff window just opened.
if (vault.status === "triggered") {
  const beneficiaries = await getBeneficiariesByVaultId(vault.id);
  if (beneficiaries.some((b) => !b.claimed)) {
    await settleVaultPayout(vault, beneficiaries);
    actions.push(`settle-retry:${vault.id}`);
  }
  continue; // Skip remaining checks for already-triggered vaults
}
2

Send warning email when deadline is within 432 blocks

When deadline − currentBlock ≤ WARNING_BLOCKS (432 blocks, ≈ 3 days) and the deadline has not yet passed, the keeper sends a warning email to the vault owner and advances status to warning. This notification is deduplicated — it fires at most once per 24-hour window per vault.
if (deadline - currentBlock <= WARNING_BLOCKS && deadline > currentBlock) {
  if (!(await hasRecentNotification(vault.id, "warning", 24))) {
    await sendWarningEmail(vault, vault.owner_email || "");
    await addNotification({
      vault_id: vault.id,
      type: "warning",
      recipient_email: vault.owner_email,
      recipient_address: vault.owner_address,
      status: "sent",
    });
    await updateVaultStatus(vault.id, "warning");
    actions.push(`warning:${vault.id}`);
  }
}
3

Advance to grace_period when deadline passes

When currentBlock > deadline and the grace window (currentBlock ≤ deadline + 288) is still open, the keeper advances status to grace_period and sends a critical grace period email. This notification is deduplicated to at most once per 48 hours.
if (currentBlock > deadline && currentBlock <= deadline + GRACE_PERIOD_BLOCKS) {
  await updateVaultStatus(vault.id, "grace_period");
  if (!(await hasRecentNotification(vault.id, "grace_period", 48))) {
    await sendGracePeriodEmail(vault, vault.owner_email || "");
    await addNotification({
      vault_id: vault.id,
      type: "grace_period",
      recipient_email: vault.owner_email,
      recipient_address: vault.owner_address,
      status: "sent",
    });
    actions.push(`grace:${vault.id}`);
  }
}
4

Trigger vault when grace period expires

When currentBlock > deadline + GRACE_PERIOD_BLOCKS (288 blocks, ≈ 2 days after deadline), the vault is irreversibly triggered. The keeper:
  1. Sets status to triggered and records triggered_at
  2. Calls scheduleVesting to compute and persist each beneficiary’s vesting_release_at
  3. Re-fetches beneficiaries so settlement sees the freshly-computed vesting timestamps
  4. Calls settleVaultPayout to attempt immediate payout
  5. Sends claim-ready emails to every beneficiary and logs each notification
if (currentBlock > deadline + GRACE_PERIOD_BLOCKS) {
  const triggeredAt = new Date();
  await updateVaultStatus(vault.id, "triggered");
  await setVaultTriggeredAt(vault.id, triggeredAt);

  const beneficiaries = await getBeneficiariesByVaultId(vault.id);
  await scheduleVesting(beneficiaries, triggeredAt);
  const scheduled = await getBeneficiariesByVaultId(vault.id); // re-fetch

  await settleVaultPayout(vault, scheduled);

  for (const ben of scheduled) {
    await sendClaimReadyEmail(ben, vault);
    await addNotification({
      vault_id: vault.id,
      type: "claim_ready",
      recipient_email: ben.email,
      recipient_address: ben.address,
      status: "sent",
    });
  }
  actions.push(`triggered:${vault.id}`);
}

The Custodial Keeper Keypair

Each vault has its own dedicated Stacks keypair, generated at vault creation time by generateKeeperAccount. The private key is encrypted with AES-256-GCM using a server-side KEEPER_ENCRYPTION_KEY environment variable and stored in the keeper_key_ciphertext column:
// Encryption format: iv:authTag:ciphertext (all hex)
// Algorithm: aes-256-gcm with a 32-byte key from KEEPER_ENCRYPTION_KEY

export function encrypt(plaintext: string): string { ... }
export function decrypt(payload: string): string { ... }
When the keeper needs to act on a vault — whether for a heartbeat extension or settlement — it calls decrypt(vault.keeper_key_ciphertext) to recover the private key, instantiates a FlowVault client with that key, and submits transactions on behalf of the vault owner.
This is a custodial, testnet-only design. The server holds the decryption key for every vault’s keeper keypair. This is acceptable for testnet demonstration purposes but is not suitable for production mainnet deployment, where user funds should never be accessible to a server. The production-grade alternative is a non-custodial Clarity smart contract that enforces the heartbeat deadline and beneficiary payouts entirely on-chain, without any server-held keys.

How settleVaultPayout Works

Settlement is handled by settleVaultPayout in src/lib/keeper/settlement.ts. It is designed to be idempotent — safe to call multiple times without double-paying anyone:
1

Filter to unpaid, vesting-matured beneficiaries

Only beneficiaries where claimed = false and either vesting_release_at is null or has already passed are included in the current payout run.
2

Withdraw unlocked balance from FlowVault

The keeper checks the vault’s unlockedBalance via flowVault.getVaultState(). If there is an unlocked balance greater than zero, it calls flowVault.withdraw(unlockedBalance) to move funds to the keeper’s wallet.
3

Calculate each beneficiary's proportional share

Shares are computed in micro-USDCx from total_deposited, using integer arithmetic to avoid rounding drift:
const totalDepositedMicro = BigInt(Math.round(Number(vault.total_deposited) * 1_000_000));
const share = (totalDepositedMicro * BigInt(beneficiary.allocation_percent)) / BigInt(totalPercent);
4

Send USDCx to each eligible beneficiary

For each unpaid beneficiary, sendTokenTransfer dispatches a USDCx transfer directly to their Stacks address. On success, recordClaim marks the beneficiary as claimed and stores the transaction ID.
5

Advance to claimed when all beneficiaries are paid

After each payout loop, if every beneficiary in the vault is claimed, the vault status is advanced to claimed. No further keeper processing occurs.

Return Type

runKeeper returns a structured summary for logging and monitoring. The return type is declared inline in the source:
async function runKeeper(currentBlock: number): Promise<{ checked: number; actions: string[] }>
  • checked — total number of vaults evaluated in this sweep
  • actions — action tags emitted during the sweep, e.g. ["warning:uuid", "triggered:uuid", "settle-retry:uuid"]
The actions array uses colon-delimited tags (action:vaultId) so that each entry is both human-readable in logs and machine-parseable for alerting pipelines.

Notifications Sent by the Keeper

TriggerFunctionDeduplication
Deadline within 432 blockssendWarningEmailOnce per 24 hours
Deadline passed, grace opensendGracePeriodEmailOnce per 48 hours
Vault triggeredsendClaimReadyEmail (per beneficiary)Not deduplicated
Beneficiary paidsendFundsReleasedEmailNot deduplicated
All sent notifications are logged to the notifications table with vault_id, type, recipient_email, recipient_address, and sent_at — providing a complete audit trail of every keeper communication.

Build docs developers (and LLMs) love