Accessing other blockchains from Bitcoin

Previously, we have shown how to access Bitcoin SV blockchain data from smart contracts running on it, using SPV. We extend the idea to access other SPV-compatible blockchains.

Previously, we have shown how to access Bitcoin SV blockchain data from smart contracts running on it, using SPV. We extend the idea to access other SPV-compatible blockchains.

As an example, anyone can mint a token on Bitcoin SV as long as she locks up BTC for a certain period of time. We provide first ever implementation, which can be a foundation for a plethora of other applications.

How “Lock to Mint” Works

Recently, there has been a surge of interest in the “Lock to Mint” mechanism. This mechanism facilitates the time-locking of Bitcoin assets to mint BSV-20 fungible tokens on the Bitcoin network.

It is an instantiation of using a smart contract to control minting of BSV20 tokens (v2) we introduced before. The “Lock to Mint” mechanism serves as a constraint in the token minting process of BSV-20. The contract ensures that the spending transaction must include a secondary output in a time-lock.

a Bitcoin-only “lock to mint” transaction

More details about how this process works in a BSV-only context can be found in this gist.

Cross-Chain

This smart contract can be extended to require the time-locking of funds on a different chain that supports timelocks and SPV, such as BTC. This is achieved by validating an SPV proof within the smart contract. Essentially, the contract, before minting, verifies the inclusion of a time-locked transaction with the correct parameters (locktime and payment destination) in a BTC block. The efficiency of SPV allows for this validation to be computationally viable. This is especially amenable for BTC, because it enjoys the highest hashing difficulty in its proof of work and thus renders it extremely costly to produce a valid but fake block header not in the main chain.

a “lock BTC to mint” transaction

Implementation

The following code is an implementation of such a smart contract. We leverage the scrypt-ord library for easy integration of ordinals.





class Bsv20LockBtcToMint extends BSV20V2 {
    static readonly MIN_CONF = 3
    static readonly BTC_MAX_INPUTS = 3

    @prop(true)
    supply: bigint

    // Amount of sats (BTC) to lock up in order to mint a single token.
    @prop()
    hodlRate: bigint

    // Time until which the BTC needs to be locked in order to mint.
    @prop()
    hodlDeadline: bigint

    @prop()
    targetDifficulty: bigint

    @prop(true)
    usedLockPubKeys: HashedSet<PubKey>

    ...

    @method()
    public mint(
        ordinalAddress: Addr,
        lockPubKey: PubKey,
        amount: bigint,
        btcTx: ByteString,
        merkleProof: MerkleProof,
        headers: FixedArray<BlockHeader, typeof Bsv20LockBtcToMint.MIN_CONF>,
    ) {
        // Check lock public key was not yet used. This is to avoid replay
        // attacks where the same BTC tx would be used to mint multiple times.
        assert(!this.usedLockPubKeys.has(lockPubKey), 'lock pub key already used')
        this.usedLockPubKeys.add(lockPubKey)

        let outputs = toByteString('')
        let transferAmt = amount

        if (this.supply > transferAmt) {
            // If there are still tokens left, then update supply and
            // build state output inscribed with leftover tokens.
            this.supply -= transferAmt
            outputs += this.buildStateOutputFT(this.supply)
        } else {
            // If not, then transfer all the remaining supply.
            transferAmt = this.supply
        }

        // Check btc tx.
        this.checkBtcTx(btcTx, lockPubKey, transferAmt * this.hodlRate)

        // Calc merkle root.
        const txID = hash256(btcTx)
        const merkleRoot = MerklePath.calcMerkleRoot(txID, merkleProof)

        // Check if merkle root is included in the first BH.
        assert(
            merkleRoot == headers[0].merkleRoot,
            "Merkle root of proof doesn't match the one in the BH."
        )

        // Check target diff for headers.
        for (let i = 0; i < Bsv20LockBtcToMint.MIN_CONF; i++) {
            assert(
                Blockchain.isValidBlockHeader(
                    headers[i],
                    this.targetDifficulty
                ),
                `${i}-nth BH doesn't meet target difficulty`
            )
        }

        // Check header chain.
        let h = Blockchain.blockHeaderHash(headers[0])
        for (let i = 0; i < Bsv20LockBtcToMint.MIN_CONF; i++) {
            if (i >= 1n) {
                const header = headers[i]
                // Check if prev block hash matches.
                assert(
                    header.prevBlockHash == h,
                    `${i}-th BH wrong prevBlockHash`
                )
                // Update header hash.
                h = Blockchain.blockHeaderHash(header)
            }
        }

        // Build FT P2PKH output paying specified amount of tokens.
        outputs += BSV20V2.buildTransferOutput(
            ordinalAddress,
            this.id,
            transferAmt
        )

        // Build change output.
        outputs += this.buildChangeOutput()

        assert(hash256(outputs) == this.ctx.hashOutputs, 'hashOutputs mismatch')
    }

