Skip to content

Cross-Chain Swap Between Symbol and Ethereum⚓︎

Two parties, Alice and Bob, want to exchange 0.01 ETH (on Ethereum) for 1 XYM (on Symbol) without trusting each other or using an intermediary.

CrossChainOverviewclusterSymbolSymbolclusterEthereumEthereumAliceSAliceBobSBobAliceS->BobS1 XYMAliceEAliceBobEBobAliceE->BobE0.01 ETH

Since the tokens exist on two separate blockchains, a direct transfer is not possible. If both tokens were on Symbol, this exchange could be done in a single aggregate transaction, as shown in the Atomic Swap tutorial. Because the tokens live on different chains, the swap must instead be coordinated using a cross-chain swap.

This tutorial shows how to perform this token swap between chains using an HTLC smart contract on Ethereum and Symbol's native transactions.

To interact with both chains, the tutorial uses the Symbol SDK and an Ethereum client library.

Supported chains

This tutorial demonstrates the swap between Symbol and Ethereum, but Symbol's secret lock mechanism works with any blockchain that supports HTLCs.

For background on the HTLC protocol, timing constraints, and limitations, see the Cross-Chain Swaps concept page.

Prerequisites⚓︎

Before you start, make sure to:

  • Set up your development environment. See Setting Up a Development Environment.
  • Create two Symbol accounts, one for Alice and one for Bob. See Creating an Account from a Private Key.
  • Obtain XYM for Bob's account to pay for the secret lock transaction fee and locked amount. See Getting Testnet Funds from the Faucet.
  • Create two Ethereum accounts, one for Alice and one for Bob. You can use Foundry's cast wallet new command or any Ethereum wallet such as MetaMask.
  • Have Sepolia testnet ETH in both Ethereum accounts to pay for gas fees and enough in Alice's account to fund the HTLC. Sepolia ETH can be obtained from the Google Cloud faucet or any other Ethereum testnet faucet.

  • Install the Ethereum library for your language:

    pip install web3
    
    npm install ethers
    

Full Code⚓︎

import hashlib
import json
import os
import time
import urllib.request

from symbolchain.CryptoTypes import Hash256, PrivateKey
from symbolchain.facade.SymbolFacade import SymbolFacade
from symbolchain.symbol.IdGenerator import generate_mosaic_alias_id
from symbolchain.symbol.Network import NetworkTimestamp
from symbolchain.sc import Amount
from web3 import Web3

SYMBOL_NODE_URL = os.getenv(
    'SYMBOL_NODE_URL', 'https://reference.symboltest.net:3001')
print(f'Using Symbol node {SYMBOL_NODE_URL}')

ETH_RPC_URL = os.getenv('ETH_RPC_URL',
    'https://ethereum-sepolia-rpc.publicnode.com')
print(f'Using Ethereum RPC {ETH_RPC_URL}')

# Ethereum HTLC contract on Sepolia
HTLC_ADDRESS = '0xd58e030bd21c7788897aE5Ea845DaBA936e91D2B'
HTLC_ABI = [
    {
        'name': 'newContract',
        'type': 'function',
        'stateMutability': 'payable',
        'inputs': [
            {'name': '_receiver', 'type': 'address'},
            {'name': '_hashlock', 'type': 'bytes32'},
            {'name': '_timelock', 'type': 'uint256'}
        ],
        'outputs': [{'name': 'contractId', 'type': 'bytes32'}]
    },
    {
        'name': 'withdraw',
        'type': 'function',
        'stateMutability': 'nonpayable',
        'inputs': [
            {'name': '_contractId', 'type': 'bytes32'},
            {'name': '_preimage', 'type': 'bytes'}
        ],
        'outputs': [{'name': '', 'type': 'bool'}]
    },
    {
        'name': 'getContract',
        'type': 'function',
        'stateMutability': 'view',
        'inputs': [
            {'name': '_contractId', 'type': 'bytes32'}
        ],
        'outputs': [
            {'name': 'sender', 'type': 'address'},
            {'name': 'receiver', 'type': 'address'},
            {'name': 'amount', 'type': 'uint256'},
            {'name': 'hashlock', 'type': 'bytes32'},
            {'name': 'timelock', 'type': 'uint256'},
            {'name': 'withdrawn', 'type': 'bool'},
            {'name': 'refunded', 'type': 'bool'},
            {'name': 'preimage', 'type': 'bytes'}
        ]
    },
    {
        'name': 'LogHTLCNew',
        'type': 'event',
        'inputs': [
            {'name': 'contractId', 'type': 'bytes32', 'indexed': True},
            {'name': 'sender', 'type': 'address', 'indexed': True},
            {'name': 'receiver', 'type': 'address', 'indexed': True},
            {'name': 'amount', 'type': 'uint256', 'indexed': False},
            {'name': 'hashlock', 'type': 'bytes32', 'indexed': False},
            {'name': 'timelock', 'type': 'uint256', 'indexed': False}
        ]
    }
]

# Helper function to fetch current Symbol network time
def get_network_time():
    time_path = '/node/time'
    print(f'Fetching current network time from {time_path}')
    with urllib.request.urlopen(
        f'{SYMBOL_NODE_URL}{time_path}'
    ) as response:
        response_json = json.loads(response.read().decode())
        timestamp = NetworkTimestamp(int(
            response_json['communicationTimestamps'][
                'receiveTimestamp']))
        print(f'  Network time: {timestamp.timestamp}'
            ' ms since nemesis')
        return timestamp


# Helper function to fetch recommended Symbol fee multiplier
def get_fee_multiplier():
    fee_path = '/network/fees/transaction'
    print(f'Fetching recommended fees from {fee_path}')
    with urllib.request.urlopen(
        f'{SYMBOL_NODE_URL}{fee_path}'
    ) as response:
        response_json = json.loads(response.read().decode())
        median_mult = response_json['medianFeeMultiplier']
        minimum_mult = response_json['minFeeMultiplier']
        fee_mult = max(median_mult, minimum_mult)
        print(f'  Fee multiplier: {fee_mult}')
        return fee_mult


# Helper function to announce a Symbol transaction
def announce_transaction(payload, endpoint, label):
    print(f'Announcing {label} to {endpoint}')
    request = urllib.request.Request(
        f'{SYMBOL_NODE_URL}{endpoint}',
        data=payload.encode(),
        headers={'Content-Type': 'application/json'},
        method='PUT'
    )
    with urllib.request.urlopen(request) as response:
        print(f'  Response: {response.read().decode()}')


# Helper function to wait for Symbol transaction status
def wait_for_status(hash_value, expected_status, label):
    print(f'Waiting for {label} to reach {expected_status} status...')
    attempts = 0
    max_attempts = 60

    while attempts < max_attempts:
        try:
            url = (f'{SYMBOL_NODE_URL}/transactionStatus/{hash_value}')
            with urllib.request.urlopen(url) as response:
                status = json.loads(response.read().decode())
                print(f'  Transaction status: {status["group"]}')

                if status['group'] == 'failed':
                    raise Exception(f'{label} failed: {status["code"]}')

                if status['group'] == expected_status:
                    print(f'{label} {expected_status}'
                        f' in {attempts} seconds')
                    return

        except urllib.error.HTTPError as e:
            if e.code != 404:
                raise
            print('  Transaction status: not yet available')

        attempts += 1
        time.sleep(1)

    raise Exception(
        f'{label} not {expected_status} after {max_attempts} attempts')


