├── .gitignore ├── btc └── bitcoin.conf ├── test ├── util │ ├── index.js │ ├── common.js │ ├── eth.js │ ├── btc.js │ └── stx.js ├── 1-stx-btc.js └── 2-stx-eth.js ├── scripts ├── keccak256.js ├── sha256.js └── btc-htlc.js ├── stx ├── settings │ ├── Mocknet.toml │ └── Devnet.toml ├── contracts │ ├── traits │ │ ├── sip009-trait.clar │ │ └── sip010-trait.clar │ ├── test │ │ ├── test-sip009.clar │ │ └── test-sip010.clar │ ├── stx-htlc.clar │ └── sip009-sip010-htlc.clar ├── Clarinet.toml ├── scripts │ └── rpc.ts └── tests │ ├── common.ts │ ├── stx-htlc_test.ts │ └── sip009-sip010-htlc_test.ts ├── eth ├── contracts │ ├── test │ │ ├── TestERC20.sol │ │ └── TestERC721.sol │ ├── EthHTLC.sol │ └── Erc20Erc721HTLC.sol ├── migrations │ └── 1_deploy.js ├── test │ ├── util.js │ ├── EthHTLC.js │ └── Erc20Erc721HTLC.js └── truffle-config.js ├── LICENSE ├── package.json ├── README.md └── sequence_diagram.svg /.gitignore: -------------------------------------------------------------------------------- 1 | /eth/build 2 | /btc/regtest 3 | node_modules 4 | history.txt 5 | .DS_Store 6 | /scraps.txt 7 | -------------------------------------------------------------------------------- /btc/bitcoin.conf: -------------------------------------------------------------------------------- 1 | regtest=1 2 | server=1 3 | rpcuser=stxswap 4 | rpcpassword=stxswappassword 5 | min=1 6 | [regtest] 7 | rpcport=18332 8 | -------------------------------------------------------------------------------- /test/util/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('./stx'), 3 | ...require('./btc'), 4 | ...require('./eth'), 5 | ...require('./common') 6 | }; 7 | -------------------------------------------------------------------------------- /scripts/keccak256.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const web3 = require('web3'); 3 | const input = process.argv[2]; 4 | process.argv[2] && console.log(web3.utils.keccak256(input)); 5 | -------------------------------------------------------------------------------- /scripts/sha256.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const crypto = require('crypto'); 3 | const input = process.argv[2]; 4 | process.argv[2] && console.log('0x'+crypto.createHash('sha256').update(input).digest('hex')); 5 | -------------------------------------------------------------------------------- /stx/settings/Mocknet.toml: -------------------------------------------------------------------------------- 1 | [network] 2 | name = "mocknet" 3 | node_rpc_address = "http://localhost:20443" 4 | 5 | [accounts.deployer] 6 | mnemonic = "point approve language letter cargo rough similar wrap focus edge polar task olympic tobacco cinnamon drop lawn boring sort trade senior screen tiger climb" 7 | -------------------------------------------------------------------------------- /eth/contracts/test/TestERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.6; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract TestERC20 is ERC20("TestERC20", "TE20") 7 | { 8 | constructor() {} 9 | 10 | function mint(address to, uint256 value) public returns (bool) 11 | { 12 | _mint(to, value); 13 | return true; 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /eth/contracts/test/TestERC721.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.6; 3 | 4 | import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 5 | 6 | contract TestERC721 is ERC721("TestERC721", "TE721") 7 | { 8 | constructor() {} 9 | 10 | uint256 private last_token_id = 0; 11 | 12 | function mint(address to) public returns (bool) 13 | { 14 | _mint(to, last_token_id++); 15 | return true; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /eth/migrations/1_deploy.js: -------------------------------------------------------------------------------- 1 | const EthHTLC = artifacts.require("EthHTLC"); 2 | const Erc20Erc721HTLC = artifacts.require("Erc20Erc721HTLC"); 3 | 4 | const TestERC20 = artifacts.require("TestERC20"); 5 | const TestERC721 = artifacts.require("TestERC721"); 6 | 7 | module.exports = async function (deployer, network) 8 | { 9 | if (network === 'development') 10 | await Promise.all([deployer.deploy(TestERC20), deployer.deploy(TestERC721)]); 11 | await Promise.all([deployer.deploy(EthHTLC), deployer.deploy(Erc20Erc721HTLC)]); 12 | }; 13 | -------------------------------------------------------------------------------- /stx/contracts/traits/sip009-trait.clar: -------------------------------------------------------------------------------- 1 | (define-trait nft-trait 2 | ( 3 | ;; Last token ID, limited to uint range 4 | (get-last-token-id () (response uint uint)) 5 | 6 | ;; URI for metadata associated with the token 7 | (get-token-uri (uint) (response (optional (string-ascii 256)) uint)) 8 | 9 | ;; Owner of a given token identifier 10 | (get-owner (uint) (response (optional principal) uint)) 11 | 12 | ;; Transfer from the sender to a new principal 13 | (transfer (uint principal principal) (response bool uint)) 14 | ) 15 | ) 16 | -------------------------------------------------------------------------------- /scripts/btc-htlc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | if (process.argv.length !== 6) 3 | { 4 | console.log('Usage: btc-htlc.js preimage expiration-height sender-pubkey recipient-pubkey'); 5 | process.exit(0); 6 | } 7 | const crypto = require('crypto'); 8 | const {btc_generate_htlc} = require('../test/util'); 9 | const [,,preimage,expiration_height,sender_pubkey,recipient_pubkey] = process.argv; 10 | const hash = crypto.createHash('sha256').update(preimage).digest('hex'); 11 | const script = btc_generate_htlc(hash, sender_pubkey, recipient_pubkey, parseInt(expiration_height)); 12 | console.log(script.toString('hex')); 13 | -------------------------------------------------------------------------------- /stx/Clarinet.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "Stacks HTLC contracts" 3 | requirements = [] 4 | 5 | [contracts.stx-htlc] 6 | path = "contracts/stx-htlc.clar" 7 | depends_on = [] 8 | 9 | [contracts.sip009-sip010-htlc] 10 | path = "contracts/sip009-sip010-htlc.clar" 11 | depends_on = [] 12 | 13 | [contracts.sip009-trait] 14 | path = "contracts/traits/sip009-trait.clar" 15 | depends_on = [] 16 | 17 | [contracts.sip010-trait] 18 | path = "contracts/traits/sip010-trait.clar" 19 | depends_on = [] 20 | 21 | [contracts.test-sip009] 22 | path = "contracts/test/test-sip009.clar" 23 | depends_on = ["sip009-trait"] 24 | 25 | [contracts.test-sip010] 26 | path = "contracts/test/test-sip010.clar" 27 | depends_on = ["sip010-trait"] 28 | -------------------------------------------------------------------------------- /test/util/common.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | function generate_secret(length) 4 | { 5 | return new Promise((resolve,reject) => crypto.randomBytes(length || 64,(err,buff) => (err && reject(err)) || resolve(buff))); 6 | } 7 | 8 | function calculate_hash(secret) 9 | { 10 | // return Buffer.from(Web3.utils.keccak256(secret).substr(2),'hex'); 11 | return crypto.createHash('sha256').update(secret).digest(); 12 | } 13 | 14 | function buffer_to_hex(buffer) 15 | { 16 | if (buffer instanceof Buffer) 17 | return '0x' + buffer.toString('hex'); 18 | else if (buffer.substr(0,2) === '0x') 19 | return buffer; 20 | throw new Error(`buffer should be of type Buffer or a 0x prefixed hex string`); 21 | } 22 | 23 | module.exports = { 24 | generate_secret, 25 | calculate_hash, 26 | buffer_to_hex 27 | }; -------------------------------------------------------------------------------- /stx/scripts/rpc.ts: -------------------------------------------------------------------------------- 1 | import { Clarinet, Contract, Account, StacksNode } from 'https://deno.land/x/clarinet@v0.13.0/index.ts'; 2 | import { readline } from "https://deno.land/x/readline/mod.ts"; 3 | 4 | Clarinet.run({ 5 | async fn(accounts: Map, contracts: Map, node: StacksNode) { 6 | console.log(JSON.stringify( 7 | { 8 | ready: true, 9 | accounts: Object.fromEntries(accounts), // --allow-wallets has to be set, otherwise this will be empty. 10 | contracts: Object.fromEntries(contracts) 11 | })); 12 | const decoder = new TextDecoder(); 13 | for await (const line of readline(Deno.stdin)) { 14 | const json = JSON.parse(decoder.decode(line)); 15 | const result = JSON.parse((Deno as any).core.opSync(json.op, json.params)); 16 | console.log(JSON.stringify({ id: json.id, result })); 17 | } 18 | } 19 | }); -------------------------------------------------------------------------------- /stx/contracts/traits/sip010-trait.clar: -------------------------------------------------------------------------------- 1 | (define-trait ft-trait 2 | ( 3 | ;; Transfer from the caller to a new principal 4 | (transfer (uint principal principal (optional (buff 34))) (response bool uint)) 5 | 6 | ;; the human readable name of the token 7 | (get-name () (response (string-ascii 32) uint)) 8 | 9 | ;; the ticker symbol, or empty if none 10 | (get-symbol () (response (string-ascii 32) uint)) 11 | 12 | ;; the number of decimals used, e.g. 6 would mean 1_000_000 represents 1 token 13 | (get-decimals () (response uint uint)) 14 | 15 | ;; the balance of the passed principal 16 | (get-balance (principal) (response uint uint)) 17 | 18 | ;; the current total supply (which does not need to be a constant) 19 | (get-total-supply () (response uint uint)) 20 | 21 | ;; an optional URI that represents metadata of this token 22 | (get-token-uri () (response (optional (string-utf8 256)) uint)) 23 | ) 24 | ) 25 | -------------------------------------------------------------------------------- /stx/contracts/test/test-sip009.clar: -------------------------------------------------------------------------------- 1 | ;; Test SIP009 NFT 2 | 3 | (impl-trait .sip009-trait.nft-trait) 4 | 5 | (define-constant err-owner-only (err u100)) 6 | (define-constant err-not-token-owner (err u101)) 7 | 8 | (define-non-fungible-token test-sip009 uint) 9 | (define-data-var token-id-nonce uint u0) 10 | 11 | (define-read-only (get-last-token-id) 12 | (ok (var-get token-id-nonce)) 13 | ) 14 | 15 | (define-read-only (get-token-uri (token-id uint)) 16 | (ok none) 17 | ) 18 | 19 | (define-read-only (get-owner (token-id uint)) 20 | (ok (nft-get-owner? test-sip009 token-id)) 21 | ) 22 | 23 | (define-public (transfer (token-id uint) (sender principal) (recipient principal)) 24 | (begin 25 | (asserts! (is-eq tx-sender sender) err-not-token-owner) 26 | (nft-transfer? test-sip009 token-id sender recipient) 27 | ) 28 | ) 29 | 30 | (define-public (mint (recipient principal)) 31 | (let ((token-id (+ (var-get token-id-nonce) u1))) 32 | (try! (nft-mint? test-sip009 token-id recipient)) 33 | (var-set token-id-nonce token-id) 34 | (ok token-id) 35 | ) 36 | ) 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Marvin Janssen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 19 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 20 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 21 | OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /stx/contracts/test/test-sip010.clar: -------------------------------------------------------------------------------- 1 | ;; sip010-token 2 | ;; A SIP010-compliant fungible token with a mint function. 3 | 4 | (impl-trait .sip010-trait.ft-trait) 5 | 6 | (define-fungible-token test-sip010) 7 | 8 | (define-constant err-not-token-owner (err u100)) 9 | 10 | (define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34)))) 11 | (begin 12 | (asserts! (is-eq tx-sender sender) err-not-token-owner) 13 | (match memo to-print (print to-print) 0x) 14 | (ft-transfer? test-sip010 amount sender recipient) 15 | ) 16 | ) 17 | 18 | (define-read-only (get-name) 19 | (ok "test-sip010") 20 | ) 21 | 22 | (define-read-only (get-symbol) 23 | (ok "test") 24 | ) 25 | 26 | (define-read-only (get-decimals) 27 | (ok u6) 28 | ) 29 | 30 | (define-read-only (get-balance (who principal)) 31 | (ok (ft-get-balance test-sip010 who)) 32 | ) 33 | 34 | (define-read-only (get-total-supply) 35 | (ok (ft-get-supply test-sip010)) 36 | ) 37 | 38 | (define-read-only (get-token-uri) 39 | (ok none) 40 | ) 41 | 42 | (define-public (mint (amount uint) (recipient principal)) 43 | (ft-mint? test-sip010 amount recipient) 44 | ) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stx-atomic-swap", 3 | "version": "1.0.0", 4 | "description": "A proof of concept for atomic swaps between Stacks <> Bitcoin, and Stacks <> Ethereum (EVM chains).", 5 | "scripts": { 6 | "test": "mocha --timeout 100000", 7 | "stx-test": "cd stx && clarinet test", 8 | "stx-test-rpc": "cd stx && clarinet run ./scripts/rpc.ts --allow-wallets", 9 | "stx-console": "cd stx && clarinet console", 10 | "eth-test": "cd eth && npx truffle test", 11 | "eth-test-rpc": "ganache-cli", 12 | "eth-deploy": "cd eth && npx truffle deploy", 13 | "eth-clean": "rm -rf ./eth/build", 14 | "eth-console": "cd eth && truffle console", 15 | "btc-test-rpc": "bitcoind -datadir=./btc", 16 | "btc-clean": "rm -rf ./btc/regtest" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/MarvinJanssen/stx-atomic-swap.git" 21 | }, 22 | "keywords": [ 23 | "stacks", 24 | "bitcoin", 25 | "ethereum", 26 | "stx", 27 | "btc", 28 | "eth", 29 | "atomic", 30 | "swap" 31 | ], 32 | "author": "Marvin Janssen", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/MarvinJanssen/stx-atomic-swap/issues" 36 | }, 37 | "homepage": "https://github.com/MarvinJanssen/stx-atomic-swap#readme", 38 | "devDependencies": { 39 | "@openzeppelin/contracts": "^4.2.0", 40 | "bip65": "^1.0.3", 41 | "bitcoin-core": "^3.0.0", 42 | "bitcoinjs-lib": "^5.2.0", 43 | "bn.js": "^5.2.0", 44 | "chai": "^4.3.4", 45 | "ganache-cli": "^6.12.2", 46 | "mocha": "^8.1.2", 47 | "truffle": "^5.4.3", 48 | "web3": "^1.5.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /stx/contracts/stx-htlc.clar: -------------------------------------------------------------------------------- 1 | ;; STX Hashed Timelock Contract (HTLC) 2 | ;; By Marvin Janssen 3 | 4 | (define-constant err-invalid-hash-length (err u1000)) 5 | (define-constant err-expiry-in-past (err u1001)) 6 | (define-constant err-swap-intent-already-exists (err u1002)) 7 | (define-constant err-unknown-swap-intent (err u1003)) 8 | (define-constant err-swap-intent-expired (err u1004)) 9 | (define-constant err-swap-intent-not-expired (err u1005)) 10 | 11 | (define-map swap-intents {sender: principal, hash: (buff 32)} {expiration-height: uint, amount: uint, recipient: principal}) 12 | 13 | (define-read-only (get-swap-intent (hash (buff 32)) (sender principal)) 14 | (map-get? swap-intents {sender: sender, hash: hash}) 15 | ) 16 | 17 | (define-public (register-swap-intent (hash (buff 32)) (expiration-height uint) (amount uint) (recipient principal)) 18 | (begin 19 | (asserts! (is-eq (len hash) u32) err-invalid-hash-length) 20 | (asserts! (< block-height expiration-height) err-expiry-in-past) 21 | (asserts! (map-insert swap-intents {sender: tx-sender, hash: hash} {expiration-height: expiration-height, amount: amount, recipient: recipient}) err-swap-intent-already-exists) 22 | (try! (stx-transfer? amount tx-sender (as-contract tx-sender))) 23 | (ok true) 24 | ) 25 | ) 26 | 27 | (define-public (cancel-swap-intent (hash (buff 32))) 28 | (let 29 | ( 30 | (swap-intent (unwrap! (get-swap-intent hash tx-sender) err-unknown-swap-intent)) 31 | (sender tx-sender) 32 | ) 33 | (asserts! (>= block-height (get expiration-height swap-intent)) err-swap-intent-not-expired) 34 | (try! (as-contract (stx-transfer? (get amount swap-intent) tx-sender sender))) 35 | (map-delete swap-intents {sender: tx-sender, hash: hash}) 36 | (ok true) 37 | ) 38 | ) 39 | 40 | (define-public (swap (sender principal) (preimage (buff 64))) 41 | (let 42 | ( 43 | (hash (sha256 preimage)) 44 | (swap-intent (unwrap! (get-swap-intent hash sender) err-unknown-swap-intent)) 45 | ) 46 | (asserts! (< block-height (get expiration-height swap-intent)) err-swap-intent-expired) 47 | (try! (as-contract (stx-transfer? (get amount swap-intent) tx-sender (get recipient swap-intent)))) 48 | (map-delete swap-intents {sender: sender, hash: hash}) 49 | (ok true) 50 | ) 51 | ) 52 | -------------------------------------------------------------------------------- /stx/settings/Devnet.toml: -------------------------------------------------------------------------------- 1 | [network] 2 | name = "Development" 3 | 4 | [accounts.deployer] 5 | mnemonic = "fetch outside black test wash cover just actual execute nice door want airport betray quantum stamp fish act pen trust portion fatigue scissors vague" 6 | balance = 1_000_000_000 7 | 8 | [accounts.wallet_1] 9 | mnemonic = "spoil sock coyote include verify comic jacket gain beauty tank flush victory illness edge reveal shallow plug hobby usual juice harsh pact wreck eight" 10 | balance = 1_000_000_000 11 | 12 | [accounts.wallet_2] 13 | mnemonic = "arrange scale orient half ugly kid bike twin magnet joke hurt fiber ethics super receive version wreck media fluid much abstract reward street alter" 14 | balance = 1_000_000_000 15 | 16 | [accounts.wallet_3] 17 | mnemonic = "glide clown kitchen picnic basket hidden asset beyond kid plug carbon talent drama wet pet rhythm hero nest purity baby bicycle ghost sponsor dragon" 18 | balance = 1_000_000_000 19 | 20 | [accounts.wallet_4] 21 | mnemonic = "pulp when detect fun unaware reduce promote tank success lecture cool cheese object amazing hunt plug wing month hello tunnel detect connect floor brush" 22 | balance = 1_000_000_000 23 | 24 | [accounts.wallet_5] 25 | mnemonic = "replace swing shove congress smoke banana tired term blanket nominee leave club myself swing egg virus answer bulk useful start decrease family energy february" 26 | balance = 1_000_000_000 27 | 28 | [accounts.wallet_6] 29 | mnemonic = "apology together shy taxi glare struggle hip camp engage lion possible during squeeze hen exotic marriage misery kiwi once quiz enough exhibit immense tooth" 30 | balance = 1_000_000_000 31 | 32 | [accounts.wallet_7] 33 | mnemonic = "antenna bitter find rely gadget father exact excuse cross easy elbow alcohol injury loud silk bird crime cabbage winter fit wide screen update october" 34 | balance = 1_000_000_000 35 | 36 | [accounts.wallet_8] 37 | mnemonic = "east load echo merit ignore hip tag obvious truly adjust smart panther deer aisle north hotel process frown lock property catch bless notice topple" 38 | balance = 1_000_000_000 39 | 40 | [accounts.wallet_9] 41 | mnemonic = "market ocean tortoise venue vivid coach machine category conduct enable insect jump fog file test core book chaos crucial burst version curious prosper fever" 42 | balance = 1_000_000_000 43 | -------------------------------------------------------------------------------- /eth/test/util.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const BN = require('bn.js'); 3 | 4 | const NULL_ADDRESS = '0x0000000000000000000000000000000000000000'; 5 | 6 | const required_swap_fields = ["intent_hash","expiration_height","recipient"]; 7 | 8 | function generate_secret(length) 9 | { 10 | return new Promise((resolve,reject) => crypto.randomBytes(length || 64,(err,buff) => (err && reject(err)) || resolve(buff))); 11 | } 12 | 13 | function calculate_hash(secret) 14 | { 15 | return crypto.createHash('sha256').update(secret).digest(); 16 | } 17 | 18 | // Truffle does not expose chai so it is impossible to add chai-as-promised. 19 | // This is a simple replacement function. 20 | // https://github.com/trufflesuite/truffle/issues/2090 21 | function assert_is_rejected(promise,error_match,message) 22 | { 23 | return promise.then( 24 | () => assert.fail(message || 'Expected promise to be rejected'), 25 | error => 26 | { 27 | if (error_match) 28 | { 29 | if (typeof error_match === 'string') 30 | return assert.equal(error_match,error.message,message); 31 | if (error_match instanceof RegExp) 32 | return error.message.match(error_match) || assert.fail(error.message,error_match.toString(),`'${error.message}' does not match ${error_match.toString()}: ${message}`); 33 | return assert.instanceOf(error,error_match,message); 34 | } 35 | } 36 | ) 37 | } 38 | 39 | async function balance(address) 40 | { 41 | return new BN(await web3.eth.getBalance(address)); 42 | } 43 | 44 | async function block_height(increment) 45 | { 46 | const height = new BN(await web3.eth.getBlockNumber()); 47 | return increment ? height.add(new BN(increment)) : height; 48 | } 49 | 50 | async function mine_empty_block() 51 | { 52 | return new Promise((resolve,reject) => 53 | web3.currentProvider.send( 54 | { 55 | jsonrpc: '2.0', 56 | method: 'evm_mine', 57 | id: +new Date() 58 | }, 59 | (err, result) => err ? reject(err) : resolve(result)) 60 | ); 61 | } 62 | 63 | async function mine_blocks(n) 64 | { 65 | let blocks = []; 66 | if (n.toNumber) 67 | n = n.toNumber(); 68 | for (let i = 0 ; i < n ; ++i) 69 | blocks.push(mine_empty_block()); 70 | return Promise.all(blocks); 71 | } 72 | 73 | function wei(ether) 74 | { 75 | return new BN(web3.utils.toWei(ether)); 76 | } 77 | 78 | module.exports = { 79 | NULL_ADDRESS, 80 | generate_secret, 81 | calculate_hash, 82 | assert_is_rejected, 83 | balance, 84 | block_height, 85 | mine_blocks, 86 | wei 87 | }; 88 | -------------------------------------------------------------------------------- /eth/contracts/EthHTLC.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // By Marvin Janssen 3 | 4 | pragma solidity 0.8.6; 5 | 6 | contract EthHTLC 7 | { 8 | struct SwapIntent 9 | { 10 | uint256 expiration_height; 11 | uint256 amount; 12 | address recipient; 13 | } 14 | mapping(bytes32 => SwapIntent) swap_intents; 15 | 16 | uint256 private constant REENTRANCY_NOT_ENTERED = 1; 17 | uint256 private constant REENTRANCY_ENTERED = 2; 18 | uint256 private reentrancy_state = REENTRANCY_NOT_ENTERED; 19 | 20 | modifier reentrancy_guard() 21 | { 22 | require(reentrancy_state != REENTRANCY_ENTERED,"Reentrancy"); 23 | reentrancy_state = REENTRANCY_ENTERED; 24 | _; 25 | reentrancy_state = REENTRANCY_NOT_ENTERED; 26 | } 27 | 28 | function swap_key(address sender, bytes32 intent_hash) 29 | private 30 | pure 31 | returns (bytes32) 32 | { 33 | return keccak256(abi.encodePacked(sender, intent_hash)); 34 | } 35 | 36 | function get_swap_intent(bytes32 intent_hash, address sender) 37 | public 38 | view 39 | returns (SwapIntent memory) 40 | { 41 | return swap_intents[swap_key(sender, intent_hash)]; 42 | } 43 | 44 | function register_swap_intent(bytes32 intent_hash, uint256 expiration_height, address recipient) 45 | public 46 | payable 47 | { 48 | require(msg.value > 0, "No value"); 49 | require(block.number < expiration_height, "Expiry in the past"); 50 | bytes32 key = swap_key(msg.sender, intent_hash); 51 | require(swap_intents[key].amount == 0, "Swap intent already exists"); 52 | swap_intents[key] = SwapIntent({ 53 | amount: msg.value, 54 | expiration_height: expiration_height, 55 | recipient: recipient 56 | }); 57 | } 58 | 59 | function cancel_swap_intent(bytes32 intent_hash) 60 | public 61 | reentrancy_guard 62 | { 63 | bytes32 key = swap_key(msg.sender, intent_hash); 64 | require(swap_intents[key].amount > 0, "Unknown swap"); 65 | require(block.number >= swap_intents[key].expiration_height, "Swap intent not expired"); 66 | safe_transfer_value(msg.sender, swap_intents[key].amount); 67 | delete swap_intents[key]; 68 | } 69 | 70 | function swap(address sender, bytes calldata preimage) 71 | public 72 | reentrancy_guard 73 | { 74 | require(preimage.length <= 64, "Preimage too large"); 75 | bytes32 intent_hash = sha256(preimage); 76 | bytes32 key = swap_key(sender, intent_hash); 77 | require(swap_intents[key].amount > 0, "Unknown swap"); 78 | require(block.number < swap_intents[key].expiration_height, "Swap intent expired"); 79 | safe_transfer_value(swap_intents[key].recipient, swap_intents[key].amount); 80 | delete swap_intents[key]; 81 | } 82 | 83 | function safe_transfer_value(address recipient, uint256 amount) 84 | private 85 | { 86 | (bool success,) = recipient.call{value: amount}(""); 87 | require(success,"Transfer failed (call)"); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /eth/contracts/Erc20Erc721HTLC.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // By Marvin Janssen, h 3 | 4 | pragma solidity 0.8.6; 5 | 6 | contract Erc20Erc721HTLC 7 | { 8 | bytes4 private constant TRANSFER_FROM_SELECTOR = bytes4(keccak256("transferFrom(address,address,uint256)")); 9 | bytes4 private constant TRANSFER_SELECTOR = bytes4(keccak256("transfer(address,uint256)")); 10 | 11 | struct SwapIntent 12 | { 13 | uint256 expiration_height; 14 | uint256 amount_or_token_id; 15 | address recipient; 16 | address asset_contract; 17 | } 18 | mapping(bytes32 => SwapIntent) swap_intents; 19 | 20 | uint256 private constant REENTRANCY_NOT_ENTERED = 1; 21 | uint256 private constant REENTRANCY_ENTERED = 2; 22 | uint256 private reentrancy_state = REENTRANCY_NOT_ENTERED; 23 | 24 | modifier reentrancy_guard() 25 | { 26 | require(reentrancy_state != REENTRANCY_ENTERED,"Reentrancy"); 27 | reentrancy_state = REENTRANCY_ENTERED; 28 | _; 29 | reentrancy_state = REENTRANCY_NOT_ENTERED; 30 | } 31 | 32 | function swap_key(address sender, bytes32 intent_hash) 33 | private 34 | pure 35 | returns (bytes32) 36 | { 37 | return keccak256(abi.encodePacked(sender, intent_hash)); 38 | } 39 | 40 | function get_swap_intent(bytes32 intent_hash, address sender) 41 | public 42 | view 43 | returns (SwapIntent memory) 44 | { 45 | return swap_intents[swap_key(sender, intent_hash)]; 46 | } 47 | 48 | function register_swap_intent(bytes32 intent_hash, uint256 expiration_height, address recipient, address asset_contract, uint256 amount_or_token_id) 49 | public 50 | reentrancy_guard 51 | { 52 | require(block.number < expiration_height, "Expiry in the past"); 53 | bytes32 key = swap_key(msg.sender, intent_hash); 54 | require(swap_intents[key].recipient == address(0x0), "Swap intent already exists"); 55 | safe_transfer_from(asset_contract, msg.sender, address(this), amount_or_token_id); 56 | swap_intents[key] = SwapIntent({ 57 | amount_or_token_id: amount_or_token_id, 58 | expiration_height: expiration_height, 59 | recipient: recipient, 60 | asset_contract: asset_contract 61 | }); 62 | } 63 | 64 | function cancel_swap_intent(bytes32 intent_hash) 65 | public 66 | reentrancy_guard 67 | { 68 | bytes32 key = swap_key(msg.sender, intent_hash); 69 | require(swap_intents[key].recipient != address(0x0), "Unknown swap"); 70 | require(block.number >= swap_intents[key].expiration_height, "Swap intent not expired"); 71 | safe_transfer_from(swap_intents[key].asset_contract, address(this), msg.sender, swap_intents[key].amount_or_token_id); 72 | delete swap_intents[key]; 73 | } 74 | 75 | function swap(address sender, bytes calldata preimage) 76 | public 77 | reentrancy_guard 78 | { 79 | require(preimage.length <= 64, "Preimage too large"); 80 | bytes32 intent_hash = sha256(preimage); 81 | bytes32 key = swap_key(sender, intent_hash); 82 | require(swap_intents[key].recipient != address(0x0), "Unknown swap"); 83 | require(block.number < swap_intents[key].expiration_height, "Swap intent expired"); 84 | safe_transfer_from(swap_intents[key].asset_contract, address(this), swap_intents[key].recipient, swap_intents[key].amount_or_token_id); 85 | delete swap_intents[key]; 86 | } 87 | 88 | function safe_transfer_from(address asset_contract, address from, address to, uint256 amount_or_token_id) 89 | private 90 | { 91 | bool success; 92 | bytes memory data; 93 | (success, data) = asset_contract.call(abi.encodeWithSelector(TRANSFER_FROM_SELECTOR, from, to, amount_or_token_id)); 94 | if (!success) // Not optimal. If transferFrom fails then it must be an ERC20. Sadly, transferFrom does not work when sender is equal to msg.sender. 95 | (success, data) = asset_contract.call(abi.encodeWithSelector(TRANSFER_SELECTOR, to, amount_or_token_id)); 96 | require(success, "Transfer failed (function call)"); 97 | if (data.length > 0) 98 | require(abi.decode(data, (bool)), "Transfer failed (false returned)"); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /eth/truffle-config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Use this file to configure your truffle project. It's seeded with some 3 | * common settings for different networks and features like migrations, 4 | * compilation and testing. Uncomment the ones you need or modify 5 | * them to suit your project as necessary. 6 | * 7 | * More information about configuration can be found at: 8 | * 9 | * trufflesuite.com/docs/advanced/configuration 10 | * 11 | * To deploy via Infura you'll need a wallet provider (like @truffle/hdwallet-provider) 12 | * to sign your transactions before they're sent to a remote public node. Infura accounts 13 | * are available for free at: infura.io/register. 14 | * 15 | * You'll also need a mnemonic - the twelve word phrase the wallet uses to generate 16 | * public/private key pairs. If you're publishing your code to GitHub make sure you load this 17 | * phrase from a file you've .gitignored so it doesn't accidentally become public. 18 | * 19 | */ 20 | 21 | // const HDWalletProvider = require('@truffle/hdwallet-provider'); 22 | // 23 | // const fs = require('fs'); 24 | // const mnemonic = fs.readFileSync(".secret").toString().trim(); 25 | 26 | module.exports = { 27 | /** 28 | * Networks define how you connect to your ethereum client and let you set the 29 | * defaults web3 uses to send transactions. If you don't specify one truffle 30 | * will spin up a development blockchain for you on port 9545 when you 31 | * run `develop` or `test`. You can ask a truffle command to use a specific 32 | * network from the command line, e.g 33 | * 34 | * $ truffle test --network 35 | */ 36 | 37 | networks: { 38 | // Useful for testing. The `development` name is special - truffle uses it by default 39 | // if it's defined here and no other network is specified at the command line. 40 | // You should run a client (like ganache-cli, geth or parity) in a separate terminal 41 | // tab if you use this network and you must also set the `host`, `port` and `network_id` 42 | // options below to some value. 43 | // 44 | development: { 45 | host: "127.0.0.1", // Localhost (default: none) 46 | port: 8545, // Standard Ethereum port (default: none) 47 | network_id: "*", // Any network (default: none) 48 | }, 49 | // Another network with more advanced options... 50 | // advanced: { 51 | // port: 8777, // Custom port 52 | // network_id: 1342, // Custom network 53 | // gas: 8500000, // Gas sent with each transaction (default: ~6700000) 54 | // gasPrice: 20000000000, // 20 gwei (in wei) (default: 100 gwei) 55 | // from:
, // Account to send txs from (default: accounts[0]) 56 | // websocket: true // Enable EventEmitter interface for web3 (default: false) 57 | // }, 58 | // Useful for deploying to a public network. 59 | // NB: It's important to wrap the provider as a function. 60 | // ropsten: { 61 | // provider: () => new HDWalletProvider(mnemonic, `https://ropsten.infura.io/v3/YOUR-PROJECT-ID`), 62 | // network_id: 3, // Ropsten's id 63 | // gas: 5500000, // Ropsten has a lower block limit than mainnet 64 | // confirmations: 2, // # of confs to wait between deployments. (default: 0) 65 | // timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50) 66 | // skipDryRun: true // Skip dry run before migrations? (default: false for public nets ) 67 | // }, 68 | // Useful for private networks 69 | // private: { 70 | // provider: () => new HDWalletProvider(mnemonic, `https://network.io`), 71 | // network_id: 2111, // This network is yours, in the cloud. 72 | // production: true // Treats this network as if it was a public net. (default: false) 73 | // } 74 | }, 75 | 76 | // Set default mocha options here, use special reporters etc. 77 | mocha: { 78 | // timeout: 100000 79 | }, 80 | 81 | // Configure your compilers 82 | compilers: { 83 | solc: { 84 | version: "0.8.6", // Fetch exact version from solc-bin (default: truffle's version) 85 | docker: false, // Use "0.5.1" you've installed locally with docker (default: false) 86 | settings: { // See the solidity docs for advice about optimization and evmVersion 87 | optimizer: { 88 | enabled: true, 89 | runs: 800 90 | }, 91 | // evmVersion: "byzantium" 92 | } 93 | } 94 | }, 95 | 96 | // Truffle DB is currently disabled by default; to enable it, change enabled: false to enabled: true 97 | // 98 | // Note: if you migrated your contracts prior to enabling this field in your Truffle project and want 99 | // those previously migrated contracts available in the .db directory, you will need to run the following: 100 | // $ truffle migrate --reset --compile-all 101 | 102 | db: { 103 | enabled: false 104 | } 105 | }; 106 | `` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stacks <> Bitcoin & Ethereum atomic swaps 2 | 3 | A proof of concept for atomic swaps between Stacks <> Bitcoin, and Stacks <> Ethereum (EVM chains). 4 | 5 | Supported swaps: 6 | - STX, SIP009 NFTs, SIP010 fungible tokens <> BTC. 7 | - STX, SIP009 NFTs, SIP010 fungible tokens <> ETH, ERC721 NFTs, ERC20 fungible tokens. 8 | 9 | The project contains Hashed Timelock Contract (HTLC) implementations in Clarity, Bitcoin Script, and Solidity. It features individual unit tests as well as integration tests that trigger swaps between the different chains. 10 | 11 | # How to use 12 | 13 | First, install dependencies using `npm install`. The Stacks, Bitcoin, and Ethereum project files are found in their respective directories (`./stx`, `./btc`, `./eth`). You need to have `clarinet` and `bitcoind` in your `PATH` in order to run integration tests. (You can also symlink `Bitcoin-Qt` to `bitcoind`.) 14 | 15 | To run integration tests, simply run `npm test`. The chains will automatically be started and prepared for test. 16 | 17 | - Stacks tests run on a Clarinet session. (Tested up to version 0.15.2.) 18 | - Bitcoin tests run on regtest. 19 | - Ethereum tests run on a Ganache session. 20 | 21 | The individual project directories also have their own unit tests. 22 | 23 | - `npm run stx-test` to test the Clarity contracts. 24 | - `npm run eth-test` to test the Solidity contracts. Be sure to start the test RPC first using `npm run eth-test-rpc`. 25 | 26 | Integration tests: 27 | 28 | ``` 29 | STX <> BTC 30 | Starting STX and BTC chains... 31 | 32 | ✓ Can swap STX and BTC (436ms) 33 | ✓ Can swap SIP009 and BTC (377ms) 34 | ✓ Can swap SIP010 and BTC (391ms) 35 | ✓ BTC HTLC rejects wrong preimage (329ms) 36 | ✓ Sender can recover BTC from HTLC after expiry (358ms) 37 | ✓ Sender cannot recover BTC from HTLC before expiry (353ms) 38 | ✓ Receiver cannot recover BTC from HTLC after expiry (378ms) 39 | ✓ Sender cannot recover BTC from HTLC with preimage (364ms) 40 | Stopping chains... 41 | 42 | STX <> ETH 43 | Starting STX and ETH chains... 44 | 45 | ✓ Can register swap intent on STX and ETH (59ms) 46 | ✓ Can swap STX and ETH (113ms) 47 | ✓ Can swap SIP009 and ETH (73ms) 48 | ✓ Can swap SIP010 and ETH (78ms) 49 | ✓ Can swap STX and ERC20 (221ms) 50 | ✓ Can swap STX and ERC721 (198ms) 51 | ✓ Can swap SIP009 and ERC20 (139ms) 52 | ✓ Can swap SIP009 and ERC721 (125ms) 53 | ✓ Can swap SIP010 and ERC20 (128ms) 54 | ✓ Can swap SIP010 and ERC721 (112ms) 55 | Stopping chains... 56 | 57 | 58 | 18 passing (44s) 59 | ``` 60 | 61 | # Hashed Timelock Contracts 62 | 63 | HTLCs are simple smart contracts that lock up some sort of asset or action. They are locked based on two factors: a hashlock and a timelock. The hashlock is achieved by encoding the hash of an off-chain secret, and waiting for that secret to be revealed. A timelock is normally based on block height and expires once a specific height is reached. 64 | 65 | The asset contained in the HTLC will be released to a different recipient based on how it is unlocked. For example, Carl can create a HTLC that will: 66 | - release the contained tokens back to him after the timelock expires; or, 67 | - send the tokens to Bill if the secret is revealed. 68 | 69 | The secret can only unlock the HTLC for as long as the timelock has not expired. 70 | 71 | # Atomic swaps 72 | 73 | Atomic swaps are trustless peer-to-peer exchanges of crypto assets. There is no third party involved in the process. HTLCs can be used to perform atomic swaps across two different chains. 74 | 75 | The way it works is that both parties agree on a shared secret and use that to lock their respective HTLCs. You can imagine a shared secret to be a key that opens two locks. However, the secret is generated by one party and only revealed when the right conditions are met. 76 | 77 | Imagine that Carl and Bill decide to exchange STX for BTC and agree on a price and time period. Carl generates a secret and calculates the hash of that secret. Carl locks a HTLC on Stacks that contains 100 STX using the generated hash. Bill then verifies that the conditions are right. If so, he generates a HTLC on Bitcoin that contains 1 BTC and uses the same hash. Bill does not know the secret used to calculate the hash at that point, but that is no problem because he will be able to get the 1 BTC back once the timelock expires. Carl has to submit the secret to Bill's HTLC in order to claim the 1 BTC, thereby revealing it to the world. Bill can then use the revealed secret to claim the 100 STX. 78 | 79 | See the sequence diagram at the end for a visual representation of the process. 80 | 81 | # Risks 82 | 83 | Two immediate risks are the different timelocks and the length of the secret. Since different blockchains have different block times (how often a block is produced on average), it is challenging to find expiration heights that correspond to the same time on both chains. The party that does not know the secret should therefore always submit a HTLC that expires before the counter party. In case of the example, Bill's HTLC timelock should expire before Carl's. Otherwise Carl could try to time the expiry and claim Bill's 1 BTC after Carl's HTLC has expired. 84 | 85 | The length of the secret is important because it could be the case that a secret is too large to be processed by one HTLC but not by another. If Carl generates a secret that is too long to be processed by his HTLC, but not Bill's HTLC, then he could craft a swap that Bill cannot settle. Carl can claim the 1 BTC but Bill cannot claim the 100 STX, although the hashes are the same. 86 | 87 | # Sequence diagram 88 | 89 | ![sequence diagram](sequence_diagram.svg) 90 | -------------------------------------------------------------------------------- /stx/tests/common.ts: -------------------------------------------------------------------------------- 1 | import { Clarinet, Tx, Chain, Account, types } from 'https://deno.land/x/clarinet@v0.14.0/index.ts'; 2 | export { Clarinet, Tx, Chain, types }; 3 | export type { Account }; 4 | 5 | import { createHash } from "https://deno.land/std@0.104.0/hash/mod.ts"; 6 | 7 | export const ErrorCodes = { 8 | // built-in error codes 9 | ERR_STX_TRANSFER_INSUFFICIENT_BALANCE: 1, 10 | ERR_STX_TRANSFER_NON_POSITIVE: 3, 11 | 12 | ERR_NFT_TRANSFER_NOT_OWNER: 1, 13 | ERR_NFT_TRANSFER_UNKNOWN_ASSET: 3, 14 | 15 | ERR_FT_TRANSFER_INSUFFICIENT_BALANCE: 1, 16 | ERR_FT_TRANSFER_NON_POSITIVE: 3, 17 | 18 | // HTLC error codes 19 | ERR_INVALID_HASH_LENGTH: 1000, 20 | ERR_EXPIRY_IN_PAST: 1001, 21 | ERR_SWAP_INTENT_ALREADY_EXISTS: 1002, 22 | ERR_UNKNOWN_SWAP_INTENT: 1003, 23 | ERR_SWAP_INTENT_EXPIRED: 1004, 24 | ERR_SWAP_INTENT_NOT_EXPIRED: 1005, 25 | ERR_INVALID_ASSET_CONTRACT: 1006, 26 | ERR_ASSET_CONTRACT_NOT_WHITELISTED: 1007, 27 | ERR_OWNER_ONLY: 1008 28 | }; 29 | 30 | export function generate_secret(length: number = 64): Uint8Array { 31 | const buff = new Uint8Array(new ArrayBuffer(length)); 32 | crypto.getRandomValues(buff); 33 | return buff; 34 | } 35 | 36 | export function calculate_hash(input: Uint8Array): Uint8Array { 37 | return new Uint8Array(createHash('sha256').update(input).digest()); 38 | } 39 | 40 | export function typed_array_to_hex(input: Uint8Array): string { 41 | return input.reduce((hex: string, byte: number) => `${hex}${byte < 16 ? '0' : ''}${byte.toString(16)}`, '0x'); 42 | } 43 | 44 | export function hex_to_typed_array(input: string): Uint8Array { 45 | input = input.substr(0, 2) === '0x' ? input.substr(2) : input; 46 | if (input.length % 2 || !/^[0-9a-fA-F]+$/.test(input)) 47 | throw new Error(`Not a valid hex string: ${input} `); 48 | const buff = new Uint8Array(new ArrayBuffer(~~(input.length / 2))); 49 | for (let b = 0, i = 0; i < input.length; b++, i += 2) 50 | buff[b] = parseInt(input.substr(i, 2), 16); 51 | return buff; 52 | } 53 | 54 | export type SwapIntent = { 55 | hash: Uint8Array, 56 | expiration_height: number, // uintCV 57 | amount_or_token_id: number, // uintCV 58 | sender: string, // principalCV 59 | recipient: string, // principalCV 60 | asset_contract?: string // principalCV 61 | asset_type?: 'sip009' | 'sip010' 62 | } 63 | 64 | export function register_swap_intent(chain: Chain, swap_contract_principal: string, swap_intent: SwapIntent) { 65 | let functions_args = [types.buff(swap_intent.hash), types.uint(swap_intent.expiration_height), types.uint(swap_intent.amount_or_token_id), types.principal(swap_intent.recipient)]; 66 | if (swap_intent.asset_contract) 67 | functions_args.push(types.principal(swap_intent.asset_contract)); 68 | const block = chain.mineBlock([Tx.contractCall(swap_contract_principal, swap_intent.asset_contract ? `register-swap-intent-${swap_intent.asset_type || 'sip009'}` : 'register-swap-intent', functions_args, swap_intent.sender)]); 69 | return block.receipts[0] || false; 70 | } 71 | 72 | export function get_swap_intent(chain: Chain, swap_contract_principal: string, hash: Uint8Array, sender: string) { 73 | return chain.callReadOnlyFn(swap_contract_principal, 'get-swap-intent', [types.buff(hash), types.principal(sender)], sender); 74 | } 75 | 76 | export function cancel_swap_intent(chain: Chain, swap_contract_principal: string, swap_intent: SwapIntent, transaction_sender?: string) { 77 | let function_args = [types.buff(swap_intent.hash)]; 78 | if (swap_intent.asset_contract) 79 | function_args.push(types.principal(swap_intent.asset_contract)); 80 | const block = chain.mineBlock([Tx.contractCall(swap_contract_principal, swap_intent.asset_contract ? `cancel-swap-intent-${swap_intent.asset_type}` : 'cancel-swap-intent', function_args, transaction_sender || swap_intent.sender)]); 81 | return block.receipts[0] || false; 82 | } 83 | 84 | export function execute_swap(chain: Chain, swap_contract_principal: string, swap_intent: SwapIntent, preimage: Uint8Array, transaction_sender?: string) { 85 | let function_args = [types.principal(swap_intent.sender), types.buff(preimage)]; 86 | if (swap_intent.asset_contract) 87 | function_args.push(types.principal(swap_intent.asset_contract)); 88 | const block = chain.mineBlock([Tx.contractCall(swap_contract_principal, swap_intent.asset_contract ? `swap-${swap_intent.asset_type}` : 'swap', function_args, transaction_sender || swap_intent.recipient)]); 89 | return block.receipts[0] || false; 90 | } 91 | 92 | export function swap_contract_principal(deployer: Account, swap_intent: SwapIntent) { 93 | return `${deployer.address}.${swap_intent.asset_contract ? 'sip009-sip010-htlc' : 'stx-htlc'}`; 94 | } 95 | 96 | export function sip009_mint(chain: Chain, token_contract_principal: string, recipient: string) { 97 | const block = chain.mineBlock([Tx.contractCall(token_contract_principal, 'mint', [types.principal(recipient)], recipient)]); 98 | const event = block.receipts[0].events[0].nft_mint_event; 99 | return { ...event, token_id: parseInt(event.value.substr(1)) }; 100 | } 101 | 102 | export function sip010_mint(chain: Chain, token_contract_principal: string, amount: number, recipient: string) { 103 | const block = chain.mineBlock([Tx.contractCall(token_contract_principal, 'mint', [types.uint(amount), types.principal(recipient)], recipient)]); 104 | return { ...block.receipts[0].events[0].ft_mint_event, amount }; 105 | } 106 | 107 | export function sip009_sip010_htlc_set_whitelisted(chain: Chain, swap_contract_principal: string, list: { token_contract: string, whitelisted: boolean }[], sender: string) { 108 | const list_cv = types.list(list.map(({ token_contract, whitelisted }) => types.tuple({ 'asset-contract': types.principal(token_contract), whitelisted: types.bool(whitelisted) }))); 109 | return chain.mineBlock([Tx.contractCall(swap_contract_principal, 'set-whitelisted', [list_cv], sender)]).receipts[0]; 110 | } -------------------------------------------------------------------------------- /stx/contracts/sip009-sip010-htlc.clar: -------------------------------------------------------------------------------- 1 | ;; SIP009 (FT) & SIP010 (NFT) Hashed Timelock Contract (HTLC) 2 | ;; By Marvin Janssen 3 | 4 | (define-constant contract-owner tx-sender) 5 | 6 | (define-constant err-invalid-hash-length (err u1000)) 7 | (define-constant err-expiry-in-past (err u1001)) 8 | (define-constant err-swap-intent-already-exists (err u1002)) 9 | (define-constant err-unknown-swap-intent (err u1003)) 10 | (define-constant err-swap-intent-expired (err u1004)) 11 | (define-constant err-swap-intent-not-expired (err u1005)) 12 | (define-constant err-invalid-asset-contract (err u1006)) 13 | (define-constant err-asset-contract-not-whitelisted (err u1007)) 14 | (define-constant err-owner-only (err u1008)) 15 | 16 | (define-trait sip009-transfer-trait 17 | ( 18 | (transfer (uint principal principal) (response bool uint)) 19 | ) 20 | ) 21 | 22 | (define-trait sip010-transfer-trait 23 | ( 24 | (transfer (uint principal principal (optional (buff 34))) (response bool uint)) 25 | ) 26 | ) 27 | 28 | (define-map token-contract-whitelist principal bool) 29 | (define-map swap-intents {sender: principal, hash: (buff 32)} {expiration-height: uint, amount-or-token-id: uint, recipient: principal, asset-contract: principal}) 30 | 31 | (define-read-only (is-whitelisted (who principal)) 32 | (default-to false (map-get? token-contract-whitelist who)) 33 | ) 34 | 35 | (define-private (set-whitelisted-iter (item {asset-contract: principal, whitelisted: bool}) (previous bool)) 36 | (if (get whitelisted item) (map-set token-contract-whitelist (get asset-contract item) true) (map-delete token-contract-whitelist (get asset-contract item))) 37 | ) 38 | 39 | (define-public (set-whitelisted (asset-contracts (list 200 {asset-contract: principal, whitelisted: bool}))) 40 | (begin 41 | (asserts! (is-eq tx-sender contract-owner) err-owner-only) 42 | (ok (fold set-whitelisted-iter asset-contracts true)) 43 | ) 44 | ) 45 | 46 | (define-read-only (get-swap-intent (hash (buff 32)) (sender principal)) 47 | (map-get? swap-intents {sender: sender, hash: hash}) 48 | ) 49 | 50 | (define-private (register-swap-intent (hash (buff 32)) (expiration-height uint) (amount-or-token-id uint) (recipient principal) (asset-contract principal)) 51 | (begin 52 | (asserts! (is-eq (len hash) u32) err-invalid-hash-length) 53 | (asserts! (< block-height expiration-height) err-expiry-in-past) 54 | (asserts! (is-some (map-get? token-contract-whitelist asset-contract)) err-asset-contract-not-whitelisted) 55 | (asserts! (map-insert swap-intents {sender: tx-sender, hash: hash} {expiration-height: expiration-height, amount-or-token-id: amount-or-token-id, recipient: recipient, asset-contract: asset-contract}) err-swap-intent-already-exists) 56 | (ok true) 57 | ) 58 | ) 59 | 60 | (define-public (register-swap-intent-sip009 (hash (buff 32)) (expiration-height uint) (amount uint) (recipient principal) (asset-contract )) 61 | (begin 62 | (try! (register-swap-intent hash expiration-height amount recipient (contract-of asset-contract))) 63 | (contract-call? asset-contract transfer amount tx-sender (as-contract tx-sender)) 64 | ) 65 | ) 66 | 67 | (define-public (register-swap-intent-sip010 (hash (buff 32)) (expiration-height uint) (token-id uint) (recipient principal) (asset-contract )) 68 | (begin 69 | (try! (register-swap-intent hash expiration-height token-id recipient (contract-of asset-contract))) 70 | (contract-call? asset-contract transfer token-id tx-sender (as-contract tx-sender) none) 71 | ) 72 | ) 73 | 74 | (define-private (cancel-swap-intent (hash (buff 32)) (asset-contract principal)) 75 | (let 76 | ( 77 | (swap-intent (unwrap! (get-swap-intent hash tx-sender) err-unknown-swap-intent)) 78 | ) 79 | (asserts! (is-eq (get asset-contract swap-intent) asset-contract) err-invalid-asset-contract) 80 | (asserts! (>= block-height (get expiration-height swap-intent)) err-swap-intent-not-expired) 81 | (map-delete swap-intents {sender: tx-sender, hash: hash}) 82 | (ok (get amount-or-token-id swap-intent)) 83 | ) 84 | ) 85 | 86 | (define-public (cancel-swap-intent-sip009 (hash (buff 32)) (asset-contract )) 87 | (let 88 | ( 89 | (token-id (try! (cancel-swap-intent hash (contract-of asset-contract)))) 90 | (sender tx-sender) 91 | ) 92 | (as-contract (contract-call? asset-contract transfer token-id tx-sender sender)) 93 | ) 94 | ) 95 | 96 | (define-public (cancel-swap-intent-sip010 (hash (buff 32)) (asset-contract )) 97 | (let 98 | ( 99 | (amount (try! (cancel-swap-intent hash (contract-of asset-contract)))) 100 | (sender tx-sender) 101 | ) 102 | (as-contract (contract-call? asset-contract transfer amount tx-sender sender none)) 103 | ) 104 | ) 105 | 106 | (define-private (swap (sender principal) (preimage (buff 64)) (asset-contract principal)) 107 | (let 108 | ( 109 | (hash (sha256 preimage)) 110 | (swap-intent (unwrap! (get-swap-intent hash sender) err-unknown-swap-intent)) 111 | ) 112 | (asserts! (is-eq (get asset-contract swap-intent) asset-contract) err-invalid-asset-contract) 113 | (asserts! (< block-height (get expiration-height swap-intent)) err-swap-intent-expired) 114 | (map-delete swap-intents {sender: sender, hash: hash}) 115 | (ok swap-intent) 116 | ) 117 | ) 118 | 119 | (define-public (swap-sip009 (sender principal) (preimage (buff 64)) (asset-contract )) 120 | (let 121 | ( 122 | (swap-intent (try! (swap sender preimage (contract-of asset-contract)))) 123 | ) 124 | (as-contract (contract-call? asset-contract transfer (get amount-or-token-id swap-intent) tx-sender (get recipient swap-intent))) 125 | ) 126 | ) 127 | 128 | (define-public (swap-sip010 (sender principal) (preimage (buff 64)) (asset-contract )) 129 | (let 130 | ( 131 | (swap-intent (try! (swap sender preimage (contract-of asset-contract)))) 132 | ) 133 | (as-contract (contract-call? asset-contract transfer (get amount-or-token-id swap-intent) tx-sender (get recipient swap-intent) none)) 134 | ) 135 | ) -------------------------------------------------------------------------------- /eth/test/EthHTLC.js: -------------------------------------------------------------------------------- 1 | const EthHTLC = artifacts.require('EthHTLC'); 2 | 3 | const { 4 | generate_secret, 5 | calculate_hash, 6 | assert_is_rejected, 7 | balance, 8 | block_height, 9 | mine_blocks, 10 | wei} = require('./util'); 11 | 12 | contract('EthHTLC',accounts => 13 | { 14 | const get_contract = async () => 15 | { 16 | return await EthHTLC.deployed(); 17 | }; 18 | 19 | it('Can register swap intent',async () => 20 | { 21 | const eth_htlc = await get_contract(); 22 | const [, sender, recipient] = accounts; 23 | const secret = await generate_secret(); 24 | const hash = calculate_hash(secret); 25 | const expiration_height = await block_height(10); 26 | const value = wei('1.1'); 27 | const contract_starting_balance = await balance(eth_htlc.address); 28 | assert.isOk(await eth_htlc.register_swap_intent(hash, expiration_height, recipient, {from: sender, value})); 29 | assert.isTrue((await balance(eth_htlc.address)).sub(contract_starting_balance).eq(value)); 30 | }); 31 | 32 | it('Cannot register swap intent with 0 value',async () => 33 | { 34 | const eth_htlc = await get_contract(); 35 | const [, sender, recipient] = accounts; 36 | const secret = await generate_secret(); 37 | const hash = calculate_hash(secret); 38 | const expiration_height = await block_height(10); 39 | const value = '0'; 40 | return assert_is_rejected( 41 | eth_htlc.register_swap_intent(hash, expiration_height, recipient, {from: sender, value}), 42 | /No value/, 43 | 'Should have rejected' 44 | ); 45 | }); 46 | 47 | it('Expiration height cannot be in the past',async () => 48 | { 49 | await mine_blocks(5); 50 | const eth_htlc = await get_contract(); 51 | const [, sender, recipient] = accounts; 52 | const secret = await generate_secret(); 53 | const hash = calculate_hash(secret); 54 | const expiration_height = await block_height(-1); 55 | const value = wei('1.23'); 56 | return assert_is_rejected( 57 | eth_htlc.register_swap_intent(hash, expiration_height, recipient, {from: sender, value}), 58 | /Expiry in the past/, 59 | 'Should have rejected' 60 | ); 61 | }); 62 | 63 | it('Swap intent cannot already exist',async () => 64 | { 65 | const eth_htlc = await get_contract(); 66 | const [, sender, recipient] = accounts; 67 | const secret = await generate_secret(); 68 | const hash = calculate_hash(secret); 69 | const expiration_height = await block_height(10); 70 | const value = wei('0.98') 71 | await eth_htlc.register_swap_intent(hash, expiration_height, recipient, {from: sender, value}) 72 | return assert_is_rejected( 73 | eth_htlc.register_swap_intent(hash, expiration_height, recipient, {from: sender, value}), 74 | /Swap intent already exists/, 75 | 'Should have rejected' 76 | ); 77 | }); 78 | 79 | it('Sender can cancel a swap intent after expiry',async () => 80 | { 81 | const eth_htlc = await get_contract(); 82 | const [, sender, recipient] = accounts; 83 | const secret = await generate_secret(); 84 | const hash = calculate_hash(secret); 85 | const blocks = 10; 86 | const expiration_height = await block_height(blocks); 87 | const value = wei('1.3'); 88 | await eth_htlc.register_swap_intent(hash, expiration_height, recipient, {from: sender, value}); 89 | await mine_blocks(blocks); 90 | const sender_starting_balance = await balance(sender); 91 | const cancellation = await eth_htlc.cancel_swap_intent(hash, {from: sender}); 92 | assert.isOk(cancellation); 93 | assert.isTrue((await balance(sender)).gt(sender_starting_balance)); 94 | }); 95 | 96 | it('Sender cannot cancel a swap intent before expiry',async () => 97 | { 98 | const eth_htlc = await get_contract(); 99 | const [, sender, recipient] = accounts; 100 | const secret = await generate_secret(); 101 | const hash = calculate_hash(secret); 102 | const blocks = 10; 103 | const expiration_height = await block_height(blocks); 104 | const value = wei('1.09') 105 | await eth_htlc.register_swap_intent(hash, expiration_height, recipient, {from: sender, value}); 106 | return assert_is_rejected( 107 | eth_htlc.cancel_swap_intent(hash, {from: sender}), 108 | /Swap intent not expired/, 109 | 'Should have rejected' 110 | ); 111 | }); 112 | 113 | it('Sender cannot cancel a swap intent that does not exist',async () => 114 | { 115 | const eth_htlc = await get_contract(); 116 | const [, sender] = accounts; 117 | const secret = await generate_secret(); 118 | const hash = calculate_hash(secret); 119 | return assert_is_rejected( 120 | eth_htlc.cancel_swap_intent(hash, {from: sender}), 121 | /Unknown swap/, 122 | 'Should have rejected' 123 | ); 124 | }); 125 | 126 | it('Third party cannot cancel a swap intent after expiry',async () => 127 | { 128 | const eth_htlc = await get_contract(); 129 | const [, sender, recipient, third_party] = accounts; 130 | const secret = await generate_secret(); 131 | const hash = calculate_hash(secret); 132 | const blocks = 10; 133 | const expiration_height = await block_height(blocks); 134 | const value = wei('1.5'); 135 | await eth_htlc.register_swap_intent(hash, expiration_height, recipient, {from: sender, value}); 136 | await mine_blocks(blocks); 137 | return assert_is_rejected( 138 | eth_htlc.cancel_swap_intent(hash, {from: third_party}), 139 | /Unknown swap/, 140 | 'Should have rejected' 141 | ); 142 | }); 143 | 144 | it('Anyone can trigger swap before expiry using the correct preimage',async () => 145 | { 146 | const eth_htlc = await get_contract(); 147 | const [, sender, recipient, third_party] = accounts; 148 | const secret = await generate_secret(); 149 | const hash = calculate_hash(secret); 150 | const expiration_height = await block_height(10); 151 | const value = wei('0.87') 152 | await eth_htlc.register_swap_intent(hash, expiration_height, recipient, {from: sender, value}); 153 | assert.isOk(eth_htlc.swap(sender, secret, {from: third_party})); 154 | }); 155 | 156 | it('Nobody can trigger swap after expiry using the correct preimage',async () => 157 | { 158 | const eth_htlc = await get_contract(); 159 | const [, sender, recipient, third_party] = accounts; 160 | const secret = await generate_secret(); 161 | const hash = calculate_hash(secret); 162 | const blocks = 12; 163 | const expiration_height = await block_height(blocks); 164 | const value = wei('0.03'); 165 | await eth_htlc.register_swap_intent(hash, expiration_height, recipient, {from: sender, value}); 166 | await mine_blocks(blocks); 167 | return assert_is_rejected( 168 | eth_htlc.swap(sender, secret, {from: third_party}), 169 | /Swap intent expired/, 170 | 'Should have rejected' 171 | ); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /test/util/eth.js: -------------------------------------------------------------------------------- 1 | const spawn = require('child_process').spawn; 2 | const readline = require('readline'); 3 | const Web3 = require('web3'); 4 | const fs = require('fs/promises'); 5 | const BN = require('bn.js'); 6 | 7 | const DEBUG = !!process.env.DEBUG; 8 | 9 | async function start_eth_chain() 10 | { 11 | return new Promise((resolve,reject) => 12 | { 13 | const child = spawn('npm run eth-test-rpc',{shell: true}); 14 | DEBUG && child.stdout.on('data',chunk => console.debug(chunk.toString())); 15 | child.on('error',reject); 16 | child.on('exit',code => code > 0 && reject(`ETH process exited with ${code}. Set DEBUG=1 for more information.`)); 17 | 18 | let deploy_contracts = () => 19 | { 20 | return new Promise((resolve,reject) => 21 | { 22 | const child = spawn('npm run eth-deploy',{shell: true}); 23 | DEBUG && child.stdout.on('data',chunk => console.debug(chunk.toString())); 24 | child.on('exit',code => code === 0 ? resolve() : reject()); 25 | }); 26 | }; 27 | 28 | let session_setup = async (web3) => 29 | { 30 | const net_id = await web3.eth.net.getId(); 31 | const eth_contracts_build_dir = process.env.ETH_CONTRACTS_BUILD_DIR || './eth/build/contracts'; 32 | const dir = await fs.readdir(eth_contracts_build_dir); 33 | const accounts = await web3.eth.getAccounts(); 34 | const contracts = {}; 35 | for (const file of dir) 36 | { 37 | if (file.substr(-5) !== '.json') 38 | continue; 39 | const json = JSON.parse(await fs.readFile(`${eth_contracts_build_dir}/${file}`,'utf8')); 40 | if (!json.networks[net_id]) 41 | continue; 42 | const address = json.networks[net_id].address; 43 | contracts[json.contractName] = new web3.eth.Contract(json.abi,address); 44 | contracts[json.contractName].defaultAccount = accounts[0]; 45 | } 46 | return {contracts,accounts}; 47 | }; 48 | 49 | let rl = readline.createInterface({input: child.stdout, terminal: true}); 50 | rl.on('line',line => 51 | { 52 | let match; 53 | if (match = line.match(/^Listening on (.+)$/)) 54 | { 55 | const address = match[1]; 56 | const web3 = new Web3(new Web3.providers.HttpProvider(`http://${address}`)); 57 | DEBUG && console.debug('Deploying ETH contracts...'); 58 | deploy_contracts() 59 | .catch(() => {child.kill(); reject("ETH contract deployment failed")}) 60 | .then(() => session_setup(web3)) 61 | .then(session => resolve( 62 | { 63 | child, 64 | web3, 65 | block_height: async (increment) => 66 | { 67 | const height = new BN(await web3.eth.getBlockNumber()); 68 | return increment ? height.add(new BN(increment)) : height; 69 | }, 70 | balance: async (address) => new BN(await web3.eth.getBalance(address)), 71 | session, 72 | kill: signal => 73 | { 74 | rl.close(); 75 | child.kill(signal); 76 | } 77 | })); 78 | } 79 | else 80 | DEBUG && console.debug(`ETH: ${line}`); 81 | }); 82 | }); 83 | } 84 | 85 | async function erc20_mint(eth_chain, recipient, amount) 86 | { 87 | const response = await eth_chain.session.contracts.TestERC20.methods.mint(recipient, amount).send({from: recipient}); 88 | return {...response.events.Transfer.returnValues, asset_contract: eth_chain.session.contracts.TestERC20.options.address}; 89 | } 90 | 91 | async function erc20_approve_htlc(eth_chain, owner) 92 | { 93 | const max_int = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; 94 | return eth_chain.session.contracts.TestERC20.methods.approve(eth_chain.session.contracts.Erc20Erc721HTLC.options.address, max_int).send({from: owner}); 95 | } 96 | 97 | async function erc20_balance(eth_chain, address) 98 | { 99 | return new BN(await eth_chain.session.contracts.TestERC20.methods.balanceOf(address).call()); 100 | } 101 | 102 | async function erc721_mint(eth_chain, recipient) 103 | { 104 | const response = await eth_chain.session.contracts.TestERC721.methods.mint(recipient).send({from: recipient}); 105 | return {...response.events.Transfer.returnValues, asset_contract: eth_chain.session.contracts.TestERC721.options.address}; 106 | } 107 | 108 | async function erc721_approve_htlc(eth_chain, owner) 109 | { 110 | return eth_chain.session.contracts.TestERC721.methods.setApprovalForAll(eth_chain.session.contracts.Erc20Erc721HTLC.options.address, true).send({from: owner}); 111 | } 112 | 113 | async function erc721_owner(eth_chain, token_id) 114 | { 115 | return await eth_chain.session.contracts.TestERC721.methods.ownerOf(token_id).call(); 116 | } 117 | 118 | async function eth_register_swap_intent(options) 119 | { 120 | const { 121 | eth_chain, // object, ETH chain instance from start_eth_chain() 122 | contract_name, 123 | sender, // principal 124 | recipient, // principal 125 | hash, // buffer 126 | gas, 127 | amount_or_token_id, // BN, amount for ETH or ERC20, token ID for ERC721 128 | expiration_height, // BN, expiration block height 129 | asset_contract, // principal | null, null for ETH swap 130 | } = options; 131 | if (!eth_chain || !sender || !recipient || !hash || !amount_or_token_id || !expiration_height) 132 | throw new Error('Missing options'); 133 | let contract = (contract_name && eth_chain.session.contracts[contract_name]) || (asset_contract ? eth_chain.session.contracts.Erc20Erc721HTLC : eth_chain.session.contracts.EthHTLC); 134 | if (asset_contract) 135 | return contract.methods.register_swap_intent(hash, expiration_height, recipient, asset_contract, amount_or_token_id).send({from: sender, gas: gas || 999999}); 136 | return contract.methods.register_swap_intent(hash, expiration_height, recipient).send({value: amount_or_token_id, from: sender}); 137 | } 138 | 139 | async function eth_execute_swap(options) 140 | { 141 | const { 142 | eth_chain, // object, ETH chain instance from start_eth_chain() 143 | contract_name, 144 | sender, // address 145 | transaction_sender, // address 146 | preimage, // buffer, 147 | gas, 148 | asset_contract // bool / address 149 | } = options; 150 | if (!eth_chain || !sender || !preimage) 151 | throw new Error('Missing options'); 152 | let contract = (contract_name && eth_chain.session.contracts[contract_name]) || (asset_contract ? eth_chain.session.contracts.Erc20Erc721HTLC : eth_chain.session.contracts.EthHTLC); 153 | return contract.methods.swap(sender, preimage).send({from: transaction_sender || sender, gas: gas || 999999}); 154 | } 155 | 156 | module.exports = { 157 | start_eth_chain, 158 | eth_register_swap_intent, 159 | eth_execute_swap, 160 | erc20_mint, 161 | erc20_approve_htlc, 162 | erc20_balance, 163 | erc721_mint, 164 | erc721_approve_htlc, 165 | erc721_owner 166 | }; 167 | -------------------------------------------------------------------------------- /test/util/btc.js: -------------------------------------------------------------------------------- 1 | const spawn = require('child_process').spawn; 2 | const BitcoinClient = require('bitcoin-core'); 3 | const bitcoin = require('bitcoinjs-lib'); 4 | const bip65 = require('bip65'); 5 | const fs = require('fs/promises'); 6 | const crypto = require('crypto'); 7 | const BN = require('bn.js'); 8 | var net = require('net'); 9 | 10 | const DEBUG = !!process.env.DEBUG; 11 | 12 | async function local_port_open(port) 13 | { 14 | return new Promise(resolve => 15 | { 16 | let socket; 17 | const timeout = setTimeout(() => {socket.end();resolve(false);},10000); 18 | socket = net.createConnection(port,'127.0.0.1',() => {socket.end();clearTimeout(timeout);resolve(true);}); 19 | socket.on('error',() => {clearTimeout(timeout);resolve(false);}); 20 | }); 21 | } 22 | 23 | async function wait_btc_client() 24 | { 25 | return new Promise((resolve,reject) => 26 | { 27 | const port = 18332; 28 | let attempts = 0; 29 | let try_client = async () => 30 | { 31 | ++attempts; 32 | if (await local_port_open(port)) 33 | return setTimeout(() => resolve(new BitcoinClient({network: 'regtest', port, username: 'stxswap', password: 'stxswappassword'})),1000); // needs some time to load block index, we should query instead of setting a timer. 34 | if (attempts > 40) // 10 sec 35 | return reject("BTC RPC not responding after 10 seconds"); 36 | setTimeout(try_client,250); 37 | } 38 | try_client(); 39 | }); 40 | } 41 | 42 | async function start_btc_chain() 43 | { 44 | return new Promise(async (resolve,reject) => 45 | { 46 | const regtest_directory = './btc/regtest'; 47 | await fs.rm(regtest_directory,{recursive: true, force: true}); 48 | const child = spawn('npm run btc-test-rpc',{shell: true}); 49 | DEBUG && child.stdout.on('data',chunk => console.debug(chunk.toString())); 50 | child.on('error',reject); 51 | child.on('exit',code => code > 0 && reject(`BTC process exited with ${code}. Set DEBUG=1 for more information.`)); 52 | const client = await wait_btc_client(); 53 | await client.createWallet('test'); 54 | await client.setTxFee('0.00001'); 55 | const addresses = await Promise.all([client.getNewAddress(),client.getNewAddress(),client.getNewAddress()]); 56 | const private_keys = await Promise.all(addresses.map(address => client.dumpPrivKey(address))); 57 | const accounts = private_keys.map(private_key => bitcoin.ECPair.fromWIF(private_key, bitcoin.networks.regtest)); 58 | accounts.map((account,index) => account.address = addresses[index]); 59 | await client.generateToAddress(101, accounts[0].address); // mine 101 blocks so we have 50 BTC of mature balance. 60 | await Promise.all(accounts.slice(1).map(account => client.sendToAddress(account.address, '10'))); // send 10 BTC to the other addresses 61 | resolve({ 62 | child, 63 | client, 64 | session: {accounts}, 65 | mine_empty_blocks: async function(count) 66 | { 67 | return client.generateToAddress(count, accounts[0].address); 68 | }, 69 | block_height: async (increment) => 70 | { 71 | const height = new BN(await client.getBlockCount()); 72 | return increment ? height.add(new BN(increment)) : height; 73 | }, 74 | balance: async function(address, confirmations) 75 | { 76 | return client.getReceivedByAddress(address, confirmations || 0); 77 | }, 78 | kill: function(signal) 79 | { 80 | this.child.kill(signal); 81 | fs.rm(regtest_directory,{recursive: true, force: true}); 82 | } 83 | }); 84 | }); 85 | } 86 | 87 | function btc_to_sat(n) 88 | { 89 | return (new BN(n)).mul(new BN('100000000')); 90 | } 91 | 92 | function btc_generate_htlc(hash, sender_public_key, recipient_public_key, expiration_height) 93 | { 94 | const script = bitcoin.script.fromASM(` 95 | OP_SHA256 ${hash.toString('hex')} 96 | OP_EQUAL 97 | OP_IF 98 | ${recipient_public_key.toString('hex')} 99 | OP_ELSE 100 | ${bitcoin.script.number.encode(bip65.encode({blocks: expiration_height})).toString('hex')} 101 | OP_CHECKLOCKTIMEVERIFY 102 | OP_DROP 103 | ${sender_public_key.toString('hex')} 104 | OP_ENDIF 105 | OP_CHECKSIG`.replace(/\s+/g,' ').trim()); 106 | return script; 107 | } 108 | 109 | function btc_htlc_scriptpubkey(script) 110 | { 111 | const script_buffer = typeof script === 'string' ? Buffer.from(script,'hex') : script; 112 | return Buffer.concat([Buffer.from('0020', 'hex'),crypto.createHash('sha256').update(script_buffer).digest()]); 113 | } 114 | 115 | async function btc_register_swap_intent(options) 116 | { 117 | const { 118 | btc_chain, // object, BTC chain instance from start_btc_chain() 119 | sender, // ECPair 120 | recipient_public_key, 121 | hash, 122 | amount, 123 | expiration_height, 124 | network, 125 | tx_fee_sat // tx fee in sat to add 126 | } = options; 127 | if (!btc_chain || !sender || !sender.publicKey || !recipient_public_key || !hash || !amount || !expiration_height) 128 | throw new Error(`Missing options`); 129 | const net = typeof network === 'string' ? bitcoin.networks[network || 'regtest'] : network; 130 | const htlc = btc_generate_htlc(hash, sender.publicKey, recipient_public_key, expiration_height); 131 | const p2wsh = bitcoin.payments.p2wsh({redeem: {output: htlc, network: net}, network: net}); 132 | const total_btc = (amount + (tx_fee_sat / 100000000)).toFixed(8); 133 | const htlc_txid = await btc_chain.client.sendToAddress(p2wsh.address, total_btc); //TODO- sent from sender_private_key 134 | const tx = await btc_chain.client.getTransaction(htlc_txid); 135 | return { 136 | htlc, 137 | htlc_address: p2wsh.address, 138 | htlc_txid, 139 | vout: tx.details[0].vout, 140 | network, 141 | amount, 142 | htlc_tx_fee_sat: tx_fee_sat, 143 | expiration_height 144 | }; 145 | } 146 | 147 | // lifted from bitcoinjs-lib/src/psbt.js 148 | const varuint = require('bip174/src/lib/converter/varint'); 149 | function witnessStackToScriptWitness(witness) { 150 | let buffer = Buffer.allocUnsafe(0); 151 | function writeSlice(slice) { 152 | buffer = Buffer.concat([buffer, Buffer.from(slice)]); 153 | } 154 | function writeVarInt(i) { 155 | const currentLen = buffer.length; 156 | const varintLen = varuint.encodingLength(i); 157 | buffer = Buffer.concat([buffer, Buffer.allocUnsafe(varintLen)]); 158 | varuint.encode(i, buffer, currentLen); 159 | } 160 | function writeVarSlice(slice) { 161 | writeVarInt(slice.length); 162 | writeSlice(slice); 163 | } 164 | function writeVector(vector) { 165 | writeVarInt(vector.length); 166 | vector.forEach(writeVarSlice); 167 | } 168 | writeVector(witness); 169 | return buffer; 170 | } 171 | 172 | function btc_build_htlc_redeem_transaction(options) 173 | { 174 | const { 175 | preimage, // buff 176 | htlc, 177 | htlc_txid, 178 | vout, 179 | network, 180 | amount, 181 | htlc_tx_fee_sat, 182 | expiration_height, 183 | recipient, 184 | refund 185 | } = options; 186 | if (!preimage || !htlc || !amount || !expiration_height || !recipient || !recipient.publicKey) 187 | throw new Error('Missing options'); 188 | const net = typeof network === 'string' ? bitcoin.networks[network || 'regtest'] : network; 189 | const psbt = new bitcoin.Psbt({network: net}); 190 | const amount_sat = new BN(Math.round(amount * 100000000)); 191 | const total_sat = amount_sat.add(new BN(htlc_tx_fee_sat || '200')); 192 | if (refund) 193 | psbt.setLocktime(bip65.encode({blocks: expiration_height})); 194 | psbt.addInput({ 195 | hash: htlc_txid, 196 | index: vout, 197 | sequence: 0xfffffffe, //UINT_MAX - 1 198 | witnessUtxo: { 199 | script: btc_htlc_scriptpubkey(htlc), 200 | value: total_sat.toNumber() 201 | }, 202 | witnessScript: htlc 203 | }); 204 | psbt.addOutput({ 205 | address: recipient.address, 206 | value: amount_sat.toNumber() 207 | }); 208 | psbt.signInput(0, recipient); 209 | psbt.finalizeInput(0, (index, input, script) => 210 | { 211 | //console.log(script.toString('hex')); 212 | const claim_branch = bitcoin.payments.p2wsh({ 213 | redeem: { 214 | input: bitcoin.script.compile([input.partialSig[0].signature,refund ? Buffer.from([]) : preimage]), 215 | output: htlc, 216 | } 217 | }); 218 | return { 219 | finalScriptWitness: witnessStackToScriptWitness(claim_branch.witness) 220 | }; 221 | }); 222 | return psbt.extractTransaction(); 223 | } 224 | 225 | async function btc_execute_swap(options) 226 | { 227 | const { 228 | btc_chain, 229 | preimage, // buff 230 | recipient 231 | } = options; 232 | if (!preimage || !recipient || !recipient.publicKey) 233 | throw new Error('Missing options'); 234 | const tx = btc_build_htlc_redeem_transaction({...options, refund: false}); 235 | return btc_chain.client.sendRawTransaction(tx.toHex()); 236 | } 237 | 238 | async function btc_refund_swap_intent(options) 239 | { 240 | const { 241 | btc_chain, 242 | preimage, // buff 243 | recipient 244 | } = options; 245 | if (!preimage || !recipient || !recipient.publicKey) 246 | throw new Error('Missing options'); 247 | const tx = btc_build_htlc_redeem_transaction({...options, refund: true}); 248 | return btc_chain.client.sendRawTransaction(tx.toHex()); 249 | } 250 | 251 | module.exports = { 252 | start_btc_chain, 253 | btc_generate_htlc, 254 | btc_register_swap_intent, 255 | btc_build_htlc_redeem_transaction, 256 | btc_execute_swap, 257 | btc_refund_swap_intent 258 | }; 259 | -------------------------------------------------------------------------------- /eth/test/Erc20Erc721HTLC.js: -------------------------------------------------------------------------------- 1 | const Erc20Erc721HTLC = artifacts.require('Erc20Erc721HTLC'); 2 | const TestERC20 = artifacts.require('TestERC20'); 3 | const TestERC721 = artifacts.require('TestERC721'); 4 | 5 | const { 6 | generate_secret, 7 | calculate_hash, 8 | assert_is_rejected, 9 | block_height, 10 | mine_blocks} = require('./util'); 11 | 12 | const MAX_INT = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; 13 | 14 | contract('Erc20Erc721HTLC',accounts => 15 | { 16 | let htlc_approved = false; 17 | 18 | const deployed = async (list) => Promise.all(list.map(list => list.deployed())); 19 | 20 | const get_contracts = async () => 21 | { 22 | const [erc20_erc721_htlc,erc20,erc721] = await deployed([Erc20Erc721HTLC,TestERC20,TestERC721]); 23 | return {erc20_erc721_htlc,erc20,erc721}; 24 | }; 25 | 26 | const prepare_basic_test = async (options) => 27 | { 28 | const {sender, erc20_amount} = options; 29 | const {erc20_erc721_htlc, erc20, erc721} = await get_contracts(); 30 | const amount = erc20_amount || (~~(Math.random()*5000) + 2000); 31 | const result = await Promise.all( 32 | [ 33 | erc721.mint(sender), 34 | erc20.mint(sender, amount), 35 | htlc_approved || erc721.setApprovalForAll(erc20_erc721_htlc.address,true,{from: sender}), 36 | htlc_approved || erc20.approve(erc20_erc721_htlc.address,MAX_INT,{from: sender}) 37 | ]); 38 | htlc_approved = true; 39 | const erc721_token_id = result[0].receipt.logs[0].args.tokenId.toNumber(); 40 | return {erc20_erc721_htlc, erc20, erc721, erc721_token_id, erc20_amount: amount}; 41 | }; 42 | 43 | it('ERC20: can register swap intent',async () => 44 | { 45 | const [, sender, recipient] = accounts; 46 | const {erc20_erc721_htlc, erc20, erc20_amount} = await prepare_basic_test({sender}); 47 | const secret = await generate_secret(); 48 | const hash = calculate_hash(secret); 49 | const expiration_height = await block_height(10); 50 | const contract_starting_balance = (await erc20.balanceOf(erc20_erc721_htlc.address)).toNumber(); 51 | assert.isOk(await erc20_erc721_htlc.register_swap_intent(hash, expiration_height, recipient, erc20.address, erc20_amount, {from: sender})); 52 | assert.equal((await erc20.balanceOf(erc20_erc721_htlc.address)).toNumber() - contract_starting_balance, erc20_amount); 53 | }); 54 | 55 | it('ERC20: sender can cancel a swap intent after expiry',async () => 56 | { 57 | const [, sender, recipient] = accounts; 58 | const {erc20_erc721_htlc, erc20, erc20_amount} = await prepare_basic_test({sender}); 59 | const secret = await generate_secret(); 60 | const hash = calculate_hash(secret); 61 | const blocks = 10; 62 | const expiration_height = await block_height(blocks); 63 | await erc20_erc721_htlc.register_swap_intent(hash, expiration_height, recipient, erc20.address, erc20_amount, {from: sender}); 64 | await mine_blocks(blocks); 65 | const sender_starting_balance = (await erc20.balanceOf(sender)).toNumber(); 66 | const cancellation = await erc20_erc721_htlc.cancel_swap_intent(hash, {from: sender}); 67 | assert.isOk(cancellation); 68 | assert.equal((await erc20.balanceOf(sender)).toNumber() - sender_starting_balance, erc20_amount); 69 | }); 70 | 71 | it('ERC20: anyone can trigger swap before expiry using the correct preimage',async () => 72 | { 73 | const [, sender, recipient, third_party] = accounts; 74 | const {erc20_erc721_htlc, erc20, erc20_amount} = await prepare_basic_test({sender}); 75 | const secret = await generate_secret(); 76 | const hash = calculate_hash(secret); 77 | const expiration_height = await block_height(10); 78 | const recipient_starting_balance = (await erc20.balanceOf(recipient)).toNumber(); 79 | await erc20_erc721_htlc.register_swap_intent(hash, expiration_height, recipient, erc20.address, erc20_amount, {from: sender}); 80 | assert.isOk(await erc20_erc721_htlc.swap(sender, secret, {from: third_party})); 81 | assert.equal((await erc20.balanceOf(recipient)).toNumber() - recipient_starting_balance, erc20_amount); 82 | }); 83 | 84 | it('ERC721: can register swap intent',async () => 85 | { 86 | const [, sender, recipient] = accounts; 87 | const {erc20_erc721_htlc, erc721, erc721_token_id} = await prepare_basic_test({sender}); 88 | const secret = await generate_secret(); 89 | const hash = calculate_hash(secret); 90 | const expiration_height = await block_height(10); 91 | assert.isOk(await erc20_erc721_htlc.register_swap_intent(hash, expiration_height, recipient, erc721.address, erc721_token_id, {from: sender})); 92 | assert.equal(await erc721.ownerOf(erc721_token_id), erc20_erc721_htlc.address); 93 | }); 94 | 95 | it('ERC721: sender can cancel a swap intent after expiry',async () => 96 | { 97 | const [, sender, recipient] = accounts; 98 | const {erc20_erc721_htlc, erc721, erc721_token_id} = await prepare_basic_test({sender}); 99 | const secret = await generate_secret(); 100 | const hash = calculate_hash(secret); 101 | const blocks = 12; 102 | const expiration_height = await block_height(blocks); 103 | await erc20_erc721_htlc.register_swap_intent(hash, expiration_height, recipient, erc721.address, erc721_token_id, {from: sender}); 104 | assert.equal(await erc721.ownerOf(erc721_token_id), erc20_erc721_htlc.address); 105 | await mine_blocks(blocks); 106 | const cancellation = await erc20_erc721_htlc.cancel_swap_intent(hash, {from: sender}); 107 | assert.isOk(cancellation); 108 | assert.equal(await erc721.ownerOf(erc721_token_id), sender); 109 | }); 110 | 111 | it('ERC721: anyone can trigger swap before expiry using the correct preimage',async () => 112 | { 113 | const [, sender, recipient, third_party] = accounts; 114 | const {erc20_erc721_htlc, erc721, erc721_token_id} = await prepare_basic_test({sender}); 115 | const secret = await generate_secret(); 116 | const hash = calculate_hash(secret); 117 | const expiration_height = await block_height(10); 118 | await erc20_erc721_htlc.register_swap_intent(hash, expiration_height, recipient, erc721.address, erc721_token_id, {from: sender}); 119 | assert.equal(await erc721.ownerOf(erc721_token_id), erc20_erc721_htlc.address); 120 | await erc20_erc721_htlc.swap(sender, secret, {from: third_party}); 121 | assert.equal(await erc721.ownerOf(erc721_token_id), recipient); 122 | }); 123 | 124 | it('ERC20/ERC721: expiration height cannot be in the past',async () => 125 | { 126 | await mine_blocks(5); 127 | const [, sender, recipient] = accounts; 128 | const {erc20_erc721_htlc, erc20, erc20_amount} = await prepare_basic_test({sender}); 129 | const secret = await generate_secret(); 130 | const hash = calculate_hash(secret); 131 | const expiration_height = await block_height(-1); 132 | return assert_is_rejected( 133 | erc20_erc721_htlc.register_swap_intent(hash, expiration_height, recipient, erc20.address, erc20_amount, {from: sender}), 134 | /Expiry in the past/, 135 | 'Should have rejected' 136 | ); 137 | }); 138 | 139 | it('ERC20/ERC721: swap intent cannot already exist',async () => 140 | { 141 | const [, sender, recipient] = accounts; 142 | const {erc20_erc721_htlc, erc20, erc20_amount} = await prepare_basic_test({sender}); 143 | const secret = await generate_secret(); 144 | const hash = calculate_hash(secret); 145 | const expiration_height = await block_height(10); 146 | await erc20_erc721_htlc.register_swap_intent(hash, expiration_height, recipient, erc20.address, erc20_amount, {from: sender}); 147 | return assert_is_rejected( 148 | erc20_erc721_htlc.register_swap_intent(hash, expiration_height, recipient, erc20.address, erc20_amount, {from: sender}), 149 | /Swap intent already exists/, 150 | 'Should have rejected' 151 | ); 152 | }); 153 | 154 | it('ERC20/ERC721: sender cannot cancel a swap intent before expiry',async () => 155 | { 156 | const [, sender, recipient] = accounts; 157 | const {erc20_erc721_htlc, erc20, erc20_amount} = await prepare_basic_test({sender}); 158 | const secret = await generate_secret(); 159 | const hash = calculate_hash(secret); 160 | const expiration_height = await block_height(10); 161 | await erc20_erc721_htlc.register_swap_intent(hash, expiration_height, recipient, erc20.address, erc20_amount, {from: sender}); 162 | return assert_is_rejected( 163 | erc20_erc721_htlc.cancel_swap_intent(hash, {from: sender}), 164 | /Swap intent not expired/, 165 | 'Should have rejected' 166 | ); 167 | }); 168 | 169 | it('ERC20/ERC721: sender cannot cancel a swap intent that does not exist',async () => 170 | { 171 | const [erc20_erc721_htlc] = await deployed([Erc20Erc721HTLC]); 172 | const [, sender] = accounts; 173 | const secret = await generate_secret(); 174 | const hash = calculate_hash(secret); 175 | return assert_is_rejected( 176 | erc20_erc721_htlc.cancel_swap_intent(hash, {from: sender}), 177 | /Unknown swap/, 178 | 'Should have rejected' 179 | ); 180 | }); 181 | 182 | it('ERC20/ERC721: third party cannot cancel a swap intent after expiry',async () => 183 | { 184 | const [, sender, recipient, third_party] = accounts; 185 | const {erc20_erc721_htlc, erc20, erc20_amount} = await prepare_basic_test({sender}); 186 | const secret = await generate_secret(); 187 | const hash = calculate_hash(secret); 188 | const blocks = 10; 189 | const expiration_height = await block_height(blocks); 190 | await erc20_erc721_htlc.register_swap_intent(hash, expiration_height, recipient, erc20.address, erc20_amount, {from: sender}); 191 | await mine_blocks(blocks); 192 | return assert_is_rejected( 193 | erc20_erc721_htlc.cancel_swap_intent(hash, {from: third_party}), 194 | /Unknown swap/, 195 | 'Should have rejected' 196 | ); 197 | }); 198 | 199 | it('ERC20/ERC721: nobody can trigger swap after expiry using the correct preimage',async () => 200 | { 201 | const [, sender, recipient, third_party] = accounts; 202 | const {erc20_erc721_htlc, erc20, erc20_amount} = await prepare_basic_test({sender}); 203 | const secret = await generate_secret(); 204 | const hash = calculate_hash(secret); 205 | const blocks = 16; 206 | const expiration_height = await block_height(blocks); 207 | await erc20_erc721_htlc.register_swap_intent(hash, expiration_height, recipient, erc20.address, erc20_amount, {from: sender}); 208 | await mine_blocks(blocks); 209 | return assert_is_rejected( 210 | erc20_erc721_htlc.swap(sender, secret, {from: third_party}), 211 | /Swap intent expired/, 212 | 'Should have rejected' 213 | ); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /test/util/stx.js: -------------------------------------------------------------------------------- 1 | const spawn = require('child_process').spawn; 2 | const readline = require('readline'); 3 | const BN = require('bn.js'); 4 | const assert = require('assert'); 5 | const {buffer_to_hex} = require('./common'); 6 | 7 | const DEBUG = !!process.env.DEBUG; 8 | 9 | function wrap_clarinet_process(child,ready) 10 | { 11 | let rl = readline.createInterface({input: child.stdout, terminal: true}); 12 | let running = false; 13 | const queue = []; 14 | const session = {}; 15 | let block_height = 0; 16 | 17 | rl.on('line',line => 18 | { 19 | if (!running) 20 | { 21 | try 22 | { 23 | const response = JSON.parse(line); 24 | if (response.ready) 25 | { 26 | running = true; 27 | session.accounts = response.accounts; 28 | session.contracts = response.contracts; 29 | DEBUG && console.debug(`Received Clarinet session state: ${line}`); 30 | ready(); 31 | } 32 | } 33 | catch (e){} 34 | return; 35 | } 36 | DEBUG && console.debug(`STX: ${line}`); 37 | if (!queue.length) 38 | return; 39 | const [resolve,reject,op] = queue.shift(); 40 | try 41 | { 42 | const response = JSON.parse(line); 43 | if (response && response.result && response.result.block_height) 44 | block_height = response.result.block_height; 45 | switch (op) 46 | { 47 | case "mine_empty_blocks": return resolve(response.result.block_height); 48 | case "call_read_only_fn": return resolve({result: response.result.result, events: response.result.events}); 49 | case "mine_block": return resolve(response.result.receipts); 50 | case "get_assets_maps": return resolve(response.result.assets); 51 | } 52 | resolve(response); 53 | } 54 | catch (error) 55 | { 56 | reject({error,line}); 57 | } 58 | }); 59 | 60 | return { 61 | child, 62 | session_id: 0, 63 | request_id: 0, 64 | session, 65 | block_height: async (increment) => 66 | { 67 | const height = new BN(block_height); 68 | return increment ? height.add(new BN(increment)) : height; 69 | }, 70 | send: async function (op,params) 71 | { 72 | if (!running) 73 | return Promise.reject("not running"); 74 | return new Promise((resolve,reject) => 75 | { 76 | queue.push([resolve,reject,op]); 77 | DEBUG && console.debug(`Sending to Clarinet session: ${JSON.stringify({op,params})}`); 78 | this.child.stdin.write(JSON.stringify({id: this.request_id++, op, params})+"\n"); 79 | }); 80 | }, 81 | mine_empty_blocks: async function(count) 82 | { 83 | return this.send("mine_empty_blocks",{sessionId: this.session_id, count}); 84 | }, 85 | contract_call: async function(contract,function_name,function_args,sender) 86 | { 87 | if (contract[0] === '.') 88 | contract = this.session.accounts.deployer.address + contract; 89 | const transactions = 90 | [ 91 | { 92 | type: 2, // contract call 93 | sender: sender || this.session.accounts.deployer.address, 94 | contractCall: 95 | { 96 | contract, 97 | method: function_name, 98 | args: function_args || [] 99 | } 100 | } 101 | ]; 102 | return this.send("mine_block",{sessionId: this.session_id, transactions}); 103 | }, 104 | read_only_call: async function(contract,function_name,function_args,sender) 105 | { 106 | if (contract[0] === '.') 107 | contract = this.session.accounts.deployer.address + contract; 108 | return this.send("call_read_only_fn", 109 | { 110 | sessionId: this.session_id, 111 | contract, 112 | method: function_name, 113 | args: function_args, 114 | sender: sender || this.session.accounts.deployer.address 115 | }); 116 | }, 117 | asset_maps: async function() 118 | { 119 | return this.send("get_assets_maps", {sessionId: this.session_id}); 120 | }, 121 | balance: async function(principal) 122 | { 123 | const assets = await this.asset_maps(); 124 | return new BN(assets.STX[principal] || 0); 125 | }, 126 | kill: function (signal) 127 | { 128 | queue.forEach(([,reject]) => reject()); 129 | rl.close(); 130 | this.child.kill(signal); 131 | } 132 | }; 133 | } 134 | 135 | async function start_stx_chain() 136 | { 137 | return new Promise((resolve,reject) => 138 | { 139 | const child = spawn("npm run stx-test-rpc",{shell: true}); 140 | child.on('error',reject); 141 | child.on('exit',code => code > 0 && reject(`Clarinet process exited with ${code}. Set DEBUG=1 for more information.`)) 142 | const clarinet_session = wrap_clarinet_process(child,() => resolve(clarinet_session)); 143 | }); 144 | } 145 | 146 | function uintCV(uint) 147 | { 148 | if (typeof uint === 'string' && uint[0] === 'u') 149 | return uint; 150 | return `u${uint.toString()}`; 151 | } 152 | 153 | function principalCV(principal) 154 | { 155 | return `'${typeof principal === 'string' ? principal : principal.address}`; 156 | } 157 | 158 | function bufferCV(buffer) 159 | { 160 | return buffer_to_hex(buffer); 161 | } 162 | 163 | function listCV(list) 164 | { 165 | return `(list ${list.join(' ')})`; 166 | } 167 | 168 | function booleanCV(bool) 169 | { 170 | return bool && 'true' || 'false'; 171 | } 172 | 173 | function tupleCV(obj) 174 | { 175 | return '{' + Object.entries(obj).map(([key, value]) => `${key}: ${value}`).join(', ') + '}'; 176 | } 177 | 178 | async function stx_register_swap_intent(options) 179 | { 180 | const { 181 | stx_chain, // object, STX chain instance from start_stx_chain() 182 | contract_name, 183 | sender, // principal 184 | recipient, // principal 185 | hash, // string, 186 | amount_or_token_id, // BN, amount for STX or SIP010, token ID for SIP009 187 | expiration_height, // BN, expiration block height 188 | asset_contract, // principal | null, null for STX swap 189 | asset_type // null | "sip009" | "sip010" 190 | } = options; 191 | if (!stx_chain || !sender || !recipient || !hash || !amount_or_token_id || !expiration_height) 192 | throw new Error(`Missing options`); 193 | const contract_principal = contract_name || (asset_contract ? '.sip009-sip010-htlc' : '.stx-htlc'); 194 | const parameters = [bufferCV(hash), uintCV(expiration_height), uintCV(amount_or_token_id), principalCV(recipient)]; 195 | if (asset_contract) 196 | parameters.push(principalCV(asset_contract)); 197 | return stx_chain.contract_call(contract_principal, asset_contract ? `register-swap-intent-${asset_type}` : 'register-swap-intent', parameters, sender); 198 | } 199 | 200 | async function stx_execute_swap(options) 201 | { 202 | const { 203 | stx_chain, // object, STX chain instance from start_stx_chain() 204 | preimage, 205 | contract_name, 206 | sender, // principal 207 | transaction_sender, // principal 208 | asset_contract, // principal | null, null for STX swap 209 | asset_type // null | "sip009" | "sip010" 210 | } = options; 211 | const contract_principal = contract_name || (asset_contract ? '.sip009-sip010-htlc' : '.stx-htlc'); 212 | const parameters = [principalCV(sender), bufferCV(preimage)]; 213 | if (asset_contract) 214 | parameters.push(principalCV(asset_contract)); 215 | return stx_chain.contract_call(contract_principal, asset_contract ? `swap-${asset_type}` : 'swap', parameters, transaction_sender || sender); 216 | } 217 | 218 | async function stx_verify_swap(swap_intent,swap_result) 219 | { 220 | const { 221 | recipient, // principal 222 | amount_or_token_id, // BN, amount for STX or SIP010, token ID for SIP009 223 | asset_contract // principal | null, null for STX swap 224 | } = swap_intent; 225 | if (Array.isArray(swap_result)) 226 | swap_result = swap_result[0]; 227 | const {result, events} = swap_result; 228 | assert(result === '(ok true)'); 229 | assert(events.length === 1, 'Should be only one chain event'); 230 | if (asset_contract) 231 | { 232 | assert(events[0].type === 'ft_transfer_event' || events[0].type === 'nft_transfer_event', 'Should be an FT/NFT transfer event'); 233 | const event = events[0].ft_transfer_event || events[0].nft_transfer_event; 234 | assert(event.recipient === recipient, 'Wrong recipient'); 235 | assert((new BN(event.amount)).eq(new BN(amount_or_token_id)), events[0].ft_transfer_event ? 'Wrong amount' : 'Wrong token ID'); 236 | } 237 | else 238 | { 239 | assert(events[0].type === 'stx_transfer_event', 'Should be a STX transfer event'); 240 | assert(events[0].stx_transfer_event.recipient === recipient, 'Wrong recipient'); 241 | assert((new BN(events[0].stx_transfer_event.amount)).eq(new BN(amount_or_token_id)), 'Wrong amount'); 242 | } 243 | return true; 244 | } 245 | 246 | async function sip009_mint(stx_chain, recipient) 247 | { 248 | const [response] = await stx_chain.contract_call('.test-sip009','mint',[principalCV(recipient)]); 249 | if (response.result.substr(0,3) !== '(ok') 250 | throw new Error('SIP009 minting failed'); 251 | return {...response.events[0].nft_mint_event, asset_contract: response.events[0].nft_mint_event.asset_identifier.split('::')[0]}; 252 | } 253 | 254 | async function sip009_owner(stx_chain, token_id) 255 | { 256 | const response = await stx_chain.read_only_call('.test-sip009','get-owner',[uintCV(token_id)]); 257 | const match = response.result.match(/^\(ok \(some (.+?)\)\)$/); 258 | return (match && match[1]) || null; 259 | } 260 | 261 | async function sip010_mint(stx_chain, recipient, amount) 262 | { 263 | const [response] = await stx_chain.contract_call('.test-sip010','mint',[uintCV(amount),principalCV(recipient)]); 264 | if (response.result.substr(0,3) !== '(ok') 265 | throw new Error('SIP010 minting failed'); 266 | return {...response.events[0].ft_mint_event, asset_contract: response.events[0].ft_mint_event.asset_identifier.split('::')[0]}; 267 | } 268 | 269 | async function sip010_balance(stx_chain, principal) 270 | { 271 | const response = await stx_chain.read_only_call('.test-sip010','get-balance',[principalCV(principal)]); 272 | const match = response.result.match(/^\(ok u(.+?)\)$/); 273 | return match && new BN(match[1]) || new BN(0); 274 | } 275 | 276 | async function sip009_sip010_htlc_set_whitelisted(stx_chain, list) 277 | { 278 | const list_cv = listCV(list.map(({token_contract, whitelisted}) => tupleCV({'asset-contract': principalCV(token_contract), whitelisted: booleanCV(whitelisted)}))); 279 | const [response] = await stx_chain.contract_call('.sip009-sip010-htlc','set-whitelisted',[list_cv]); 280 | if (response.result.substr(0,3) !== '(ok') 281 | throw new Error('Whitelisting failed'); 282 | return true; 283 | } 284 | 285 | module.exports = { 286 | start_stx_chain, 287 | uintCV, 288 | principalCV, 289 | bufferCV, 290 | listCV, 291 | stx_register_swap_intent, 292 | stx_execute_swap, 293 | stx_verify_swap, 294 | sip009_sip010_htlc_set_whitelisted, 295 | sip009_mint, 296 | sip009_owner, 297 | sip010_mint, 298 | sip010_balance 299 | }; -------------------------------------------------------------------------------- /stx/tests/stx-htlc_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Clarinet, 3 | Chain, 4 | Account, 5 | types, 6 | generate_secret, 7 | calculate_hash, 8 | swap_contract_principal, 9 | register_swap_intent, 10 | get_swap_intent, 11 | cancel_swap_intent, 12 | execute_swap, 13 | ErrorCodes 14 | } from './common.ts'; 15 | import { assertEquals } from 'https://deno.land/std@0.90.0/testing/asserts.ts'; 16 | 17 | Clarinet.test({ 18 | name: "Can register a swap intent", 19 | async fn(chain: Chain, accounts: Map) { 20 | const secret = generate_secret(); 21 | const hash = calculate_hash(secret); 22 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 23 | const swap_intent = { 24 | hash, 25 | expiration_height: chain.blockHeight + 10, 26 | amount_or_token_id: 100, 27 | sender: sender.address, 28 | recipient: recipient.address 29 | }; 30 | const swap_contract = swap_contract_principal(deployer, swap_intent); 31 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 32 | swap.result.expectOk().expectBool(true); 33 | swap.events.expectSTXTransferEvent(swap_intent.amount_or_token_id, sender.address, swap_contract); 34 | } 35 | }); 36 | 37 | Clarinet.test({ 38 | name: "Can retrieve a swap intent", 39 | async fn(chain: Chain, accounts: Map) { 40 | const secret = generate_secret(); 41 | const hash = calculate_hash(secret); 42 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 43 | const swap_intent = { 44 | hash, 45 | expiration_height: chain.blockHeight + 10, 46 | amount_or_token_id: 100, 47 | sender: sender.address, 48 | recipient: recipient.address 49 | }; 50 | const swap_contract = swap_contract_principal(deployer, swap_intent); 51 | register_swap_intent(chain, swap_contract, swap_intent); 52 | const swap = get_swap_intent(chain, swap_contract, hash, sender.address); 53 | const result = swap.result.expectSome().expectTuple(); 54 | assertEquals(result, { 55 | "amount": types.uint(swap_intent.amount_or_token_id), 56 | "expiration-height": types.uint(swap_intent.expiration_height), 57 | "recipient": swap_intent.recipient 58 | }); 59 | } 60 | }); 61 | 62 | Clarinet.test({ 63 | name: "Hash has to be 32 bytes in length", 64 | async fn(chain: Chain, accounts: Map) { 65 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 66 | const swap_intent = { 67 | hash: new Uint8Array(new ArrayBuffer(31)), 68 | expiration_height: chain.blockHeight + 10, 69 | amount_or_token_id: 110, 70 | sender: sender.address, 71 | recipient: recipient.address 72 | }; 73 | const swap_contract = swap_contract_principal(deployer, swap_intent); 74 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 75 | swap.result.expectErr().expectUint(ErrorCodes.ERR_INVALID_HASH_LENGTH); 76 | assertEquals(swap.events.length, 0); 77 | } 78 | }); 79 | 80 | Clarinet.test({ 81 | name: "Expiration height cannot be in the past", 82 | async fn(chain: Chain, accounts: Map) { 83 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 84 | chain.mineEmptyBlock(5); 85 | const swap_intent = { 86 | hash: new Uint8Array(new ArrayBuffer(32)), 87 | expiration_height: chain.blockHeight - 1, 88 | amount_or_token_id: 120, 89 | sender: sender.address, 90 | recipient: recipient.address 91 | }; 92 | const swap_contract = swap_contract_principal(deployer, swap_intent); 93 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 94 | swap.result.expectErr().expectUint(ErrorCodes.ERR_EXPIRY_IN_PAST); 95 | assertEquals(swap.events.length, 0); 96 | } 97 | }); 98 | 99 | Clarinet.test({ 100 | name: "Swap intent cannot already exist", 101 | async fn(chain: Chain, accounts: Map) { 102 | const secret = generate_secret(); 103 | const hash = calculate_hash(secret); 104 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 105 | const swap_intent = { 106 | hash, 107 | expiration_height: chain.blockHeight + 10, 108 | amount_or_token_id: 130, 109 | sender: sender.address, 110 | recipient: recipient.address 111 | }; 112 | const swap_contract = swap_contract_principal(deployer, swap_intent); 113 | register_swap_intent(chain, swap_contract, swap_intent); 114 | const second_swap = register_swap_intent(chain, swap_contract, swap_intent); 115 | second_swap.result.expectErr().expectUint(ErrorCodes.ERR_SWAP_INTENT_ALREADY_EXISTS); 116 | assertEquals(second_swap.events.length, 0); 117 | } 118 | }); 119 | 120 | Clarinet.test({ 121 | name: "Amount cannot be 0", 122 | async fn(chain: Chain, accounts: Map) { 123 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 124 | const swap_intent = { 125 | hash: new Uint8Array(new ArrayBuffer(32)), 126 | expiration_height: chain.blockHeight + 10, 127 | amount_or_token_id: 0, 128 | sender: sender.address, 129 | recipient: recipient.address 130 | }; 131 | const swap_contract = swap_contract_principal(deployer, swap_intent); 132 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 133 | swap.result.expectErr().expectUint(ErrorCodes.ERR_STX_TRANSFER_NON_POSITIVE); 134 | assertEquals(swap.events.length, 0); 135 | } 136 | }); 137 | 138 | Clarinet.test({ 139 | name: "Sender cannot pledge more STX than owned", 140 | async fn(chain: Chain, accounts: Map) { 141 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 142 | const swap_intent = { 143 | hash: new Uint8Array(new ArrayBuffer(32)), 144 | expiration_height: chain.blockHeight + 10, 145 | amount_or_token_id: sender.balance + 100, 146 | sender: sender.address, 147 | recipient: recipient.address 148 | }; 149 | const swap_contract = swap_contract_principal(deployer, swap_intent); 150 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 151 | swap.result.expectErr().expectUint(ErrorCodes.ERR_STX_TRANSFER_INSUFFICIENT_BALANCE); 152 | assertEquals(swap.events.length, 0); 153 | } 154 | }); 155 | 156 | Clarinet.test({ 157 | name: "Sender can cancel a swap intent after expiry", 158 | async fn(chain: Chain, accounts: Map) { 159 | const secret = generate_secret(); 160 | const hash = calculate_hash(secret); 161 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 162 | const swap_intent = { 163 | hash, 164 | expiration_height: chain.blockHeight + 10, 165 | amount_or_token_id: 140, 166 | sender: sender.address, 167 | recipient: recipient.address 168 | }; 169 | const swap_contract = swap_contract_principal(deployer, swap_intent); 170 | register_swap_intent(chain, swap_contract, swap_intent); 171 | chain.mineEmptyBlock(swap_intent.expiration_height - chain.blockHeight + 1); 172 | const cancellation = cancel_swap_intent(chain, swap_contract, swap_intent); 173 | cancellation.result.expectOk().expectBool(true); 174 | cancellation.events.expectSTXTransferEvent(swap_intent.amount_or_token_id, swap_contract, sender.address); 175 | } 176 | }); 177 | 178 | Clarinet.test({ 179 | name: "Sender cannot cancel a swap intent before expiry", 180 | async fn(chain: Chain, accounts: Map) { 181 | const secret = generate_secret(); 182 | const hash = calculate_hash(secret); 183 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 184 | const swap_intent = { 185 | hash, 186 | expiration_height: chain.blockHeight + 10, 187 | amount_or_token_id: 150, 188 | sender: sender.address, 189 | recipient: recipient.address 190 | }; 191 | const swap_contract = swap_contract_principal(deployer, swap_intent); 192 | register_swap_intent(chain, swap_contract, swap_intent); 193 | chain.mineEmptyBlock(swap_intent.expiration_height - chain.blockHeight - 1); 194 | const cancellation = cancel_swap_intent(chain, swap_contract, swap_intent); 195 | cancellation.result.expectErr().expectUint(ErrorCodes.ERR_SWAP_INTENT_NOT_EXPIRED); 196 | assertEquals(cancellation.events.length, 0); 197 | } 198 | }); 199 | 200 | Clarinet.test({ 201 | name: "Sender cannot cancel a swap that does not exist", 202 | async fn(chain: Chain, accounts: Map) { 203 | const secret = generate_secret(); 204 | const hash = calculate_hash(secret); 205 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 206 | const swap_intent = { 207 | hash, 208 | expiration_height: chain.blockHeight + 10, 209 | amount_or_token_id: 160, 210 | sender: sender.address, 211 | recipient: recipient.address 212 | }; 213 | const swap_contract = swap_contract_principal(deployer, swap_intent); 214 | const cancellation = cancel_swap_intent(chain, swap_contract, swap_intent); 215 | cancellation.result.expectErr().expectUint(ErrorCodes.ERR_UNKNOWN_SWAP_INTENT); 216 | assertEquals(cancellation.events.length, 0); 217 | } 218 | }); 219 | 220 | Clarinet.test({ 221 | name: "Third party cannot cancel a swap intent after expiry", 222 | async fn(chain: Chain, accounts: Map) { 223 | const secret = generate_secret(); 224 | const hash = calculate_hash(secret); 225 | const [deployer, sender, recipient, third_party] = ['deployer', 'wallet_1', 'wallet_2', 'wallet_3'].map(name => accounts.get(name)!); 226 | const swap_intent = { 227 | hash, 228 | expiration_height: chain.blockHeight + 10, 229 | amount_or_token_id: 170, 230 | sender: sender.address, 231 | recipient: recipient.address 232 | }; 233 | const swap_contract = swap_contract_principal(deployer, swap_intent); 234 | register_swap_intent(chain, swap_contract, swap_intent); 235 | chain.mineEmptyBlock(swap_intent.expiration_height - chain.blockHeight + 1); 236 | const cancellation = cancel_swap_intent(chain, swap_contract, swap_intent, third_party.address); 237 | cancellation.result.expectErr().expectUint(ErrorCodes.ERR_UNKNOWN_SWAP_INTENT); 238 | assertEquals(cancellation.events.length, 0); 239 | } 240 | }); 241 | 242 | Clarinet.test({ 243 | name: "Anyone can trigger swap before expiry using the correct preimage", 244 | async fn(chain: Chain, accounts: Map) { 245 | const secret = generate_secret(); 246 | const hash = calculate_hash(secret); 247 | const [deployer, sender, recipient, third_party] = ['deployer', 'wallet_1', 'wallet_2', 'wallet_3'].map(name => accounts.get(name)!); 248 | const swap_intent = { 249 | hash, 250 | expiration_height: chain.blockHeight + 10, 251 | amount_or_token_id: 180, 252 | sender: sender.address, 253 | recipient: recipient.address 254 | }; 255 | const swap_contract = swap_contract_principal(deployer, swap_intent); 256 | register_swap_intent(chain, swap_contract, swap_intent); 257 | const swap = execute_swap(chain, swap_contract, swap_intent, secret, third_party.address); 258 | swap.result.expectOk().expectBool(true); 259 | swap.events.expectSTXTransferEvent(swap_intent.amount_or_token_id, swap_contract, recipient.address); 260 | } 261 | }); 262 | 263 | Clarinet.test({ 264 | name: "Nobody can trigger swap after expiry using the correct preimage", 265 | async fn(chain: Chain, accounts: Map) { 266 | const secret = generate_secret(); 267 | const hash = calculate_hash(secret); 268 | const [deployer, sender, recipient, third_party] = ['deployer', 'wallet_1', 'wallet_2', 'wallet_3'].map(name => accounts.get(name)!); 269 | const swap_intent = { 270 | hash, 271 | expiration_height: chain.blockHeight + 10, 272 | amount_or_token_id: 190, 273 | sender: sender.address, 274 | recipient: recipient.address 275 | }; 276 | const swap_contract = swap_contract_principal(deployer, swap_intent); 277 | register_swap_intent(chain, swap_contract, swap_intent); 278 | chain.mineEmptyBlock(swap_intent.expiration_height - chain.blockHeight + 1); 279 | const swap = execute_swap(chain, swap_contract, swap_intent, secret, third_party.address); 280 | swap.result.expectErr().expectUint(ErrorCodes.ERR_SWAP_INTENT_EXPIRED); 281 | assertEquals(swap.events.length, 0); 282 | } 283 | }); -------------------------------------------------------------------------------- /test/1-stx-btc.js: -------------------------------------------------------------------------------- 1 | const assert = require('chai').assert; 2 | const BN = require('bn.js'); 3 | 4 | const { 5 | start_stx_chain, 6 | start_btc_chain, 7 | generate_secret, 8 | calculate_hash, 9 | stx_register_swap_intent, 10 | stx_execute_swap, 11 | sip009_mint, 12 | sip009_owner, 13 | sip010_mint, 14 | sip010_balance, 15 | sip009_sip010_htlc_set_whitelisted, 16 | btc_register_swap_intent, 17 | btc_execute_swap, 18 | btc_refund_swap_intent 19 | } = require('./util'); 20 | 21 | describe('STX <> BTC',async function() 22 | { 23 | before(async function() 24 | { 25 | console.debug("Starting STX and BTC chains..."); 26 | console.debug(""); 27 | let stx_chain_process, btc_chain_process; 28 | const kill = () => 29 | { 30 | console.log('Stopping chains...'); 31 | try {stx_chain_process.kill('SIGINT');} catch(e){}; 32 | try {btc_chain_process.kill('SIGINT');} catch(e){}; 33 | }; 34 | process.on('SIGINT',kill); 35 | process.on('uncaughtException',kill); 36 | try 37 | { 38 | [stx_chain_process,btc_chain_process] = await Promise.all([start_stx_chain(),start_btc_chain()]); 39 | } 40 | catch (error) 41 | { 42 | console.error(error); 43 | kill(); 44 | process.exit(1); 45 | } 46 | this.stx = stx_chain_process; 47 | this.btc = btc_chain_process; 48 | }); 49 | 50 | after(function() 51 | { 52 | console.debug("Stopping chains..."); 53 | this.stx.kill(); 54 | this.btc.kill(); 55 | }); 56 | 57 | it("Can swap STX and BTC",async function() 58 | { 59 | const secret = await generate_secret(); 60 | const hash = calculate_hash(secret); 61 | const stx_expiration = await this.stx.block_height(10); 62 | const stx_amount = 340; 63 | const btc_expiration = (await this.btc.block_height(20)).toNumber(); 64 | const btc_amount = 1.3; // 1.3 BTC 65 | const {deployer, wallet_1} = this.stx.session.accounts; 66 | const [, btc_wallet_1, btc_wallet_2] = this.btc.session.accounts; 67 | 68 | const party_a = { 69 | stx_address: deployer.address, 70 | btc_account: btc_wallet_1 71 | }; 72 | 73 | const party_b = { 74 | stx_address: wallet_1.address, 75 | btc_account: btc_wallet_2 76 | }; 77 | 78 | // STX side 79 | const stx_side = { 80 | stx_chain: this.stx, 81 | sender: party_a.stx_address, 82 | recipient: party_b.stx_address, 83 | hash, 84 | amount_or_token_id: stx_amount, 85 | expiration_height: stx_expiration 86 | }; 87 | 88 | await stx_register_swap_intent(stx_side); 89 | 90 | 91 | // BTC side 92 | const btc_side = await btc_register_swap_intent({ 93 | btc_chain: this.btc, 94 | sender: party_b.btc_account, 95 | recipient_public_key: party_a.btc_account.publicKey, 96 | hash, 97 | amount: btc_amount, 98 | expiration_height: btc_expiration, 99 | network: 'regtest', 100 | tx_fee_sat: 500 101 | }); 102 | 103 | const party_a_starting_btc_balance = await this.btc.balance(party_a.btc_account.address); 104 | const party_b_starting_stx_balance = await this.stx.balance(party_b.stx_address); 105 | 106 | // Execute swap 107 | const stx_swap = await stx_execute_swap({ 108 | ...stx_side, 109 | preimage: secret 110 | }); 111 | 112 | const btc_swap = await btc_execute_swap({ 113 | ...btc_side, 114 | btc_chain: this.btc, 115 | preimage: secret, 116 | recipient: party_a.btc_account 117 | }); 118 | 119 | assert.isOk(stx_swap); 120 | assert.isOk(btc_swap); 121 | assert.equal(((await this.btc.balance(party_a.btc_account.address)) - party_a_starting_btc_balance).toFixed(8), btc_amount.toFixed(8), "Unexpected BTC balance"); 122 | assert.isTrue((await this.stx.balance(party_b.stx_address)).gt(party_b_starting_stx_balance), "STX balance did not increase"); 123 | }); 124 | 125 | it("Can swap SIP009 and BTC",async function() 126 | { 127 | const secret = await generate_secret(); 128 | const hash = calculate_hash(secret); 129 | 130 | const {deployer, wallet_1} = this.stx.session.accounts; 131 | const [, btc_wallet_1, btc_wallet_2] = this.btc.session.accounts; 132 | 133 | const party_a = { 134 | stx_address: deployer.address, 135 | btc_account: btc_wallet_1 136 | }; 137 | 138 | const party_b = { 139 | stx_address: wallet_1.address, 140 | btc_account: btc_wallet_2 141 | }; 142 | 143 | const sip009 = await sip009_mint(this.stx, party_a.stx_address); 144 | const stx_expiration = await this.stx.block_height(10); 145 | const btc_expiration = (await this.btc.block_height(20)).toNumber(); 146 | const btc_amount = 1.6; 147 | 148 | await sip009_sip010_htlc_set_whitelisted(this.stx, [{token_contract: sip009.asset_contract, whitelisted: true}]); 149 | 150 | // STX side 151 | const stx_side = { 152 | stx_chain: this.stx, 153 | sender: party_a.stx_address, 154 | recipient: party_b.stx_address, 155 | hash, 156 | amount_or_token_id: sip009.value, 157 | expiration_height: stx_expiration, 158 | asset_contract: sip009.asset_contract, 159 | asset_type: 'sip009' 160 | }; 161 | 162 | await stx_register_swap_intent(stx_side); 163 | 164 | // BTC side 165 | const btc_side = await btc_register_swap_intent({ 166 | btc_chain: this.btc, 167 | sender: party_b.btc_account, 168 | recipient_public_key: party_a.btc_account.publicKey, 169 | hash, 170 | amount: btc_amount, 171 | expiration_height: btc_expiration, 172 | network: 'regtest', 173 | tx_fee_sat: 500 174 | }); 175 | 176 | const party_a_starting_btc_balance = await this.btc.balance(party_a.btc_account.address); 177 | 178 | // Execute swap 179 | await stx_execute_swap({ 180 | ...stx_side, 181 | preimage: secret 182 | }); 183 | 184 | await btc_execute_swap({ 185 | ...btc_side, 186 | btc_chain: this.btc, 187 | preimage: secret, 188 | recipient: party_a.btc_account 189 | }); 190 | 191 | assert.equal(((await this.btc.balance(party_a.btc_account.address)) - party_a_starting_btc_balance).toFixed(8), btc_amount.toFixed(8), "Unexpected BTC balance"); 192 | assert.equal(party_b.stx_address, await sip009_owner(this.stx, sip009.value), "Wrong SIP009 owner"); 193 | }); 194 | 195 | it("Can swap SIP010 and BTC",async function() 196 | { 197 | const secret = await generate_secret(); 198 | const hash = calculate_hash(secret); 199 | 200 | const {deployer, wallet_1} = this.stx.session.accounts; 201 | const [, btc_wallet_1, btc_wallet_2] = this.btc.session.accounts; 202 | 203 | const party_a = { 204 | stx_address: deployer.address, 205 | btc_account: btc_wallet_1 206 | }; 207 | 208 | const party_b = { 209 | stx_address: wallet_1.address, 210 | btc_account: btc_wallet_2 211 | }; 212 | 213 | const sip010_amount = new BN(5660); 214 | const sip010 = await sip010_mint(this.stx, party_a.stx_address, sip010_amount); 215 | const stx_expiration = await this.stx.block_height(10); 216 | const btc_expiration = (await this.btc.block_height(20)).toNumber(); 217 | const btc_amount = 1.7; 218 | 219 | await sip009_sip010_htlc_set_whitelisted(this.stx, [{token_contract: sip010.asset_contract, whitelisted: true}]); 220 | 221 | // STX side 222 | const stx_side = { 223 | stx_chain: this.stx, 224 | sender: party_a.stx_address, 225 | recipient: party_b.stx_address, 226 | hash, 227 | amount_or_token_id: sip010_amount, 228 | expiration_height: stx_expiration, 229 | asset_contract: sip010.asset_contract, 230 | asset_type: 'sip010' 231 | }; 232 | 233 | await stx_register_swap_intent(stx_side); 234 | 235 | // BTC side 236 | const btc_side = await btc_register_swap_intent({ 237 | btc_chain: this.btc, 238 | sender: party_b.btc_account, 239 | recipient_public_key: party_a.btc_account.publicKey, 240 | hash, 241 | amount: btc_amount, 242 | expiration_height: btc_expiration, 243 | network: 'regtest', 244 | tx_fee_sat: 500 245 | }); 246 | 247 | const party_a_starting_btc_balance = await this.btc.balance(party_a.btc_account.address); 248 | const party_b_sip010_starting_balance = await sip010_balance(this.stx, stx_side.recipient); 249 | 250 | // Execute swap 251 | await stx_execute_swap({ 252 | ...stx_side, 253 | preimage: secret 254 | }); 255 | 256 | await btc_execute_swap({ 257 | ...btc_side, 258 | btc_chain: this.btc, 259 | preimage: secret, 260 | recipient: party_a.btc_account 261 | }); 262 | 263 | assert.equal(((await this.btc.balance(party_a.btc_account.address)) - party_a_starting_btc_balance).toFixed(8), btc_amount.toFixed(8), "Unexpected BTC balance"); 264 | assert.isTrue((await sip010_balance(this.stx, stx_side.recipient)).sub(party_b_sip010_starting_balance).eq(sip010_amount), "SIP010 balance did not increase by the right amount"); 265 | }); 266 | 267 | it("BTC HTLC rejects wrong preimage",async function() 268 | { 269 | const secret = await generate_secret(); // shorter for BTC 270 | const hash = calculate_hash(secret); 271 | const btc_expiration = (await this.btc.block_height(20)).toNumber(); 272 | const btc_amount = 1.8; 273 | 274 | const party_a = {btc_account: this.btc.session.accounts[1]}; 275 | const party_b = {btc_account: this.btc.session.accounts[2]}; 276 | 277 | const btc_side = await btc_register_swap_intent({ 278 | btc_chain: this.btc, 279 | sender: party_b.btc_account, 280 | recipient_public_key: party_a.btc_account.publicKey, 281 | hash, 282 | amount: btc_amount, 283 | expiration_height: btc_expiration, 284 | network: 'regtest', 285 | tx_fee_sat: 500 286 | }); 287 | 288 | const btc_swap = btc_execute_swap({ 289 | ...btc_side, 290 | btc_chain: this.btc, 291 | preimage: Buffer.from('bogus'), 292 | recipient: party_a.btc_account 293 | }); 294 | 295 | return btc_swap 296 | .then( 297 | () => assert.fail('Should have failed'), 298 | error => assert.include(error.message, 'non-mandatory-script-verify-flag') 299 | ); 300 | }); 301 | 302 | it("Sender can recover BTC from HTLC after expiry",async function() 303 | { 304 | const secret = await generate_secret(); // shorter for BTC 305 | const hash = calculate_hash(secret); 306 | const btc_blocks = 20; 307 | const btc_expiration = (await this.btc.block_height(btc_blocks)).toNumber(); 308 | const btc_amount = 1.9; 309 | 310 | const party_a = {btc_account: this.btc.session.accounts[1]}; 311 | const party_b = {btc_account: this.btc.session.accounts[2]}; 312 | 313 | const btc_side = await btc_register_swap_intent({ 314 | btc_chain: this.btc, 315 | sender: party_b.btc_account, 316 | recipient_public_key: party_a.btc_account.publicKey, 317 | hash, 318 | amount: btc_amount, 319 | expiration_height: btc_expiration, 320 | network: 'regtest', 321 | tx_fee_sat: 500 322 | }); 323 | 324 | const party_b_starting_btc_balance = await this.btc.balance(party_b.btc_account.address); 325 | 326 | await this.btc.mine_empty_blocks(btc_blocks); // advance chain 327 | 328 | const btc_swap = await btc_refund_swap_intent({ 329 | ...btc_side, 330 | btc_chain: this.btc, 331 | preimage: Buffer.from([]), 332 | recipient: party_b.btc_account 333 | }); 334 | 335 | assert.isOk(btc_swap); 336 | assert.equal(((await this.btc.balance(party_b.btc_account.address)) - party_b_starting_btc_balance).toFixed(8), btc_amount.toFixed(8), "Unexpected BTC balance"); 337 | }); 338 | 339 | it("Sender cannot recover BTC from HTLC before expiry",async function() 340 | { 341 | const secret = await generate_secret(); // shorter for BTC 342 | const hash = calculate_hash(secret); 343 | const btc_blocks = 20; 344 | const btc_expiration = (await this.btc.block_height(btc_blocks)).toNumber(); 345 | const btc_amount = 2.1; 346 | 347 | const party_a = {btc_account: this.btc.session.accounts[1]}; 348 | const party_b = {btc_account: this.btc.session.accounts[2]}; 349 | 350 | const btc_side = await btc_register_swap_intent({ 351 | btc_chain: this.btc, 352 | sender: party_b.btc_account, 353 | recipient_public_key: party_a.btc_account.publicKey, 354 | hash, 355 | amount: btc_amount, 356 | expiration_height: btc_expiration, 357 | network: 'regtest', 358 | tx_fee_sat: 500 359 | }); 360 | 361 | return btc_refund_swap_intent({ 362 | ...btc_side, 363 | btc_chain: this.btc, 364 | preimage: Buffer.from([]), 365 | recipient: party_b.btc_account 366 | }) 367 | .then( 368 | () => assert.fail('Should have failed'), 369 | error => assert.include(error.message,'non-final') 370 | ); 371 | }); 372 | 373 | it("Receiver cannot recover BTC from HTLC after expiry",async function() 374 | { 375 | const secret = await generate_secret(); // shorter for BTC 376 | const hash = calculate_hash(secret); 377 | const btc_blocks = 20; 378 | const btc_expiration = (await this.btc.block_height(btc_blocks)).toNumber(); 379 | const btc_amount = 2.2; 380 | 381 | const party_a = {btc_account: this.btc.session.accounts[1]}; 382 | const party_b = {btc_account: this.btc.session.accounts[2]}; 383 | 384 | const btc_side = await btc_register_swap_intent({ 385 | btc_chain: this.btc, 386 | sender: party_b.btc_account, 387 | recipient_public_key: party_a.btc_account.publicKey, 388 | hash, 389 | amount: btc_amount, 390 | expiration_height: btc_expiration, 391 | network: 'regtest', 392 | tx_fee_sat: 500 393 | }); 394 | 395 | await this.btc.mine_empty_blocks(btc_blocks); // advance chain 396 | 397 | return btc_refund_swap_intent({ 398 | ...btc_side, 399 | btc_chain: this.btc, 400 | preimage: Buffer.from([]), 401 | recipient: party_a.btc_account 402 | }) 403 | .then( 404 | () => assert.fail('Should have failed'), 405 | error => assert.include(error.message,'non-mandatory-script-verify-flag') 406 | ); 407 | }); 408 | 409 | it("Sender cannot recover BTC from HTLC with preimage",async function() 410 | { 411 | const secret = await generate_secret(); // shorter for BTC 412 | const hash = calculate_hash(secret); 413 | const btc_blocks = 20; 414 | const btc_expiration = (await this.btc.block_height(btc_blocks)).toNumber(); 415 | const btc_amount = 2.3; 416 | 417 | const party_a = {btc_account: this.btc.session.accounts[1]}; 418 | const party_b = {btc_account: this.btc.session.accounts[2]}; 419 | 420 | const btc_side = await btc_register_swap_intent({ 421 | btc_chain: this.btc, 422 | sender: party_b.btc_account, 423 | recipient_public_key: party_a.btc_account.publicKey, 424 | hash, 425 | amount: btc_amount, 426 | expiration_height: btc_expiration, 427 | network: 'regtest', 428 | tx_fee_sat: 500 429 | }); 430 | 431 | return btc_execute_swap({ 432 | ...btc_side, 433 | btc_chain: this.btc, 434 | preimage: secret, 435 | recipient: party_b.btc_account 436 | }) 437 | .then( 438 | () => assert.fail('Should have failed'), 439 | error => assert.include(error.message, 'non-mandatory-script-verify-flag') 440 | ); 441 | }); 442 | }); -------------------------------------------------------------------------------- /test/2-stx-eth.js: -------------------------------------------------------------------------------- 1 | const assert = require('chai').assert; 2 | const BN = require('bn.js'); 3 | 4 | const { 5 | start_stx_chain, 6 | start_eth_chain, 7 | generate_secret, 8 | calculate_hash, 9 | uintCV, 10 | principalCV, 11 | bufferCV, 12 | stx_register_swap_intent, 13 | stx_execute_swap, 14 | sip009_mint, 15 | sip009_owner, 16 | sip010_mint, 17 | sip010_balance, 18 | sip009_sip010_htlc_set_whitelisted, 19 | eth_register_swap_intent, 20 | eth_execute_swap, 21 | erc20_mint, 22 | erc20_approve_htlc, 23 | erc20_balance, 24 | erc721_mint, 25 | erc721_approve_htlc, 26 | erc721_owner 27 | } = require('./util'); 28 | 29 | async function register_swap_intents(hash, stx_options, eth_options) 30 | { 31 | const [stx,eth] = await Promise.all([stx_register_swap_intent({...stx_options, hash}), eth_register_swap_intent({...eth_options, hash})]); 32 | return {stx,eth}; 33 | } 34 | 35 | async function execute_swaps(preimage, stx_options, eth_options) 36 | { 37 | stx_options = {...stx_options, preimage}; 38 | eth_options = {...eth_options, preimage}; 39 | const [stx,eth] = await Promise.all([stx_execute_swap(stx_options), eth_execute_swap(eth_options)]); 40 | return {stx,eth}; 41 | } 42 | 43 | async function generate_standard_swap_intents(options) 44 | { 45 | const { 46 | party_a_stx_wallet, 47 | party_b_stx_wallet, 48 | party_a_eth_wallet, 49 | party_b_eth_wallet, 50 | stx_amount_or_token_id, 51 | eth_amount_or_token_id, 52 | stx_asset_contract, 53 | stx_asset_type, 54 | eth_asset_contract, 55 | stx_chain, 56 | eth_chain 57 | } = options; 58 | const preimage = await generate_secret(); 59 | const hash = calculate_hash(preimage); 60 | const stx_expiration = await stx_chain.block_height(100); 61 | const eth_expiration = await eth_chain.block_height(100); 62 | 63 | const {deployer: deployer_stx} = stx_chain.session.accounts; 64 | const [deployer_eth] = eth_chain.session.accounts; 65 | 66 | const stx_side = { 67 | stx_chain, 68 | sender: (party_a_stx_wallet && party_a_stx_wallet.address) || stx_chain.session.accounts.wallet_1.address, 69 | recipient: (party_b_stx_wallet && party_b_stx_wallet.address) || stx_chain.session.accounts.wallet_2.address, 70 | transaction_sender: deployer_stx.address, 71 | amount_or_token_id: stx_amount_or_token_id, 72 | asset_contract: stx_asset_contract, 73 | asset_type: stx_asset_type, 74 | expiration_height: stx_expiration 75 | }; 76 | 77 | const eth_side = { 78 | eth_chain, 79 | sender: party_b_eth_wallet || eth_chain.session.accounts[2], 80 | recipient: party_a_eth_wallet || eth_chain.session.accounts[1], 81 | transaction_sender: deployer_eth, 82 | amount_or_token_id: eth_amount_or_token_id, 83 | asset_contract: eth_asset_contract, 84 | expiration_height: eth_expiration 85 | }; 86 | 87 | return {hash, preimage, stx_side, eth_side}; 88 | } 89 | 90 | describe('STX <> ETH',async function() 91 | { 92 | before(async function() 93 | { 94 | console.debug("Starting STX and ETH chains..."); 95 | console.debug(""); 96 | let stx_chain_process, eth_chain_process; 97 | const kill = () => 98 | { 99 | console.log('Stopping chains...'); 100 | try {stx_chain_process.kill('SIGINT');} catch(e){}; 101 | try {eth_chain_process.kill('SIGINT');} catch(e){}; 102 | }; 103 | process.on('SIGINT',kill); 104 | process.on('uncaughtException',kill); 105 | try 106 | { 107 | [stx_chain_process,eth_chain_process] = await Promise.all([start_stx_chain(),start_eth_chain()]); 108 | } 109 | catch (error) 110 | { 111 | console.error(error); 112 | kill(); 113 | process.exit(1); 114 | } 115 | this.stx = stx_chain_process; 116 | this.eth = eth_chain_process; 117 | }); 118 | 119 | after(function() 120 | { 121 | console.debug("Stopping chains..."); 122 | this.stx.kill(); 123 | this.eth.kill(); 124 | }); 125 | 126 | it('Can register swap intent on STX and ETH',async function() 127 | { 128 | const preimage = await generate_secret(); 129 | const hash = calculate_hash(preimage); 130 | const stx_expiration = await this.stx.block_height(10); 131 | const stx_amount = 100; 132 | const eth_expiration = await this.eth.block_height(100); 133 | const eth_amount = 150; 134 | 135 | // STX side 136 | const {deployer, wallet_1} = this.stx.session.accounts; 137 | const stx_call = await this.stx.contract_call('.stx-htlc', 'register-swap-intent', [bufferCV(hash), uintCV(stx_expiration), uintCV(stx_amount), principalCV(wallet_1.address)], deployer.address); 138 | assert.equal(stx_call[0].result, '(ok true)'); 139 | 140 | // ETH side 141 | const [eth_deployer, eth_wallet_1] = this.eth.session.accounts; 142 | const eth_call = await this.eth.session.contracts.EthHTLC.methods.register_swap_intent(hash, eth_expiration, eth_wallet_1).send({value: eth_amount, from: eth_deployer}); 143 | assert.isTrue(eth_call.status); 144 | 145 | // Check if the swap exists on both chains. 146 | const stx_swap_intent = await this.stx.read_only_call('.stx-htlc', 'get-swap-intent', [bufferCV(hash), principalCV(deployer.address)]); 147 | assert.equal(stx_swap_intent.result, `(some {amount: u${stx_amount}, expiration-height: u${stx_expiration}, recipient: ${wallet_1.address}})`); 148 | 149 | const eth_swap_intent = await this.eth.session.contracts.EthHTLC.methods.get_swap_intent(hash, eth_deployer).call(); 150 | assert.equal(eth_swap_intent.expiration_height, eth_expiration); 151 | assert.equal(eth_swap_intent.amount, eth_amount); 152 | assert.equal(eth_swap_intent.recipient, eth_wallet_1); 153 | }); 154 | 155 | it('Can swap STX and ETH',async function() 156 | { 157 | // Swap: 158 | // 2000000 mSTX from Party A -> Party B 159 | // 1500000000 wei ETH from Party B -> Party A 160 | 161 | // I will spell the first one out, the other ones will use helper functions to prepare 162 | // and trigger the swaps. 163 | 164 | const preimage = await generate_secret(); 165 | const hash = calculate_hash(preimage); 166 | const stx_amount = new BN(2000000); 167 | const eth_amount = new BN(1500000000); 168 | const stx_expiration = await this.stx.block_height(10); 169 | const eth_expiration = await this.eth.block_height(100); 170 | const {deployer, wallet_1} = this.stx.session.accounts; 171 | const [eth_deployer, eth_wallet_1, eth_matcher] = this.eth.session.accounts; 172 | 173 | const party_a = { 174 | stx_address: deployer.address, 175 | eth_address: eth_wallet_1 176 | }; 177 | 178 | const party_b = { 179 | stx_address: wallet_1.address, 180 | eth_address: eth_deployer 181 | }; 182 | 183 | const party_a_starting_eth_balance = await this.eth.balance(party_a.eth_address); 184 | const party_b_starting_stx_balance = await this.stx.balance(party_b.stx_address); 185 | 186 | // STX swap intent 187 | await this.stx.contract_call('.stx-htlc', 'register-swap-intent', [bufferCV(hash), uintCV(stx_expiration), uintCV(stx_amount), principalCV(party_b.stx_address)], party_a.stx_address); 188 | 189 | // ETH swap intent 190 | await this.eth.session.contracts.EthHTLC.methods.register_swap_intent(hash, eth_expiration, party_a.eth_address).send({value: eth_amount, from: party_b.eth_address}); 191 | 192 | // Trigger STX side 193 | const stx_swap = await this.stx.contract_call('.stx-htlc', 'swap', [principalCV(party_a.stx_address), bufferCV(preimage)], party_b.stx_address); 194 | assert.equal(stx_swap[0].result, '(ok true)'); 195 | assert.equal(stx_swap[0].events[0].type, 'stx_transfer_event'); 196 | assert.equal(stx_swap[0].events[0].stx_transfer_event.recipient, party_b.stx_address); 197 | assert.equal(stx_swap[0].events[0].stx_transfer_event.amount, stx_amount); 198 | 199 | // Trigger ETH side 200 | const eth_swap = await this.eth.session.contracts.EthHTLC.methods.swap(party_b.eth_address, preimage).send({from: eth_matcher}); // we send the swap call from a third party address so that the fees do not influence the result. 201 | assert.isTrue(eth_swap.status); 202 | 203 | // Assert that the balances increased 204 | assert.isTrue((await this.eth.balance(party_a.eth_address)).gt(party_a_starting_eth_balance), "ETH balance did not increase"); 205 | assert.isTrue((await this.stx.balance(party_b.stx_address)).gt(party_b_starting_stx_balance), "STX balance did not increase"); 206 | }); 207 | 208 | it('Can swap SIP009 and ETH',async function() 209 | { 210 | const party_a_stx_wallet = this.stx.session.accounts.wallet_1; 211 | const sip009 = await sip009_mint(this.stx, party_a_stx_wallet.address); 212 | 213 | const options = { 214 | party_a_stx_wallet, 215 | stx_asset_contract: sip009.asset_contract, 216 | stx_asset_type: 'sip009', 217 | stx_amount_or_token_id: sip009.value, 218 | eth_amount_or_token_id: new BN(1500000000), 219 | stx_chain: this.stx, 220 | eth_chain: this.eth 221 | }; 222 | 223 | const {preimage, hash, stx_side, eth_side} = await generate_standard_swap_intents(options); 224 | 225 | const party_a_starting_eth_balance = await this.eth.balance(eth_side.recipient); 226 | 227 | await sip009_sip010_htlc_set_whitelisted(this.stx, [{token_contract: sip009.asset_contract, whitelisted: true}]); 228 | await register_swap_intents(hash, stx_side, eth_side); 229 | await execute_swaps(preimage, stx_side, eth_side); 230 | 231 | assert.isTrue((await this.eth.balance(eth_side.recipient)).gt(party_a_starting_eth_balance), "ETH balance did not increase"); 232 | assert.equal(stx_side.recipient, await sip009_owner(this.stx, sip009.value), "Wrong SIP009 owner"); 233 | }); 234 | 235 | it('Can swap SIP010 and ETH',async function() 236 | { 237 | const party_a_stx_wallet = this.stx.session.accounts.wallet_1; 238 | const sip010_amount = new BN(1040); 239 | const sip010 = await sip010_mint(this.stx, party_a_stx_wallet.address, sip010_amount); 240 | await sip009_sip010_htlc_set_whitelisted(this.stx, [{token_contract: sip010.asset_contract, whitelisted: true}]); 241 | 242 | const options = { 243 | party_a_stx_wallet, 244 | stx_asset_contract: sip010.asset_contract, 245 | stx_asset_type: 'sip010', 246 | stx_amount_or_token_id: sip010_amount, 247 | eth_amount_or_token_id: new BN(1250000000), 248 | stx_chain: this.stx, 249 | eth_chain: this.eth 250 | }; 251 | 252 | const {preimage, hash, stx_side, eth_side} = await generate_standard_swap_intents(options); 253 | 254 | const party_a_starting_eth_balance = await this.eth.balance(eth_side.recipient); 255 | const party_b_sip010_starting_balance = await sip010_balance(this.stx, stx_side.recipient); 256 | 257 | await register_swap_intents(hash, stx_side, eth_side); 258 | await execute_swaps(preimage, stx_side, eth_side); 259 | 260 | assert.isTrue((await this.eth.balance(eth_side.recipient)).gt(party_a_starting_eth_balance), "ETH balance did not increase"); 261 | assert.isTrue((await sip010_balance(this.stx, stx_side.recipient)).sub(party_b_sip010_starting_balance).eq(sip010_amount), "SIP010 balance did not increase by the right amount"); 262 | }); 263 | 264 | it('Can swap STX and ERC20',async function() 265 | { 266 | const erc20_amount = new BN(500); 267 | const party_b_eth_wallet = this.eth.session.accounts[2]; 268 | const erc20 = await erc20_mint(this.eth, party_b_eth_wallet, erc20_amount); 269 | await erc20_approve_htlc(this.eth, party_b_eth_wallet); 270 | 271 | const options = { 272 | party_b_eth_wallet, 273 | stx_amount_or_token_id: new BN(2000), 274 | eth_asset_contract: erc20.asset_contract, 275 | eth_amount_or_token_id: erc20.value, 276 | stx_chain: this.stx, 277 | eth_chain: this.eth 278 | }; 279 | 280 | const {preimage, hash, stx_side, eth_side} = await generate_standard_swap_intents(options); 281 | 282 | const party_a_starting_erc20_balance = await erc20_balance(this.eth, eth_side.recipient); 283 | const party_b_starting_stx_balance = await this.stx.balance(stx_side.recipient); 284 | 285 | await register_swap_intents(hash, stx_side, eth_side); 286 | await execute_swaps(preimage, stx_side, eth_side); 287 | 288 | assert.isTrue((await erc20_balance(this.eth, eth_side.recipient)).sub(party_a_starting_erc20_balance).eq(erc20_amount), "ERC20 balance did not increase by the right amount"); 289 | assert.isTrue((await this.stx.balance(stx_side.recipient)).gt(party_b_starting_stx_balance), "STX balance did not increase"); 290 | }); 291 | 292 | it('Can swap STX and ERC721',async function() 293 | { 294 | const party_b_eth_wallet = this.eth.session.accounts[2]; 295 | const erc721 = await erc721_mint(this.eth, party_b_eth_wallet); 296 | await erc721_approve_htlc(this.eth, party_b_eth_wallet); 297 | 298 | const options = { 299 | party_b_eth_wallet, 300 | stx_amount_or_token_id: new BN(8600), 301 | eth_asset_contract: erc721.asset_contract, 302 | eth_amount_or_token_id: erc721.tokenId, 303 | stx_chain: this.stx, 304 | eth_chain: this.eth 305 | }; 306 | 307 | const {preimage, hash, stx_side, eth_side} = await generate_standard_swap_intents(options); 308 | 309 | const party_b_starting_stx_balance = await this.stx.balance(stx_side.recipient); 310 | 311 | await register_swap_intents(hash, stx_side, eth_side); 312 | await execute_swaps(preimage, stx_side, eth_side); 313 | 314 | assert.equal(eth_side.recipient, await erc721_owner(this.eth, erc721.tokenId), "Wrong ERC721 owner"); 315 | assert.isTrue((await this.stx.balance(stx_side.recipient)).gt(party_b_starting_stx_balance), "STX balance did not increase"); 316 | }); 317 | 318 | it('Can swap SIP009 and ERC20',async function() 319 | { 320 | const party_a_stx_wallet = this.stx.session.accounts.wallet_1; 321 | const sip009 = await sip009_mint(this.stx, party_a_stx_wallet.address); 322 | await sip009_sip010_htlc_set_whitelisted(this.stx, [{token_contract: sip009.asset_contract, whitelisted: true}]); 323 | 324 | const erc20_amount = new BN(500); 325 | const party_b_eth_wallet = this.eth.session.accounts[2]; 326 | const erc20 = await erc20_mint(this.eth, party_b_eth_wallet, erc20_amount); 327 | await erc20_approve_htlc(this.eth, party_b_eth_wallet); 328 | 329 | const options = { 330 | party_a_stx_wallet, 331 | stx_asset_contract: sip009.asset_contract, 332 | stx_asset_type: 'sip009', 333 | stx_amount_or_token_id: sip009.value, 334 | party_b_eth_wallet, 335 | eth_asset_contract: erc20.asset_contract, 336 | eth_amount_or_token_id: erc20.value, 337 | stx_chain: this.stx, 338 | eth_chain: this.eth 339 | }; 340 | 341 | const {preimage, hash, stx_side, eth_side} = await generate_standard_swap_intents(options); 342 | 343 | const party_a_starting_erc20_balance = await erc20_balance(this.eth, eth_side.recipient); 344 | 345 | await register_swap_intents(hash, stx_side, eth_side); 346 | await execute_swaps(preimage, stx_side, eth_side); 347 | 348 | assert.isTrue((await erc20_balance(this.eth, eth_side.recipient)).sub(party_a_starting_erc20_balance).eq(erc20_amount), "ERC20 balance did not increase by the right amount"); 349 | assert.equal(stx_side.recipient, await sip009_owner(this.stx, sip009.value), "Wrong SIP009 owner"); 350 | }); 351 | 352 | it('Can swap SIP009 and ERC721',async function() 353 | { 354 | const party_a_stx_wallet = this.stx.session.accounts.wallet_1; 355 | const sip009 = await sip009_mint(this.stx, party_a_stx_wallet.address); 356 | await sip009_sip010_htlc_set_whitelisted(this.stx, [{token_contract: sip009.asset_contract, whitelisted: true}]); 357 | 358 | const party_b_eth_wallet = this.eth.session.accounts[2]; 359 | const erc721 = await erc721_mint(this.eth, party_b_eth_wallet); 360 | await erc721_approve_htlc(this.eth, party_b_eth_wallet); 361 | 362 | const options = { 363 | party_a_stx_wallet, 364 | party_b_eth_wallet, 365 | stx_asset_contract: sip009.asset_contract, 366 | stx_asset_type: 'sip009', 367 | stx_amount_or_token_id: sip009.value, 368 | eth_asset_contract: erc721.asset_contract, 369 | eth_amount_or_token_id: erc721.tokenId, 370 | stx_chain: this.stx, 371 | eth_chain: this.eth 372 | }; 373 | 374 | const {preimage, hash, stx_side, eth_side} = await generate_standard_swap_intents(options); 375 | 376 | await register_swap_intents(hash, stx_side, eth_side); 377 | await execute_swaps(preimage, stx_side, eth_side); 378 | 379 | assert.equal(eth_side.recipient, await erc721_owner(this.eth, erc721.tokenId), "Wrong ERC721 owner"); 380 | assert.equal(stx_side.recipient, await sip009_owner(this.stx, sip009.value), "Wrong SIP009 owner"); 381 | }); 382 | 383 | it('Can swap SIP010 and ERC20',async function() 384 | { 385 | const party_a_stx_wallet = this.stx.session.accounts.wallet_1; 386 | const sip010_amount = new BN(41090); 387 | const sip010 = await sip010_mint(this.stx, party_a_stx_wallet.address, sip010_amount); 388 | const erc20_amount = new BN(832); 389 | await sip009_sip010_htlc_set_whitelisted(this.stx, [{token_contract: sip010.asset_contract, whitelisted: true}]); 390 | 391 | const party_b_eth_wallet = this.eth.session.accounts[2]; 392 | const erc20 = await erc20_mint(this.eth, party_b_eth_wallet, erc20_amount); 393 | await erc20_approve_htlc(this.eth, party_b_eth_wallet); 394 | 395 | const options = { 396 | party_a_stx_wallet, 397 | party_b_eth_wallet, 398 | stx_asset_contract: sip010.asset_contract, 399 | stx_asset_type: 'sip010', 400 | stx_amount_or_token_id: sip010_amount, 401 | eth_asset_contract: erc20.asset_contract, 402 | eth_amount_or_token_id: erc20.value, 403 | stx_chain: this.stx, 404 | eth_chain: this.eth 405 | }; 406 | 407 | const {preimage, hash, stx_side, eth_side} = await generate_standard_swap_intents(options); 408 | 409 | const party_a_starting_erc20_balance = await erc20_balance(this.eth, eth_side.recipient); 410 | const party_b_sip010_starting_balance = await sip010_balance(this.stx, stx_side.recipient); 411 | 412 | await register_swap_intents(hash, stx_side, eth_side); 413 | await execute_swaps(preimage, stx_side, eth_side); 414 | 415 | assert.isTrue((await erc20_balance(this.eth, eth_side.recipient)).sub(party_a_starting_erc20_balance).eq(erc20_amount), "ERC20 balance did not increase by the right amount"); 416 | assert.isTrue((await sip010_balance(this.stx, stx_side.recipient)).sub(party_b_sip010_starting_balance).eq(sip010_amount), "SIP010 balance did not increase by the right amount"); 417 | }); 418 | 419 | it('Can swap SIP010 and ERC721',async function() 420 | { 421 | const party_a_stx_wallet = this.stx.session.accounts.wallet_1; 422 | const sip010_amount = new BN(41090); 423 | const sip010 = await sip010_mint(this.stx, party_a_stx_wallet.address, sip010_amount); 424 | await sip009_sip010_htlc_set_whitelisted(this.stx, [{token_contract: sip010.asset_contract, whitelisted: true}]); 425 | 426 | const party_b_eth_wallet = this.eth.session.accounts[2]; 427 | const erc721 = await erc721_mint(this.eth, party_b_eth_wallet); 428 | await erc721_approve_htlc(this.eth, party_b_eth_wallet); 429 | 430 | const options = { 431 | party_a_stx_wallet, 432 | party_b_eth_wallet, 433 | stx_asset_contract: sip010.asset_contract, 434 | stx_asset_type: 'sip010', 435 | stx_amount_or_token_id: sip010_amount, 436 | eth_asset_contract: erc721.asset_contract, 437 | eth_amount_or_token_id: erc721.tokenId, 438 | stx_chain: this.stx, 439 | eth_chain: this.eth 440 | }; 441 | 442 | const {preimage, hash, stx_side, eth_side} = await generate_standard_swap_intents(options); 443 | 444 | const party_b_sip010_starting_balance = await sip010_balance(this.stx, stx_side.recipient); 445 | 446 | await register_swap_intents(hash, stx_side, eth_side); 447 | await execute_swaps(preimage, stx_side, eth_side); 448 | 449 | assert.equal(eth_side.recipient, await erc721_owner(this.eth, erc721.tokenId), "Wrong ERC721 owner"); 450 | assert.isTrue((await sip010_balance(this.stx, stx_side.recipient)).sub(party_b_sip010_starting_balance).eq(sip010_amount), "SIP010 balance did not increase by the right amount"); 451 | }); 452 | }); -------------------------------------------------------------------------------- /stx/tests/sip009-sip010-htlc_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Clarinet, 3 | Chain, 4 | Account, 5 | types, 6 | generate_secret, 7 | calculate_hash, 8 | sip009_sip010_htlc_set_whitelisted, 9 | swap_contract_principal, 10 | register_swap_intent, 11 | get_swap_intent, 12 | cancel_swap_intent, 13 | execute_swap, 14 | sip009_mint, 15 | sip010_mint, 16 | ErrorCodes, 17 | SwapIntent 18 | } from './common.ts'; 19 | import { assertEquals } from 'https://deno.land/std@0.90.0/testing/asserts.ts'; 20 | 21 | interface Sip009NftTransferEvent { 22 | type: string, 23 | nft_transfer_event: { 24 | asset_identifier: string, 25 | sender: string, 26 | recipient: string, 27 | value: string 28 | } 29 | } 30 | 31 | function assertNftTransfer(event: Sip009NftTransferEvent, asset_contract_principal: string, token_id: number, sender: string, recipient: string) { 32 | assertEquals(typeof event, 'object'); 33 | assertEquals(event.type, 'nft_transfer_event'); 34 | assertEquals(event.nft_transfer_event.asset_identifier.substr(0, asset_contract_principal.length), asset_contract_principal); 35 | event.nft_transfer_event.sender.expectPrincipal(sender); 36 | event.nft_transfer_event.recipient.expectPrincipal(recipient); 37 | event.nft_transfer_event.value.expectUint(token_id); 38 | } 39 | 40 | Clarinet.test({ 41 | name: "SIP009: can register a swap intent", 42 | async fn(chain: Chain, accounts: Map) { 43 | const secret = generate_secret(); 44 | const hash = calculate_hash(secret); 45 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 46 | const token_contract = `${deployer.address}.test-sip009`; 47 | const { token_id } = sip009_mint(chain, token_contract, sender.address); 48 | const swap_intent: SwapIntent = { 49 | hash, 50 | expiration_height: chain.blockHeight + 10, 51 | amount_or_token_id: token_id, 52 | asset_contract: token_contract, 53 | asset_type: "sip009", 54 | sender: sender.address, 55 | recipient: recipient.address 56 | }; 57 | const swap_contract = swap_contract_principal(deployer, swap_intent); 58 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 59 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 60 | swap.result.expectOk().expectBool(true); 61 | assertNftTransfer(swap.events[0], token_contract, token_id, sender.address, swap_contract); 62 | } 63 | }); 64 | 65 | Clarinet.test({ 66 | name: "SIP009: cannot register a swap intent for non-whitelisted token", 67 | async fn(chain: Chain, accounts: Map) { 68 | const secret = generate_secret(); 69 | const hash = calculate_hash(secret); 70 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 71 | const token_contract = `${deployer.address}.test-sip009`; 72 | const { token_id } = sip009_mint(chain, token_contract, sender.address); 73 | const swap_intent: SwapIntent = { 74 | hash, 75 | expiration_height: chain.blockHeight + 10, 76 | amount_or_token_id: token_id, 77 | asset_contract: token_contract, 78 | asset_type: "sip009", 79 | sender: sender.address, 80 | recipient: recipient.address 81 | }; 82 | const swap_contract = swap_contract_principal(deployer, swap_intent); 83 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 84 | swap.result.expectErr().expectUint(ErrorCodes.ERR_ASSET_CONTRACT_NOT_WHITELISTED); 85 | assertEquals(swap.events.length, 0); 86 | } 87 | }); 88 | 89 | Clarinet.test({ 90 | name: "SIP009: can retrieve a swap intent", 91 | async fn(chain: Chain, accounts: Map) { 92 | const secret = generate_secret(); 93 | const hash = calculate_hash(secret); 94 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 95 | const token_contract = `${deployer.address}.test-sip009`; 96 | const { token_id } = sip009_mint(chain, token_contract, sender.address); 97 | const swap_intent: SwapIntent = { 98 | hash, 99 | expiration_height: chain.blockHeight + 10, 100 | amount_or_token_id: token_id, 101 | asset_contract: token_contract, 102 | asset_type: "sip009", 103 | sender: sender.address, 104 | recipient: recipient.address 105 | }; 106 | const swap_contract = swap_contract_principal(deployer, swap_intent); 107 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 108 | register_swap_intent(chain, swap_contract, swap_intent); 109 | const swap = get_swap_intent(chain, swap_contract, hash, sender.address); 110 | const result = swap.result.expectSome().expectTuple(); 111 | assertEquals(result, { 112 | "amount-or-token-id": types.uint(swap_intent.amount_or_token_id), 113 | "expiration-height": types.uint(swap_intent.expiration_height), 114 | "recipient": swap_intent.recipient, 115 | "asset-contract": token_contract 116 | }); 117 | } 118 | }); 119 | 120 | Clarinet.test({ 121 | name: "SIP009: sender cannot pledge a token it does not own", 122 | async fn(chain: Chain, accounts: Map) { 123 | const [deployer, sender, recipient, third_party] = ['deployer', 'wallet_1', 'wallet_2', 'wallet_3'].map(name => accounts.get(name)!); 124 | const token_contract = `${deployer.address}.test-sip009`; 125 | const { token_id } = sip009_mint(chain, token_contract, third_party.address); 126 | const swap_intent: SwapIntent = { 127 | hash: new Uint8Array(new ArrayBuffer(32)), 128 | expiration_height: chain.blockHeight + 10, 129 | amount_or_token_id: token_id, 130 | asset_contract: token_contract, 131 | asset_type: "sip009", 132 | sender: sender.address, 133 | recipient: recipient.address 134 | }; 135 | const swap_contract = swap_contract_principal(deployer, swap_intent); 136 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 137 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 138 | swap.result.expectErr().expectUint(ErrorCodes.ERR_NFT_TRANSFER_NOT_OWNER); 139 | assertEquals(swap.events.length, 0); 140 | } 141 | }); 142 | 143 | Clarinet.test({ 144 | name: "SIP009: sender can cancel a swap intent after expiry", 145 | async fn(chain: Chain, accounts: Map) { 146 | const secret = generate_secret(); 147 | const hash = calculate_hash(secret); 148 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 149 | const token_contract = `${deployer.address}.test-sip009`; 150 | const { token_id } = sip009_mint(chain, token_contract, sender.address); 151 | const swap_intent: SwapIntent = { 152 | hash, 153 | expiration_height: chain.blockHeight + 10, 154 | amount_or_token_id: token_id, 155 | asset_contract: token_contract, 156 | asset_type: "sip009", 157 | sender: sender.address, 158 | recipient: recipient.address 159 | }; 160 | const swap_contract = swap_contract_principal(deployer, swap_intent); 161 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 162 | register_swap_intent(chain, swap_contract, swap_intent); 163 | chain.mineEmptyBlock(swap_intent.expiration_height - chain.blockHeight + 1); 164 | const cancellation = cancel_swap_intent(chain, swap_contract, swap_intent); 165 | cancellation.result.expectOk().expectBool(true); 166 | assertNftTransfer(cancellation.events[0], token_contract, token_id, swap_contract, sender.address); 167 | } 168 | }); 169 | 170 | Clarinet.test({ 171 | name: "SIP009: anyone can trigger swap before expiry using the correct preimage", 172 | async fn(chain: Chain, accounts: Map) { 173 | const secret = generate_secret(); 174 | const hash = calculate_hash(secret); 175 | const [deployer, sender, recipient, third_party] = ['deployer', 'wallet_1', 'wallet_2', 'wallet_3'].map(name => accounts.get(name)!); 176 | const token_contract = `${deployer.address}.test-sip009`; 177 | const { token_id } = sip009_mint(chain, token_contract, sender.address); 178 | const swap_intent: SwapIntent = { 179 | hash, 180 | expiration_height: chain.blockHeight + 10, 181 | amount_or_token_id: token_id, 182 | asset_contract: token_contract, 183 | asset_type: "sip009", 184 | sender: sender.address, 185 | recipient: recipient.address 186 | }; 187 | const swap_contract = swap_contract_principal(deployer, swap_intent); 188 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 189 | register_swap_intent(chain, swap_contract, swap_intent); 190 | const swap = execute_swap(chain, swap_contract, swap_intent, secret, third_party.address); 191 | swap.result.expectOk().expectBool(true); 192 | assertNftTransfer(swap.events[0], token_contract, token_id, swap_contract, recipient.address); 193 | } 194 | }); 195 | 196 | Clarinet.test({ 197 | name: "SIP010: can retrieve a swap intent", 198 | async fn(chain: Chain, accounts: Map) { 199 | const secret = generate_secret(); 200 | const hash = calculate_hash(secret); 201 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 202 | const token_contract = `${deployer.address}.test-sip010`; 203 | const amount = 198; 204 | sip010_mint(chain, token_contract, amount, sender.address); 205 | const swap_intent: SwapIntent = { 206 | hash, 207 | expiration_height: chain.blockHeight + 10, 208 | amount_or_token_id: amount, 209 | asset_contract: token_contract, 210 | asset_type: "sip010", 211 | sender: sender.address, 212 | recipient: recipient.address 213 | }; 214 | const swap_contract = swap_contract_principal(deployer, swap_intent); 215 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 216 | register_swap_intent(chain, swap_contract, swap_intent); 217 | const swap = get_swap_intent(chain, swap_contract, hash, sender.address); 218 | const result = swap.result.expectSome().expectTuple(); 219 | assertEquals(result, { 220 | "amount-or-token-id": types.uint(swap_intent.amount_or_token_id), 221 | "expiration-height": types.uint(swap_intent.expiration_height), 222 | "recipient": swap_intent.recipient, 223 | "asset-contract": token_contract 224 | }); 225 | } 226 | }); 227 | 228 | Clarinet.test({ 229 | name: "SIP010: can register a swap intent", 230 | async fn(chain: Chain, accounts: Map) { 231 | const secret = generate_secret(); 232 | const hash = calculate_hash(secret); 233 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 234 | const token_contract = `${deployer.address}.test-sip010`; 235 | const amount = 144; 236 | const { asset_identifier } = sip010_mint(chain, token_contract, amount, sender.address); 237 | const swap_intent: SwapIntent = { 238 | hash, 239 | expiration_height: chain.blockHeight + 10, 240 | amount_or_token_id: amount, 241 | asset_contract: token_contract, 242 | asset_type: "sip010", 243 | sender: sender.address, 244 | recipient: recipient.address 245 | }; 246 | const swap_contract = swap_contract_principal(deployer, swap_intent); 247 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 248 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 249 | swap.result.expectOk().expectBool(true); 250 | swap.events.expectFungibleTokenTransferEvent(amount, sender.address, swap_contract, asset_identifier); 251 | } 252 | }); 253 | 254 | Clarinet.test({ 255 | name: "SIP010: amount cannot be 0", 256 | async fn(chain: Chain, accounts: Map) { 257 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 258 | const token_contract = `${deployer.address}.test-sip010`; 259 | const swap_intent: SwapIntent = { 260 | hash: new Uint8Array(new ArrayBuffer(32)), 261 | expiration_height: chain.blockHeight + 10, 262 | amount_or_token_id: 0, 263 | asset_contract: token_contract, 264 | asset_type: "sip010", 265 | sender: sender.address, 266 | recipient: recipient.address 267 | }; 268 | const swap_contract = swap_contract_principal(deployer, swap_intent); 269 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 270 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 271 | swap.result.expectErr().expectUint(ErrorCodes.ERR_FT_TRANSFER_NON_POSITIVE); 272 | assertEquals(swap.events.length, 0); 273 | } 274 | }); 275 | 276 | Clarinet.test({ 277 | name: "SIP010: sender cannot pledge more tokens than owned", 278 | async fn(chain: Chain, accounts: Map) { 279 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 280 | const token_contract = `${deployer.address}.test-sip010`; 281 | const amount = 400; 282 | sip010_mint(chain, token_contract, amount, sender.address); 283 | const swap_intent: SwapIntent = { 284 | hash: new Uint8Array(new ArrayBuffer(32)), 285 | expiration_height: chain.blockHeight + 10, 286 | amount_or_token_id: amount + 100, 287 | asset_contract: token_contract, 288 | asset_type: "sip010", 289 | sender: sender.address, 290 | recipient: recipient.address 291 | }; 292 | const swap_contract = swap_contract_principal(deployer, swap_intent); 293 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 294 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 295 | swap.result.expectErr().expectUint(ErrorCodes.ERR_FT_TRANSFER_INSUFFICIENT_BALANCE); 296 | assertEquals(swap.events.length, 0); 297 | } 298 | }); 299 | 300 | Clarinet.test({ 301 | name: "SIP010: sender can cancel a swap intent after expiry", 302 | async fn(chain: Chain, accounts: Map) { 303 | const secret = generate_secret(); 304 | const hash = calculate_hash(secret); 305 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 306 | const token_contract = `${deployer.address}.test-sip010`; 307 | const amount = 567; 308 | const { asset_identifier } = sip010_mint(chain, token_contract, amount, sender.address); 309 | const swap_intent: SwapIntent = { 310 | hash, 311 | expiration_height: chain.blockHeight + 10, 312 | amount_or_token_id: amount, 313 | asset_contract: token_contract, 314 | asset_type: "sip010", 315 | sender: sender.address, 316 | recipient: recipient.address 317 | }; 318 | const swap_contract = swap_contract_principal(deployer, swap_intent); 319 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 320 | register_swap_intent(chain, swap_contract, swap_intent); 321 | chain.mineEmptyBlock(swap_intent.expiration_height - chain.blockHeight + 1); 322 | const cancellation = cancel_swap_intent(chain, swap_contract, swap_intent); 323 | cancellation.result.expectOk().expectBool(true); 324 | cancellation.events.expectFungibleTokenTransferEvent(amount, swap_contract, sender.address, asset_identifier); 325 | } 326 | }); 327 | 328 | Clarinet.test({ 329 | name: "SIP010: anyone can trigger swap before expiry using the correct preimage", 330 | async fn(chain: Chain, accounts: Map) { 331 | const secret = generate_secret(); 332 | const hash = calculate_hash(secret); 333 | const [deployer, sender, recipient, third_party] = ['deployer', 'wallet_1', 'wallet_2', 'wallet_3'].map(name => accounts.get(name)!); 334 | const token_contract = `${deployer.address}.test-sip010`; 335 | const amount = 783; 336 | const { asset_identifier } = sip010_mint(chain, token_contract, amount, sender.address); 337 | const swap_intent: SwapIntent = { 338 | hash, 339 | expiration_height: chain.blockHeight + 10, 340 | amount_or_token_id: amount, 341 | asset_contract: token_contract, 342 | asset_type: "sip010", 343 | sender: sender.address, 344 | recipient: recipient.address 345 | }; 346 | const swap_contract = swap_contract_principal(deployer, swap_intent); 347 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 348 | register_swap_intent(chain, swap_contract, swap_intent); 349 | const swap = execute_swap(chain, swap_contract, swap_intent, secret, third_party.address); 350 | swap.result.expectOk().expectBool(true); 351 | swap.events.expectFungibleTokenTransferEvent(amount, swap_contract, recipient.address, asset_identifier); 352 | } 353 | }); 354 | 355 | Clarinet.test({ 356 | name: "SIP009/SIP010: sender cannot cancel a swap intent before expiry", 357 | async fn(chain: Chain, accounts: Map) { 358 | const secret = generate_secret(); 359 | const hash = calculate_hash(secret); 360 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 361 | const token_contract = `${deployer.address}.test-sip009`; 362 | const { token_id } = sip009_mint(chain, token_contract, sender.address); 363 | const swap_intent: SwapIntent = { 364 | hash, 365 | expiration_height: chain.blockHeight + 10, 366 | amount_or_token_id: token_id, 367 | asset_contract: token_contract, 368 | asset_type: "sip009", 369 | sender: sender.address, 370 | recipient: recipient.address 371 | }; 372 | const swap_contract = swap_contract_principal(deployer, swap_intent); 373 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 374 | register_swap_intent(chain, swap_contract, swap_intent); 375 | chain.mineEmptyBlock(swap_intent.expiration_height - chain.blockHeight - 1); 376 | const cancellation = cancel_swap_intent(chain, swap_contract, swap_intent); 377 | cancellation.result.expectErr().expectUint(ErrorCodes.ERR_SWAP_INTENT_NOT_EXPIRED); 378 | assertEquals(cancellation.events.length, 0); 379 | } 380 | }); 381 | 382 | Clarinet.test({ 383 | name: "SIP009/SIP010: sender cannot cancel a swap that does not exist", 384 | async fn(chain: Chain, accounts: Map) { 385 | const secret = generate_secret(); 386 | const hash = calculate_hash(secret); 387 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 388 | const token_contract = `${deployer.address}.test-sip009`; 389 | const swap_intent: SwapIntent = { 390 | hash, 391 | expiration_height: chain.blockHeight + 10, 392 | amount_or_token_id: 45, 393 | asset_contract: token_contract, 394 | asset_type: "sip009", 395 | sender: sender.address, 396 | recipient: recipient.address 397 | }; 398 | const swap_contract = swap_contract_principal(deployer, swap_intent); 399 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 400 | const cancellation = cancel_swap_intent(chain, swap_contract, swap_intent); 401 | cancellation.result.expectErr().expectUint(ErrorCodes.ERR_UNKNOWN_SWAP_INTENT); 402 | assertEquals(cancellation.events.length, 0); 403 | } 404 | }); 405 | 406 | Clarinet.test({ 407 | name: "SIP009/SIP010: third party cannot cancel a swap intent after expiry", 408 | async fn(chain: Chain, accounts: Map) { 409 | const secret = generate_secret(); 410 | const hash = calculate_hash(secret); 411 | const [deployer, sender, recipient, third_party] = ['deployer', 'wallet_1', 'wallet_2', 'wallet_3'].map(name => accounts.get(name)!); 412 | const token_contract = `${deployer.address}.test-sip009`; 413 | const { token_id } = sip009_mint(chain, token_contract, sender.address); 414 | const swap_intent: SwapIntent = { 415 | hash, 416 | expiration_height: chain.blockHeight + 10, 417 | amount_or_token_id: token_id, 418 | asset_contract: token_contract, 419 | asset_type: "sip009", 420 | sender: sender.address, 421 | recipient: recipient.address 422 | }; 423 | const swap_contract = swap_contract_principal(deployer, swap_intent); 424 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 425 | register_swap_intent(chain, swap_contract, swap_intent); 426 | chain.mineEmptyBlock(swap_intent.expiration_height - chain.blockHeight + 1); 427 | const cancellation = cancel_swap_intent(chain, swap_contract, swap_intent, third_party.address); 428 | cancellation.result.expectErr().expectUint(ErrorCodes.ERR_UNKNOWN_SWAP_INTENT); 429 | assertEquals(cancellation.events.length, 0); 430 | } 431 | }); 432 | 433 | Clarinet.test({ 434 | name: "SIP009/SIP010: hash has to be 32 bytes in length", 435 | async fn(chain: Chain, accounts: Map) { 436 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 437 | const token_contract = `${deployer.address}.test-sip009`; 438 | const swap_intent: SwapIntent = { 439 | hash: new Uint8Array(new ArrayBuffer(31)), 440 | expiration_height: chain.blockHeight + 10, 441 | amount_or_token_id: 1, 442 | asset_contract: token_contract, 443 | asset_type: "sip009", 444 | sender: sender.address, 445 | recipient: recipient.address 446 | }; 447 | const swap_contract = swap_contract_principal(deployer, swap_intent); 448 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 449 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 450 | swap.result.expectErr().expectUint(ErrorCodes.ERR_INVALID_HASH_LENGTH); 451 | assertEquals(swap.events.length, 0); 452 | } 453 | }); 454 | 455 | Clarinet.test({ 456 | name: "SIP009/SIP010: expiration height cannot be in the past", 457 | async fn(chain: Chain, accounts: Map) { 458 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 459 | chain.mineEmptyBlock(5); 460 | const token_contract = `${deployer.address}.test-sip009`; 461 | const swap_intent: SwapIntent = { 462 | hash: new Uint8Array(new ArrayBuffer(32)), 463 | expiration_height: chain.blockHeight - 1, 464 | amount_or_token_id: 1, 465 | asset_contract: token_contract, 466 | asset_type: "sip009", 467 | sender: sender.address, 468 | recipient: recipient.address 469 | }; 470 | const swap_contract = swap_contract_principal(deployer, swap_intent); 471 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 472 | const swap = register_swap_intent(chain, swap_contract, swap_intent); 473 | swap.result.expectErr().expectUint(ErrorCodes.ERR_EXPIRY_IN_PAST); 474 | assertEquals(swap.events.length, 0); 475 | } 476 | }); 477 | 478 | Clarinet.test({ 479 | name: "SIP009/SIP010: swap intent cannot already exist", 480 | async fn(chain: Chain, accounts: Map) { 481 | const secret = generate_secret(); 482 | const hash = calculate_hash(secret); 483 | const [deployer, sender, recipient] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); 484 | const token_contract = `${deployer.address}.test-sip009`; 485 | const { token_id } = sip009_mint(chain, token_contract, sender.address); 486 | const swap_intent: SwapIntent = { 487 | hash, 488 | expiration_height: chain.blockHeight + 10, 489 | amount_or_token_id: token_id, 490 | asset_contract: token_contract, 491 | asset_type: "sip009", 492 | sender: sender.address, 493 | recipient: recipient.address 494 | }; 495 | const swap_contract = swap_contract_principal(deployer, swap_intent); 496 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 497 | register_swap_intent(chain, swap_contract, swap_intent); 498 | const second_swap = register_swap_intent(chain, swap_contract, swap_intent); 499 | second_swap.result.expectErr().expectUint(ErrorCodes.ERR_SWAP_INTENT_ALREADY_EXISTS); 500 | assertEquals(second_swap.events.length, 0); 501 | } 502 | }); 503 | 504 | Clarinet.test({ 505 | name: "SIP009/SIP010: nobody can trigger swap after expiry using the correct preimage", 506 | async fn(chain: Chain, accounts: Map) { 507 | const secret = generate_secret(); 508 | const hash = calculate_hash(secret); 509 | const [deployer, sender, recipient, third_party] = ['deployer', 'wallet_1', 'wallet_2', 'wallet_3'].map(name => accounts.get(name)!); 510 | const token_contract = `${deployer.address}.test-sip009`; 511 | const { token_id } = sip009_mint(chain, token_contract, sender.address); 512 | const swap_intent: SwapIntent = { 513 | hash, 514 | expiration_height: chain.blockHeight + 10, 515 | amount_or_token_id: token_id, 516 | asset_contract: token_contract, 517 | asset_type: "sip009", 518 | sender: sender.address, 519 | recipient: recipient.address 520 | }; 521 | const swap_contract = swap_contract_principal(deployer, swap_intent); 522 | sip009_sip010_htlc_set_whitelisted(chain, swap_contract, [{ token_contract, whitelisted: true }], deployer.address); 523 | register_swap_intent(chain, swap_contract, swap_intent); 524 | chain.mineEmptyBlock(swap_intent.expiration_height - chain.blockHeight + 1); 525 | const swap = execute_swap(chain, swap_contract, swap_intent, secret, third_party.address); 526 | swap.result.expectErr().expectUint(ErrorCodes.ERR_SWAP_INTENT_EXPIRED); 527 | assertEquals(swap.events.length, 0); 528 | } 529 | }); 530 | 531 | Clarinet.test({ 532 | name: "SIP009/SIP010: only owner can whitelist a token contract", 533 | async fn(chain: Chain, accounts: Map) { 534 | const [deployer, non_owner] = ['deployer', 'wallet_1'].map(name => accounts.get(name)!); 535 | const token_contract = `${deployer.address}.test-sip009`; 536 | const whitelisted = sip009_sip010_htlc_set_whitelisted(chain, 'sip009-sip010-htlc', [{ token_contract, whitelisted: true }], non_owner.address); 537 | whitelisted.result.expectErr().expectUint(ErrorCodes.ERR_OWNER_ONLY); 538 | } 539 | }); 540 | -------------------------------------------------------------------------------- /sequence_diagram.svg: -------------------------------------------------------------------------------- 1 | actor%20Carl%0Aparticipant%20STX%20HTLC%0Aparticipant%20BTC%20HTLC%0Aactor%20Bill%0Abox%20over%20Carl%3A%20Generate%20%2F%2F**secret**%2F%2F%5CnCalculate%20%2F%2F**hash**%2F%2F%20from%20secret%0Abox%20over%20Carl%3A%20Create%20STX%20swap%20intent%5Cn%22%22Asset%3A%20%20%20%20%20100%20STX%5CnSender%3A%20%20%20%20Carl%5CnRecipient%3A%20Bill%5CnHash%3A%20%20%20%20%20%20%2F%2F**hash**%2F%2F%5CnExpiry%3A%20%20%20%20%2F%2FX%2F%2F%20blocks%22%22%0ACarl-%3ESTX%20HTLC%3ASubmit%20swap%20intent%5Cnand%20transfer%20100%20STX%0ACarl-%3EBill%3AShare%20%2F%2F**hash**%2F%2F%0ABill-%3ESTX%20HTLC%3AVerify%20Carl's%20swap%20intent%20exists%0Aactivate%20STX%20HTLC%0ABill%3C-STX%20HTLC%3ASwap%20intent%20proof%0Adeactivate%20STX%20HTLC%0Abox%20over%20Bill%3A%20Create%20BTC%20swap%20intent%5Cn%22%22Asset%3A%20%20%20%20%201%20BTC%5CnSender%3A%20%20%20%20Bill%5CnRecipient%3A%20Carl%5CnHash%3A%20%20%20%20%20%20%2F%2F**hash**%2F%2F%5CnExpiry%3A%20%20%20%20%2F%2FY%2F%2F%20blocks%22%22%0ABill-%3EBTC%20HTLC%3ASubmit%20swap%20intent%5Cnand%20transfer%201%20BTC%0A%3D%3DBefore%20expiry%3D%3D%0ACarl-%3EBTC%20HTLC%3AReveal%20%2F%2F**secret**%2F%2F%20to%20unlock%20Bill's%20asset%0Aactivate%20BTC%20HTLC%0ABTC%20HTLC-%3ECarl%3A%20Transfer%201%20BTC%20to%20recipient%0Adeactivate%20BTC%20HTLC%0Anote%20over%20BTC%20HTLC%3A%20%2F%2F**secret**%2F%2F%20is%20now%20public%5Cnknowledge%2C%20as%20it%20was%5Cnposted%20to%20the%20Bitcoin%5Cnchain.%0ASTX%20HTLC%3C-Bill%3AUse%20revealed%20%2F%2F**secret**%2F%2F%20to%20unlock%20Carl's%20asset%0Aactivate%20STX%20HTLC%0ASTX%20HTLC-%3EBill%3A%20Transfer%20100%20STX%20to%20recipient%0Adeactivate%20STX%20HTLC%0A%3D%3DAfter%20expiry%3D%3D%0Aparallel%0ACarl-%3ESTX%20HTLC%3A%20Reclaim%20asset%0ABill-%3EBTC%20HTLC%3A%20Reclaim%20asset%0Aparallel%20off%0Aparallel%20on%0Aactivate%20STX%20HTLC%0Aactivate%20BTC%20HTLC%0ASTX%20HTLC-%3ECarl%3A%20Return%20100%20STX%5Cnto%20sender%0Adeactivate%20STX%20HTLC%0ABTC%20HTLC-%3EBill%3A%20Return%201%20BTC%5Cnto%20sender%0Adeactivate%20BTC%20HTLC%0Aparallel%20off%0ACarlSTX HTLCBTC HTLCBillGenerate secretCalculate hash from secretCreate STX swap intentAsset:     100 STXSender:    CarlRecipient: BillHash:      hashExpiry:    X blocksSubmit swap intentand transfer 100 STXShare hashVerify Carl's swap intent existsSwap intent proofCreate BTC swap intentAsset:     1 BTCSender:    BillRecipient: CarlHash:      hashExpiry:    Y blocksSubmit swap intentand transfer 1 BTCBefore expiryReveal secret to unlock Bill's assetTransfer 1 BTC to recipientsecret is now publicknowledge, as it wasposted to the Bitcoinchain.Use revealed secret to unlock Carl's assetTransfer 100 STX to recipientAfter expiryReclaim assetReclaim assetReturn 100 STXto senderReturn 1 BTCto sender --------------------------------------------------------------------------------