Skip to content

Commit

Permalink
Merge branch 'main' into add-coinbase-prime
Browse files Browse the repository at this point in the history
  • Loading branch information
habibitcoin committed Dec 11, 2023
2 parents c28ffe4 + 3d8bc7c commit d1d3018
Show file tree
Hide file tree
Showing 11 changed files with 162 additions and 565 deletions.
9 changes: 9 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ EXCLUDE_TAGS_HIGH_FEE_THRESHOLD=60
#MIN_TAG_SIZES_HIGH_FEE_THRESHOLD=50
#MIN_TAG_SIZES_HIGH_FEE="vintage_nakamoto:10000 block_78:10000"

# It is a configuration option that allows you to map specific tags to Bitcoin addresses.
# This is useful when you want to direct certain types of sats to specific addresses.
# The format for this configuration is an array of tag:address pairs, specified in order of priority.
# The script will use the first matching tag to determine where to send the sat.
# For example, if your configuration is uncommon:address123 omega:address345 and the script finds an uncommon omega sat, it will be sent to address123.
# However, if your configuration is omega:address345 uncommon:address123, the sat would be sent to address345.
# Therefore, the order of the tag:address pairs in your configuration matters.
#TAG_BY_ADDRESS="vintage_nakamoto:bc1p1.... block_78:bc1p2...."

# You can split up the output(s) to your exchange under certain conditions to help shake things up and maybe unlock new coins.
# Valid options are NEVER (never split), NO_SATS (only split if no sats found), or ALWAYS (always split)
#SPLIT_TRIGGER=NO_SATS
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/test-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ jobs:
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
fail_ci_if_error: true
fail_ci_if_error: true
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
node_modules
data
.DS_Store
coverage/
coverage/
README.local.md
47 changes: 46 additions & 1 deletion __tests__/utils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { get_excluded_tags, get_min_tag_sizes, get_included_tags } = require('../utils')
const { get_excluded_tags, get_min_tag_sizes, get_included_tags, get_tag_by_address, sleep } = require('../utils')