# Poll Symbol for a confirmed secret proof transaction matching
# a hashlock.
def wait_for_secret_proof(signer_address, hashlock):
    hashlock_hex = hashlock.hex().upper()
    url = (f'{SYMBOL_NODE_URL}/transactions/confirmed'
        f'?address={signer_address}&type=16978&order=desc')
    print(f'Polling {url}')
    print(f'  Looking for secret: {hashlock_hex}')

    attempts = 0
    max_attempts = 60
    while attempts < max_attempts:
        with urllib.request.urlopen(url) as response:
            data = json.loads(response.read().decode())
        for tx in data.get('data', []):
            secret = tx['transaction'].get('secret', '')
            if secret.upper() == hashlock_hex:
                print(f'  Found proof transaction after {attempts}s')
                return bytes.fromhex(tx['transaction']['proof'])
        attempts += 1
        time.sleep(1)

    raise Exception(
        f'Secret proof not found after {max_attempts} attempts')


# Symbol accounts
facade = SymbolFacade('testnet')

ALICE_XYM_PRIVATE_KEY = os.getenv('ALICE_XYM_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000')
alice_xym_key_pair = SymbolFacade.KeyPair(
    PrivateKey(ALICE_XYM_PRIVATE_KEY))
alice_xym_address = facade.network.public_key_to_address(
    alice_xym_key_pair.public_key)
print(f'Alice Symbol address: {alice_xym_address}')

BOB_XYM_PRIVATE_KEY = os.getenv('BOB_XYM_PRIVATE_KEY',
    '1111111111111111111111111111111111111111111111111111111111111111')
bob_xym_key_pair = SymbolFacade.KeyPair(PrivateKey(BOB_XYM_PRIVATE_KEY))
bob_xym_address = facade.network.public_key_to_address(
    bob_xym_key_pair.public_key)
print(f'Bob Symbol address: {bob_xym_address}')

# Ethereum accounts
w3 = Web3(Web3.HTTPProvider(ETH_RPC_URL))

ALICE_ETH_PRIVATE_KEY = os.getenv('ALICE_ETH_PRIVATE_KEY',
    '0xa73276699ba72dc7b5c9d08deaf8cd88eec33c866341b120304432b89586d45d')
alice_eth_account = w3.eth.account.from_key(ALICE_ETH_PRIVATE_KEY)
print(f'Alice ETH address: {alice_eth_account.address}')

BOB_ETH_PRIVATE_KEY = os.getenv('BOB_ETH_PRIVATE_KEY',
    '0x8e85561005f27d926af79a7ce3e76e75108a09ff2ab78bf65b5578d2e5d509bf')
bob_eth_account = w3.eth.account.from_key(BOB_ETH_PRIVATE_KEY)
print(f'Bob ETH address: {bob_eth_account.address}')

try:
    # --- Alice: Generate proof and hashlock ---
    print('\n--- Alice: Generate proof and hashlock ---')

    proof = os.urandom(32)
    print(f'Proof (hex): {proof.hex()}')

    first_hash = hashlib.sha256(proof).digest()
    secret = hashlib.sha256(first_hash).digest()
    print(f'Secret (double SHA-256): {secret.hex()}')

    # --- Step 1. Alice: Lock ETH on Ethereum ---
    print('\n--- Step 1. Alice: Lock ETH on Ethereum ---')

    htlc = w3.eth.contract(address=HTLC_ADDRESS, abi=HTLC_ABI)
    timelock = int(time.time()) + 72 * 60 * 60
    print(f'Ethereum timelock (Unix): {timelock}')

    lock_call = htlc.functions.newContract(
        bob_eth_account.address, secret, timelock)
    lock_tx = lock_call.build_transaction({
        'from': alice_eth_account.address,
        'value': w3.to_wei(0.01, 'ether'),
        'nonce': w3.eth.get_transaction_count(alice_eth_account.address)
    })
    signed_lock_tx = alice_eth_account.sign_transaction(lock_tx)
    lock_tx_hash = w3.eth.send_raw_transaction(
        signed_lock_tx.raw_transaction)
    print(f'Lock TX hash: {lock_tx_hash.hex()}')

    lock_receipt = w3.eth.wait_for_transaction_receipt(lock_tx_hash)
    print(f'Lock confirmed in block {lock_receipt.blockNumber}')

    # Extract the contractId from the LogHTLCNew event
    contract_id = lock_receipt.logs[0].topics[1]
    print(f'HTLC contract ID: {contract_id.hex()}')

    # --- Step 2. Bob: Create secret lock on Symbol ---
    print('\n--- Step 2. Bob: Create secret lock on Symbol ---')

    # Bob queries the Ethereum contract to get the hashlock
    contract_info = htlc.functions.getContract(contract_id).call()
    hashlock = contract_info[3]  # hashlock field
    print(f'Hashlock from chain: {hashlock.hex()}')

    lock_duration = 5760  # ~48h at 30s blocks
    print(f'Lock duration: {lock_duration} blocks')

    secret_lock_transaction = facade.transaction_factory.create({
        'type': 'secret_lock_transaction_v1',
        'signer_public_key': bob_xym_key_pair.public_key,
        'deadline': get_network_time().add_hours(2).timestamp,
        'recipient_address': alice_xym_address,
        'mosaic': {
            'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
            'amount': 1_000000  # 1 XYM
        },
        'duration': lock_duration,
        'secret': Hash256(hashlock),
        'hash_algorithm': 'hash_256'
    })
    secret_lock_transaction.fee = Amount(
        get_fee_multiplier() * secret_lock_transaction.size)

    # Sign and announce
    lock_signature = facade.sign_transaction(
        bob_xym_key_pair, secret_lock_transaction)
    lock_payload = facade.transaction_factory.attach_signature(
        secret_lock_transaction, lock_signature)

    print('Built secret lock transaction:')
    print(json.dumps(secret_lock_transaction.to_json(), indent=2))

    lock_hash = facade.hash_transaction(secret_lock_transaction)
    print(f'Secret lock transaction hash: {lock_hash}')
    announce_transaction(lock_payload, '/transactions', 'secret lock')
    wait_for_status(lock_hash, 'confirmed', 'Secret lock')

    # --- Step 3. Alice: Claim XYM on Symbol ---
    print('\n--- Step 3. Alice: Claim XYM on Symbol ---')

    secret_proof_transaction = facade.transaction_factory.create({
        'type': 'secret_proof_transaction_v1',
        'signer_public_key': alice_xym_key_pair.public_key,
        'deadline': get_network_time().add_hours(2).timestamp,
        'recipient_address': alice_xym_address,
        'secret': Hash256(hashlock),
        'hash_algorithm': 'hash_256',
        'proof': proof
    })
    secret_proof_transaction.fee = Amount(
        get_fee_multiplier() * secret_proof_transaction.size)

    # Sign and announce
    proof_signature = facade.sign_transaction(
        alice_xym_key_pair, secret_proof_transaction)
    proof_payload = facade.transaction_factory.attach_signature(
        secret_proof_transaction, proof_signature)

    print('Built secret proof transaction:')
    print(json.dumps(secret_proof_transaction.to_json(), indent=2))

    proof_hash = facade.hash_transaction(secret_proof_transaction)
    print(f'Secret proof transaction hash: {proof_hash}')
    announce_transaction(proof_payload, '/transactions', 'secret proof')
    wait_for_status(proof_hash, 'confirmed', 'Secret proof')

    # --- Step 4. Bob: Withdraw ETH on Ethereum ---
    print('\n--- Step 4. Bob: Withdraw ETH on Ethereum ---')

    # Bob waits for Alice to reveal the proof on Symbol.
    revealed_proof = wait_for_secret_proof(alice_xym_address, hashlock)
    print(f'Proof from chain: {revealed_proof.hex()}')

    withdraw_call = htlc.functions.withdraw(contract_id, revealed_proof)
    withdraw_tx = withdraw_call.build_transaction({
        'from': bob_eth_account.address,
        'nonce': w3.eth.get_transaction_count(bob_eth_account.address)
    })
    signed_withdraw_tx = bob_eth_account.sign_transaction(withdraw_tx)
    withdraw_tx_hash = w3.eth.send_raw_transaction(
        signed_withdraw_tx.raw_transaction)
    print(f'Withdraw TX hash: {withdraw_tx_hash.hex()}')

    withdraw_receipt = w3.eth.wait_for_transaction_receipt(
        withdraw_tx_hash)
    print(f'Withdraw confirmed in block {withdraw_receipt.blockNumber}')

    print('\n--- Cross-chain swap complete ---')

