Building a Hashed Timelock Contract on Algorand

Introduction

A hashed timelock contract (HTLC) is a type of smart contract used in blockchain applications. It reduces counterparty risk by creating a time-based escrow that requires a cryptographic passphrase for unlocking.

In practical terms, this means that the person receiving the funds in a transaction has to perform two actions to access the funds: enter the correct passphrase and claim payment within a specific timeframe. If they enter an incorrect passphrase or do not claim the funds within the timeframe, they lose access to the payment.

Source: Hashed Timelock Contract (HTLC): Overview and Examples in Crypto

There's a nice formal definition of HTLC in the paper 'A Formal Model of Algorand Smart Contracts':

Citation
Bartoletti, M., Bracciali, A., Lepore, C., Scalas, A., Zunino, R. (2021). A Formal Model of Algorand Smart Contracts. In: Borisov, N., Diaz, C. (eds) Financial Cryptography and Data Security. FC 2021. Lecture Notes in Computer Science(), vol 12674. Springer, Berlin, Heidelberg. https://doi.org/10.1007/978-3-662-64322-8_5

To make the contract code a bit easier to read, I'll follow the naming conventions used in kaleido's HTLC Solidity contract:

a = receiver

b = sender

s = preimage

h = hashlock

rmax = timelock

The Contract

The contract code will be written in Python, using PuyaPy.

We'll start by defining the global state of the contract using the __init__ method:

class HTLC(ARC4Contract):
    """A hashed timelock contract."""

    def __init__(self) -> None:
        self.sender: Account
        self.receiver: Account
        self.hashlock: Bytes
        self.timelock: UInt64

This isn't strictly necessary but I think it makes it more readable.

hashlock is expected to be a SHA-256 hash, and timelock is a UNIX timestamp.

It would probably be more reliable to use a round number instead of a timestamp, but I will stick to the approach taken in the kaleido contract.

Next, we define a method to allow a user to create a new application:

@arc4.abimethod(create="allow")
def new(
    self,
    receiver: Account,
    hashlock: Bytes,
    timelock: UInt64,
) -> None:
    assert not Txn.application_id, "Application already exists"
    assert timelock > Global.latest_timestamp, "Timelock must be in the future"
    self.sender = Txn.sender
    self.receiver = receiver
    self.hashlock = hashlock
    self.timelock = timelock

And a subroutine to handle the payment (either to the receiver or the sender):

@subroutine
def close_to(self, account: Account) -> None:
    assert Txn.fee >= Global.min_txn_fee * 2, "Fee must be >= 2 * min fee"
    itxn.Payment(
        fee=0,
        receiver=account,
        close_remainder_to=account,
    ).submit()

Instead of specifying an amount or checking the balance of the contract, we can use the close_remainder_to argument to ensure the total remaining balance is transferred.

Now we need a method to let the receiver withdraw, if they provide the correct secret:

@arc4.abimethod
def withdraw(self, preimage: Bytes) -> None:
    assert Txn.sender == self.receiver, "Only the receiver can withdraw"
    assert op.sha256(preimage) == self.hashlock, "Invalid preimage"
    self.close_to(self.receiver)

In the kaleido contract, there is an additional condition that the withdrawal can only occur prior to the timelock time.

But here, we will leave it up to the sender to refund their account once the timelock has expired. This is done by calling the refund method:

@arc4.abimethod
def refund(self) -> None:
    assert Txn.first_valid_time >= self.timelock, "Timelock not expired"
    assert Txn.sender == self.sender, "Only the sender can trigger a refund"
    self.close_to(self.sender)

Off-Chain Code

To simulate the HTLC on a LocalNet instance, we'll start by creating a secret and hashing it:

from hashlib import sha256
from random import randbytes

preimage = randbytes(8)
hashlock = sha256(preimage).digest()

Obviously this is not a secure secret - it's just an example.

For testing on LocalNet, I found it easiest to set the timelock by checking the latest timestamp and offsetting it by some amount:

last_round = algod_client.status()["last-round"]
timelock = indexer_client.block_info(last_round)["timestamp"] + 10

Now we can construct an app client instance:

from smart_contracts.artifacts.htlc.client import HtlcClient

app_client = HtlcClient(
    algod_client=algod_client,
    creator=account,
    indexer_client=indexer_client,
)

And create a new application:

app_client.create_new(
    receiver=account.address,
    hashlock=hashlock,
    timelock=timelock,
)

For the sake of this example, the sender and receiver are the same account.

We can check the global state using Dappflow:

Now we need to transfer some funds to the application account:

from algokit_utils import (
    TransferParameters,
    transfer,
)

transfer(
    algod_client,
    TransferParameters(
        from_account=account,
        to_address=app_client.app_address,
        micro_algos=7_000_000,
    ),
)

And again, we can verify the transfer in Dappflow:

To withdraw funds:

tp.suggested_params.fee = 2_000
app_client.withdraw(preimage=preimage, transaction_parameters=tp)

And to trigger a refund once the timelock has expired (if the receiver has not claimed the funds yet):

import time

time.sleep(15)

tp.suggested_params.fee = 2_000
app_client.refund(transaction_parameters=tp)

The Complete Contract Code

from puyapy import (
    Account,
    ARC4Contract,
    Bytes,
    Global,
    Txn,
    UInt64,
    arc4,
    itxn,
    op,
    subroutine,
)


class HTLC(ARC4Contract):
    """A hashed timelock contract."""

    def __init__(self) -> None:
        self.sender: Account
        self.receiver: Account
        self.hashlock: Bytes
        self.timelock: UInt64

    @arc4.abimethod(create="allow")
    def new(
        self,
        receiver: Account,
        hashlock: Bytes,
        timelock: UInt64,
    ) -> None:
        assert not Txn.application_id, "Application already exists"
        assert timelock > Global.latest_timestamp, "Timelock must be in the future"
        self.sender = Txn.sender
        self.receiver = receiver
        self.hashlock = hashlock
        self.timelock = timelock

    @arc4.abimethod
    def withdraw(self, preimage: Bytes) -> None:
        assert Txn.sender == self.receiver, "Only the receiver can withdraw"
        assert op.sha256(preimage) == self.hashlock, "Invalid preimage"
        self.close_to(self.receiver)

    @arc4.abimethod
    def refund(self) -> None:
        assert Txn.first_valid_time >= self.timelock, "Timelock not expired"
        assert Txn.sender == self.sender, "Only the sender can trigger a refund"
        self.close_to(self.sender)

    @subroutine
    def close_to(self, account: Account) -> None:
        assert Txn.fee >= Global.min_txn_fee * 2, "Fee must be >= 2 * min fee"
        itxn.Payment(
            fee=0,
            receiver=account,
            close_remainder_to=account,
        ).submit()