Building Vaults on Bitcoin

Day 10: Rubin's Bitcoin Advent Calendar

on December 7, 2021

This post is syndicated from rubin.io.

Welcome to day 10 of my Bitcoin Advent Calendar. You can see an index of all the posts here or subscribe at judica.org/join to get new posts in your inbox

A “Vault” is a general concept for a way of protecting Bitcoin from theft through a cold-storage smart contract. While there is not formal definition of what is and is not a Vault, generally a Vault has more structure around a withdrawal than just a multisig.

One of the earlier references for Vaults was a design whereby every time you request to withdraw from it you can “reset” the request within a time limit. This means that while an attacker might steal your keys, you can “fight” to make it a negative sum game – e.g., they’ll just keep on paying fees to eventually steal an amount less than they paid. This might serve to disincentivize hacking exchanges if hackers are less likely to actually get coins.

Similar Vaults can be built using Sapio, but the logic for them involves unrolling the contract a predefined number of steps. This isn’t bad because if the period of timeout is 1 week then just unrolling 5,200 times gets you one thousand years of hacking disincentive.

The contract for that might look something like this in Sapio (note: I was running behind on this post so I may make modifications to make these examples better later):

struct VaultOne {
    /// Key that will authorize:
    /// 1) Recursing with the vault
    /// 2) Spending from the vault after not moved for a period
    key: bitcoin::PublicKey,
    /// How long should the vault live for
    steps: u32,
}

impl VaultOne {
    /// Checks if steps are remaining
    #[compile_if]
    fn not_out_of_steps(self, ctx: Context) {
        if self.steps == 0 {
            ConditionalCompileType::Never
        } else {
            ConditionalCompileType::NoConstraint
        }
    }

    #[guard]
    fn authorize(self, ctx: Context) {
        Clause::Key(self.key.clone())
    }

    /// Recurses the vault if authorized
    #[then(compile_if = "[Self::not_out_of_steps]", guarded_by = "[Self::authorize]")]
    fn step(self, ctx: Context) {
        let next = VaultOne {
            key: self.key.clone(),
            steps: self.steps - 1,
        };
        let amt = ctx.funds();
        ctx.template()
            .add_output(amt, &next, None)?
            // For Paying fees via CPFP. Note that we should totally definitely
            // get rid of the dust limit for contracts like this, or enable
            // IUTXOS with 0 Value
            .add_output(Amount::from_sat(0), &self.key, None)?
            .into()
    }
    /// Allow spending after a week long delay
    #[guard]
    fn finish(self, ctx: Context) {
        Clause::And(vec![
            Clause::Key(self.key.clone()),
            RelTime::try_from(Duration::from_secs(7 * 24 * 60 * 60))
                .unwrap()
                .into(),
        ])
    }
}
/// Binds the logic to the Contract
impl Contract for VaultOne {
    declare! {then, Self::step}
    declare! {finish, Self::finish}
}

But we can also build much more sophisticated Vaults that do more. Suppose we want to have a vault where once a week you can claim a trickle of bitcoin into a hot wallet, or you can send it back to a cold storage key. This is a “structured liquidity vault” that gives you time-release Bitcoin. Let’s check out some code and talk about it more:

#[derive(Clone)]
struct VaultTwo {
    /// Key just for authorizing steps
    authorize_key: bitcoin::PublicKey,
    amount_per_step: bitcoin::Amount,
    /// Hot wallet key
    hot_key: bitcoin::PublicKey,
    /// Cold wallet key
    cold_key: bitcoin::PublicKey,
    steps: u32,
}

impl VaultTwo {
    #[compile_if]
    fn not_out_of_steps(self, ctx: Context) {
        if self.steps == 0 {
            ConditionalCompileType::Never
        } else {
            ConditionalCompileType::NoConstraint
        }
    }

    #[guard]
    fn authorized(self, ctx: Context) {
        Clause::Key(self.authorize_key.clone())
    }
    #[then(compile_if = "[Self::not_out_of_steps]", guarded_by = "[Self::authorized]")]
    fn step(self, ctx: Context) {
        // Creates a recursive vault with one fewer steps
        let next = VaultTwo {
            steps: self.steps - 1,
            ..self.clone()
        };
        let amt = ctx.funds();
        ctx.template()
            // send to the new vault
            .add_output(amt - self.amount_per_step, &next, None)?
            // withdraw some to hot storage
            .add_output(self.amount_per_step, &self.hot_key, None)?
            // For Paying fees via CPFP. Note that we should totally definitely
            // get rid of the dust limit for contracts like this, or enable
            // IUTXOS with 0 Value
            .add_output(Amount::from_sat(0), &self.authorize_key, None)?
            // restrict that we have to wait a week
            .set_sequence(
                -1,
                RelTime::try_from(Duration::from_secs(7 * 24 * 60 * 60))?.into(),
            )?
            .into()
    }
    /// allow sending the remaining funds into cold storage
    #[then(compile_if = "[Self::not_out_of_steps]", guarded_by = "[Self::authorized]")]
    fn terminate(self, ctx: Context) {
        ctx.template()
            // send the remaining funds to cold storage
            .add_output(self.amount_per_step*self.steps, &self.cold_key, None)?
            // For Paying fees via CPFP. Note that we should totally definitely
            // get rid of the dust limit for contracts like this, or enable
            // IUTXOS with 0 Value
            .add_output(Amount::from_sat(0), &self.authorize_key, None)?
            .into()
    }
}