except urllib.error.URLError as e:
    print(e.reason)
except Exception as e:
    print(e)

Download source

import { PrivateKey } from 'symbol-sdk';
import {
    SymbolFacade,
    NetworkTimestamp,
    models,
    generateMosaicAliasId
} from 'symbol-sdk/symbol';
import { createHash, randomBytes } from 'crypto';
import { ethers } from 'ethers';

const SYMBOL_NODE_URL = process.env.SYMBOL_NODE_URL ||
    'https://reference.symboltest.net:3001';
console.log('Using Symbol node', SYMBOL_NODE_URL);

const ETH_RPC_URL = process.env.ETH_RPC_URL ||
    'https://ethereum-sepolia-rpc.publicnode.com';
console.log('Using Ethereum RPC', ETH_RPC_URL);

// Ethereum HTLC contract on Sepolia
const HTLC_ADDRESS = '0xd58e030bd21c7788897aE5Ea845DaBA936e91D2B';
const HTLC_ABI = [
    'function newContract(address, bytes32, uint) '
        + 'external payable returns (bytes32)',
    'function withdraw(bytes32, bytes) '
        + 'external returns (bool)',
    'function getContract(bytes32) external view '
        + 'returns (address sender, address receiver, '
        + 'uint amount, bytes32 hashlock, '
        + 'uint timelock, bool withdrawn, '
        + 'bool refunded, bytes preimage)',
    'event LogHTLCNew(bytes32 indexed contractId, '
        + 'address indexed sender, '
        + 'address indexed receiver, uint amount, '
        + 'bytes32 hashlock, uint timelock)'
];

// Helper function to fetch current Symbol network time
async function getNetworkTime() {
    const timePath = '/node/time';
    console.log('Fetching current network time from', timePath);
    const timeResponse =
        await fetch(`${SYMBOL_NODE_URL}${timePath}`);
    const timeJSON = await timeResponse.json();
    const timestamp = new NetworkTimestamp(
        timeJSON.communicationTimestamps.receiveTimestamp);
    console.log('  Network time:', timestamp.timestamp,
        'ms since nemesis');
    return timestamp;
}

// Helper function to fetch recommended Symbol fee multiplier
async function getFeeMultiplier() {
    const feePath = '/network/fees/transaction';
    console.log('Fetching recommended fees from', feePath);
    const feeResponse =
        await fetch(`${SYMBOL_NODE_URL}${feePath}`);
    const feeJSON = await feeResponse.json();
    const medianMult = feeJSON.medianFeeMultiplier;
    const minimumMult = feeJSON.minFeeMultiplier;
    const feeMult = Math.max(medianMult, minimumMult);
    console.log('  Fee multiplier:', feeMult);
    return feeMult;
}

// Helper function to announce a Symbol transaction
async function announceTransaction(payload, endpoint, label) {
    console.log(`Announcing ${label} to ${endpoint}`);
    const response = await fetch(
        `${SYMBOL_NODE_URL}${endpoint}`, {
            method: 'PUT',
            headers: { 'Content-Type': 'application/json' },
            body: payload
        });
    console.log('  Response:', await response.text());
}

// Helper function to wait for Symbol transaction status
async function waitForStatus(hash, expectedStatus, label) {
    console.log(
        `Waiting for ${label} to reach ${expectedStatus} status...`);
    let attempts = 0;
    const maxAttempts = 60;

    while (attempts < maxAttempts) {
        try {
            const url = `${SYMBOL_NODE_URL}/transactionStatus/${hash}`;
            const response = await fetch(url);

            if (!response.ok) {
                const error = new Error(
                    `HTTP ${response.status}: ${response.statusText}`);
                error.status = response.status;
                throw error;
            }

            const status = await response.json();
            console.log('  Transaction status:', status.group);

            if (status.group === 'failed') {
                throw new Error(`${label} failed: ${status.code}`);
            }

            if (status.group === expectedStatus) {
                console.log(
                    `${label} ${expectedStatus} in ${attempts} seconds`
                );
                return;
            }
        } catch (error) {
            if (error.status === 404) {
                console.log('  Transaction status: not yet available');
            } else {
                throw error;
            }
        }

        attempts++;
        await new Promise(resolve => setTimeout(resolve, 1000));
    }

    throw new Error(
        `${label} not ${expectedStatus} after ${maxAttempts} attempts`);
}

// Poll Symbol for a confirmed secret proof transaction matching
// a hashlock.
async function waitForSecretProof(signerAddress, hashlock) {
    const hashlockHex = hashlock.toUpperCase();
    const url = `${SYMBOL_NODE_URL}/transactions/confirmed`
        + `?address=${signerAddress}&type=16978&order=desc`;
    console.log(`Polling ${url}`);
    console.log(`  Looking for secret: ${hashlockHex}`);

    let attempts = 0;
    const maxAttempts = 60;
    while (attempts < maxAttempts) {
        const response = await fetch(url);
        const data = await response.json();
        for (const tx of data.data || []) {
            const secret = (tx.transaction.secret || '').toUpperCase();
            if (secret === hashlockHex) {
                console.log(
                    `  Found proof transaction after ${attempts}s`);
                return Buffer.from(tx.transaction.proof, 'hex');
            }
        }
        attempts++;
        await new Promise(resolve => setTimeout(resolve, 1000));
    }

    throw new Error(
        `Secret proof not found after ${maxAttempts} attempts`);
}

