The Constraint: Covenant complexity vs real-world failure modes
The Question: What belongs in the covenant (on-chain), and what belongs in the client (off-chain)?
The principle:
Covenant = technical capability (“CAN this action happen?”)
Client = business logic (“SHOULD this action happen now?”)
“The app enforces fairness. The covenant enforces ownership.”
In practice:
┌─────────────────────────────────────────────────┐
│ COVENANT (on-chain, immutable) │
│ │
│ function refund(sig senderSig) { │
│ require(checkSig(senderSig, sender)); │
│ // Sender CAN refund ANYTIME │
│ } │
└─────────────────────────────────────────────────┘
▲
│
│ enforces when appropriate
│
┌─────────────────────────────────────────────────┐
│ CLIENT (off-chain, updatable) │
│ │
│ if (timeExpired || priceDropped) { │
│ await covenant.refund(); │
│ // Client SHOULD refund when conditions met │
│ } │
└─────────────────────────────────────────────────┘
What the covenant provides:
What the client provides:
| Gain | Loss |
|---|---|
| Simple covenant code (easier to audit) | Relies on client behavior (off-chain enforcement) |
| Emergency escape hatch (sender can always exit) | Recipient trusts sender won’t abuse early refund |
| Updatable business logic (client can improve) | Social layer needed (reputation, Nostr monitoring) |
| No stuck funds (works even if client crashes) | UX must hide refund button (prevent user confusion) |
| Flexible without redeployment | Two-layer mental model (covenant + client) |
Just because a covenant CAN enforce a rule doesn’t mean it SHOULD.
When we lock user funds “for their protection,” we’re trading their sovereignty for our safety preferences. Covenants are powerful, but that power creates the temptation to over-constrain.
Example of over-constraint:
We COULD enforce a 24-hour waiting period before refund (prevents “impulsive” refunds). But:
The principle:
It’s the user’s money. Covenants should enable, not imprison.
We could build covenants that enforce every safety rule on-chain, but we choose not to.
Complex covenant approach (what we could do):
function refund(...) {
// "Safety" rules that trap user funds:
require(oracleTimestamp >= expiryTime); // Can't refund early
require(currentPrice < priceFloor); // Can't refund unless price dropped
require(tx.time >= expiryMTP); // Belt AND suspenders
// User funds locked until ALL conditions met
}
This “protects” the sender by trapping their money. If sender changes mind, device fails, or oracle goes offline → funds stuck.
Our v2.2 approach:
// Sender's money, sender's decision
function refund(sig senderSig) {
require(checkSig(senderSig, sender)); // Just verify ownership
// Covenant enables. Client decides when.
}
The app enforces fairness:
// Client auto-refunds when appropriate
if (timeExpired || priceDropped) {
await covenant.refund(); // But sender CAN refund anytime if needed
}
Bitcoin does this already:
| Layer | Bitcoin | Asgaya |
|---|---|---|
| Protocol | Any valid transaction allowed | Covenant allows sender refund anytime |
| Wallet | “Are you sure?” warnings, fee estimation | Client hides refund button until appropriate |
| Result | Permissionless at base, sensible UX on top | Same pattern |
Bitcoin example:
Asgaya example:
Why this works: Users run wallets they trust. Covenant permissionlessness means users can switch wallets if one misbehaves.
The progression from complex to simple:
| Version | What changed | Problem it solved | Problem it created |
|---|---|---|---|
| Phase 1 | MTP-only refund | Basic refund path | Too slow for testing (hours) |
| v2.0 | Added oracle fast path | Fast refund (5 min) | Oracle dependency for refund |
| v2.1 | Added price drop protection | Claim rejects below floor | Can’t refund on price drop before expiry |
| v2.2 | Removed conditions from refund; client enforces | Simple covenant, emergency escape, no oracle needed for refund | Two-layer mental model |
The realization: We kept adding complexity to handle edge cases. Moving logic to the client solved all issues at once.
Client enforcement pattern:
// Client decides when auto-refund is appropriate
async function shouldAutoRefund() {
const timeExpired = currentTime >= expiryTime;
const priceDropped = currentPrice < priceFloor;
return timeExpired || priceDropped;
}
Result:
Why sender gets flexibility:
Recipient/merchant have deadlines:
function claim(...) {
require(oracleTimestamp < expiryTime); // Must claim before deadline
}
Seller is passive:
Asymmetry is intentional: Protect the party funding the system (sender), give them maximum flexibility.
For senders:
For developers:
For auditors:
| Limitation | Impact | Mitigation | Why We Accept |
|---|---|---|---|
| Sender can refund early | Recipient might not get payment | Client hides refund button, Nostr monitoring, reputation | Sender funded the covenant - they own it |
| Client enforces fairness | Malicious client could auto-refund immediately | Open-source client, users choose wallet, social reputation | Same as Bitcoin wallets - users trust what they run |
| Two-layer mental model | Harder to explain (covenant vs client) | UX hides complexity (“auto-refund protection”), docs explain “why” | Simpler covenant = less risk, worth the explanation cost |
| Oracle only needed for claim | Can’t trustlessly verify refund conditions | MTP still available as fallback, refund is sender’s right anyway | Permissionless escape hatch more important than perfect verification |
Discovered: July 2026 during Phase 1 covenant testing (v2.0 → v2.2 evolution)
Inspiration:
The realization came from chipnet testing:
Key insight: We were designing for the success case (time expiry, oracle available). Real-world testing revealed failure modes (oracle offline, price drops, sender emergency exit). Simplifying the covenant solved all of them.
Current version: v2.2 (simplified refund)
Planned: v2.3 (adds seller buffer recovery for sender-offline edge case)
v2.2 Covenant code:
function refund(sig senderSig) {
require(checkSig(senderSig, sender));
// Output 0: Payment → sender
// Output 1: Buffer → seller
}
v2.3 Addition: Seller Buffer Recovery
Handles edge case where sender device offline after expiry:
function sellerRecoverBuffer(sig sellerSig, datasig oracleSig, bytes oracleMessage) {
require(checkSig(sellerSig, seller));
require(checkDataSig(oracleSig, oracleMessage, oraclePubkey));
int oracleTimestamp = int(oracleMessage.split(8)[0]);
require(oracleTimestamp >= expiryOracleTime); // Only after expiry
// Output 0: Payment → sender (fair, even if sender offline)
// Output 1: Buffer → seller (recovers own capital)
}
Same principle, different actor: Seller gets independent recovery path. Simplicity doesn’t mean fewer functions—it means each function does one clear thing.
Client implementation: Auto-refund monitoring (20-second checks, distributed across sender/recipient/seller devices)
Testing status:
This principle succeeds when:
Phase 0 validates: Does separation of concerns translate to real-world robustness?
| 🏠 Home | ↑ Why This Design? | 📖 Glossary |
| Related: Constraints | Time Oracle |