Contracts and deployments
Reference for ParalockHTLC and ParalockAdaptorEscrow: storage model, functions, events, errors, security properties, token support and testnet deployments.
Paralock's EVM leg is two small escrow contracts. Each holds a maker's stablecoins for a taker until the swap settles one way or the other:
ParalockHTLC(htlc mode): a sha256 hash-time-locked escrow. It pairs with a transparent P2SH HTLC on Zcash that uses the same hashlock.ParalockAdaptorEscrow(adaptor mode): released by an ECDSA signature from a one-timesigneroverclaimDigest(id). No hashlock on-chain, so nothing links the two legs. The completed adaptor signature(r, s)published inClaimedgives the maker the secret for the Zcash side.
Both work with TIP-20 stablecoins on Tempo and standard ERC-20s elsewhere. They have no owner, admin, pause switch or upgrade path. The TypeScript clients are in @paralock/evm.
Storage model
Each contract writes one storage slot per swap. Tempo charges about 250k gas for every new slot (TIP-1000), so nothing else is stored.
| Contract | Key | Value |
|---|---|---|
ParalockHTLC | hashlock | keccak256( while active, then SETTLED (bytes32() forever. 0 if never used. |
ParalockAdaptorEscrow | id = keccak256( | Status: 0 empty, 1 active, 2 settled forever. |
Callers pass the full Swap struct back to claim and refund. The contract recomputes the commitment or id, so changing any field addresses a different swap and fails. The Locked event carries every field, so a watcher can rebuild the struct from the log alone (the @paralock/evm clients do this).
ParalockHTLC
struct Swap {
bytes32 hashlock; // sha256 of the 32-byte secret; unique key of the swap
address token; // TIP-20 or ERC-20 held in escrow
address sender; // locks the funds; refunded after expiry (the maker)
address recipient; // paid on claim (the taker, or a payee)
uint256 amount; // token base units
uint64 expiry; // Unix seconds from which the swap can only be refunded
}
enum Status { Empty, Active, Settled }Functions (HTLC)
| Function | Behavior |
|---|---|
lock( | Locks s. of s. under s.. The caller must be s. and must have approved the contract for at least s.. s. must be in the future. |
claim( | Pays s. when sha256(. Callable by anyone before s.. Publishes the preimage in Claimed. |
refund( | Returns the funds to s.. Callable by anyone from s. on. |
stateOf( | Raw state: 0, the commitment, or SETTLED. |
statusOf( | Lifecycle status. |
isActive( | True while locked and not yet claimed or refunded. |
commitment( | keccak256(: the value stored while active. |
SETTLED( | The settled sentinel, bytes32(. |
Events (HTLC)
| Event | When |
|---|---|
Locked( | Funds were locked. |
Claimed( | The recipient was paid. preimage is the secret that unlocks the Zcash leg. |
Refunded( | The sender was refunded. |
Errors (HTLC)
| Error | Cause |
|---|---|
NotSender( | msg. is not s. (lock). |
InvalidHashlock( | The hashlock is zero. |
InvalidAmount( | The amount is zero. |
InvalidRecipient( | The recipient is the zero address. |
InvalidExpiry( | The expiry is not in the future. |
HashlockUsed( | The hashlock was already used by an active or settled swap. |
SwapNotFound( | No swap was ever locked under this hashlock. |
SwapSettled( | Already claimed or refunded. |
SwapMismatch( | The provided parameters don't match the locked swap. |
InvalidPreimage( | sha256( is not the hashlock. |
SwapExpired( | Claim at or after the expiry. |
SwapNotExpired( | Refund before the expiry. |
TokenTransferFailed( | The token call failed without a reason, or returned false. |
UnsupportedToken( | The escrow received a different amount than requested (fee-on-transfer or rebasing token). |
ParalockAdaptorEscrow
struct Swap {
address token; // TIP-20 or ERC-20 held in escrow
address sender; // locks the funds; refunded after expiry (the maker)
address recipient; // paid on claim
address signer; // whose signature over claimDigest(id) releases the funds
uint256 amount; // token base units
uint64 expiry; // Unix seconds from which the swap can only be refunded
bytes32 salt; // random: makes the id unique and unlinkable
}
enum Status { Empty, Active, Settled }Functions (adaptor escrow)
| Function | Behavior |
|---|---|
lock( | Locks s. of s. for s. and returns id = keccak256(. The caller must be s. and must have approved the contract. s. must be non-zero; s. should be random. |
claim( | Pays s. with s.'s signature over claim. Callable by anyone (for example a relayer) before s.. Publishes ( in Claimed. |
refund( | Returns the funds to s.. Callable by anyone from s. on. |
claimDigest( | keccak256(: the raw digest the signer signs, with no EIP-191 prefix. |
idOf( | keccak256(. |
statusOf( | Lifecycle status. |
isActive( | True while locked and not yet claimed or refunded. |
CLAIM_TYPEHASH( | keccak256(, the digest's domain tag. |
Events (adaptor escrow)
| Event | When |
|---|---|
Locked( | Funds were locked. |
Claimed( | The recipient was paid. ( is the completed adaptor signature, from which the maker extracts the adaptor secret. |
Refunded( | The sender was refunded. |
Errors (adaptor escrow)
| Error | Cause |
|---|---|
NotSender( | msg. is not s. (lock). |
InvalidAmount( | The amount is zero. |
InvalidRecipient( | The recipient is the zero address. |
InvalidSigner( | The signer is the zero address. |
InvalidExpiry( | The expiry is not in the future. |
SwapExists( | A swap with this id was already locked (active or settled). |
SwapNotFound( | No swap was ever locked under this id (unknown or tampered parameters). |
SwapSettled( | Already claimed or refunded. |
SwapExpired( | Claim at or after the expiry. |
SwapNotExpired( | Refund before the expiry. |
InvalidSignature( | The signature does not recover to s.. |
MalleableSignature( | s is in the upper half of the curve order, or v is not 27 or 28. |
TokenTransferFailed( | The token call failed without a reason, or returned false. |
UnsupportedToken( | The escrow received a different amount than requested. |
Security properties
- Single-use ids. A hashlock or escrow id is marked settled forever after a claim or refund, and can never be locked again (
HashlockUsed,SwapExists). In htlc mode this matters: the preimage is public once claimed, so re-locking under it would let anyone take the funds. - Recipient fixed at lock time. Anyone may submit
claimorrefund, so relayers and watchtowers can settle on a party's behalf, but funds only ever go tos.recipient(claim) ors.sender(refund). - Tamper-proof parameters. The stored commitment (HTLC) or the id itself (adaptor escrow) covers every field of the swap. Changing any field fails with
SwapMismatchorSwapNotFound. - Expiry rules.
lockneedsexpiry > block.timestamp.claimworks only whileblock.timestamp < expiry;refundonly whenblock.timestamp >= expiry. The two windows never overlap. - Canonical signatures (adaptor escrow). The claim signature must have
vin {27, 28} and a lows, so the published(r, s)is exactly the completed adaptor signature and can't be swapped for its malleated twin. The digest binds the chain id and the contract address, so a signature never replays elsewhere.ecrecover's zero-address failure value can't match, becausesigneris never zero. - Checks, effects, interactions. State is updated and the event emitted before any token transfer. Token reverts are bubbled up (for example TIP-20
InsufficientAllowance). - No privileged roles. No owner, admin, pause or upgrade.
The protocol around the contracts (window ordering, verify before revealing) is described in Security model.
Token support
| Token type | Detection | Transfers |
|---|---|---|
| TIP-20 (Tempo) | Address starts with Tempo's reserved prefix 0x20c000000000000000000000 (no external call) | transfer on lock and transfer on claim and refund, with the hashlock (HTLC) or id (adaptor escrow) as memo, so explorers can link every movement to its swap. |
| ERC-20 (other chains) | Any other address | transfer and transfer with SafeERC20-style checks: a false return reverts, and empty return data is accepted only from a contract. The lock checks the escrow's balance delta, so fee-on-transfer tokens revert with Unsupported. Rebasing tokens are not supported. |
MockUSD is a 6-decimal test token (symbol USDC) that only its deployer can mint. The devnet deploys it on its local plain-EVM chains.
Gas
Measured on local chains (anvil):
| Call | ParalockHTLC, Tempo | ParalockAdaptorEscrow, Tempo | Plain EVM (MockUSD) |
|---|---|---|---|
lock | about 305,000 (250k is the new slot) | about 317,000 | about 88,000 to 93,000 |
claim | about 56,000 to 61,000 | about 66,000 | about 49,000 (66,000 when the recipient's balance slot is new) |
refund | about 54,500 | about 60,000 | about 43,000 |
On Tempo an account's first transaction costs about 250k more (a new nonce slot).
Deployments
| Tempo Testnet (Moderato)Chain ID 42431 | |
|---|---|
| ParalockHTLC | 0xD9B278625B5946B8CD15dad6c0c776095B8D0513Source |
| ParalockAdaptorEscrow | 0xd2f352Fa313d23b3536dAA3d783263C03CED2673Source |
| OUSD | 0x20c0000000000000000000006a37DA5C996874BE |
| Start block | 39235852 |
| Base SepoliaChain ID 84532 | |
| ParalockHTLC | 0xD9B278625B5946B8CD15dad6c0c776095B8D0513Source |
| ParalockAdaptorEscrow | 0xd2f352Fa313d23b3536dAA3d783263C03CED2673Source |
| USDC | 0x036CbD53842c5426634e7929541eC2318f3dCF7e |
| Start block | 48009147 |
| SepoliaChain ID 11155111 | |
| ParalockHTLC | 0x9a6f3F47423f590afd1Df06E2729057b10baB1a1Source |
| ParalockAdaptorEscrow | 0xf44bc4Cc4B4b6b94E4142Bb403cCeEa82AFd55B7Source |
| USDC | 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 |
| Start block | 11897116 |
| Arbitrum SepoliaChain ID 421614 | |
| ParalockHTLC | 0xD9B278625B5946B8CD15dad6c0c776095B8D0513Source |
| ParalockAdaptorEscrow | 0xd2f352Fa313d23b3536dAA3d783263C03CED2673Source |
| USDC | 0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d |
| Start block | 318328857 |
| Robinhood Chain TestnetChain ID 46630 | |
| ParalockHTLC | 0x9a6f3F47423f590afd1Df06E2729057b10baB1a1Source |
| ParalockAdaptorEscrow | 0xf44bc4Cc4B4b6b94E4142Bb403cCeEa82AFd55B7Source |
| USDCMockUSD test token | 0xD9B278625B5946B8CD15dad6c0c776095B8D0513Source |
| Start block | 133451919 |
| HyperEVM TestnetChain ID 998No official explorer | |
| ParalockHTLC | 0xD9B278625B5946B8CD15dad6c0c776095B8D0513Source |
| ParalockAdaptorEscrow | 0xd2f352Fa313d23b3536dAA3d783263C03CED2673Source |
| USDC | 0x2B3370eE501B4a559b57D449569354196457D8Ab |
| Start block | 66684352 |
Source verified: exact bytecode match on Sourcify (Tempo: Tempo's contract verifier).
The Solidity source is in contracts/src.
- Same addresses on most chains. Contract addresses come from the deployer's address and nonce. The deployer's nonces matched on most testnets, so
ParalockHTLCandParalockAdaptorEscrowhave the same addresses there. - Sepolia and Robinhood Chain Testnet differ. On Sepolia a failed first deployment used up nonces. On Robinhood Chain Testnet a MockUSD token had to be deployed first. Both contracts therefore landed at other addresses on these two chains.
- Robinhood Chain Testnet uses MockUSD. The chain has no USDC, so the deployment uses a MockUSD test token (6 decimals), listed as
USDCin its deployment file. The SDK's network registry lists no stablecoin for this chain: pass the token in the taker'stokensoption. - Use
startBlock. Eachcontracts/deployments/<chainId>.jsonrecordsstartBlock, the block event scans start from. Pass it asfromBlockto the SDK and the clients.
Deployment files have this shape:
{
"chain": "base",
"chainId": 84532,
"htlc": "0x…",
"adaptorEscrow": "0x…",
"deployer": "0x…",
"quoteToken": "USDC",
"startBlock": 48009147,
"tokens": { "USDC": "0x036CbD53842c5426634e7929541eC2318f3dCF7e" }
}Deploy it yourself
The contracts are a Foundry project in contracts/. Build and test them first:
cd contracts
forge build
forge test # 146 tests: ERC-20, MockUSD and in-process TIP-20 suitesTEMPO_FORK_URL=http://localhost:8545 forge test also runs the HTLC suite against a live Tempo node; without it that fork suite is skipped.
script/Deploy.s.sol deploys both contracts and writes deployments/<chainId>.json. It is idempotent: contracts recorded in an existing file are reused while the chain still holds exactly their current bytecode.
| Variable | Meaning |
|---|---|
PRIVATE_KEY | Deployer key. Required on public networks (local chains default to anvil dev account 0). |
USDC | The stablecoin to record on non-Tempo chains. On Tempo the script records OUSD. |
FOUNDRY_NETWORK | Set FOUNDRY_ for every non-Tempo chain: foundry. defaults to Tempo's EVM. |
PARALOCK_EVM_CHAIN | Override the chain name (tempo, base, ethereum, arbitrum, robinhood, hyperevm) derived from the chain id. |
# Tempo Moderato: fees paid in pathUSD
PRIVATE_KEY=0x… forge script script/Deploy.s.sol --rpc-url moderato --broadcast --tempo.fee-token PathUSD
# Base Sepolia (any non-Tempo chain)
PRIVATE_KEY=0x… USDC=0x036CbD53842c5426634e7929541eC2318f3dCF7e FOUNDRY_NETWORK=ethereum \
forge script script/Deploy.s.sol --rpc-url https://sepolia.base.org --broadcast
# Sepolia prices contract creation far above forge's local simulation: skip it
PRIVATE_KEY=0x… USDC=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 FOUNDRY_NETWORK=ethereum \
forge script script/Deploy.s.sol --rpc-url "$SEPOLIA_RPC_URL" --broadcast --skip-simulationVerify the source on Sourcify (non-Tempo chains need FOUNDRY_NETWORK=ethereum here too):
FOUNDRY_NETWORK=ethereum forge verify-contract <address> src/ParalockHTLC.sol:ParalockHTLC \
--verifier sourcify --chain-id <id>
FOUNDRY_NETWORK=ethereum forge verify-contract <address> src/ParalockAdaptorEscrow.sol:ParalockAdaptorEscrow \
--verifier sourcify --chain-id <id>After changing a contract, regenerate the TypeScript ABIs with pnpm --filter @paralock/evm generate:abi. For local chains, paralock devnet up runs the deployment for you.