// Symbol accounts
const facade = new SymbolFacade('testnet');

// Alice (creates the ETH lock, claims XYM on Symbol)
const ALICE_XYM_PRIVATE_KEY = process.env.ALICE_XYM_PRIVATE_KEY ||
    '0000000000000000000000000000000000000000000000000000000000000000';
const aliceXymKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(ALICE_XYM_PRIVATE_KEY));
const aliceXymAddress = facade.network.publicKeyToAddress(
    aliceXymKeyPair.publicKey);
console.log('Alice Symbol address:', aliceXymAddress.toString());

// Bob (creates the XYM lock, claims ETH on Ethereum)
const BOB_XYM_PRIVATE_KEY = process.env.BOB_XYM_PRIVATE_KEY ||
    '1111111111111111111111111111111111111111111111111111111111111111';
const bobXymKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(BOB_XYM_PRIVATE_KEY));
const bobXymAddress = facade.network.publicKeyToAddress(
    bobXymKeyPair.publicKey);
console.log('Bob Symbol address:', bobXymAddress.toString());

// Ethereum accounts
const ethProvider = new ethers.JsonRpcProvider(ETH_RPC_URL);

const ALICE_ETH_PRIVATE_KEY = process.env.ALICE_ETH_PRIVATE_KEY ||
    '0xa73276699ba72dc7b5c9d08deaf8cd88eec33c866341b120304432b89586d45d';
const aliceEthWallet = new ethers.Wallet(
    ALICE_ETH_PRIVATE_KEY, ethProvider);
console.log('Alice ETH address:', aliceEthWallet.address);

const BOB_ETH_PRIVATE_KEY = process.env.BOB_ETH_PRIVATE_KEY ||
    '0x8e85561005f27d926af79a7ce3e76e75108a09ff2ab78bf65b5578d2e5d509bf';
const bobEthWallet = new ethers.Wallet(BOB_ETH_PRIVATE_KEY, ethProvider);
console.log('Bob ETH address:', bobEthWallet.address);

try {
    // --- Alice: Generate proof and hashlock ---
    console.log('\n--- Alice: Generate proof and hashlock ---');

    const proof = randomBytes(32);
    console.log('Proof (hex):', proof.toString('hex'));

    const firstHash = createHash('sha256').update(proof).digest();
    const secret = createHash('sha256').update(firstHash).digest();
    console.log('Secret (double SHA-256):', secret.toString('hex'));

    // --- Step 1. Alice: Lock ETH on Ethereum ---
    console.log('\n--- Step 1. Alice: Lock ETH on Ethereum ---');

    const htlcAsAlice = new ethers.Contract(
        HTLC_ADDRESS, HTLC_ABI, aliceEthWallet);

    const timelock = Math.floor(Date.now() / 1000) + 72 * 60 * 60;
    console.log('Ethereum timelock (Unix):', timelock);

    const lockTx = await htlcAsAlice.newContract(
        bobEthWallet.address,
        '0x' + secret.toString('hex'),
        timelock,
        { value: ethers.parseEther('0.01') }
    );
    console.log('Lock TX hash:', lockTx.hash);

    const lockReceipt = await lockTx.wait();
    console.log('Lock confirmed in block', lockReceipt.blockNumber);

    // Extract the contractId from the LogHTLCNew event
    const contractId = lockReceipt.logs[0].topics[1];
    console.log('HTLC contract ID:', contractId);

    // --- Step 2. Bob: Create secret lock on Symbol ---
    console.log('\n--- Step 2. Bob: Create secret lock on Symbol ---');

    // Bob queries the Ethereum contract to get the hashlock
    const htlcAsBob = new ethers.Contract(
        HTLC_ADDRESS, HTLC_ABI, bobEthWallet);
    const contractInfo = await htlcAsBob.getContract(contractId);
    const hashlock = contractInfo.hashlock.slice(2); // strip 0x prefix
    console.log('Hashlock from chain:', hashlock);

    const lockDuration = 5760n; // ~48h at 30s blocks
    console.log('Lock duration:', lockDuration.toString(), 'blocks');

    const secretLockTransaction =
        facade.transactionFactory.create({
            type: 'secret_lock_transaction_v1',
            signerPublicKey: bobXymKeyPair.publicKey.toString(),
            deadline: (await getNetworkTime()).addHours(2).timestamp,
            recipientAddress: aliceXymAddress.toString(),
            mosaic: {
                mosaicId: generateMosaicAliasId('symbol.xym'),
                amount: 1_000000n // 1 XYM
            },
            duration: lockDuration,
            secret: hashlock,
            hashAlgorithm: 'hash_256'
        });
    secretLockTransaction.fee = new models.Amount(
        (await getFeeMultiplier()) * secretLockTransaction.size);

    // Sign and announce
    const lockSignature = facade.signTransaction(
        bobXymKeyPair, secretLockTransaction);
    const lockPayload = facade.transactionFactory.static.attachSignature(
        secretLockTransaction, lockSignature);

    console.log('Built secret lock transaction:');
    console.dir(secretLockTransaction.toJson(), { colors: true });

    const lockHash = facade.hashTransaction(
        secretLockTransaction).toString();
    console.log('Secret lock transaction hash:', lockHash);
    await announceTransaction(lockPayload, '/transactions',
        'secret lock');
    await waitForStatus(lockHash, 'confirmed', 'Secret lock');

    // --- Step 3. Alice: Claim XYM on Symbol ---
    console.log('\n--- Step 3. Alice: Claim XYM on Symbol ---');

    const secretProofTransaction =
        facade.transactionFactory.create({
            type: 'secret_proof_transaction_v1',
            signerPublicKey:
                aliceXymKeyPair.publicKey.toString(),
            deadline: (await getNetworkTime()).addHours(2).timestamp,
            recipientAddress: aliceXymAddress.toString(),
            secret: hashlock,
            hashAlgorithm: 'hash_256',
            proof: proof
        });
    secretProofTransaction.fee = new models.Amount(
        (await getFeeMultiplier()) * secretProofTransaction.size);

    // Sign and announce
    const proofSignature = facade.signTransaction(
        aliceXymKeyPair, secretProofTransaction);
    const proofPayload = facade.transactionFactory.static.attachSignature(
        secretProofTransaction, proofSignature);

    console.log('Built secret proof transaction:');
    console.dir(secretProofTransaction.toJson(), { colors: true });

    const proofHash = facade.hashTransaction(
        secretProofTransaction).toString();
    console.log('Secret proof transaction hash:', proofHash);
    await announceTransaction(
        proofPayload, '/transactions', 'secret proof');
    await waitForStatus(proofHash, 'confirmed', 'Secret proof');

    // --- Step 4. Bob: Withdraw ETH on Ethereum ---
    console.log('\n--- Step 4. Bob: Withdraw ETH on Ethereum ---');

    // Bob waits for Alice to reveal the proof on Symbol.
    const revealedProof = await waitForSecretProof(
        aliceXymAddress.toString(), hashlock);
    console.log('Proof from chain:', revealedProof.toString('hex'));

    const withdrawTx = await htlcAsBob.withdraw(
        contractId, revealedProof);
    console.log('Withdraw TX hash:', withdrawTx.hash);

    const withdrawReceipt = await withdrawTx.wait();
    console.log('Withdraw confirmed in block',
        withdrawReceipt.blockNumber);

    console.log('\n--- Cross-chain swap complete ---');
} catch (e) {
    console.error(e.message);
}

