Payment Channels in a CTV+Sapio World

Day 14: Rubin's Bitcoin Advent Calendar

on December 11, 2021

This post is syndicated from rubin.io.

Welcome to day 14 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

Lightning Lightning Lightning

Everybody loves Lightning. I love Lightining, you love Lightning. We love everyone who works on Lightning. Heck, even Chainalysis loves Lightning these days :(…

We all love Lightning.

But what if I told you we could love Lightning even more? Crazy, right?

With CTV + Sapio we can improve on Lightning is some pretty cool ways you may not have heard too much about before. Buckle up, we’re in for another doozy of a post.

Let a thousand channels bloom

The main thing we’re going to talk about in this post is the opening and closing of channels. There are some other things that CTV/Sapio can do that are a bit more niche to talk about1, but there will always be future posts.

How do we open channels today?

Let’s say I want to open a channel up with you. I shoot you a text on signal or something and say “hey what’s up, happy holidays friend. I would like to open a payment channel with you”. You say back, “Tis the season! Let’s do it, my Tor Hidden Service address is ABCXYZ”. Then I connect to your node from my computer and then I say I want to open a channel with you for 500,000 sats (at writing in 2021 this was $250 US Dollars, not $250 Million Dollars). Then, you might authorize opening up the channel with me, or your node might just roll the dice and do it without your permission (IDK how the nodes actually work, depends on your client, and maybe in the future some reputation thingy).

So now we have agreed to create a channel.

Now, I ask you for a key to use in the channel and you send it to me. Then, I create an unsigned transaction F that is going to create and fund our channel. The channel is in Output C. I send you F and C. Then, I ask you to pre-sign a transaction spending from C that doesn’t yet exist, but would refund me and give you nothing in the event you go offline. This is basically just using the channel like it exists already for a payment 0 paying me. After I get those sweet sweet signatures from you, then I send you the signatures as well in case you want to close things out like normal.

Houston, we have a channel.

Now we can revoke old states and stuff and sign new states and all that fancy channel HTLC routing jazz. We don’t really need to know how a lot of that works down in the details so don’t ask.

Something a little more nifty, perhaps?

Technically I presented you how single funded channels work, but you can also dual fund where we both contribute some funds. It’s relatively new feature to land and was a lot of work… Dual funded channels are important because when I opened the channel to you I had all the sats and I couldn’t receive any Bitcoin. Dual funded channels means you can immediately send both directions.

What can we do with CTV?

With CTV, the single funded channel opening story is a bit simpler. I ask you if you want to open a channel, you say “sure!” (maybe I even look up your key from a Web-of-Trust system), and send me a key. I then use Sapio to compile a channel for 500k sats to our keys, I send Bitcoin to it. The channel is created. I send you the Outpoint + the arguments to the channel, either through email, connecting to your node, or pigeon with a thumbdrive, and later you verify that I paid to the channel for our keys that Sapio output by running the compiler with the same arguments (500k sats to our keys).

This is called a non-interactive channel open. Why’s that? Beyond having to do some basics (e.g., I have to know a key for you, which could be on a public Web-of-Trust), there is no step in the flow that requires any back-and-forth negotiation to create the channel. I just create it unilaterally, and then I could tell you about it a year later. You’d be able to verify it fine!

For dual-funded channels, I send you a transaction you can pay into to finish opening it and I can go offline. Once opened, the channel works for us both recovering our funds.

sounds niche

It kinda is. It’s an esoteric nerdy property. But I promise you it’s really cool! Let’s look at some examples:

Cafe Latte Anyone?

Let’s say that I go to a cafe I’ve never been to and there is a QR code posted on the wall. I then go about my business, ordering a 10,000 sat breakfast combo. To pay, I scan the QR-code, and then it has a XPUB for Non Interactive Channels on it.

I can then plug in that XPUB into my Sapio Channel Creator and create a channel with a first payment of 10k sats and a total balance of 100k sats. I show a QR code on my phone to the barista, who scans it, getting the details of the channel I made. Barista says looks good, acknowledging both the payment and the channel open. The details get backed up to The Cloud.

But just then something happens: a masked figure comes in with a gun and tells the barista, “GIVE ME ALL YOUR SATOSHIS”. A child begins to cry, their parent covering their mouth with their hand. The bad guy barks, “GIVE ME ALL YOUR SATOSHIS… and no one gets hurt,” tapping the muzzle of the gun on the countertop. The barista smirks and snarls, “stupid thief, surely you’ve been reading the post on non-interactive lightning channels on Rubin’s Bitcoin Advent Calendar.” The robber adjusts the straps on their mask for some relief from the ear irritation. “If you had been reading it, you would know that I don’t need to have a key online in order for someone to create a channel with me! I just need the XPUB to verify they are made correctly. This is not those old-school channels. I have no ability to spend. We keep our keys colder than our cold brew.” The robbers shoulders sag and they mutter, “fine, in that case, I’ll have a medium cold brew coffee, one sugar with a splash of oat milk. And that big chocolate chip cookie”.

That’s right. Because our cafe used non-interactive channels, they didn’t have to have a key online to create a channel with me! They just needed durable storage for the channel definition.

And when I go to spend a bit extra for a bottle of Topo Chicoâ„¢ later, they still don’t need to be online, I can start making payments without them counter-signing2.

Where did my corn come from?

How did I get the bitcoin for the channel I’m opening? Usually this is an assumption for Lightning (you have Bitcoin!), but in this case it’s central to the plot here. You probably got them from an exchange, mining, or something else.

This means that in order to open a channel to someone, I need to do two transactions:

  1. Get some money
  2. Make the channel

It’s possible, if I had a really legit hip exchange, they’d let me directly open a channel by offering me a transaction unsigned with the channel output C that I can presign with you! But then they can’t really batch payments (otherwise one user going offline can be a DoS attack on the batch payout) and they can also get DoS’d unbatched since we can “lock up” a coin while we run the protocol.

If instead, we had CTV we could just generate an address for the channel we wanted and request the exchange pay to it the appropriate amount of coin. The exchange could pay the channel address however they want, and we’d be able to use it right away.

However they want?

Yes. Let’s look at some options:

  1. A normal transaction – Works great.
  2. A batch transaction – No Problemo.
  3. A Congestion Control Tree – Even that!

What was that last one? You read it right, a channel can be created in a Congestion Control tree, and be immediately usable!

How’s this work? Well, because you can fully verify you’d receive a payment in a congestion control tree, you can likewise fully verify that your channel will be created.

This is big. This means that you can just directly request a channel from a third party without even telling them that you’re making a channel!

And this technique – channels in congestion control tree – generalizes beautifully. It means you could create as many immediately usable channels as you like and lazily fully open them over their lifetime whenever blockspace is affordable.

I Lied (a little)

If the exchange doesn’t follow your payment instructions to the T, e.g. if they split it into two UTXOs then it won’t work. Exchanges should probably not do anything other than what you asked them to do (this should be something to ensure in the exchanges terms of service…).

Come on in the water’s warm?

This concept also composes nicely with the Payment Pools we saw yesterday. Imagine you embed channels as the terminal outputs after a full-ejection from the pool. Then, what you can do is have the N-of-N agree to an on-chain state update that respects (or preserves) any channel updates before you switch. Embedding the channels inside means that Payment Pools would only need to do on-chain transactions when they need to make an external payment or re-configure liquidity among participants.

For example, imagine a pool with Alice, Bob, Carol, and Dave each having one coin in a channel. We’ll do some channel updates, and then reconfigure.

Start:
Pool(Channel([A, 1], [B, 1]), Channel([C, 1], [D, 1]))

Channel Update (off-chain):
Pool(Channel([A, 0.4], [B, 1.6]), Channel([C, 1], [D, 1]))

Channel Update (off-chain):
Pool(Channel([A, 0.4], [B, 1.6]), Channel([C, 1.3], [D, 0.7]))

Pool Reconfigure (on-chainl swap channel partners):
Pool(Channel([A, 0.4], [D, 0.7]), Channel([C, 1.3], [B, 1.6]))

Pool Reconfigure (on-chain; add Eve/Bob Channel):
Pool(Channel([A, 0.4], [D, 0.7]), Channel([C, 1.3], [B, 0.6]), Channel([E, 0.5], [B, 0.5]))

Pretty neat, right?

This is particularly a big win for Scalability and Privacy, since we’re now containing tons of activity within a single UTXO, and even within that UTXO most of the information doesn’t need to be known to all participants.

I’m not going to show you all of these integrations directly (Congestion Control, Pools, etc), because you gotta cut an article somewhere. But we do have enough…

Time to Code

OK enough ‘how it works’ and ‘what it can do’. Let’s get cracking on a basic channel implementation so you know I’m not bullshitting you3.

First, let’s define the basic information we’ll need:

/// Information for each Participant
struct Participant {
    /// signing key
    key: PublicKey,
    /// amount of funds
    amount: AmountF64,
}

/// A Channel can be either in an Open or Closing state.
enum State {
    Open,
    Closing
}

/// Channel definition.
struct Channel {
    /// If it is opening or closing
    state: State,
    /// Each participant's balances
    parties: [Participant; 2],
    /// Amount of time transactions must be broadcast within
    timeout: AnyRelTimeLock,
}

Pretty straightforward.

Now, let’s define the API:

impl Contract for Channel {
    declare!{then, Self::finish_close, Self::begin_close}
    declare!{updatable<Update>, Self::update} 
}

Next, we’ll define the being_close logic. Essentially all it’s going to do is, if we’re in the Open state allow transitioning the pool to the Closing state.

impl Channel {
    #[compile_if]
    fn if_open(self, ctx: Context) {
        if let State::Open = self.state {
            ConditionalCompileType::Required
        } else {
            ConditionalCompileType::Never
        }
    }

    #[then(compile_if = "[Self::if_open]")]
    fn begin_close(self, ctx: Context) {
        // copy the channel data and change to closing state
        // begin_close can happen at any time
        let mut close = self.clone();
        close.state = State::Closing;
        ctx.template()
            .add_output(Amount::from(self.parties[0].amount) +
                        Amount::from(self.parties[1].amount),
                        &close, None)?
            .into()
    }
}

Next we’ll define the logic for the Closing state. Essentially, if the state as been in Closing and the timeout expires, then we allow a transaction to return the funds to the initial state. We’ll only add an output for a participant if they have any money!

impl Channel {
    #[compile_if]
    fn if_closing(self, ctx: Context) {
        if let State::Closing = self.state {
            ConditionalCompileType::Required
        } else {
            ConditionalCompileType::Never
        }
    }

    #[then(compile_if = "[Self::if_closing]")]
    fn finish_close(self, ctx: Context) {
        // only allow finish_close after waiting for timelock
        let mut tmpl = ctx.template().set_sequence(-1, self.timelock)?;
        // add party 0 if they have funds
        if Amount::from(self.parties[0].amount).as_sat() != 0 {
            tmpl = tmpl.add_output(self.parties[0].amount.into(), &self.parties[0].key, None)?;
        }
        // add party 1 if they have funds
        if Amount::from(self.parties[1].amount).as_sat() != 0 {
            tmpl = tmpl.add_output(self.parties[1].amount.into(), &self.parties[1].key, None)?;
        }
        tmpl.into()
    }
}

Almost lastly, we’ll add the updating logic. The updating logic has to be used in a very particular way in this contract, but it’s pretty basic by itself!

// updating a channel
enum Update {
    // nothing to do!
    None,
    // An update that can later 'burned'
    Revokable(Revokable),
    // An update that is formed to terminate a channel
    Cooperate([Participants; 2])
}

impl Channel {
    #[guard]
    fn both_signed(self, ctx: Context) {
        Clause::And(vec![Clause::Key(self.parties[0].key),
                         Clause::Key(self.parties[1].key)])
    }

    #[continuation(guarded_by = "[Self::both_signed]")]
    fn update(self, ctx: Context, u: Update) {
        match u {
            // don't do anything
            Update::None => empty(),
            // send funds to the revokable contract
            Update::Revokable(r) => {
                // note -- technically we only need to sign revokables where
                // state == State::Closing, but we do both for efficiency
                ctx.template()
                    .add_output(Amount::from(self.parties[0].amount) + 
                                Amount::from(self.parties[1].amount), &r, None)?
                    .into()
            },
            // Terminate the channel into two payouts.
            Update::Cooperate(c) => {
                ctx.template()
                   .add_output(c[0].amount.into(), &c[0].key, None)?
                   .add_output(c[1].amount.into(), &c[1].key, None)?
                   .into()

            }
        }
    }
}

Now to finish we need to define some sort of thing for Revokable. Revokables are used to update a channel from one set of balances to another. This will depend on your payment channel implementation. I’ve defined a basic one below, but this could be anything you like.

Essentially, a Revokable is an offer from party A to party B to close the channel such that B can later provably “reject” the offer. If B uses a rejected offer, A can take the entire balance of the channel.

How to use this to update a channel? To start, all parties agree on the new balances with a timeout.

Next, party one gets a hash H(V) from party two that party two knows V and party one does not. Party one then creates a Revokable with from_idx = 0, the updated balances, timelock, and hash H(V). They feed the update arguments to Channel::update and sign the resulting transaction, sending the signed transaction to party two. In particular in non-interactive channels, party one only has to sign revokable updates at the branch where state == State::Closing, but it’s better for cases where your counterparty might not be malicious and just offline if you sign updates on both Open and Closing. Just signing on Open would be insecure.

Then, we repeat this with roles reversed with one generating a hash and two signing transactions.

Lastly, both reveals the hash preimage (V to H(V)) from any prior round to revoke the state from their counterparty.

If either party ever broadcasts the Revokable that they received by signing the other half of the Channel::update after revealing their Hash preimage, the other party can take all the funds in the channel.

Kinda a bit tough to understand, but you don’t really need to get it, you can embed whatever protocol like this inside that you want.

struct Revokable {
    // updated balances
    parties: [Participant; 2],
    // preimage from the other party
    hash: Hash,
    // how long the other party has to revoke
    timelock: AnyRelTimeLock,
    // who is this update from
    from_idx: u8,
}

impl Contract for Revokable {
    declare!{then, Self::finish}
    declare!{finish, Self::revoked}
}

impl Revokable {
    /// after waiting for the timeout, close the balances out at the appropriate values.
    #[then]
    fn finish(self, ctx: Context) {
        let mut tmpl = ctx.template().set_sequence(-1, self.timelock)?;
        if Amount::from(self.parties[0].amount).as_sat() != 0 {
            tmpl = tmpl.add_output(self.parties[0].amount.into(), &self.parties[0].key, None)?;
        }
        if Amount::from(self.parties[1].amount).as_sat() != 0 {
            tmpl = tmpl.add_output(self.parties[1].amount.into(), &self.parties[1].key, None)?;
        }
        tmpl.into()
    }

    /// if this was revoked by the other party
    /// we can sweep all the funds
    #[guard]
    fn revoked(self, ctx: Context) {
        Clause::And(vec![
            Clause::Sha256(self.hash),
            Clause::Key(self.parties[self.from_idx])])
    }
}

And now some closing remarks:

CTV Required?

You don’t need CTV for these channel specs to work, but you do need CTV for the channels to be non-interactive. Without CTV you just use a multi-sig oracle of both parties, and the contracts come out logically similar to an existing lightning channel. Does that mean we’re going to enter…

The Era of Sapio Lightning?

It’s probably going to be a while/never before this actually becomes a “Lightning” standard thing, even if you could use this with self-hosted oracles today, although perhaps one day it could be!

However, it’s possible! One path towards that would be if, perhaps, Sapio gets used to help define the “spec” that all lightning protocols should implement. Then it’d be theoretically possible to use Sapio for a channel implementation! Or maybe Sapio becomes a “plugin engine” for negotiating channels and updates can just be shipping some WASM.

What didn’t make the cut?

Some ideas to mention, but not fully flesh out (yet?):

Eltoo

So, so very much. To start CTV+CSFS can do something like Eltoo, no need for AnyPrevout. Very neat! If we had some Eltoo primitive available, I could show you revocation-free channels.

Embedded Sapio States

Instead of making the channel state a boring “pay X to 0, pay Y to 1” resolution, we can actually embed all sorts of contracts inside of channels.

E.g., imagine if you have a channel whereby if you contested close it your counterparty’s funds (who is offline conceivably) go to a cold-storage vault.

Or imagine if you had some sort of oracle resolved synthetic bitcoin settled derivative contract, like a DLC, embedded inside. You could then use this to HFT your synths!

Or what if there were some new-fangled token protocol that lived inside state transition to state transition, and you could update you and your counterparty’s stake into those?

You can really put anything you want. We’ll see in a couple days how you can define a Channel Plugin Interface so that you can dynamically link a logic module into a contract, rather than compiling it in.

Embedded Channels

We saw a little bit of embedded channels. Channels embedded in congestion control, or in payment pools. But the concept can be a lot more diverse. Remember our Vaults and inheritence schemes? We could make the hot-wallet payouts from those go directly into Channels with some channel operator hub. Or what about making channels directly out of coinjoins? Not having to pre-sign everything really helps. Don’t sleep on this.

Embedded Channel Creation Args

We said earlier that channel creation required some sort of email. But it’s also sometimes possible to embed the channel metadata into e.g. an op_return on the channel creation. Perhaps as an IPFS hash or something. In this case, you would just need to scan over txs, download the relevant data, and then attempt plugging it into WASM (heck – the WASM could just receive the txn in question and do all the heavy lifting). If the WASM spits out a matching output/channel address, you now have a channel you can detect automatically. This doesn’t have to be bad for privacy if the data is encrypted somehow!

How will this impact the world?

Non interactive channel creation is going to, for many users, dramatically decrease the cost of channel opening. Firstly you can defer paying fees when you open many channels (big news)! In fact, if the channel is long lived enough, you may never pay fees if someone else does first! That incentive to wait is called backpressure. It’s also going to “cut through” a lot of cases (e.g., exchange withdraw, move from cold storage, etc) that would otherwise require 2 transactions. And channels in Payment Pools have big opportunities to leverage cooperative actions/updates to dramatically reduce chain load in the happy-case.

This is a gigantic boon not just for scalability, but also for privacy. The less that happens on chain the better!

I think it’s also likely that with non-interactive channels, one might always (as was the case with our cafe) opportunistically open channels instead of normal payments. Removing the “counterparty online” constraint is huge. Being able to just open it up and bet that you’ll be able to route is a big win. This is similar to “PayJoin”, whereby you try to always coin-join transactions on all payments for both privacy and fee savings.

Tomorrow, we’ll see sort of a magnum opus of using non-interactive channels, so stay tuned folks, that’s all for today.


  1. CTV + CSFS can do something like Eltoo/Decker channels with a script like CTV <pk> CSFSV↩︎

  2. There are some caveats to this, but it should generally work when you’re making payments in one direction. ↩︎

  3. Writing 27 posts is really hard and a big crunch, so I’m permitting myself a little micro-bullshit in that I’m not actually compiling this code so it probably has some bugs and stuff, but it should “read true” for the most part. I may clean this post up in the future and make sure everything works perfectly as described. ↩︎


comments powered by Disqus