Skip to content

Cross-Chain Swap Between Symbol and Ethereum⚓︎

ADVANCED

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
    

    org.web3j:core is installed automatically by jbang from code annotations.

Full Code⚓︎

The following is the complete code listing for this tutorial. A detailed, step-by-step explanation follows in the next section.

import hashlib
import json
import os
import secrets
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 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 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_multiplier = response_json['medianFeeMultiplier']
        minimum_multiplier = response_json['minFeeMultiplier']
        fee_multiplier = max(median_multiplier, minimum_multiplier)
        print(f'  Fee multiplier: {fee_multiplier}')
        return fee_multiplier


# 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 confirmation
def wait_for_confirmation(hash_value, label):
    print(f'Waiting for {label} confirmation...')
    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 RuntimeError(
                        f'{label} failed: {status["code"]}')

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

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

        attempts += 1
        time.sleep(1)

    raise TimeoutError(
        f'{label} not confirmed after {max_attempts} attempts')


# Poll Symbol for a confirmed secret proof transaction matching
# a hashlock.
def wait_for_secret_proof(signer_address, hlock):
    hashlock_hex = hlock.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 trans in data.get('data', []):
            found_secret = trans['transaction'].get('secret', '')
            if found_secret.upper() == hashlock_hex:
                print(f'  Found proof transaction after {attempts}s')
                return bytes.fromhex(trans['transaction']['proof'])
        attempts += 1
        time.sleep(1)

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


# Symbol accounts
facade = SymbolFacade('testnet')

# Alice (creates the ETH lock, claims XYM on Symbol)
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 (creates the XYM lock, claims ETH on Ethereum)
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 = secrets.token_bytes(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.create_transaction_from_descriptor(
        {
            'type': 'secret_lock_transaction_v1',
            'recipient_address': alice_xym_address,
            'secret': Hash256(hashlock),
            'mosaic': {
                'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
                'amount': 1_000_000  # 1 XYM
            },
            'duration': lock_duration,
            'hash_algorithm': 'hash_256'
        },
        bob_xym_key_pair.public_key,
        get_fee_multiplier(),
        2 * 60 * 60)

    # 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_confirmation(lock_hash, 'Secret lock')

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

    secret_proof_transaction = facade.create_transaction_from_descriptor(
        {
            'type': 'secret_proof_transaction_v1',
            'recipient_address': alice_xym_address,
            'secret': Hash256(hashlock),
            'hash_algorithm': 'hash_256',
            'proof': proof
        },
        alice_xym_key_pair.public_key,
        get_fee_multiplier(),
        2 * 60 * 60)

    # 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_confirmation(proof_hash, '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

// eslint-disable-next-line import/no-unresolved
import { ethers } from 'ethers';
import { Hash256, PrivateKey } from 'symbol-sdk';
import {
    SymbolFacade,
    descriptors,
    generateMosaicAliasId,
    models
} from 'symbol-sdk/symbol';
import { createHash, randomBytes } from 'crypto';

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 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 medianMultiplier = feeJSON.medianFeeMultiplier;
    const minimumMultiplier = feeJSON.minFeeMultiplier;
    const feeMultiplier = Math.max(medianMultiplier, minimumMultiplier);
    console.log('  Fee multiplier:', feeMultiplier);
    return feeMultiplier;
}