Download source

Ethereum HTLC Contract⚓︎

This tutorial uses a sample HTLC contract deployed on Ethereum as the other side of Symbol's secret lock. The contract source is available in the hashed-timelock-contract-ethereum repository.

Educational use only

Any contract used in production must carefully calibrate lock and contract expiry times, as timing is critical for the security of both parties.

The contract provides three key methods:

  • newContract(address receiver, bytes32 hashlock, uint timelock): Creates a new HTLC with a recipient, hashlock, and a Unix timestamp as timelock. Comparable to Symbol's SecretLockTransactionV1.
  • withdraw(bytes32 contractId, bytes proof): Allows the recipient to claim funds by providing the proof that matches the hashlock. Comparable to Symbol's SecretProofTransactionV1.
  • refund(bytes32 contractId): Returns funds to the creator after the timelock expires. In Symbol, refunds happen automatically when a secret lock expires.

The contract has been deployed on the Sepolia testnet at address 0xd58e030bd21c7788897aE5Ea845DaBA936e91D2B.

Code Explanation⚓︎

Alice and Bob each need an account on both chains: Alice locks ETH on Ethereum and claims XYM on Symbol, while Bob locks XYM on Symbol and claims ETH on Ethereum. Alice is the initiator: she generates a random secret (the proof), computes its cryptographic hash (the hashlock), and locks her ETH on Ethereum behind it. Bob then locks his XYM on Symbol using the same hashlock, so only revealing the proof can unlock either side.

The code runs these four steps in order:

CrossChainSwapStepsA_startA_endA_start->A_endA_labelAlice's ETH lock on Ethereum (72h)B_startB_endB_start->B_endB_labelBob's XYM lock on Symbol (48h)T1_topT1_botT1_top->T1_botT2_topT2_botT2_top->T2_botT3_topT3_botT3_top->T3_botT5_topT5_botT5_top->T5_botL11. Alicelocks ETHL22. Boblocks XYML33. Aliceclaims XYMand reveals proofL54. Bobclaims ETH

  1. Alice locks ETH on Ethereum in the Ethereum HTLC contract, guarded by the hashlock. The matching proof, which only Alice knows at this point, can release the lock.
  2. Bob locks XYM on Symbol using a SecretLockTransactionV1 with the same hashlock.
  3. Alice claims XYM on Symbol by revealing the proof through a SecretProofTransactionV1, making the proof public on Symbol.
  4. Bob claims ETH on Ethereum by reading Alice's proof from Symbol and calling withdraw on the Ethereum HTLC contract.

In practice, Alice and Bob would each run their own part on different machines. This tutorial combines both sides in a single script for simplicity.

The code defines helper functions to fetch the network time and fees, announce transactions, and poll for confirmation, following the same patterns described in the Transfer tutorial.

This tutorial does not wait for transaction finality between steps, which a production implementation must do to prevent rollback-related risks.

Setting Up Accounts⚓︎

# Symbol accounts
facade = SymbolFacade('testnet')

ALICE_XYM_PRIVATE_KEY = os.getenv('ALICE_XYM_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000')
alice_xym_key_pair = SymbolFacade.KeyPair(
    PrivateKey(ALICE_XYM_PRIVATE_KEY))
alice_xym_address = facade.network.public_key_to_address(
    alice_xym_key_pair.public_key)
print(f'Alice Symbol address: {alice_xym_address}')

BOB_XYM_PRIVATE_KEY = os.getenv('BOB_XYM_PRIVATE_KEY',
    '1111111111111111111111111111111111111111111111111111111111111111')
bob_xym_key_pair = SymbolFacade.KeyPair(PrivateKey(BOB_XYM_PRIVATE_KEY))
bob_xym_address = facade.network.public_key_to_address(
    bob_xym_key_pair.public_key)
print(f'Bob Symbol address: {bob_xym_address}')

# Ethereum accounts
w3 = Web3(Web3.HTTPProvider(ETH_RPC_URL))

ALICE_ETH_PRIVATE_KEY = os.getenv('ALICE_ETH_PRIVATE_KEY',
    '0xa73276699ba72dc7b5c9d08deaf8cd88eec33c866341b120304432b89586d45d')
alice_eth_account = w3.eth.account.from_key(ALICE_ETH_PRIVATE_KEY)
print(f'Alice ETH address: {alice_eth_account.address}')

BOB_ETH_PRIVATE_KEY = os.getenv('BOB_ETH_PRIVATE_KEY',
    '0x8e85561005f27d926af79a7ce3e76e75108a09ff2ab78bf65b5578d2e5d509bf')
bob_eth_account = w3.eth.account.from_key(BOB_ETH_PRIVATE_KEY)
print(f'Bob ETH address: {bob_eth_account.address}')
// Symbol accounts
const facade = new SymbolFacade('testnet');

// Alice (creates the ETH lock, claims XYM on Symbol)
const ALICE_XYM_PRIVATE_KEY = process.env.ALICE_XYM_PRIVATE_KEY ||
    '0000000000000000000000000000000000000000000000000000000000000000';
const aliceXymKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(ALICE_XYM_PRIVATE_KEY));
const aliceXymAddress = facade.network.publicKeyToAddress(
    aliceXymKeyPair.publicKey);
console.log('Alice Symbol address:', aliceXymAddress.toString());

// Bob (creates the XYM lock, claims ETH on Ethereum)
const BOB_XYM_PRIVATE_KEY = process.env.BOB_XYM_PRIVATE_KEY ||
    '1111111111111111111111111111111111111111111111111111111111111111';
const bobXymKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(BOB_XYM_PRIVATE_KEY));
const bobXymAddress = facade.network.publicKeyToAddress(
    bobXymKeyPair.publicKey);
console.log('Bob Symbol address:', bobXymAddress.toString());

// Ethereum accounts
const ethProvider = new ethers.JsonRpcProvider(ETH_RPC_URL);

const ALICE_ETH_PRIVATE_KEY = process.env.ALICE_ETH_PRIVATE_KEY ||
    '0xa73276699ba72dc7b5c9d08deaf8cd88eec33c866341b120304432b89586d45d';
const aliceEthWallet = new ethers.Wallet(
    ALICE_ETH_PRIVATE_KEY, ethProvider);
console.log('Alice ETH address:', aliceEthWallet.address);

const BOB_ETH_PRIVATE_KEY = process.env.BOB_ETH_PRIVATE_KEY ||
    '0x8e85561005f27d926af79a7ce3e76e75108a09ff2ab78bf65b5578d2e5d509bf';
