├── .prettierrc.json ├── .gitignore ├── user-agents.test.js ├── user-agents.d.ts ├── tsconfig.json ├── .github └── workflows │ └── publish-npm.yml ├── package.json ├── LICENSE ├── user-agents.js ├── README.md └── index.ts /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /build/ 3 | /lib 4 | /index.js 5 | /index.d.ts 6 | /index.d.ts.map 7 | /pack -------------------------------------------------------------------------------- /user-agents.test.js: -------------------------------------------------------------------------------- 1 | import { random } from './user-agents.js'; 2 | for (let i = 0; i < 6000; i++) { 3 | console.log(random()); 4 | } -------------------------------------------------------------------------------- /user-agents.d.ts: -------------------------------------------------------------------------------- 1 | declare const updatedAt: Date; 2 | export { updatedAt }; 3 | export declare function randomList(ignoreAge?: boolean): string[]; 4 | export declare function random(): string; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "outDir": ".", 7 | "target": "es2020", 8 | "module": "node16", 9 | "noUnusedLocals": true, 10 | "removeComments": false 11 | }, 12 | "include": ["index.ts"], 13 | "exclude": [ 14 | "node_modules", 15 | "lib", 16 | ], 17 | } -------------------------------------------------------------------------------- /.github/workflows/publish-npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npm 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | id-token: write 11 | steps: 12 | - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3 13 | - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3 14 | with: 15 | node-version: 20 16 | registry-url: 'https://registry.npmjs.org' 17 | cache: npm 18 | - run: npm install -g npm 19 | - run: npm ci 20 | - run: npm run build 21 | - run: npm publish --provenance --access public 22 | env: 23 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tx-tor-broadcaster", 3 | "description": "CLI utility that broadcasts BTC, ETH, SOL, ZEC & XMR transactions through tor using public block explorers", 4 | "version": "0.1.0", 5 | "type": "module", 6 | "main": "index.js", 7 | "module": "index.js", 8 | "files": [ 9 | "index.js", 10 | "index.d.ts", 11 | "index.d.ts.map", 12 | "index.ts" 13 | ], 14 | "bin": { 15 | "txtor": "index.js" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "20.0.0", 19 | "typescript": "5.3.2" 20 | }, 21 | "license": "MIT", 22 | "author": "Paul Miller (https://paulmillr.com)", 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/paulmillr/tx-tor-broadcaster.git" 26 | }, 27 | "scripts": { 28 | "build": "tsc" 29 | }, 30 | "keywords": [ 31 | "tx", 32 | "transaction", 33 | "tor", 34 | "broadcast", 35 | "onion", 36 | "network", 37 | "dns", 38 | "btc", 39 | "eth", 40 | "zec", 41 | "xmr", 42 | "bch", 43 | "fingerprint", 44 | "cli" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Paul Miller (https://paulmillr.com) 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 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /user-agents.js: -------------------------------------------------------------------------------- 1 | const updatedAt = new Date('2024-12-20 00:00:00'); 2 | const maxAcceptableAge = 1000 * 60 * 60 * 24 * 120; // 120 days 3 | export { updatedAt }; 4 | // Make sure to update all locations. 5 | // Chrome @ Windows NT 10 has just 1 version location: Chrome/104 6 | // Firefox has 2: rv:103.0, Firefox/103.0 7 | // iOS has 2: iPhone OS 15_6, Version/15.6 8 | const agents = [ 9 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0', 10 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0', 11 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0', 12 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0', 13 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:78.0) Gecko/20100101 Firefox/78.0', 14 | ]; 15 | export function randomList(ignoreAge = false) { 16 | const diff = +new Date() - +updatedAt; 17 | if (!ignoreAge && diff > maxAcceptableAge) 18 | throw new Error('The user agent list is too old; update the package'); 19 | return agents 20 | .slice() 21 | .map((value) => ({ value, sorter: Math.random() })) 22 | .sort((a, b) => a.sorter - b.sorter) 23 | .map(({ value }) => value); 24 | } 25 | export function random() { 26 | return randomList()[0]; 27 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tx-tor-broadcaster 2 | 3 | CLI utility that broadcasts BTC, ETH, SOL, ZEC & XMR transactions through [TOR](https://www.torproject.org) using public block explorers. 4 | 5 | Provides a great degree of anonymity for your transactions. 6 | 7 | Ensures no traffic is passed outside TOR, including DNS requests. Uses one small dependency which 8 | provides list of popular user agents. See [fingerprinting](#fingerprinting) section for additional information. 9 | 10 | _Check out all web3 utility libraries:_ [ETH](https://github.com/paulmillr/micro-eth-signer), [BTC](https://github.com/paulmillr/scure-btc-signer), [SOL](https://github.com/paulmillr/micro-sol-signer), [tx-tor-broadcaster](https://github.com/paulmillr/tx-tor-broadcaster) 11 | 12 | ## Usage 13 | 14 | > npm install -g tx-tor-broadcaster 15 | 16 | The command line interface is simple: call `txtor ` command through terminal. 17 | 18 | You must have Tor or Tor Browser up & running. 19 | 20 | You can specify a few options via env variables, if needed: 21 | 22 | - `TOR_HOST=192.168.2.5 txtor zec `; default is `127.0.0.1` 23 | - `TOR_SOCKS_PORT=9051 txtor bch `; default is `9050` (`9150` should be used for Tor Browser) 24 | - `TOR_RETRY_LIMIT=2 txtor sol `; default is `10` 25 | 26 | ```sh 27 | txtor 28 | # Usage: txtor 29 | # NET: btc, eth, sol, zec, xmr, bch 30 | 31 | txtor btc 0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000 32 | txtor eth 0xf86c0a8502540be400825208944bbeeb066ed09b7aed07bf39eee0460dfa261520880de0b6b3a7640000801ca0f3ae52c1ef3300f44df0bcfd1341c232ed6134672b16e35699ae3f5fe2493379a023d23d2955a239dd6f61c4e8b2678d174356ff424eac53da53e17706c43ef871 33 | txtor sol 4vC38p4bz7XyiXrk6HtaooUqwxTWKocf45cstASGtmrD398biNJnmTcUCVEojE7wVQvgdYbjHJqRFZPpzfCQpmUN 34 | ``` 35 | 36 | Node.js API: 37 | 38 | ```js 39 | import { Broadcaster } from 'tx-tor-broadcaster'; 40 | const br = new Broadcaster(net, tx); // , opts = { socksHost, socksPort, retryLimit } 41 | console.log(`${bold}TOR exit IP:${reset}`, await br.getIP()); 42 | const res = await br.broadcast(); 43 | if (res) console.log(`${green}${bold}Published${reset} (${res.host}): ${res.txId}`); 44 | ``` 45 | 46 | ## Fingerprinting 47 | 48 | Fingerprinting is an algorithm that allows to uniquely identify user within the global dataset. 49 | For example, if you are using obscure old browser for everything, it's easy to 50 | identify you within millions of users. 51 | 52 | The app uses popular user agents [(package)](https://github.com/paulmillr/popular-user-agents) to 53 | populate `User-Agent` header. If more than 120 days have passed since the dependency was last updated, 54 | the package will stop working. 55 | 56 | This mitigates only one variable. There are many others: 57 | 58 | - Headers 59 | - `Accept`: e.g. `text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8` 60 | - `Accept-Encoding`: compression support `gzip, deflate, br` 61 | - `Accept-Language`: OS language 62 | - TLS/SSL settings; indicating supported... 63 | - TLS protocols e.g. 1.3, 1.2 64 | - HTTP versions e.g. HTTP2, HTTP3 65 | - cipher suites e.g. `TLS_AES_256_GCM_SHA384` or `TLS_CHACHA20_POLY1305_SHA256` 66 | - named groups e.g. `x25519, secp256r1, x448, secp521r1, secp384r1` 67 | - Network/TCP settings, possibly MTU/Nagle algorithm status 68 | 69 | It has been decided the best way to go is not copying full browser behavior, 70 | but instead, just setting the `User-Agent` header; to ensure Cloudflare is bypassed properly. 71 | 72 | Since there are tens of variables that can affect fingerprint calculation, 73 | it's non-trivial to set all of them properly. Not only that, we'll need to 74 | update the params with every browser update. And we'll still probably 75 | miss some minor detail. 76 | 77 | - Let's say there are 1000 people who send TX through Tor using popular browser User Agent 78 | - Out of them, only 100 will set additional headers like `Accept-Language`. Many of them 79 | will send different information in headers; some will support Enconding, some will not 80 | - So, the more we mimic a particular browser, the more we increase our fingerprinting vector 81 | 82 | To view the data you're leaving, check out 83 | [httpbin](http://httpbin.org/headers), [httpbin ssl](https://httpbin.org/headers), 84 | [browserleaks](https://browserleaks.com/ssl) and [valdikss](http://witch.valdikss.org.ru). 85 | 86 | ## License 87 | 88 | MIT License (c) 2022 Paul Miller (https://paulmillr.com) 89 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { createHash } from 'crypto'; 3 | import { realpathSync } from 'fs'; 4 | import * as http from 'http'; 5 | import * as https from 'https'; 6 | import { isIPv4, Socket } from 'net'; 7 | import { connect as tlsConnect } from 'tls'; 8 | import * as userAgents from './user-agents.js'; 9 | 10 | // Only built-in modules are used 11 | // Note: We do not use third-party alternatives, since they 12 | // could potentially contain dangerous DNS resolver code 13 | 14 | type Option = T | undefined; 15 | type Headers = Record; 16 | 17 | const SITE_TIMEOUT = 10 * 1000; // 10 seconds 18 | const bold = '\x1b[1m'; 19 | const reset = '\x1b[0m'; 20 | const red = '\x1b[31m'; 21 | const green = '\x1b[32m'; 22 | 23 | const DEFAULTS = { host: '127.0.0.1', port: 9050, retryLimit: 10 }; 24 | 25 | function utf8ToBytes(string: string) { 26 | return new TextEncoder().encode(string); 27 | } 28 | 29 | function bytesToUtf8(bytes: Uint8Array) { 30 | return new TextDecoder().decode(bytes); 31 | } 32 | 33 | function sha256(buf: Uint8Array | string) { 34 | return createHash('sha256').update(buf).digest('hex'); 35 | } 36 | 37 | function isCloudflare(text: string) { 38 | return text.includes('Cloudflare'); 39 | } 40 | 41 | function shuffle(arr: T[]): T[] { 42 | return arr 43 | .slice() 44 | .map((value) => ({ value, sorter: Math.random() })) 45 | .sort((a, b) => a.sorter - b.sorter) 46 | .map(({ value }) => value); 47 | } 48 | 49 | function detectType(b: Uint8Array, type?: 'text' | 'json' | 'bytes') { 50 | if (!type || type === 'text' || type === 'json') { 51 | try { 52 | let text = new TextDecoder('utf8', { fatal: true }).decode(b); 53 | if (type === 'text') return text; 54 | try { 55 | return JSON.parse(text); 56 | } catch (err) { 57 | if (type === 'json') throw err; 58 | return text; 59 | } 60 | } catch (err) { 61 | if (type === 'text' || type === 'json') throw err; 62 | } 63 | } 64 | return b; 65 | } 66 | 67 | type Wait = { 68 | len: number; 69 | resolve: (value: Buffer) => void; 70 | reject: (reason?: any) => void; 71 | }; 72 | 73 | // Simple synchronyous socket interface via async/await (instead of callback abomination) 74 | class SimpleSocket { 75 | buf: Buffer = Buffer.from([]); 76 | wait?: Wait; 77 | err?: Error; 78 | private onData: (data: Buffer) => void; 79 | private onError: (err: Error) => void; 80 | private onClose: () => void; 81 | constructor(public socket: Socket) { 82 | // Create closures here, so we can remove on end 83 | this.onData = (data: Buffer) => { 84 | this.buf = Buffer.concat([this.buf, data]); 85 | this.process(); 86 | }; 87 | this.onError = (err: Error) => { 88 | this.err = err; 89 | if (!this.wait) return; 90 | this.wait.reject(this.err); 91 | this.wait = undefined; 92 | }; 93 | this.onClose = () => { 94 | this.err = new Error('EOF'); 95 | if (this.wait) this.wait.reject(this.err); 96 | }; 97 | socket.on('data', this.onData).on('error', this.onError).on('close', this.onClose); 98 | } 99 | process() { 100 | if (!this.wait) return; 101 | const { resolve, len } = this.wait; 102 | if (len > this.buf.length) return; 103 | const buf = this.buf.slice(0, len); 104 | this.buf = this.buf.slice(len); 105 | this.wait = undefined; 106 | resolve(buf); 107 | } 108 | connect(host: string, port: number): Promise { 109 | return new Promise((resolve, reject) => { 110 | if (this.wait) return reject(new Error('Socket already awaits read')); 111 | this.wait = { len: 0, resolve: resolve as any, reject }; 112 | this.socket.connect({ host, port }, () => this.process()); 113 | }); 114 | } 115 | readBytes(len: number): Promise { 116 | return new Promise((resolve, reject) => { 117 | if (this.err) reject(this.err); 118 | if (this.wait) reject(new Error('Socket already awaits read')); 119 | this.wait = { len, resolve, reject }; 120 | this.process(); 121 | }); 122 | } 123 | write(buf: Buffer) { 124 | this.socket.write(buf); 125 | } 126 | async readByte() { 127 | return (await this.readBytes(1))[0]; 128 | } 129 | end() { 130 | // pause && unshift to save any left data (since there is no onData handler) 131 | // Most servers (http for example) won't send anything at this point 132 | this.socket.pause(); 133 | this.socket 134 | .removeListener('data', this.onData) 135 | .removeListener('error', this.onError) 136 | .removeListener('close', this.onClose); 137 | this.socket.unshift(this.buf); 138 | } 139 | } 140 | 141 | // Small socks client 142 | const SOCKS_VER = 0x05; 143 | const AUTH_VER = 0x01; 144 | 145 | enum Auth { 146 | None = 0x00, 147 | UserPass = 0x02, 148 | } 149 | 150 | enum CMD { 151 | CONNECT = 0x01, 152 | } 153 | 154 | enum ATYP { 155 | IPv4 = 0x01, 156 | NAME = 0x03, 157 | } 158 | 159 | enum REP { 160 | SUCCESS = 0x00, 161 | EGENFAIL = 0x01, 162 | EACCES = 0x02, 163 | ENETUNREACH = 0x03, 164 | EHOSTUNREACH = 0x04, 165 | ECONNREFUSED = 0x05, 166 | ETTLEXPIRED = 0x06, 167 | ECMDNOSUPPORT = 0x07, 168 | EATYPNOSUPPORT = 0x08, 169 | } 170 | 171 | type SocksOpts = { 172 | proxyHost: string; 173 | proxyPort: number; 174 | host: string; 175 | port: number; 176 | user?: string; 177 | password?: string; 178 | }; 179 | 180 | // Micro-socks client 181 | export async function socksv5(opts: SocksOpts) { 182 | let sock = new SimpleSocket(new Socket()); 183 | await sock.connect(opts.proxyHost, opts.proxyPort); 184 | // Auth 185 | if (opts.user || opts.password) { 186 | // REQ: [SOCKS_VER, NMETHODS=1, METHOD] 187 | sock.write(Buffer.from([SOCKS_VER, 0x01, Auth.UserPass])); 188 | // RESP: [SOCKS_VER, AUTH_METHOD] 189 | if ((await sock.readByte()) !== SOCKS_VER) throw new Error('Wrong socks version'); 190 | if ((await sock.readByte()) !== Auth.UserPass) throw new Error('Wrong socks method'); 191 | const user = utf8ToBytes(opts.user || ''); 192 | const password = utf8ToBytes(opts.password || ''); 193 | // Send auth request 194 | // [VER, USER_LEN, USER, PASS_LEN, PASS] 195 | sock.write( 196 | Buffer.concat([ 197 | Buffer.from([0x01, user.length]), 198 | user, 199 | Buffer.from([password.length]), 200 | password, 201 | ]) 202 | ); 203 | // RESP: [AUTH_VER, STATUS] 204 | if ((await sock.readByte()) !== AUTH_VER) throw new Error('Unsupported auth version'); 205 | if ((await sock.readByte()) !== 0x00) throw new Error('Authentication failed'); 206 | } else { 207 | // REQ: [SOCKS_VER, NMETHODS=1, AUTH_METHOD] 208 | sock.write(Buffer.from([SOCKS_VER, 0x01, Auth.None])); 209 | // RESP: [SOCKS_VER, AUTH_METHOD] 210 | if ((await sock.readByte()) !== SOCKS_VER) throw new Error('Wrong socks version'); 211 | if ((await sock.readByte()) !== Auth.None) throw new Error('Wrong socks method'); 212 | } 213 | // Actual request 214 | // REQ: [VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT] 215 | const portBuf = Buffer.alloc(2); 216 | portBuf.writeUInt16BE(opts.port, 0); 217 | if (isIPv4(opts.host)) { 218 | sock.write( 219 | Buffer.from([ 220 | SOCKS_VER, 221 | CMD.CONNECT, 222 | 0x00, 223 | ATYP.IPv4, 224 | // Convert ipv4 to bytes (BE) 225 | ...opts.host.split('.', 4).map((i) => +i), 226 | ...Array.from(portBuf), 227 | ]) 228 | ); 229 | } else { 230 | const addr = utf8ToBytes(opts.host); 231 | sock.write( 232 | Buffer.from([ 233 | SOCKS_VER, 234 | CMD.CONNECT, 235 | 0x00, 236 | ATYP.NAME, 237 | addr.length, 238 | ...Array.from(addr), 239 | ...Array.from(portBuf), 240 | ]) 241 | ); 242 | } 243 | // Parse reply 244 | // RESP: [VER, REP, RSV, ATYP, DNB.ADDR, BND.PORT] 245 | if ((await sock.readByte()) !== SOCKS_VER) throw new Error('Wrong socks version'); 246 | const status = await sock.readByte(); 247 | if (status !== REP.SUCCESS) throw new Error(REP[status] || 'EUNKNOWN'); 248 | await sock.readByte(); // Skip RSV (reserved) field 249 | const atyp = await sock.readByte(); 250 | let bndAddr; 251 | if (atyp === ATYP.IPv4) bndAddr = (await sock.readBytes(4)).join('.'); 252 | else if (atyp === ATYP.NAME) bndAddr = bytesToUtf8(await sock.readBytes(await sock.readByte())); 253 | const bndPortBuf = await sock.readBytes(2); 254 | // U16BE 255 | const bndPort = (bndPortBuf[0] << 8) | bndPortBuf[1]; 256 | sock.end(); 257 | return { socket: sock.socket, bndAddr, bndPort }; 258 | } 259 | 260 | class Agent { 261 | defaultPort: number; 262 | defaultProtocol: string; 263 | keepAlive: boolean = false; 264 | maxSockets: number = 1; 265 | lastTs: number; 266 | userAgent: string; 267 | private agentId: number; 268 | 269 | constructor(private tor: Tor, private isSSL?: boolean, private reqId?: string) { 270 | this.tor = tor; 271 | this.defaultPort = isSSL ? 443 : 80; 272 | this.defaultProtocol = isSSL ? 'https' : 'http'; 273 | this.userAgent = userAgents.random(); 274 | this.agentId = 0; 275 | this.lastTs = Date.now(); 276 | } 277 | 278 | async addRequest( 279 | req: http.ClientRequest & { _last?: boolean; _hadError?: boolean }, 280 | opt: http.RequestOptions | https.RequestOptions 281 | ): Promise { 282 | this.lastTs = Date.now(); 283 | const onError = (err: Error) => { 284 | if (req._hadError) return; 285 | req.emit('error', err); 286 | req._hadError = true; 287 | }; 288 | let timedOut = false; 289 | let timeoutId: ReturnType | undefined; 290 | timeoutId = setTimeout(() => { 291 | timeoutId = undefined; 292 | timedOut = true; 293 | const err: NodeJS.ErrnoException = new Error('Timeout'); 294 | err.code = 'ETIMEOUT'; 295 | onError(err); 296 | }, SITE_TIMEOUT); 297 | if (!opt.host || !Number(opt.port)) 298 | throw new Error(`Agent: wrong host (${opt.host}) or port (${opt.port})`); 299 | try { 300 | const socket: Option = await this.tor.getSocket(opt.host, Number(opt.port), { 301 | ssl: this.isSSL, 302 | ssl_servername: (opt as any).servername, 303 | reqId: `${this.reqId}_${this.agentId}`, 304 | }); 305 | // Check for timeouts 306 | if (timedOut) return; 307 | timeoutId = timeoutId && (clearTimeout(timeoutId) as undefined); 308 | socket.once('free', () => this.freeSocket(socket as Socket)); 309 | req.onSocket(socket); 310 | } catch (err) { 311 | if (timedOut) return; 312 | timeoutId = timeoutId && (clearTimeout(timeoutId) as undefined); 313 | onError(err as Error); 314 | } 315 | } 316 | freeSocket(socket: Socket) { 317 | socket.destroy(); 318 | } 319 | resetId() { 320 | this.agentId++; 321 | this.userAgent = userAgents.random(); 322 | } 323 | } 324 | 325 | export interface TorOptions { 326 | socksHost?: string; 327 | socksPort?: number; 328 | retryLimit?: number; 329 | } 330 | 331 | export class Tor { 332 | opt: Required; 333 | readonly enabled: boolean = true; 334 | agentCache: Record = {}; 335 | constructor(opt: TorOptions = {}) { 336 | this.opt = { 337 | socksHost: opt.socksHost || DEFAULTS.host, 338 | socksPort: opt.socksPort || DEFAULTS.port, 339 | retryLimit: opt.retryLimit || DEFAULTS.retryLimit, 340 | }; 341 | } 342 | async getSocket( 343 | host: string, 344 | port: number, 345 | opt: { 346 | ssl?: boolean; 347 | ssl_servername?: string; 348 | reqId?: string; 349 | rejectUnauthorized?: boolean; 350 | } = {} 351 | ) { 352 | let { socket } = await socksv5({ 353 | proxyHost: this.opt.socksHost, 354 | proxyPort: this.opt.socksPort, 355 | // Tor assigns exit ip based on hash of username. 356 | user: sha256(`${host}_${port}_${opt.reqId}`), 357 | host, 358 | port, 359 | }); 360 | // For http/https it is safe to resume here, server won't send anything yet. 361 | socket.resume(); 362 | if (opt.ssl) 363 | socket = tlsConnect({ 364 | socket, 365 | servername: opt.ssl_servername || host, 366 | rejectUnauthorized: opt.rejectUnauthorized, 367 | }); 368 | return socket; 369 | } 370 | private getAgent(_url: string, reqId?: string) { 371 | const isSSL = _url.startsWith('https'); 372 | const parsed = new URL(_url); 373 | let host = parsed.hostname || parsed.host; 374 | if (!host) throw new Error(`empty host: ${_url}`); 375 | host += isSSL ? '_https' : '_http'; 376 | return new Agent(this, isSSL, reqId); 377 | } 378 | private fetchReq(url: string, opt: any = {}): Promise<[http.IncomingMessage, Uint8Array]> { 379 | const lib = url.startsWith('https') ? https : http; 380 | return new Promise((resolve, reject) => { 381 | let req: http.ClientRequest = lib.request(url, opt, (res: any) => { 382 | res.on('error', reject); 383 | return (async () => { 384 | let buf = []; 385 | for await (const chunk of res) buf.push(Uint8Array.from(chunk)); 386 | return resolve([res, Uint8Array.from(Buffer.concat(buf))]); 387 | })(); 388 | }); 389 | req.on('error', reject); 390 | if (opt.body) req.write(opt.body); 391 | req.on('error', reject); 392 | req.end(); 393 | }); 394 | } 395 | 396 | async fetch( 397 | url: string, 398 | opt: { 399 | type?: 'json' | 'text' | 'bytes'; 400 | expectStatusCode?: number; 401 | data?: object; 402 | reqId?: string; 403 | retry_status?: number[]; 404 | headers?: Headers; 405 | } = {} 406 | ): Promise { 407 | const { retryLimit } = this.opt; 408 | let retry = 0; 409 | let status; 410 | 411 | const agent = this.getAgent(url, opt.reqId); 412 | let reqOpt: any = { 413 | method: 'GET', 414 | agent, 415 | headers: { ...opt.headers, 'User-Agent': agent.userAgent }, 416 | }; 417 | if (opt.type === 'json') reqOpt.headers['Content-Type'] = 'application/json'; 418 | if (opt.data) { 419 | reqOpt.method = 'POST'; 420 | reqOpt.body = opt.type == 'json' ? JSON.stringify(opt.data) : opt.data; 421 | } 422 | for (retry = 0; retry < retryLimit; retry++) { 423 | let [res, data] = await this.fetchReq(url, reqOpt); 424 | status = Number(res.statusCode); 425 | // Cloudflare returns 403 on catpcha 426 | const custom_retry = opt.retry_status && opt.retry_status.includes(status); 427 | if ((status === 403 || status === 503 || custom_retry) && retry < retryLimit - 1) { 428 | let text = bytesToUtf8(data); 429 | if (isCloudflare(text) || status === 503 || custom_retry) { 430 | if (retry === retryLimit - 2) throw new Error('Cloudflare :('); 431 | agent.resetId(); 432 | continue; 433 | } 434 | } 435 | if (opt.expectStatusCode && res.statusCode !== opt.expectStatusCode) 436 | throw new Error(`Status Code: ${res.statusCode}`); 437 | // If we expect json, but cloudflare send 200 OK page 438 | try { 439 | return detectType(data, opt.type); 440 | } catch (e) { 441 | if (isCloudflare(bytesToUtf8(data))) { 442 | if (retry === retryLimit - 2) throw new Error('Cloudflare :('); 443 | agent.resetId(); 444 | continue; 445 | } 446 | } 447 | } 448 | throw new Error('fetch: too much retries'); 449 | } 450 | } 451 | 452 | // Actual broadcaster code 453 | type BroadcastFn = (tor: Tor, net: string, tx: string) => Promise; 454 | 455 | async function blockchair(tor: Tor, net: string, tx: string) { 456 | const FULLNAME_MAP: Record = { 457 | btc: 'bitcoin', 458 | bch: 'bitcoin-cash', 459 | eth: 'ethereum', 460 | zec: 'zcash', 461 | xmr: 'monero', 462 | }; 463 | const res = await tor.fetch( 464 | `https://api.blockchair.com/${FULLNAME_MAP[net]}/push/transaction?data=${tx}`, 465 | { 466 | reqId: tx, 467 | type: 'json', 468 | } 469 | ); 470 | if (res.data && res.data.error) throw new Error(res.data.error); 471 | if (res.context && res.context.error) throw new Error(res.context.error); 472 | return res.data.transaction_hash; 473 | } 474 | 475 | async function sochain(tor: Tor, net: string, tx: string) { 476 | const res = await tor.fetch(`https://sochain.com/api/v2/send_tx/${net.toUpperCase()}`, { 477 | reqId: tx, 478 | type: 'json', 479 | data: { tx_hex: tx }, 480 | }); 481 | if (res.status === 'success') return res.data.txid; 482 | if (res.status === 'fail') throw new Error(JSON.stringify(res.data)); 483 | } 484 | 485 | const etherscan = (key: string, headers: Headers) => 486 | async function etherscan(tor: Tor, net: string, tx: string) { 487 | const res = await tor.fetch( 488 | `https://api.etherscan.io/api?module=proxy&action=eth_sendRawTransaction&hex=${tx}&apikey=${key}`, 489 | { reqId: tx, type: 'json', headers } 490 | ); 491 | if (res.message === 'NOTOK') throw new Error(`status=${res.status}: ${res.result}`); 492 | if (res.error && res.error.message) 493 | throw new Error(`status=${res.error.code}: ${res.error.message}`); 494 | else return res.result; 495 | }; 496 | 497 | const web3 = (url: string, headers: Headers) => 498 | async function web3(tor: Tor, net: string, tx: string) { 499 | const res = await tor.fetch(url, { 500 | reqId: tx, 501 | type: 'json', 502 | headers, 503 | data: { method: 'eth_sendRawTransaction', params: [tx], id: 0, jsonrpc: '2.0' }, 504 | }); 505 | if (res.error && res.error.message) throw new Error(res.error.message); 506 | return res.result; 507 | }; 508 | 509 | const blockbook = (net: string, url: string, headers: Headers) => 510 | async function blockbook(tor: Tor, net: string, tx: string) { 511 | const res = await tor.fetch(`${url}/v2/sendtx/${tx}`, { reqId: tx, type: 'json', headers }); 512 | if (res.error) throw new Error(res.error); 513 | return res.result; 514 | }; 515 | 516 | const sol = (url: string, headers: Headers) => 517 | async function solana(tor: Tor, net: string, tx: string) { 518 | const res = await tor.fetch(url, { 519 | reqId: tx, 520 | type: 'json', 521 | headers, 522 | data: { 523 | method: 'sendTransaction', 524 | params: [tx, { encoding: 'base64' }], 525 | id: 0, 526 | jsonrpc: '2.0', 527 | }, 528 | }); 529 | if (res.error && res.error.message) throw new Error(res.error.message); 530 | return res.result; 531 | }; 532 | 533 | function _atob(stre: string) { 534 | return Buffer.from(stre, 'base64').toString(); 535 | } 536 | 537 | // The keys were found on the internet in public code. We won't want to use our own keys, 538 | // to decrease fingerprinting vector 539 | const registry: Record = { 540 | btc: [blockchair, sochain, blockbook('btc', 'https://btc1.trezor.io/api', {})], 541 | eth: [ 542 | blockchair, 543 | etherscan(_atob('VURKVzNBUlhXTjlFSE1URlVBMkZXNFYxS0E3UVpHQUdDQg=='), { 544 | Origin: 'https://etherscan.io', 545 | Referer: 'https://etherscan.io/', 546 | }), 547 | web3('https://node1.web3api.com/', { 548 | Origin: 'https://etherscan.io', 549 | Referer: 'https://etherscan.io/', 550 | }), 551 | web3('https://nodes.mewapi.io/rpc/eth', { 552 | Origin: 'https://www.myetherwallet.com', 553 | }), 554 | web3('https://mainnet.infura.io/v3/' + _atob('MmU1YmQyYmEwMzhkNGUzZjk2OWE1NmYyZWFkMDc0Y2E='), { 555 | Origin: 'https://www.myetherwallet.com', 556 | }), 557 | blockbook('eth', 'https://eth1.trezor.io/api', {}), 558 | ], 559 | bch: [blockchair, blockbook('bch', 'https://bch1.trezor.io/api', {})], 560 | xmr: [blockchair], 561 | zec: [blockchair, sochain, blockbook('zec', 'https://zec1.trezor.io/api', {})], 562 | sol: [ 563 | sol('https://explorer-api.mainnet-beta.solana.com/', { Origin: 'https://explorer.solana.com' }), 564 | ], 565 | }; 566 | 567 | export class Broadcaster { 568 | private tor: Tor; 569 | private sites: BroadcastFn[]; 570 | constructor(readonly network: string, readonly tx: string, opts: TorOptions) { 571 | // validate network 572 | if (!registry.hasOwnProperty(network)) throw new Error(`Network ${network} is not supported`); 573 | const fns = registry[network]; 574 | if (!Array.isArray(fns) || !fns.length) { 575 | throw new Error(`No valid broadcasters for network ${network}`); 576 | } 577 | this.sites = shuffle(fns); 578 | this.tor = new Tor(opts); 579 | } 580 | // reqId is not sent to an external server. It is only used to select Tor circuit, 581 | // so different txs will use different exit nodes. 582 | async getIP() { 583 | const res = await this.tor.fetch('http://httpbin.org/ip', { reqId: this.tx }); 584 | return res.origin; 585 | } 586 | async broadcast() { 587 | for (let fn of this.sites) { 588 | const { name: host } = fn; 589 | try { 590 | const txId = await fn(this.tor, this.network, this.tx); 591 | return { txId, host }; 592 | } catch (e) { 593 | console.log(`${red}${bold}Error${reset} (${host}): ${e}`); 594 | } 595 | } 596 | } 597 | } 598 | 599 | async function main() { 600 | const { TOR_HOST, TOR_SOCKS_PORT, TOR_RETRY_LIMIT } = process.env; 601 | const { argv } = process; 602 | const [filename, net, tx] = argv.slice(1); 603 | if (import.meta.url !== `file://${realpathSync(filename)}`) return; 604 | if (argv.length !== 4 || !(net in registry) || !tx) { 605 | return console.log(`Usage: txtor \nNET: ${Object.keys(registry).join(', ')}`); 606 | } 607 | const socksPort = Number.parseInt(TOR_SOCKS_PORT || ''); 608 | const retryLimit = Number.parseInt(TOR_RETRY_LIMIT || ''); 609 | const br = new Broadcaster(net, tx, { socksHost: TOR_HOST, socksPort, retryLimit }); 610 | console.log(`${bold}TOR exit IP:${reset}`, await br.getIP()); 611 | const res = await br.broadcast(); 612 | if (res) console.log(`${green}${bold}Published${reset} (${res.host}): ${res.txId}`); 613 | } 614 | 615 | main(); 616 | --------------------------------------------------------------------------------