    @method()
    checkBtcTx(btcTx: ByteString, lockPubKey: PubKey, amount: bigint): void {
        // Most things should be the same as in BSV except the witness data and flag.
        // - Check (first) output is a P2WSH to a time-lock script with the specified lock address.
        // - Check (first) output amount is correct.

        let idx = 4n

        // Make sure to serialize BTC tx without witness data.
        // See https://github.com/karask/python-bitcoin-utils/blob/a41c7a1e546985b759e6eb2ae4524f466be809ca/bitcoinutils/transactions.py#L913
        assert(
            slice(btcTx, idx, idx + 2n) != toByteString('0001'),
            'Witness data present. Please serialize without witness data.'
        )

        //// INPUTS:
        const inLen = Bsv20LockBtcToMint.parseVarInt(btcTx, idx)
        assert(
            inLen.val <= BigInt(Bsv20LockBtcToMint.BTC_MAX_INPUTS),
            'Number of inputs too large.'
        )
        idx = inLen.newIdx
        for (let i = 0n; i < Bsv20LockBtcToMint.BTC_MAX_INPUTS; i++) {
            if (i < inLen.val) {
                //const prevTxID = slice(btcTx, idx, idx + 32n)
                idx += 32n
                //const outIdx = slice(btcTx, idx, idx + 4n)
                idx += 4n
                const scriptLen = Bsv20LockBtcToMint.parseVarInt(btcTx, idx)
                idx = scriptLen.newIdx
                idx += scriptLen.val
                //const nSequence = slice(btcTx, idx, idx + 4n)
                idx += 4n
            }
        }

        //// FIRST OUTPUT:
        // Check first outputs amount is correct and that it's a P2WSH to the correct time-lock script.
        const outLen = Bsv20LockBtcToMint.parseVarInt(btcTx, idx)
        idx = outLen.newIdx
        const outAmt = Utils.fromLEUnsigned(slice(btcTx, idx, idx + 8n))
        assert(outAmt == amount, 'output amount invalid')
        idx += 8n
        const scriptLen = Bsv20LockBtcToMint.parseVarInt(btcTx, idx)
        idx = scriptLen.newIdx
        const script = slice(btcTx, idx, idx + scriptLen.val)

        // <nLocktime> OP_CLTV OP_DROP <lockPubKey> OP_CHECKSIG
        const witnessScript = toByteString('04') + int2ByteString(this.hodlDeadline, 4n) + toByteString('b17521') + lockPubKey + toByteString('ac')
        const expectedP2WSHScript = toByteString('0020') + sha256(witnessScript)
        assert(script == expectedP2WSHScript, 'P2WSH script invalid')

        // Data past this point is not relevant in our use-case.
    }

  ...

}

The contract exposes a single public method named “mint”. Suppose that we would like to mint 10 new tokens.

Before we call it, we have to construct and broadcast a time-locked transaction on BTC with the appropriate amount and lock-time. The transaction will be a standard P2WSH for a redeem script of the following format:

<nLocktime> OP_CLTV OP_DROP <lockPubKey> OP_CHECKSIG

After this transaction has been mined and confirmed, we construct an SPV proof for it, consisting of a Merkle-proof and its block header. Now we are able to call the public method by passing the serialized transaction along with the proof and the rest of the parameters, which are the token destination address, lock public key and token amount. To make it more reliable, we can ask for more than one confirmation.

The following is such a BTC time-lock transaction on testnet.

70bd8b876bacd6ad9df1100f8e691bada729e28ee3d311e949868c75d0bf690b

It is fed into a minting transaction on Bitcoin SV testnet.

db4d5b33eb2a095ee29ff1443613f5bc6db55c1eef724d1a5d93d0e6a2381fc7

The full code of the smart contract along with a test is available on GitHub.

Here are also two Python scripts, that we used to construct transaction for the deployment and redemption of a time-lock transaction on BTC.

Extensions

There are many ways to extend the example. It basically allows a smart contract on Bitcoin SV to access other compatible blockchain data, such as blocks and transactions, without trusting an oracle. We list some more examples below:

  • Trustless One-Way Peg-In: as in a sidechain, one can send some BTC to a given address and is sure she can mint a wrapped token on Bitcoin SV. Note it is one way, due to BTC’s lack of capability to verify SPV proofs.
  • Randomness: one can use a BTC block as entropy source, such as its block hash, for a game on Bitcoin SV. It is orders of magnitudes more expensive to create fake headers than on Bitcoin SV.

0:00
0:00