const bobEthWallet = new ethers.Wallet(BOB_ETH_PRIVATE_KEY, ethProvider);
console.log('Bob ETH address:', bobEthWallet.address);

The ALICE_XYM_PRIVATE_KEY and BOB_XYM_PRIVATE_KEY environment variables set the Symbol keys, while ALICE_ETH_PRIVATE_KEY and BOB_ETH_PRIVATE_KEY set the Ethereum keys. Although pre-funded test keys are provided as defaults for convenience, they are not maintained and may run out of funds.

Alice: Generating the Proof and Hashlock⚓︎

    print('\n--- Alice: Generate proof and hashlock ---')

    proof = os.urandom(32)
    print(f'Proof (hex): {proof.hex()}')

    first_hash = hashlib.sha256(proof).digest()
    secret = hashlib.sha256(first_hash).digest()
    print(f'Secret (double SHA-256): {secret.hex()}')
    console.log('\n--- Alice: Generate proof and hashlock ---');

    const proof = randomBytes(32);
    console.log('Proof (hex):', proof.toString('hex'));

    const firstHash = createHash('sha256').update(proof).digest();
    const secret = createHash('sha256').update(firstHash).digest();
    console.log('Secret (double SHA-256):', secret.toString('hex'));

As the swap initiator, Alice generates a random 32-byte value as the proof. She then hashes it using double SHA-256 to produce the hashlock.

The double SHA-256 algorithm is chosen because it is supported by both Symbol (as hash_256) and the Ethereum HTLC contract. Using the same algorithm on both chains is essential for the swap to work.

Other hash algorithms

Symbol supports other hash algorithms for secret locks. See LockHashAlgorithm for all available values.

Step 1. Alice: Locking ETH on Ethereum⚓︎

    print('\n--- Step 1. Alice: Lock ETH on Ethereum ---')

    htlc = w3.eth.contract(address=HTLC_ADDRESS, abi=HTLC_ABI)
    timelock = int(time.time()) + 72 * 60 * 60
    print(f'Ethereum timelock (Unix): {timelock}')

    lock_call = htlc.functions.newContract(
        bob_eth_account.address, secret, timelock)
    lock_tx = lock_call.build_transaction({
        'from': alice_eth_account.address,
        'value': w3.to_wei(0.01, 'ether'),
        'nonce': w3.eth.get_transaction_count(alice_eth_account.address)
    })
    signed_lock_tx = alice_eth_account.sign_transaction(lock_tx)
    lock_tx_hash = w3.eth.send_raw_transaction(
        signed_lock_tx.raw_transaction)
    print(f'Lock TX hash: {lock_tx_hash.hex()}')

    lock_receipt = w3.eth.wait_for_transaction_receipt(lock_tx_hash)
    print(f'Lock confirmed in block {lock_receipt.blockNumber}')

    # Extract the contractId from the LogHTLCNew event
    contract_id = lock_receipt.logs[0].topics[1]
    print(f'HTLC contract ID: {contract_id.hex()}')
    console.log('\n--- Step 1. Alice: Lock ETH on Ethereum ---');

    const htlcAsAlice = new ethers.Contract(
        HTLC_ADDRESS, HTLC_ABI, aliceEthWallet);

    const timelock = Math.floor(Date.now() / 1000) + 72 * 60 * 60;
    console.log('Ethereum timelock (Unix):', timelock);

    const lockTx = await htlcAsAlice.newContract(
        bobEthWallet.address,
        '0x' + secret.toString('hex'),
        timelock,
        { value: ethers.parseEther('0.01') }
    );
    console.log('Lock TX hash:', lockTx.hash);

    const lockReceipt = await lockTx.wait();
    console.log('Lock confirmed in block', lockReceipt.blockNumber);

    // Extract the contractId from the LogHTLCNew event
    const contractId = lockReceipt.logs[0].topics[1];
    console.log('HTLC contract ID:', contractId);

Alice calls newContract on the Ethereum HTLC contract, locking 0.01 ETH for Bob:

  • Receiver: Bob's Ethereum address.
  • Hashlock: The double SHA-256 hash of the proof. Only Alice knows the proof at this point.
  • Timelock: A Unix timestamp 72 hours in the future, after which Alice can reclaim the ETH if Bob does not complete the swap.
  • Value: 0.01 ETH sent along with the transaction.

The transaction receipt contains a LogHTLCNew event with a contractId that identifies this HTLC. Bob will need this contractId later to withdraw the ETH.

Step 2. Bob: Creating a Secret Lock on Symbol⚓︎

    print('\n--- Step 2. Bob: Create secret lock on Symbol ---')

    # Bob queries the Ethereum contract to get the hashlock
    contract_info = htlc.functions.getContract(contract_id).call()
    hashlock = contract_info[3]  # hashlock field
    print(f'Hashlock from chain: {hashlock.hex()}')

    lock_duration = 5760  # ~48h at 30s blocks
    print(f'Lock duration: {lock_duration} blocks')

    secret_lock_transaction = facade.transaction_factory.create({
        'type': 'secret_lock_transaction_v1',
        'signer_public_key': bob_xym_key_pair.public_key,
        'deadline': get_network_time().add_hours(2).timestamp,
        'recipient_address': alice_xym_address,
        'mosaic': {
            'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
            'amount': 1_000000  # 1 XYM
        },
        'duration': lock_duration,
        'secret': Hash256(hashlock),
        'hash_algorithm': 'hash_256'
    })
    secret_lock_transaction.fee = Amount(
        get_fee_multiplier() * secret_lock_transaction.size)

    # Sign and announce
    lock_signature = facade.sign_transaction(
        bob_xym_key_pair, secret_lock_transaction)
    lock_payload = facade.transaction_factory.attach_signature(
        secret_lock_transaction, lock_signature)

    print('Built secret lock transaction:')
    print(json.dumps(secret_lock_transaction.to_json(), indent=2))

    lock_hash = facade.hash_transaction(secret_lock_transaction)
    print(f'Secret lock transaction hash: {lock_hash}')
    announce_transaction(lock_payload, '/transactions', 'secret lock')
    wait_for_status(lock_hash, 'confirmed', 'Secret lock')
    console.log('\n--- Step 2. Bob: Create secret lock on Symbol ---');

    // Bob queries the Ethereum contract to get the hashlock
    const htlcAsBob = new ethers.Contract(
        HTLC_ADDRESS, HTLC_ABI, bobEthWallet);
    const contractInfo = await htlcAsBob.getContract(contractId);
    const hashlock = contractInfo.hashlock.slice(2); // strip 0x prefix
    console.log('Hashlock from chain:', hashlock);

    const lockDuration = 5760n; // ~48h at 30s blocks
    console.log('Lock duration:', lockDuration.toString(), 'blocks');

    const secretLockTransaction =
        facade.transactionFactory.create({
            type: 'secret_lock_transaction_v1',
            signerPublicKey: bobXymKeyPair.publicKey.toString(),
            deadline: (await getNetworkTime()).addHours(2).timestamp,
            recipientAddress: aliceXymAddress.toString(),
            mosaic: {
                mosaicId: generateMosaicAliasId('symbol.xym'),
                amount: 1_000000n // 1 XYM
            },
            duration: lockDuration,
            secret: hashlock,
            hashAlgorithm: 'hash_256'
        });
    secretLockTransaction.fee = new models.Amount(
        (await getFeeMultiplier()) * secretLockTransaction.size);

    // Sign and announce
    const lockSignature = facade.signTransaction(
        bobXymKeyPair, secretLockTransaction);
    const lockPayload = facade.transactionFactory.static.attachSignature(
        secretLockTransaction, lockSignature);

    console.log('Built secret lock transaction:');
    console.dir(secretLockTransaction.toJson(), { colors: true });

    const lockHash = facade.hashTransaction(
        secretLockTransaction).toString();
    console.log('Secret lock transaction hash:', lockHash);
    await announceTransaction(lockPayload, '/transactions',
        'secret lock');
    await waitForStatus(lockHash, 'confirmed', 'Secret lock');