impl Contract for VaultTwo {
    declare! {then, Self::step, Self::terminate}
}

This type of Vault is particularly interesting for e.g., withdrawing from an exchange business. Imagine a user, Elsa who wants to have a great cold storage system. So Elsa sets up a xpub key and puts it on ice. She then generates a new address, and requests that the exchange let the funds go to it. Later that month, Elsa wants to buy a coffee with her Bitcoin so she has to thaw out her cold storage to spend (maybe using a offline PSBT signing), and transfer the funds to her destination or to a hot wallet if she wants a bit of extra pocket money. Instead suppose Elsa sets up a timerelease vault. Then, she can set up her cold vault and automatically be able to claim 1 Bitcoin a month out of it, or if she notices some coins missing from her hot wallet redirect the funds solely under her ice castle.

This has many benefits for an average user. One is that you can invest in your cold storage of keys once in your life and only have to access it in unexpected circumstance. This means that: users might elect to use something more secure/inconvenient to access (e.g. strongly geo-sharded); that they won’t reveal access patterns by visiting their key storage facility; and that they don’t need to expose themselves to recurring fat-finger1 risk.

Getting a little more advanced

What are some other things we might want to do in a vault? Let’s do a quickfire – we won’t code these here, but you’ll see examples of these techniques in posts to come:

Send a percentage, not a fixed amount

Let the contract know the intended amount, and then compute the withdrawals as percentages in the program.

Non-Key Destinations

In the examples above, we use keys for hot wallet, cold wallet, and authorizations.

However, we could very well use other programs! For example, imagine a time-release vault that goes into a anti-theft locker.

Change Hot Wallet Every Step

This one is pretty simple – if you have N steps just provide a list of N different destinations and use the i-th one as you go!

Topping up:

There are advanced techniques that can be used to allow depositing into a vault after it has been created (i.e., topping up), but that’s too advanced to go into detail today. For those inclined, a small hint: make the “top up” vault consume an output from the previous vault, CTV commits to the script so you can use a salted P2SH out.

Even more advanced

What if we want to ensure that after a withdraw funds are re-inserted into the Vault?

We’ll ditch the recursion (for now), and just look at some basic logic. Imagine a coin is held by a cold storage key, and we want to use Sapio to generate a transaction that withdraws funds to an address and sends the rest back into cold storage.

struct VaultThree {
    key: bitcoin::PublicKey,
}

/// Special struct for passing arguments to a created contract
enum Withdrawal {
    Send {
        addr: bitcoin::Address,
        amount: bitcoin::Amount,
        fees: bitcoin::Amount,
    },
    Nothing,
}
/// required...
impl Default for Withdrawal {
    fn default() -> Self {
        Withdrawal::Nothing
    }
}
impl StatefulArgumentsTrait for Withdrawal {}

/// helper for rust type system issue
fn default_coerce(
    k: <VaultThree as Contract>::StatefulArguments,
) -> Result<Withdrawal, CompilationError> {
    Ok(k)
}

impl VaultThree {
    #[guard]
    fn signed(self, ctx: Context) {
        Clause::Key(self.key.clone())
    }
    #[continuation(guarded_by = "[Self::signed]", coerce_args = "default_coerce")]
    fn withdraw(self, ctx: Context, request: Withdrawal) {
        if let Withdrawal::Send { amount, fees, addr } = request {
            let amt = ctx.funds();
            ctx.template()
                // send the rest recursively to this contract
                .add_output(amt - amount - fees, self, None)?
                // process the withdrawal
                .add_output(amount, &Compiled::from_address(addr, None), None)?
                // mark fees as spent
                .spend_amount(fees)?
                .into()
        } else {
            empty()
        }
    }
}
impl Contract for VaultThree {
    declare! {updatable<Withdrawal>, Self::withdraw}
}

Now we’ve seen how updatable continuation clauses can be used to dynamically pass arguments to a Sapio contract and let the module figure out what the next transactions should be, managing recursive and non-enumerated state transitions (albeit with a trust model).

That’s probably enough for today, before I make your head explode. We’ll see more examples soon!


  1. Sending the wrong amount because you click the wrong key with your too-large hands. ↩︎


comments powered by Disqus