Building a Hashed Timelock Contract on Algorand
Table of contents
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
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()
Subscribe to my newsletter
Read articles from Alexander Codes directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Alexander Codes
Alexander Codes
Data Engineer โจ๏ธ | Pythonista ๐ | Blogger ๐ You can support my work at https://app.nf.domains/name/alexandercodes.algo