Bob first queries the Ethereum HTLC contract using getContract to retrieve the hashlock that Alice used.

Verify before locking

Bob should verify the full contract details (amount, recipient, timelock) before locking his own funds. This tutorial only reads the hashlock for simplicity.

Bob then creates a SecretLockTransactionV1 on Symbol, locking 1 XYM for Alice, using the same hashlock:

  • Recipient: Alice's Symbol address.
  • Mosaic: 1 XYM (expressed as 1_000000 atomic units with divisibility 6).
  • Duration: 5760 blocks (~48 hours at 30-second block times).

    Timelock ordering

    This duration must be shorter than Alice's 72-hour Ethereum timelock. Otherwise, Alice could refund her ETH and still claim Bob's XYM. The gap between the two must be large enough: it is the safety margin that allows Bob to withdraw on Ethereum even if Alice reveals the proof at the last moment. See Safety Considerations.

  • Hashlock (secret field): The hashlock retrieved from the Ethereum contract.

  • Hash algorithm: hash_256 (double SHA-256), must match the algorithm used in the other chain's HTLC.

Step 3. Alice: Claiming XYM on Symbol⚓︎

    print('\n--- Step 3. Alice: Claim XYM on Symbol ---')

    secret_proof_transaction = facade.transaction_factory.create({
        'type': 'secret_proof_transaction_v1',
        'signer_public_key': alice_xym_key_pair.public_key,
        'deadline': get_network_time().add_hours(2).timestamp,
        'recipient_address': alice_xym_address,
        'secret': Hash256(hashlock),
        'hash_algorithm': 'hash_256',
        'proof': proof
    })
    secret_proof_transaction.fee = Amount(
        get_fee_multiplier() * secret_proof_transaction.size)

    # Sign and announce
    proof_signature = facade.sign_transaction(
        alice_xym_key_pair, secret_proof_transaction)
    proof_payload = facade.transaction_factory.attach_signature(
        secret_proof_transaction, proof_signature)

    print('Built secret proof transaction:')
    print(json.dumps(secret_proof_transaction.to_json(), indent=2))

    proof_hash = facade.hash_transaction(secret_proof_transaction)
    print(f'Secret proof transaction hash: {proof_hash}')
    announce_transaction(proof_payload, '/transactions', 'secret proof')
    wait_for_status(proof_hash, 'confirmed', 'Secret proof')
    console.log('\n--- Step 3. Alice: Claim XYM on Symbol ---');

    const secretProofTransaction =
        facade.transactionFactory.create({
            type: 'secret_proof_transaction_v1',
            signerPublicKey:
                aliceXymKeyPair.publicKey.toString(),
            deadline: (await getNetworkTime()).addHours(2).timestamp,
            recipientAddress: aliceXymAddress.toString(),
            secret: hashlock,
            hashAlgorithm: 'hash_256',
            proof: proof
        });
    secretProofTransaction.fee = new models.Amount(
        (await getFeeMultiplier()) * secretProofTransaction.size);

    // Sign and announce
    const proofSignature = facade.signTransaction(
        aliceXymKeyPair, secretProofTransaction);
    const proofPayload = facade.transactionFactory.static.attachSignature(
        secretProofTransaction, proofSignature);

    console.log('Built secret proof transaction:');
    console.dir(secretProofTransaction.toJson(), { colors: true });

    const proofHash = facade.hashTransaction(
        secretProofTransaction).toString();
    console.log('Secret proof transaction hash:', proofHash);
    await announceTransaction(
        proofPayload, '/transactions', 'secret proof');
    await waitForStatus(proofHash, 'confirmed', 'Secret proof');

Once Bob's secret lock is confirmed and Alice has verified it matches the expected amount, hashlock, recipient, and timelock, she claims the locked XYM on Symbol by revealing the proof.

She creates a SecretProofTransactionV1 with:

  • Recipient: Alice's own Symbol address (the same address set in Bob's secret lock).
  • Hashlock (secret field): The same hashlock used in the secret lock.
  • Hash algorithm: hash_256 (must match the secret lock).
  • Proof: The original random bytes that Alice generated.

Once this transaction is announced and confirmed, Alice receives the 1 XYM Bob had locked, and the proof becomes publicly visible on the Symbol blockchain. Bob (or anyone) can read it from the transaction data.

Step 4. Bob: Withdrawing ETH on Ethereum⚓︎

    print('\n--- Step 4. Bob: Withdraw ETH on Ethereum ---')

    # Bob waits for Alice to reveal the proof on Symbol.
    revealed_proof = wait_for_secret_proof(alice_xym_address, hashlock)
    print(f'Proof from chain: {revealed_proof.hex()}')

    withdraw_call = htlc.functions.withdraw(contract_id, revealed_proof)
    withdraw_tx = withdraw_call.build_transaction({
        'from': bob_eth_account.address,
        'nonce': w3.eth.get_transaction_count(bob_eth_account.address)
    })
    signed_withdraw_tx = bob_eth_account.sign_transaction(withdraw_tx)
    withdraw_tx_hash = w3.eth.send_raw_transaction(
        signed_withdraw_tx.raw_transaction)
    print(f'Withdraw TX hash: {withdraw_tx_hash.hex()}')

    withdraw_receipt = w3.eth.wait_for_transaction_receipt(
        withdraw_tx_hash)
    print(f'Withdraw confirmed in block {withdraw_receipt.blockNumber}')
    console.log('\n--- Step 4. Bob: Withdraw ETH on Ethereum ---');

    // Bob waits for Alice to reveal the proof on Symbol.
    const revealedProof = await waitForSecretProof(
        aliceXymAddress.toString(), hashlock);
    console.log('Proof from chain:', revealedProof.toString('hex'));

    const withdrawTx = await htlcAsBob.withdraw(
        contractId, revealedProof);
    console.log('Withdraw TX hash:', withdrawTx.hash);

    const withdrawReceipt = await withdrawTx.wait();
    console.log('Withdraw confirmed in block',
        withdrawReceipt.blockNumber);

Bob discovers Alice's proof on-chain without needing the transaction hash from her.