// 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 confirmation
async function waitForConfirmation(hash, label) {
    console.log(`Waiting for ${label} confirmation...`);
    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 ('failed' === status.group)
                throw new Error(`${label} failed: ${status.code}`);

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

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

    throw new Error(
        `${label} not confirmed 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.createTransactionFromTypedDescriptor(
            new descriptors.SecretLockTransactionV1Descriptor(
                aliceXymAddress,
                new Hash256(hashlock),
                new descriptors.UnresolvedMosaicDescriptor(
                    generateMosaicAliasId('symbol.xym'),
                    new models.Amount(1_000_000n) // 1 XYM
                ),
                new models.BlockDuration(lockDuration),
                models.LockHashAlgorithm.HASH_256
            ),
            bobXymKeyPair.publicKey,
            await getFeeMultiplier(),
            2 * 60 * 60);

    // 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 waitForConfirmation(lockHash, 'Secret lock');

    // --- Step 3. Alice: Claim XYM on Symbol ---

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

    const secretProofTransaction =
        facade.createTransactionFromTypedDescriptor(
            new descriptors.SecretProofTransactionV1Descriptor(
                aliceXymAddress,
                new Hash256(hashlock),
                models.LockHashAlgorithm.HASH_256,
                proof
            ),
            aliceXymKeyPair.publicKey,
            await getFeeMultiplier(),
            2 * 60 * 60);

    // 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 waitForConfirmation(proofHash, '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

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
//JAVA 21+
//DEPS org.web3j:core:4.12.3
//DEPS org.symbol:symbol-sdk:3.3.1

import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.web3j.abi.FunctionEncoder;
import org.web3j.abi.FunctionReturnDecoder;
import org.web3j.abi.TypeReference;
import org.web3j.abi.datatypes.Bool;
import org.web3j.abi.datatypes.DynamicBytes;
import org.web3j.abi.datatypes.Function;
import org.web3j.abi.datatypes.generated.Bytes32;
import org.web3j.abi.datatypes.generated.Uint256;
import org.web3j.crypto.Credentials;
import org.web3j.protocol.Web3j;
import org.web3j.protocol.core.DefaultBlockParameterName;
import org.web3j.protocol.core.methods.response.EthCall;
import org.web3j.protocol.core.methods.response.TransactionReceipt;
import org.web3j.protocol.http.HttpService;
import org.web3j.tx.RawTransactionManager;
import org.web3j.tx.response.PollingTransactionReceiptProcessor;
import org.web3j.utils.Convert;
import org.web3j.utils.Numeric;

import org.symbol.sdk.CryptoTypes;
import org.symbol.sdk.facade.SymbolFacade;
import org.symbol.sdk.symbol.Address;
import org.symbol.sdk.symbol.IdGenerator;
import org.symbol.sdk.symbol.KeyPair;
import org.symbol.sdk.symbol.SymbolTransactionFactory;
import org.symbol.sdk.symbol.descriptors.*;
import org.symbol.sdk.symbol.models.*;
import org.symbol.sdk.utils.Converter;

public final class CrossChainSwap {
    private static final ObjectMapper JSON_MAPPER = new ObjectMapper();

    private static final HttpClient HTTP_CLIENT =
        HttpClient.newHttpClient();

    private static final SecureRandom RANDOM = new SecureRandom();

    private final String symbolNodeUrl = System.getenv().getOrDefault(
        "SYMBOL_NODE_URL", "https://reference.symboltest.net:3001");

    private final String ethRpcUrl = System.getenv().getOrDefault(
        "ETH_RPC_URL", "https://ethereum-sepolia-rpc.publicnode.com");

    // Ethereum HTLC contract on Sepolia
    private static final String HTLC_ADDRESS =
        "0xd58e030bd21c7788897aE5Ea845DaBA936e91D2B";

    private final SymbolFacade facade = new SymbolFacade("testnet");

    private Web3j ethProvider;

    private Credentials aliceEthWallet;

    private Credentials bobEthWallet;

    private Address aliceXymAddress;

    private Address bobXymAddress;

    private KeyPair aliceXymKeyPair;

    private KeyPair bobXymKeyPair;

    public static void main(final String[] args) {
        try {
            new CrossChainSwap().run();
        } catch (final Exception ex) {
            System.out.println(null == ex.getMessage()
                ? ex.toString()
                : ex.getMessage());
        }
    }

    private void run()
        throws Exception {
        System.out.printf("Using Symbol node %s%n", symbolNodeUrl);
        System.out.printf("Using Ethereum RPC %s%n", ethRpcUrl);

        // Symbol accounts
        // Alice (creates the ETH lock, claims XYM on Symbol)
        final String aliceXymPrivateKey = System.getenv().getOrDefault(
            "ALICE_XYM_PRIVATE_KEY", "0".repeat(64));
        aliceXymKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(aliceXymPrivateKey));
        aliceXymAddress = facade.network.publicKeyToAddress(
            aliceXymKeyPair.getPublicKey());
        System.out.printf("Alice Symbol address: %s%n", aliceXymAddress);

        // Bob (creates the XYM lock, claims ETH on Ethereum)
        final String bobXymPrivateKey = System.getenv().getOrDefault(
            "BOB_XYM_PRIVATE_KEY", "1".repeat(64));
        bobXymKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(bobXymPrivateKey));
        bobXymAddress = facade.network.publicKeyToAddress(
            bobXymKeyPair.getPublicKey());
        System.out.printf("Bob Symbol address: %s%n", bobXymAddress);

        // Ethereum accounts
        ethProvider = Web3j.build(new HttpService(ethRpcUrl));

        final String aliceEthPrivateKey = System.getenv().getOrDefault(
            "ALICE_ETH_PRIVATE_KEY",
            "a73276699ba72dc7b5c9d08deaf8cd88eec33c866341b12030443"
                + "2b89586d45d");
        aliceEthWallet = Credentials.create(aliceEthPrivateKey);
        System.out.printf("Alice ETH address: %s%n",
            aliceEthWallet.getAddress());

        final String bobEthPrivateKey = System.getenv().getOrDefault(
            "BOB_ETH_PRIVATE_KEY",
            "8e85561005f27d926af79a7ce3e76e75108a09ff2ab78bf65b557"
                + "8d2e5d509bf");
        bobEthWallet = Credentials.create(bobEthPrivateKey);
        System.out.printf("Bob ETH address: %s%n",
            bobEthWallet.getAddress());


        // --- Alice: Generate proof and hashlock ---
        System.out.println(
            "\n--- Alice: Generate proof and hashlock ---");

        final byte[] proof = new byte[32];
        RANDOM.nextBytes(proof);
        System.out.printf("Proof (hex): %s%n",
            HexFormat.of().formatHex(proof));

        final byte[] firstHash = sha256(proof);
        final byte[] secret = sha256(firstHash);
        System.out.printf("Secret (double SHA-256): %s%n",
            HexFormat.of().formatHex(secret));

        // --- Step 1. Alice: Lock ETH on Ethereum ---
        System.out.println(
            "\n--- Step 1. Alice: Lock ETH on Ethereum ---");

        final long timelock =
            System.currentTimeMillis() / 1000 + (72 * 60 * 60);
        System.out.printf("Ethereum timelock (Unix): %d%n", timelock);

        final String lockHash = sendNewContract(
            bobEthWallet.getAddress(),
            secret,
            BigInteger.valueOf(timelock));
        System.out.printf("Lock TX hash: %s%n", lockHash);

        final TransactionReceipt lockReceipt = waitForEthereumReceipt(
            lockHash);
        System.out.printf("Lock confirmed in block %s%n",
            lockReceipt.getBlockNumber());

        // Extract the contractId from the LogHTLCNew event
        final String contractId = lockReceipt.getLogs().get(0)
            .getTopics().get(1);
        System.out.printf("HTLC contract ID: %s%n", contractId);

        // --- Step 2. Bob: Create secret lock on Symbol ---
        System.out.println(
            "\n--- Step 2. Bob: Create secret lock on Symbol ---");

        // Bob queries the Ethereum contract to get the hashlock
        final String hashlock = getContractHashlock(contractId);
        System.out.printf("Hashlock from chain: %s%n", hashlock);

        final long lockDuration = 5760; // ~48h at 30s blocks
        System.out.printf("Lock duration: %d blocks%n", lockDuration);

        final Transaction secretLockTransaction =
            facade.createTransactionFromTypedDescriptor(
                new SecretLockTransactionV1Descriptor(
                    aliceXymAddress,
                    new CryptoTypes.Hash256(hashlock),
                    new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(
                            IdGenerator.generateMosaicAliasId(
                                "symbol.xym")),
                        new Amount(1_000_000)), // 1 XYM
                    new BlockDuration(lockDuration),
                    LockHashAlgorithm.HASH_256),
                bobXymKeyPair.getPublicKey(),
                getFeeMultiplier(),
                2 * 60 * 60);

        // Sign and announce
        final CryptoTypes.Signature lockSignature =
            facade.signTransaction(bobXymKeyPair, secretLockTransaction);
        final String lockPayload = SymbolTransactionFactory
            .attachSignature(secretLockTransaction, lockSignature);

        System.out.println("Built secret lock transaction:");
        System.out.println(JSON_MAPPER.writerWithDefaultPrettyPrinter()
            .writeValueAsString(secretLockTransaction.toJson()));

        final String symbolLockHash = facade.hashTransaction(
            secretLockTransaction).toString();
        System.out.printf("Secret lock transaction hash: %s%n",
            symbolLockHash);
        announceTransaction(lockPayload, "/transactions", "secret lock");
        waitForConfirmation(symbolLockHash, "Secret lock");

        // --- Step 3. Alice: Claim XYM on Symbol ---
        System.out.println(
            "\n--- Step 3. Alice: Claim XYM on Symbol ---");

        final Transaction secretProofTransaction =
            facade.createTransactionFromTypedDescriptor(
                new SecretProofTransactionV1Descriptor(
                    aliceXymAddress,
                    new CryptoTypes.Hash256(hashlock),
                    LockHashAlgorithm.HASH_256,
                    proof),
                aliceXymKeyPair.getPublicKey(),
                getFeeMultiplier(),
                2 * 60 * 60);

        // Sign and announce
        final CryptoTypes.Signature proofSignature =
            facade.signTransaction(
                aliceXymKeyPair, secretProofTransaction);
        final String proofPayload = SymbolTransactionFactory
            .attachSignature(secretProofTransaction, proofSignature);

        System.out.println("Built secret proof transaction:");
        System.out.println(JSON_MAPPER.writerWithDefaultPrettyPrinter()
            .writeValueAsString(secretProofTransaction.toJson()));

        final String proofHash = facade.hashTransaction(
            secretProofTransaction).toString();
        System.out.printf("Secret proof transaction hash: %s%n",
            proofHash);
        announceTransaction(proofPayload, "/transactions", "secret proof");
        waitForConfirmation(proofHash, "Secret proof");

        // --- Step 4. Bob: Withdraw ETH on Ethereum ---
        System.out.println(
            "\n--- Step 4. Bob: Withdraw ETH on Ethereum ---");

        // Bob waits for Alice to reveal the proof on Symbol.
        final byte[] revealedProof = waitForSecretProof(
            aliceXymAddress.toString(), hashlock);
        System.out.printf("Proof from chain: %s%n",
            HexFormat.of().formatHex(revealedProof));

        final String withdrawHash = sendWithdraw(
            contractId, revealedProof);
        System.out.printf("Withdraw TX hash: %s%n", withdrawHash);

        final TransactionReceipt withdrawReceipt = waitForEthereumReceipt(
            withdrawHash);
        System.out.printf("Withdraw confirmed in block %s%n",
            withdrawReceipt.getBlockNumber());

        System.out.println("\n--- Cross-chain swap complete ---");
    }

    // Helper function to fetch recommended Symbol fee multiplier
    private long getFeeMultiplier()
        throws IOException, InterruptedException {
        final String feePath = "/network/fees/transaction";
        System.out.printf("Fetching recommended fees from %s%n", feePath);
        final HttpRequest request = HttpRequest.newBuilder(
            URI.create(symbolNodeUrl + feePath)).GET().build();
        final HttpResponse<String> response = HTTP_CLIENT.send(
            request, BodyHandlers.ofString());
        final JsonNode feeJSON = JSON_MAPPER.readTree(response.body());
        final long medianMultiplier =
            feeJSON.get("medianFeeMultiplier").asLong();
        final long minimumMultiplier =
            feeJSON.get("minFeeMultiplier").asLong();
        final long feeMultiplier = Math.max(
            medianMultiplier, minimumMultiplier);
        System.out.printf("  Fee multiplier: %d%n", feeMultiplier);
        return feeMultiplier;
    }

    // Helper function to announce a Symbol transaction
    private void announceTransaction(
        final String payload,
        final String endpoint,
        final String label
    ) throws IOException, InterruptedException {
        System.out.printf("Announcing %s to %s%n", label, endpoint);
        final HttpRequest request = HttpRequest.newBuilder(
            URI.create(symbolNodeUrl + endpoint))
            .header("Content-Type", "application/json")
            .PUT(HttpRequest.BodyPublishers.ofString(payload))
            .build();
        final HttpResponse<String> response = HTTP_CLIENT.send(
            request, BodyHandlers.ofString());
        System.out.printf("  Response: %s%n", response.body());
    }

    // Helper function to wait for Symbol transaction confirmation
    private void waitForConfirmation(
        final String hash,
        final String label
    ) throws IOException, InterruptedException {
        System.out.printf("Waiting for %s confirmation...%n", label);
        int attempts = 0;
        final int maxAttempts = 60;

        while (attempts < maxAttempts) {
            final String url = symbolNodeUrl + "/transactionStatus/"
                + hash;
            final HttpRequest request = HttpRequest.newBuilder(
                URI.create(url)).GET().build();
            final HttpResponse<String> response = HTTP_CLIENT.send(
                request, BodyHandlers.ofString());

            if (response.statusCode() / 100 != 2) {
                if (404 == response.statusCode()) {
                    System.out.println(
                        "  Transaction status: not yet available");
                } else
                    throw new IOException(
                        "HTTP " + response.statusCode());
            } else {
                final JsonNode status = JSON_MAPPER.readTree(
                    response.body());
                System.out.printf("  Transaction status: %s%n",
                    status.get("group").asText());

                if ("failed".equals(status.get("group").asText()))
                    throw new IOException(String.format("%s failed: %s",
                        label, status.get("code").asText()));

                if ("confirmed".equals(status.get("group").asText())) {
                    System.out.printf("%s confirmed in %d seconds%n",
                        label, attempts);
                    return;
                }
            }

            ++attempts;
            Thread.sleep(1000);
        }

        throw new IOException(String.format(
            "%s not confirmed after %d attempts", label, maxAttempts));
    }

    // Poll Symbol for a confirmed secret proof transaction matching
    // a hashlock.
    private byte[] waitForSecretProof(
        final String signerAddress,
        final String hashlock
    ) throws IOException, InterruptedException {
        final String hashlockHex = hashlock.toUpperCase();
        final String url = symbolNodeUrl + "/transactions/confirmed"
            + "?address=" + signerAddress + "&type=16978&order=desc";
        System.out.printf("Polling %s%n", url);
        System.out.printf("  Looking for secret: %s%n", hashlockHex);

        int attempts = 0;
        final int maxAttempts = 60;
        while (attempts < maxAttempts) {
            final HttpRequest request = HttpRequest.newBuilder(
                URI.create(url)).GET().build();
            final HttpResponse<String> response = HTTP_CLIENT.send(
                request, BodyHandlers.ofString());
            final JsonNode data = JSON_MAPPER.readTree(response.body());
            for (final JsonNode tx : data.get("data")) {
                final String secret = tx.get("transaction")
                    .get("secret").asText("").toUpperCase();
                if (secret.equals(hashlockHex)) {
                    System.out.printf(
                        "  Found proof transaction after %ds%n",
                        attempts);
                    return Converter.hexToUint8(tx.get("transaction")
                        .get("proof").asText());
                }
            }
            ++attempts;
            Thread.sleep(1000);
        }

        throw new IOException(String.format(
            "Secret proof not found after %d attempts", maxAttempts));
    }

    private String sendNewContract(
        final String receiver,
        final byte[] hashlock,
        final BigInteger timelock
    ) throws Exception {
        final Function function = new Function(
            "newContract",
            List.of(
                new org.web3j.abi.datatypes.Address(receiver),
                new Bytes32(hashlock),
                new Uint256(timelock)),
            List.of(new TypeReference<Bytes32>() {})
        );
        final String data = FunctionEncoder.encode(function);
        final RawTransactionManager manager = new RawTransactionManager(
            ethProvider, aliceEthWallet);
        return manager.sendTransaction(
            BigInteger.valueOf(20_000_000_000L),
            BigInteger.valueOf(300_000),
            HTLC_ADDRESS,
            data,
            Convert.toWei("0.01", Convert.Unit.ETHER).toBigInteger()
        ).getTransactionHash();
    }

    private String getContractHashlock(
        final String contractId
    ) throws IOException {
        final Function function = new Function(
            "getContract",
            List.of(new Bytes32(Numeric.hexStringToByteArray(contractId))),
            Arrays.asList(
                new TypeReference<org.web3j.abi.datatypes.Address>() {},
                new TypeReference<org.web3j.abi.datatypes.Address>() {},
                new TypeReference<Uint256>() {},
                new TypeReference<Bytes32>() {},
                new TypeReference<Uint256>() {},
                new TypeReference<Bool>() {},
                new TypeReference<Bool>() {},
                new TypeReference<DynamicBytes>() {})
        );
        final String data = FunctionEncoder.encode(function);
        final EthCall response = ethProvider.ethCall(
            org.web3j.protocol.core.methods.request.Transaction
                .createEthCallTransaction(
                    bobEthWallet.getAddress(), HTLC_ADDRESS, data),
            DefaultBlockParameterName.LATEST
        ).send();
        return Numeric.toHexStringNoPrefix((byte[]) FunctionReturnDecoder
            .decode(response.getValue(), function.getOutputParameters())
            .get(3).getValue());
    }

    private String sendWithdraw(
        final String contractId,
        final byte[] revealedProof
    ) throws Exception {
        final Function function = new Function(
            "withdraw",
            List.of(
                new Bytes32(Numeric.hexStringToByteArray(contractId)),
                new DynamicBytes(revealedProof)),
            List.of(new TypeReference<Bool>() {})
        );
        final String data = FunctionEncoder.encode(function);
        final RawTransactionManager manager = new RawTransactionManager(
            ethProvider, bobEthWallet);
        return manager.sendTransaction(
            BigInteger.valueOf(20_000_000_000L),
            BigInteger.valueOf(200_000),
            HTLC_ADDRESS,
            data,
            BigInteger.ZERO
        ).getTransactionHash();
    }

    private TransactionReceipt waitForEthereumReceipt(
        final String transactionHash
    ) throws Exception {
        return new PollingTransactionReceiptProcessor(
            ethProvider, 1000, 60).waitForTransactionReceipt(
                transactionHash);
    }

    private static byte[] sha256(
        final byte[] data
        ) throws NoSuchAlgorithmException {
            return MessageDigest.getInstance("SHA-256").digest(data);
        }
    }

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 recommended 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 (creates the ETH lock, claims XYM on Symbol)
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 (creates the XYM lock, claims ETH on Ethereum)
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);
        // Symbol accounts
        // Alice (creates the ETH lock, claims XYM on Symbol)
        final String aliceXymPrivateKey = System.getenv().getOrDefault(
            "ALICE_XYM_PRIVATE_KEY", "0".repeat(64));
        aliceXymKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(aliceXymPrivateKey));
        aliceXymAddress = facade.network.publicKeyToAddress(
            aliceXymKeyPair.getPublicKey());
        System.out.printf("Alice Symbol address: %s%n", aliceXymAddress);

        // Bob (creates the XYM lock, claims ETH on Ethereum)
        final String bobXymPrivateKey = System.getenv().getOrDefault(
            "BOB_XYM_PRIVATE_KEY", "1".repeat(64));
        bobXymKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(bobXymPrivateKey));
        bobXymAddress = facade.network.publicKeyToAddress(
            bobXymKeyPair.getPublicKey());
        System.out.printf("Bob Symbol address: %s%n", bobXymAddress);

        // Ethereum accounts
        ethProvider = Web3j.build(new HttpService(ethRpcUrl));

        final String aliceEthPrivateKey = System.getenv().getOrDefault(
            "ALICE_ETH_PRIVATE_KEY",
            "a73276699ba72dc7b5c9d08deaf8cd88eec33c866341b12030443"
                + "2b89586d45d");
        aliceEthWallet = Credentials.create(aliceEthPrivateKey);
        System.out.printf("Alice ETH address: %s%n",
            aliceEthWallet.getAddress());

        final String bobEthPrivateKey = System.getenv().getOrDefault(
            "BOB_ETH_PRIVATE_KEY",
            "8e85561005f27d926af79a7ce3e76e75108a09ff2ab78bf65b557"
                + "8d2e5d509bf");
        bobEthWallet = Credentials.create(bobEthPrivateKey);
        System.out.printf("Bob ETH address: %s%n",
            bobEthWallet.getAddress());

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 = secrets.token_bytes(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'));
        System.out.println(
            "\n--- Alice: Generate proof and hashlock ---");

        final byte[] proof = new byte[32];
        RANDOM.nextBytes(proof);
        System.out.printf("Proof (hex): %s%n",
            HexFormat.of().formatHex(proof));

        final byte[] firstHash = sha256(proof);
        final byte[] secret = sha256(firstHash);
        System.out.printf("Secret (double SHA-256): %s%n",
            HexFormat.of().formatHex(secret));

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);
        System.out.println(
            "\n--- Step 1. Alice: Lock ETH on Ethereum ---");

        final long timelock =
            System.currentTimeMillis() / 1000 + (72 * 60 * 60);
        System.out.printf("Ethereum timelock (Unix): %d%n", timelock);

        final String lockHash = sendNewContract(
            bobEthWallet.getAddress(),
            secret,
            BigInteger.valueOf(timelock));
        System.out.printf("Lock TX hash: %s%n", lockHash);

        final TransactionReceipt lockReceipt = waitForEthereumReceipt(
            lockHash);
        System.out.printf("Lock confirmed in block %s%n",
            lockReceipt.getBlockNumber());

        // Extract the contractId from the LogHTLCNew event
        final String contractId = lockReceipt.getLogs().get(0)
            .getTopics().get(1);
        System.out.printf("HTLC contract ID: %s%n", 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.create_transaction_from_descriptor(
        {
            'type': 'secret_lock_transaction_v1',
            'recipient_address': alice_xym_address,
            'secret': Hash256(hashlock),
            'mosaic': {
                'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
                'amount': 1_000_000  # 1 XYM
            },
            'duration': lock_duration,
            'hash_algorithm': 'hash_256'
        },
        bob_xym_key_pair.public_key,
        get_fee_multiplier(),
        2 * 60 * 60)

    # 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_confirmation(lock_hash, '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.createTransactionFromTypedDescriptor(
            new descriptors.SecretLockTransactionV1Descriptor(
                aliceXymAddress,
                new Hash256(hashlock),
                new descriptors.UnresolvedMosaicDescriptor(
                    generateMosaicAliasId('symbol.xym'),
                    new models.Amount(1_000_000n) // 1 XYM
                ),
                new models.BlockDuration(lockDuration),
                models.LockHashAlgorithm.HASH_256
            ),
            bobXymKeyPair.publicKey,
            await getFeeMultiplier(),
            2 * 60 * 60);

    // 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 waitForConfirmation(lockHash, 'Secret lock');
        System.out.println(
            "\n--- Step 2. Bob: Create secret lock on Symbol ---");

        // Bob queries the Ethereum contract to get the hashlock
        final String hashlock = getContractHashlock(contractId);
        System.out.printf("Hashlock from chain: %s%n", hashlock);

        final long lockDuration = 5760; // ~48h at 30s blocks
        System.out.printf("Lock duration: %d blocks%n", lockDuration);

        final Transaction secretLockTransaction =
            facade.createTransactionFromTypedDescriptor(
                new SecretLockTransactionV1Descriptor(
                    aliceXymAddress,
                    new CryptoTypes.Hash256(hashlock),
                    new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(
                            IdGenerator.generateMosaicAliasId(
                                "symbol.xym")),
                        new Amount(1_000_000)), // 1 XYM
                    new BlockDuration(lockDuration),
                    LockHashAlgorithm.HASH_256),
                bobXymKeyPair.getPublicKey(),
                getFeeMultiplier(),
                2 * 60 * 60);

        // Sign and announce
        final CryptoTypes.Signature lockSignature =
            facade.signTransaction(bobXymKeyPair, secretLockTransaction);
        final String lockPayload = SymbolTransactionFactory
            .attachSignature(secretLockTransaction, lockSignature);

        System.out.println("Built secret lock transaction:");
        System.out.println(JSON_MAPPER.writerWithDefaultPrettyPrinter()
            .writeValueAsString(secretLockTransaction.toJson()));

        final String symbolLockHash = facade.hashTransaction(
            secretLockTransaction).toString();
        System.out.printf("Secret lock transaction hash: %s%n",
            symbolLockHash);
        announceTransaction(lockPayload, "/transactions", "secret lock");
        waitForConfirmation(symbolLockHash, "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.create_transaction_from_descriptor(
        {
            'type': 'secret_proof_transaction_v1',
            'recipient_address': alice_xym_address,
            'secret': Hash256(hashlock),
            'hash_algorithm': 'hash_256',
            'proof': proof
        },
        alice_xym_key_pair.public_key,
        get_fee_multiplier(),
        2 * 60 * 60)

    # 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_confirmation(proof_hash, 'Secret proof')
    console.log('\n--- Step 3. Alice: Claim XYM on Symbol ---');

    const secretProofTransaction =
        facade.createTransactionFromTypedDescriptor(
            new descriptors.SecretProofTransactionV1Descriptor(
                aliceXymAddress,
                new Hash256(hashlock),
                models.LockHashAlgorithm.HASH_256,
                proof
            ),
            aliceXymKeyPair.publicKey,
            await getFeeMultiplier(),
            2 * 60 * 60);

    // 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 waitForConfirmation(proofHash, 'Secret proof');
        System.out.println(
            "\n--- Step 3. Alice: Claim XYM on Symbol ---");

        final Transaction secretProofTransaction =
            facade.createTransactionFromTypedDescriptor(
                new SecretProofTransactionV1Descriptor(
                    aliceXymAddress,
                    new CryptoTypes.Hash256(hashlock),
                    LockHashAlgorithm.HASH_256,
                    proof),
                aliceXymKeyPair.getPublicKey(),
                getFeeMultiplier(),
                2 * 60 * 60);

        // Sign and announce
        final CryptoTypes.Signature proofSignature =
            facade.signTransaction(
                aliceXymKeyPair, secretProofTransaction);
        final String proofPayload = SymbolTransactionFactory
            .attachSignature(secretProofTransaction, proofSignature);

        System.out.println("Built secret proof transaction:");
        System.out.println(JSON_MAPPER.writerWithDefaultPrettyPrinter()
            .writeValueAsString(secretProofTransaction.toJson()));

        final String proofHash = facade.hashTransaction(
            secretProofTransaction).toString();
        System.out.printf("Secret proof transaction hash: %s%n",
            proofHash);
        announceTransaction(proofPayload, "/transactions", "secret proof");
        waitForConfirmation(proofHash, "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);
        System.out.println(
            "\n--- Step 4. Bob: Withdraw ETH on Ethereum ---");

        // Bob waits for Alice to reveal the proof on Symbol.
        final byte[] revealedProof = waitForSecretProof(
            aliceXymAddress.toString(), hashlock);
        System.out.printf("Proof from chain: %s%n",
            HexFormat.of().formatHex(revealedProof));

        final String withdrawHash = sendWithdraw(
            contractId, revealedProof);
        System.out.printf("Withdraw TX hash: %s%n", withdrawHash);

        final TransactionReceipt withdrawReceipt = waitForEthereumReceipt(
            withdrawHash);
        System.out.printf("Withdraw confirmed in block %s%n",
            withdrawReceipt.getBlockNumber());

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

The 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 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 confirmation...
  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 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 confirmation...
  Transaction status: unknown
  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 49: Bob's Symbol secret lock is confirmed. Alice can now claim the XYM.
  • Line 66: Alice includes the proof in her secret proof transaction. Once announced, it becomes public on Symbol.
  • Line 77: Alice's secret proof is confirmed. Alice receives the 1 XYM.
  • Line 83: Bob retrieves the revealed proof from Alice's confirmed transaction on Symbol, then uses it to withdraw on Ethereum.
  • Line 85: 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.