describe('get_excluded_tags', () => {
test('should return correct format', () => {
Expand Down Expand Up @@ -164,4 +164,49 @@ describe('get_included_tags', () => {
const result = get_included_tags({ fee_rate: 15 })
expect(result).toEqual([['special_name']])
})
})

describe('get_tag_by_address', () => {
test('should return correct format', () => {
process.env.TAG_BY_ADDRESS = 'tag1:address1 tag2:address2'
const result = get_tag_by_address()
expect(result).toEqual({ 'tag1': 'address1', 'tag2': 'address2' })
})

test('should trim leading and trailing spaces', () => {
process.env.TAG_BY_ADDRESS = ' tag1:address1 tag2:address2 '
const result = get_tag_by_address()
expect(result).toEqual({ 'tag1': 'address1', 'tag2': 'address2' })
})

test('should return null when TAG_BY_ADDRESS is not set', () => {
delete process.env.TAG_BY_ADDRESS
const result = get_tag_by_address()
expect(result).toBeNull()
})

test('should return null when TAG_BY_ADDRESS is empty', () => {
process.env.TAG_BY_ADDRESS = ' '
const result = get_tag_by_address()
expect(result).toBeNull()
})

test('should handle multiple tag-address pairs', () => {
process.env.TAG_BY_ADDRESS = 'tag1:address1 tag2:address2 tag3:address3'
const result = get_tag_by_address()
expect(result).toEqual({ 'tag1': 'address1', 'tag2': 'address2', 'tag3': 'address3' })
})
})

describe('sleep', () => {
test('should wait for the specified amount of time', async () => {
const startTime = Date.now()
await sleep(1000) // wait for 1 second
const endTime = Date.now()

// Check if the difference between the start and end times is close to 1000 milliseconds
// We use toBeGreaterThanOrEqual and toBeLessThan to account for slight variations in timing
expect(endTime - startTime).toBeGreaterThanOrEqual(1000)
expect(endTime - startTime).toBeLessThan(1010)
})
})
67 changes: 67 additions & 0 deletions __tests__/wallet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const ecc = require('tiny-secp256k1')
const {BIP32Factory} = require('bip32')
const bip32 = BIP32Factory(ecc)
const bitcoin = require('bitcoinjs-lib')
const bip39 = require('bip39')

const derivePath = "m/84'/0'/0'/0/0"

// Generate a random seed
const seed = bip39.generateMnemonic()

// Derive the root key from the seed
const root = bip32.fromSeed(Buffer.from(bip39.mnemonicToSeedSync(seed)))

// Derive the first account's node (BIP84 derivation path for the first account)
const node = root.derivePath("m/84'/0'/0'/0/0")

// Get the Bitcoin p2wpkh address associated with this node
const { address } = bitcoin.payments.p2wpkh({ pubkey: node.publicKey })

process.env.LOCAL_WALLET_SEED = seed
process.env.LOCAL_WALLET_ADDRESS = address
process.env.LOCAL_DERIVATION_PATH = derivePath

const { get_utxos } = require('../wallet')
const { listunspent } = require('../bitcoin')
const axios = require('axios')

jest.mock('../bitcoin')
jest.mock('axios')

describe('get_utxos', () => {
beforeEach(() => {
// Reset the mocks before each test
listunspent.mockReset()
axios.get.mockReset()
})

test('should return correct utxos for local wallet', async () => {
delete process.env.BITCOIN_WALLET
process.env.WALLET_TYPE = 'local'
axios.get.mockImplementation(() => Promise.resolve({
data: [
{ txid: 'tx1', vout: 0, value: 100000 },
{ txid: 'tx2', vout: 1, value: 200000 }
]
}))

const result = await get_utxos()
expect(result).toEqual(['tx1:0', 'tx2:1'])
})

test('should throw error when LOCAL_WALLET_ADDRESS is not set', async () => {
delete process.env.BITCOIN_WALLET
delete process.env.LOCAL_WALLET_ADDRESS

await expect(get_utxos()).rejects.toThrow('LOCAL_WALLET_ADDRESS must be set')
})

test('should throw error when mempool api is unreachable', async () => {
delete process.env.BITCOIN_WALLET
process.env.LOCAL_WALLET_ADDRESS = 'address'
axios.get.mockImplementation(() => Promise.reject(new Error('Network error')))

await expect(get_utxos()).rejects.toThrow('Error reaching mempool api')
})
})
5 changes: 4 additions & 1 deletion deezy.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ function check_api_key() {
throw new Error('DEEZY_API_KEY must be set')
}
}
async function post_scan_request({ utxo, exchange_address, rare_sat_address, extraction_fee_rate, excluded_tags = null, included_tags = null, min_tag_sizes = null }) {
async function post_scan_request({ utxo, exchange_address, rare_sat_address, extraction_fee_rate, excluded_tags = null, included_tags = null, min_tag_sizes = null, tag_by_address = null }) {
check_api_key()
if (!process.env.RARE_SAT_ADDRESS) {
throw new Error('RARE_SAT_ADDRESS must be set')
Expand All @@ -35,6 +35,9 @@ async function post_scan_request({ utxo, exchange_address, rare_sat_address, ext
if (min_tag_sizes) {
body.min_tag_sizes = min_tag_sizes
}
if (tag_by_address) {
body.tag_by_address = tag_by_address
}
if (process.env.SPLIT_TRIGGER) {
if (!VALID_SPLIT_TRIGGERS.includes(process.env.SPLIT_TRIGGER)) {
throw new Error(`Invalid SPLIT_TRIGGER: ${process.env.SPLIT_TRIGGER}, must be one of ${VALID_SPLIT_TRIGGERS.join(', ')}`)
Expand Down
2 changes: 1 addition & 1 deletion exchanges/coinbase.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const totp = require("totp-generator")
const BASE_URL = 'https://api.coinbase.com'

let BTC_ACCOUNT_ID = null
const FEE_BUFFER = 0.0005 // We deduct a small amount from the withdrawal amount to cover the tx fee.
const FEE_BUFFER = 0.001 // We deduct a small amount from the withdrawal amount to cover the tx fee.
function create_signature({ path, timestamp, method, body = ''}) {
const data = `${timestamp}${method}${path}${body}`
return crypto.createHmac('sha256', process.env.COINBASE_API_SECRET)
Expand Down
16 changes: 10 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const { get_fee_rate } = require('./fees')
const { post_scan_request, get_scan_request } = require('./deezy')
const { generate_satributes_messages } = require('./satributes')
const { sendNotifications, TELEGRAM_BOT_ENABLED, PUSHOVER_ENABLED } = require('./notifications.js')
const { get_excluded_tags, get_included_tags, get_min_tag_sizes } = require('./utils.js')
const { get_excluded_tags, get_included_tags, get_min_tag_sizes, sleep, get_tag_by_address } = require('./utils.js')
const LOOP_SECONDS = process.env.LOOP_SECONDS ? parseInt(process.env.LOOP_SECONDS) : 10
const available_exchanges = Object.keys(exchanges)
const FALLBACK_MAX_FEE_RATE = 200
Expand All @@ -25,7 +25,6 @@ let notified_bank_run = false
let notified_withdrawal_disabled = false
let notified_error_withdrawing = false

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function maybe_withdraw(exchange_name, exchange) {
const btc_balance = await exchange.get_btc_balance().catch((err) => {
console.error(err)
Expand Down Expand Up @@ -64,8 +63,9 @@ async function decode_sign_and_send_psbt({ psbt, exchange_address, rare_sat_addr
console.log(`Checking validity of psbt...`)
console.log(psbt)
const decoded_psbt = bitcoin.Psbt.fromBase64(psbt)
const tag_by_address = get_tag_by_address() || {}
for (const output of decoded_psbt.txOutputs) {
if (output.address !== exchange_address && output.address !== rare_sat_address) {
if (output.address !== exchange_address && output.address !== rare_sat_address && !Object.values(tag_by_address).includes(output.address)) {
throw new Error(`Invalid psbt. Output ${output.address} is not one of our addresses.`)
}
}
Expand Down Expand Up @@ -103,8 +103,7 @@ async function decode_sign_and_send_psbt({ psbt, exchange_address, rare_sat_addr
console.log(`Broadcasted transaction with txid: ${txid} and fee rate of ${final_fee_rate} sat/vbyte`)
if (!process.env.ONLY_NOTIFY_ON_SATS) {
await sendNotifications(
`Broadcasted ${
is_replacement ? 'replacement ' : ''
`Broadcasted ${is_replacement ? 'replacement ' : ''
}tx at ${final_fee_rate} sat/vbyte https://mempool.space/tx/${txid}`
)
}
Expand Down Expand Up @@ -176,7 +175,7 @@ async function run() {
console.log(`Listing existing wallet utxos...`)
const unspents = await get_utxos()
console.log(`Found ${unspents.length} utxos in wallet.`)
const utxos = unspents.concat(bump_utxos)
const utxos = unspents.concat(bump_utxos);
if (utxos.length === 0) {
return
}
Expand Down Expand Up @@ -214,6 +213,11 @@ async function run() {
console.log(`Using min tag sizes: ${min_tag_sizes}`)
request_body.min_tag_sizes = min_tag_sizes
}
let tag_by_address = get_tag_by_address()
if (tag_by_address) {
console.log(`Using tag by address: ${Object.entries(tag_by_address).map(([tag, address]) => `${tag}:${address}`).join(' ')}`)
request_body.tag_by_address = tag_by_address
}
const scan_request = await post_scan_request(request_body)
scan_request_ids.push(scan_request.id)
if (rescanned_utxos.has(utxo)) {
Expand Down
3 changes: 2 additions & 1 deletion jest.setup.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
require("dotenv").config();
// Lets skip .env variables in our tests please set them in CI/CD if needed
// require("dotenv").config();
Loading

0 comments on commit d1d3018

Please sign in to comment.