The wait_for_secret_proof helper polls the /transactions/confirmed GET endpoint filtered by Alice's address and type=16978 (SecretProofTransactionV1), then matches transaction.secret to Bob's own hashlock to pick the right entry and read transaction.proof from it.

Because hashlocks are 32 random bytes unique to each swap, only the proof transaction for this swap will match, even if Alice has posted other secret proofs in the past.

Once the proof is retrieved, Bob calls withdraw on the Ethereum HTLC contract with two arguments:

  • Contract ID: The HTLC identifier from the LogHTLCNew event emitted when Alice locked the ETH.
  • Proof: The proof Alice revealed on Symbol.

Withdrawal deadline

Bob must complete this step before Alice's Ethereum timelock expires. Once expired, Alice can call refund on the Ethereum contract and reclaim her ETH.

Once this Ethereum transaction is confirmed, Bob receives Alice's 0.01 ETH, completing the swap. Alice already received Bob's 1 XYM at the end of Step 3.

Output⚓︎

The output shown below corresponds to a typical run of the program.

Using Symbol node https://reference.symboltest.net:3001
Using Ethereum RPC https://ethereum-sepolia-rpc.publicnode.com
Alice Symbol address: TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I
Bob Symbol address: TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI
Alice ETH address: 0x8019119CD3f852B65820F0Cf0d7FA0957BEd23E2
Bob ETH address: 0xa5507aa9a7080d5916ABab889876A0910d1Ac328

--- Alice: Generate proof and hashlock ---
Proof (hex): 21e96d2665bd5a063d03656eb202f0bc0eb5bdef06d4c1a34db1f563af99fedb
Secret (double SHA-256): d128c3c3924ecff0a19f81c89a84e7fe6320b8a2b07ff22812e58a2360e1a910

--- Step 1. Alice: Lock ETH on Ethereum ---
Ethereum timelock (Unix): 1776935008
Lock TX hash: 2c198264968661cd560b9ad7bc0f9e6dc8d6d7fa5caaa3bf787fee66138f3e5f
Lock confirmed in block 10696157
HTLC contract ID: 92217fcd48907e5759e5431d8a0d606e64942cdb209d329590a68250a01b41f1

--- Step 2. Bob: Create secret lock on Symbol ---
Hashlock from chain: d128c3c3924ecff0a19f81c89a84e7fe6320b8a2b07ff22812e58a2360e1a910
Lock duration: 5760 blocks
Fetching current network time from /node/time
  Network time: 109425352582 ms since nemesis
Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100
Built secret lock transaction:
{
  "signature": "9ABE4BE7A02170FEB3E0F4A3FB551D5FB4ECE28F0E02F9FEAF9CBF58CFE491595CB1AB22B161C77C2600FC6F31CF7863C63090A3820405E570854680BA1AF602",
  "signer_public_key": "D04AB232742BB4AB3A1368BD4615E4E6D0224AB71A016BAF8520A332C9778737",
  "version": 1,
  "network": 152,
  "type": 16722,
  "fee": "20900",
  "deadline": "109432552582",
  "recipient_address": "988E1191A25A88142C2FB3F69787576E3DC713EFC1CE4DE9",
  "secret": "D128C3C3924ECFF0A19F81C89A84E7FE6320B8A2B07FF22812E58A2360E1A910",
  "mosaic": {
    "mosaic_id": "16666583871264174062",
    "amount": "1000000"
  },
  "duration": "5760",
  "hash_algorithm": 2
}
Secret lock transaction hash: 556BD67C7FB3ECE23AAD2C911224350DFB082748BBBD8D683DCE4AF7D2E4BE3A
Announcing secret lock to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for Secret lock to reach confirmed status...
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: confirmed
Secret lock confirmed in 24 seconds

--- Step 3. Alice: Claim XYM on Symbol ---
Fetching current network time from /node/time
  Network time: 109425393033 ms since nemesis
Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100
Built secret proof transaction:
{
  "signature": "A5BD627A1A466ABD62D1B7DA60C5E31337DD196A01CFFF417A9DFF04A45E1F9DE3AC7A3ED67054C7CFC2D0FF3E91ACDA225D6F0A94465C4ED39DF684D7B8480C",
  "signer_public_key": "3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29",
  "version": 1,
  "network": 152,
  "type": 16978,
  "fee": "21900",
  "deadline": "109432593033",
  "recipient_address": "988E1191A25A88142C2FB3F69787576E3DC713EFC1CE4DE9",
  "secret": "D128C3C3924ECFF0A19F81C89A84E7FE6320B8A2B07FF22812E58A2360E1A910",
  "hash_algorithm": 2,
  "proof": "21e96d2665bd5a063d03656eb202f0bc0eb5bdef06d4c1a34db1f563af99fedb"
}
Secret proof transaction hash: 1F875BF8074ADB3D40541CD44ABB2088D5588E6CE2F40CE38C4444258440CA9A
Announcing secret proof to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for Secret proof to reach confirmed status...
  Transaction status: not yet available
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: confirmed
Secret proof confirmed in 22 seconds

--- Step 4. Bob: Withdraw ETH on Ethereum ---
Polling https://reference.symboltest.net:3001/transactions/confirmed?address=TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I&type=16978&order=desc
  Looking for secret: D128C3C3924ECFF0A19F81C89A84E7FE6320B8A2B07FF22812E58A2360E1A910
  Found proof transaction after 0s
Proof from chain: 21e96d2665bd5a063d03656eb202f0bc0eb5bdef06d4c1a34db1f563af99fedb
Withdraw TX hash: 2a2feb2ce493f1a0800442e45cf3474bd0ab80b16d94f7146accf72c43195d64
Withdraw confirmed in block 10696163

--- Cross-chain swap complete ---

Key points in the output:

  • Lines 9-10: Alice generates the proof and hashlock. The proof must remain secret until Alice reveals it.
  • Line 15: Alice's ETH lock on Ethereum is confirmed.
  • Line 16: The HTLC contract ID identifies Alice's Ethereum lock. Bob uses this to query the hashlock and later to withdraw.
  • Line 19: Bob retrieves the hashlock from the Ethereum contract using getContract.
  • Line 50: Bob's Symbol secret lock is confirmed. Alice can now claim the XYM.
  • Line 70: Alice includes the proof in her secret proof transaction. Once announced, it becomes public on Symbol.
  • Line 80: Alice's secret proof is confirmed. Alice receives the 1 XYM.
  • Line 87: Bob retrieves the revealed proof from Alice's confirmed transaction on Symbol, then uses it to withdraw on Ethereum.
  • Line 89: Bob's Ethereum withdrawal is confirmed. Bob has received Alice's 0.01 ETH, completing the swap.

You can verify the transactions on each network's block explorer using the hashes printed in the output:

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Generate a proof and hashlock LockHashAlgorithm
Lock ETH on Ethereum Ethereum HTLC contract
Create a secret lock on Symbol SecretLockTransactionV1
Reveal the proof on Symbol SecretProofTransactionV1
Withdraw ETH on Ethereum Ethereum HTLC contract

Next Steps⚓︎

This tutorial is a simplified example. Before using cross-chain swaps in production, review the Safety Considerations in the textbook.