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.
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.
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.
importhashlibimportjsonimportosimporttimeimporturllib.requestfromsymbolchain.CryptoTypesimportHash256,PrivateKeyfromsymbolchain.facade.SymbolFacadeimportSymbolFacadefromsymbolchain.symbol.IdGeneratorimportgenerate_mosaic_alias_idfromsymbolchain.symbol.NetworkimportNetworkTimestampfromsymbolchain.scimportAmountfromweb3importWeb3SYMBOL_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 SepoliaHTLC_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 timedefget_network_time():time_path='/node/time'print(f'Fetching current network time from {time_path}')withurllib.request.urlopen(f'{SYMBOL_NODE_URL}{time_path}')asresponse:response_json=json.loads(response.read().decode())timestamp=NetworkTimestamp(int(response_json['communicationTimestamps']['receiveTimestamp']))print(f' Network time: {timestamp.timestamp}'' ms since nemesis')returntimestamp# Helper function to fetch recommended Symbol fee multiplierdefget_fee_multiplier():fee_path='/network/fees/transaction'print(f'Fetching recommended fees from {fee_path}')withurllib.request.urlopen(f'{SYMBOL_NODE_URL}{fee_path}')asresponse: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}')returnfee_mult# Helper function to announce a Symbol transactiondefannounce_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')withurllib.request.urlopen(request)asresponse:print(f' Response: {response.read().decode()}')# Helper function to wait for Symbol transaction statusdefwait_for_status(hash_value,expected_status,label):print(f'Waiting for {label} to reach {expected_status} status...')attempts=0max_attempts=60whileattempts<max_attempts:try:url=(f'{SYMBOL_NODE_URL}/transactionStatus/{hash_value}')withurllib.request.urlopen(url)asresponse:status=json.loads(response.read().decode())print(f' Transaction status: {status["group"]}')ifstatus['group']=='failed':raiseException(f'{label} failed: {status["code"]}')ifstatus['group']==expected_status:print(f'{label}{expected_status}'f' in {attempts} seconds')returnexcepturllib.error.HTTPErrorase:ife.code!=404:raiseprint(' Transaction status: not yet available')attempts+=1time.sleep(1)raiseException(f'{label} not {expected_status} after {max_attempts} attempts')# Poll Symbol for a confirmed secret proof transaction matching# a hashlock.defwait_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=0max_attempts=60whileattempts<max_attempts:withurllib.request.urlopen(url)asresponse:data=json.loads(response.read().decode())fortxindata.get('data',[]):secret=tx['transaction'].get('secret','')ifsecret.upper()==hashlock_hex:print(f' Found proof transaction after {attempts}s')returnbytes.fromhex(tx['transaction']['proof'])attempts+=1time.sleep(1)raiseException(f'Secret proof not found after {max_attempts} attempts')# Symbol accountsfacade=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 accountsw3=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*60print(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 eventcontract_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 hashlockcontract_info=htlc.functions.getContract(contract_id).call()hashlock=contract_info[3]# hashlock fieldprint(f'Hashlock from chain: {hashlock.hex()}')lock_duration=5760# ~48h at 30s blocksprint(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 announcelock_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 announceproof_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 ---')excepturllib.error.URLErrorase:print(e.reason)exceptExceptionase:print(e)
import{PrivateKey}from'symbol-sdk';import{SymbolFacade,NetworkTimestamp,models,generateMosaicAliasId}from'symbol-sdk/symbol';import{createHash,randomBytes}from'crypto';import{ethers}from'ethers';constSYMBOL_NODE_URL=process.env.SYMBOL_NODE_URL||'https://reference.symboltest.net:3001';console.log('Using Symbol node',SYMBOL_NODE_URL);constETH_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 SepoliaconstHTLC_ADDRESS='0xd58e030bd21c7788897aE5Ea845DaBA936e91D2B';constHTLC_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 timeasyncfunctiongetNetworkTime(){consttimePath='/node/time';console.log('Fetching current network time from',timePath);consttimeResponse=awaitfetch(`${SYMBOL_NODE_URL}${timePath}`);consttimeJSON=awaittimeResponse.json();consttimestamp=newNetworkTimestamp(timeJSON.communicationTimestamps.receiveTimestamp);console.log(' Network time:',timestamp.timestamp,'ms since nemesis');returntimestamp;}// Helper function to fetch recommended Symbol fee multiplierasyncfunctiongetFeeMultiplier(){constfeePath='/network/fees/transaction';console.log('Fetching recommended fees from',feePath);constfeeResponse=awaitfetch(`${SYMBOL_NODE_URL}${feePath}`);constfeeJSON=awaitfeeResponse.json();constmedianMult=feeJSON.medianFeeMultiplier;constminimumMult=feeJSON.minFeeMultiplier;constfeeMult=Math.max(medianMult,minimumMult);console.log(' Fee multiplier:',feeMult);returnfeeMult;}// Helper function to announce a Symbol transactionasyncfunctionannounceTransaction(payload,endpoint,label){console.log(`Announcing ${label} to ${endpoint}`);constresponse=awaitfetch(`${SYMBOL_NODE_URL}${endpoint}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:payload});console.log(' Response:',awaitresponse.text());}// Helper function to wait for Symbol transaction statusasyncfunctionwaitForStatus(hash,expectedStatus,label){console.log(`Waiting for ${label} to reach ${expectedStatus} status...`);letattempts=0;constmaxAttempts=60;while(attempts<maxAttempts){try{consturl=`${SYMBOL_NODE_URL}/transactionStatus/${hash}`;constresponse=awaitfetch(url);if(!response.ok){consterror=newError(`HTTP ${response.status}: ${response.statusText}`);error.status=response.status;throwerror;}conststatus=awaitresponse.json();console.log(' Transaction status:',status.group);if(status.group==='failed'){thrownewError(`${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{throwerror;}}attempts++;awaitnewPromise(resolve=>setTimeout(resolve,1000));}thrownewError(`${label} not ${expectedStatus} after ${maxAttempts} attempts`);}// Poll Symbol for a confirmed secret proof transaction matching// a hashlock.asyncfunctionwaitForSecretProof(signerAddress,hashlock){consthashlockHex=hashlock.toUpperCase();consturl=`${SYMBOL_NODE_URL}/transactions/confirmed`+`?address=${signerAddress}&type=16978&order=desc`;console.log(`Polling ${url}`);console.log(` Looking for secret: ${hashlockHex}`);letattempts=0;constmaxAttempts=60;while(attempts<maxAttempts){constresponse=awaitfetch(url);constdata=awaitresponse.json();for(consttxofdata.data||[]){constsecret=(tx.transaction.secret||'').toUpperCase();if(secret===hashlockHex){console.log(` Found proof transaction after ${attempts}s`);returnBuffer.from(tx.transaction.proof,'hex');}}attempts++;awaitnewPromise(resolve=>setTimeout(resolve,1000));}thrownewError(`Secret proof not found after ${maxAttempts} attempts`);}// Symbol accountsconstfacade=newSymbolFacade('testnet');// Alice (creates the ETH lock, claims XYM on Symbol)constALICE_XYM_PRIVATE_KEY=process.env.ALICE_XYM_PRIVATE_KEY||'0000000000000000000000000000000000000000000000000000000000000000';constaliceXymKeyPair=newSymbolFacade.KeyPair(newPrivateKey(ALICE_XYM_PRIVATE_KEY));constaliceXymAddress=facade.network.publicKeyToAddress(aliceXymKeyPair.publicKey);console.log('Alice Symbol address:',aliceXymAddress.toString());// Bob (creates the XYM lock, claims ETH on Ethereum)constBOB_XYM_PRIVATE_KEY=process.env.BOB_XYM_PRIVATE_KEY||'1111111111111111111111111111111111111111111111111111111111111111';constbobXymKeyPair=newSymbolFacade.KeyPair(newPrivateKey(BOB_XYM_PRIVATE_KEY));constbobXymAddress=facade.network.publicKeyToAddress(bobXymKeyPair.publicKey);console.log('Bob Symbol address:',bobXymAddress.toString());// Ethereum accountsconstethProvider=newethers.JsonRpcProvider(ETH_RPC_URL);constALICE_ETH_PRIVATE_KEY=process.env.ALICE_ETH_PRIVATE_KEY||'0xa73276699ba72dc7b5c9d08deaf8cd88eec33c866341b120304432b89586d45d';constaliceEthWallet=newethers.Wallet(ALICE_ETH_PRIVATE_KEY,ethProvider);console.log('Alice ETH address:',aliceEthWallet.address);constBOB_ETH_PRIVATE_KEY=process.env.BOB_ETH_PRIVATE_KEY||'0x8e85561005f27d926af79a7ce3e76e75108a09ff2ab78bf65b5578d2e5d509bf';constbobEthWallet=newethers.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 ---');constproof=randomBytes(32);console.log('Proof (hex):',proof.toString('hex'));constfirstHash=createHash('sha256').update(proof).digest();constsecret=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 ---');consthtlcAsAlice=newethers.Contract(HTLC_ADDRESS,HTLC_ABI,aliceEthWallet);consttimelock=Math.floor(Date.now()/1000)+72*60*60;console.log('Ethereum timelock (Unix):',timelock);constlockTx=awaithtlcAsAlice.newContract(bobEthWallet.address,'0x'+secret.toString('hex'),timelock,{value:ethers.parseEther('0.01')});console.log('Lock TX hash:',lockTx.hash);constlockReceipt=awaitlockTx.wait();console.log('Lock confirmed in block',lockReceipt.blockNumber);// Extract the contractId from the LogHTLCNew eventconstcontractId=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 hashlockconsthtlcAsBob=newethers.Contract(HTLC_ADDRESS,HTLC_ABI,bobEthWallet);constcontractInfo=awaithtlcAsBob.getContract(contractId);consthashlock=contractInfo.hashlock.slice(2);// strip 0x prefixconsole.log('Hashlock from chain:',hashlock);constlockDuration=5760n;// ~48h at 30s blocksconsole.log('Lock duration:',lockDuration.toString(),'blocks');constsecretLockTransaction=facade.transactionFactory.create({type:'secret_lock_transaction_v1',signerPublicKey:bobXymKeyPair.publicKey.toString(),deadline:(awaitgetNetworkTime()).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=newmodels.Amount((awaitgetFeeMultiplier())*secretLockTransaction.size);// Sign and announceconstlockSignature=facade.signTransaction(bobXymKeyPair,secretLockTransaction);constlockPayload=facade.transactionFactory.static.attachSignature(secretLockTransaction,lockSignature);console.log('Built secret lock transaction:');console.dir(secretLockTransaction.toJson(),{colors:true});constlockHash=facade.hashTransaction(secretLockTransaction).toString();console.log('Secret lock transaction hash:',lockHash);awaitannounceTransaction(lockPayload,'/transactions','secret lock');awaitwaitForStatus(lockHash,'confirmed','Secret lock');// --- Step 3. Alice: Claim XYM on Symbol ---console.log('\n--- Step 3. Alice: Claim XYM on Symbol ---');constsecretProofTransaction=facade.transactionFactory.create({type:'secret_proof_transaction_v1',signerPublicKey:aliceXymKeyPair.publicKey.toString(),deadline:(awaitgetNetworkTime()).addHours(2).timestamp,recipientAddress:aliceXymAddress.toString(),secret:hashlock,hashAlgorithm:'hash_256',proof:proof});secretProofTransaction.fee=newmodels.Amount((awaitgetFeeMultiplier())*secretProofTransaction.size);// Sign and announceconstproofSignature=facade.signTransaction(aliceXymKeyPair,secretProofTransaction);constproofPayload=facade.transactionFactory.static.attachSignature(secretProofTransaction,proofSignature);console.log('Built secret proof transaction:');console.dir(secretProofTransaction.toJson(),{colors:true});constproofHash=facade.hashTransaction(secretProofTransaction).toString();console.log('Secret proof transaction hash:',proofHash);awaitannounceTransaction(proofPayload,'/transactions','secret proof');awaitwaitForStatus(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.constrevealedProof=awaitwaitForSecretProof(aliceXymAddress.toString(),hashlock);console.log('Proof from chain:',revealedProof.toString('hex'));constwithdrawTx=awaithtlcAsBob.withdraw(contractId,revealedProof);console.log('Withdraw TX hash:',withdrawTx.hash);constwithdrawReceipt=awaitwithdrawTx.wait();console.log('Withdraw confirmed in block',withdrawReceipt.blockNumber);console.log('\n--- Cross-chain swap complete ---');}catch(e){console.error(e.message);}
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.
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:
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.
Alice claims XYM on Symbol by revealing the proof through a SecretProofTransactionV1, making the proof
public on Symbol.
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.
# Symbol accountsfacade=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 accountsw3=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 accountsconstfacade=newSymbolFacade('testnet');// Alice (creates the ETH lock, claims XYM on Symbol)constALICE_XYM_PRIVATE_KEY=process.env.ALICE_XYM_PRIVATE_KEY||'0000000000000000000000000000000000000000000000000000000000000000';constaliceXymKeyPair=newSymbolFacade.KeyPair(newPrivateKey(ALICE_XYM_PRIVATE_KEY));constaliceXymAddress=facade.network.publicKeyToAddress(aliceXymKeyPair.publicKey);console.log('Alice Symbol address:',aliceXymAddress.toString());// Bob (creates the XYM lock, claims ETH on Ethereum)constBOB_XYM_PRIVATE_KEY=process.env.BOB_XYM_PRIVATE_KEY||'1111111111111111111111111111111111111111111111111111111111111111';constbobXymKeyPair=newSymbolFacade.KeyPair(newPrivateKey(BOB_XYM_PRIVATE_KEY));constbobXymAddress=facade.network.publicKeyToAddress(bobXymKeyPair.publicKey);console.log('Bob Symbol address:',bobXymAddress.toString());// Ethereum accountsconstethProvider=newethers.JsonRpcProvider(ETH_RPC_URL);constALICE_ETH_PRIVATE_KEY=process.env.ALICE_ETH_PRIVATE_KEY||'0xa73276699ba72dc7b5c9d08deaf8cd88eec33c866341b120304432b89586d45d';constaliceEthWallet=newethers.Wallet(ALICE_ETH_PRIVATE_KEY,ethProvider);console.log('Alice ETH address:',aliceEthWallet.address);constBOB_ETH_PRIVATE_KEY=process.env.BOB_ETH_PRIVATE_KEY||'0x8e85561005f27d926af79a7ce3e76e75108a09ff2ab78bf65b5578d2e5d509bf';constbobEthWallet=newethers.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.
console.log('\n--- Alice: Generate proof and hashlock ---');constproof=randomBytes(32);console.log('Proof (hex):',proof.toString('hex'));constfirstHash=createHash('sha256').update(proof).digest();constsecret=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.
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*60print(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 eventcontract_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 ---');consthtlcAsAlice=newethers.Contract(HTLC_ADDRESS,HTLC_ABI,aliceEthWallet);consttimelock=Math.floor(Date.now()/1000)+72*60*60;console.log('Ethereum timelock (Unix):',timelock);constlockTx=awaithtlcAsAlice.newContract(bobEthWallet.address,'0x'+secret.toString('hex'),timelock,{value:ethers.parseEther('0.01')});console.log('Lock TX hash:',lockTx.hash);constlockReceipt=awaitlockTx.wait();console.log('Lock confirmed in block',lockReceipt.blockNumber);// Extract the contractId from the LogHTLCNew eventconstcontractId=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 hashlockcontract_info=htlc.functions.getContract(contract_id).call()hashlock=contract_info[3]# hashlock fieldprint(f'Hashlock from chain: {hashlock.hex()}')lock_duration=5760# ~48h at 30s blocksprint(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 announcelock_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 hashlockconsthtlcAsBob=newethers.Contract(HTLC_ADDRESS,HTLC_ABI,bobEthWallet);constcontractInfo=awaithtlcAsBob.getContract(contractId);consthashlock=contractInfo.hashlock.slice(2);// strip 0x prefixconsole.log('Hashlock from chain:',hashlock);constlockDuration=5760n;// ~48h at 30s blocksconsole.log('Lock duration:',lockDuration.toString(),'blocks');constsecretLockTransaction=facade.transactionFactory.create({type:'secret_lock_transaction_v1',signerPublicKey:bobXymKeyPair.publicKey.toString(),deadline:(awaitgetNetworkTime()).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=newmodels.Amount((awaitgetFeeMultiplier())*secretLockTransaction.size);// Sign and announceconstlockSignature=facade.signTransaction(bobXymKeyPair,secretLockTransaction);constlockPayload=facade.transactionFactory.static.attachSignature(secretLockTransaction,lockSignature);console.log('Built secret lock transaction:');console.dir(secretLockTransaction.toJson(),{colors:true});constlockHash=facade.hashTransaction(secretLockTransaction).toString();console.log('Secret lock transaction hash:',lockHash);awaitannounceTransaction(lockPayload,'/transactions','secret lock');awaitwaitForStatus(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.
console.log('\n--- Step 3. Alice: Claim XYM on Symbol ---');constsecretProofTransaction=facade.transactionFactory.create({type:'secret_proof_transaction_v1',signerPublicKey:aliceXymKeyPair.publicKey.toString(),deadline:(awaitgetNetworkTime()).addHours(2).timestamp,recipientAddress:aliceXymAddress.toString(),secret:hashlock,hashAlgorithm:'hash_256',proof:proof});secretProofTransaction.fee=newmodels.Amount((awaitgetFeeMultiplier())*secretProofTransaction.size);// Sign and announceconstproofSignature=facade.signTransaction(aliceXymKeyPair,secretProofTransaction);constproofPayload=facade.transactionFactory.static.attachSignature(secretProofTransaction,proofSignature);console.log('Built secret proof transaction:');console.dir(secretProofTransaction.toJson(),{colors:true});constproofHash=facade.hashTransaction(secretProofTransaction).toString();console.log('Secret proof transaction hash:',proofHash);awaitannounceTransaction(proofPayload,'/transactions','secret proof');awaitwaitForStatus(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.
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.
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.constrevealedProof=awaitwaitForSecretProof(aliceXymAddress.toString(),hashlock);console.log('Proof from chain:',revealedProof.toString('hex'));constwithdrawTx=awaithtlcAsBob.withdraw(contractId,revealedProof);console.log('Withdraw TX hash:',withdrawTx.hash);constwithdrawReceipt=awaitwithdrawTx.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/confirmedGET 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.