diff --git a/.github/workflows/apidocs-upload.yml b/.github/workflows/apidocs-upload.yml index 7c7f61cc..d550f422 100644 --- a/.github/workflows/apidocs-upload.yml +++ b/.github/workflows/apidocs-upload.yml @@ -11,7 +11,7 @@ jobs: # This follows semver recommendations, with release candidates having a dash and a dot # @see https://semver.org/spec/v2.0.0-rc.2.html check-version: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest outputs: release_status_output: ${{ steps.release_version.outputs.version_status_output }} @@ -47,7 +47,7 @@ jobs: # Initiate upload only if the version is valid upload-apidocs: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest permissions: id-token: write # This is required for requesting the JWT needs: check-version diff --git a/.github/workflows/apidocs.yml b/.github/workflows/apidocs.yml index bb277213..4c1b5d5d 100644 --- a/.github/workflows/apidocs.yml +++ b/.github/workflows/apidocs.yml @@ -7,7 +7,7 @@ on: - release jobs: lint: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest timeout-minutes: 10 strategy: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 18acd233..f8eb515a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -40,11 +40,23 @@ jobs: username: ${{ secrets.AWS_ACCESS_KEY_ID }} password: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - name: Build and push - # https://github.com/docker/build-push-action/releases/tag/v6.7.0 - uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 + # https://github.com/docker/build-push-action/releases/tag/v6.15.0 + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 with: push: true tags: ${{ steps.tags.outputs.tags }} platforms: linux/amd64,linux/arm64 cache-from: type=gha cache-to: type=gha,mode=max + - name: Build and push RabbitMQ image + # https://github.com/docker/build-push-action/releases/tag/v6.15.0 + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 + if: steps.tags.outputs.rabbitmq_tags != '' + with: + build-args: + ENABLE_RABBITMQ=true + push: true + tags: ${{ steps.tags.outputs.rabbitmq_tags }} + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index bde09011..78fc350f 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -19,8 +19,8 @@ env: jobs: itest: - runs-on: ubuntu-20.04 - timeout-minutes: 40 + runs-on: ubuntu-latest + timeout-minutes: 80 strategy: matrix: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d6f2e8b7..563ae389 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,7 @@ on: jobs: test: - runs-on: "ubuntu-latest" + runs-on: ubuntu-latest timeout-minutes: 10 strategy: matrix: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 02b0e554..9c024e5c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ on: - release jobs: test: - runs-on: "ubuntu-latest" + runs-on: ubuntu-latest timeout-minutes: 40 # default is 360 strategy: matrix: diff --git a/Dockerfile b/Dockerfile index 3f2695f6..d828d57a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG IMG=node:22.11-alpine3.19 -FROM $IMG as builder +FROM $IMG AS builder WORKDIR /usr/src/app/ @@ -17,7 +17,7 @@ COPY config.js.docker ./src/config.js RUN npm run build && npm run build-scripts -FROM $IMG as deps +FROM $IMG AS deps WORKDIR /usr/src/app/ ENV NODE_ENV=production @@ -27,8 +27,14 @@ ENV NODE_ENV=production COPY package.json package-lock.json ./ RUN apk add --no-cache --virtual .gyp python3 make g++ &&\ - npm ci --only=production &&\ - apk del .gyp &&\ + npm ci --only=production + +# Install amqp library if RabbitMQ is enabled +ARG ENABLE_RABBITMQ=false +ENV ENABLE_RABBITMQ=${ENABLE_RABBITMQ} +RUN if [ "$ENABLE_RABBITMQ" = "true" ]; then npm install amqplib@0.10.5; fi + +RUN apk del .gyp &&\ npm cache clean --force &&\ rm -rf /tmp/* /var/cache/apk/* diff --git a/Makefile b/Makefile index af324839..b1e1afaf 100644 --- a/Makefile +++ b/Makefile @@ -65,6 +65,15 @@ else node dist-scripts/get_xpub_from_seed.js $(seed) endif +# Internal target to run multisig_xpub_from_seed command. +.PHONY: .run_multisig_xpub_from_seed +.run_multisig_xpub_from_seed: +ifeq ($(seed),) + @echo "Usage: make multisig_xpub_from_seed seed=YOUR_SEED" +else + node dist-scripts/get_multisig_xpub_from_seed.js $(seed) +endif + # Command: generate words .PHONY: words words: .script-build-dirs .run_words .script-clean-dirs @@ -76,3 +85,7 @@ create_hsm_key: .script-build-dirs .run_create_hsm_key .script-clean-dirs # Command: Derive xPub from seed .PHONY: xpub_from_seed xpub_from_seed: .script-build-dirs .run_xpub_from_seed .script-clean-dirs + +# Command: Derive multisig xPub from seed +.PHONY: multisig_xpub_from_seed +multisig_xpub_from_seed: .script-build-dirs .run_multisig_xpub_from_seed .script-clean-dirs diff --git a/__tests__/__fixtures__/http-fixtures.js b/__tests__/__fixtures__/http-fixtures.js index 92b50f57..794121f5 100644 --- a/__tests__/__fixtures__/http-fixtures.js +++ b/__tests__/__fixtures__/http-fixtures.js @@ -1038,6 +1038,7 @@ export default { address: 'WewDeXWyvHP7jJTs7tjLoQfoB72LLxJQqN', timelock: null, value: 6400, + token_data: 0, }, token: '00', spent_by: null, @@ -1052,6 +1053,7 @@ export default { address: 'wgyUgNjqZ18uYr4YfE2ALW6tP5hd8MumH5', timelock: null, value: 6400, + token_data: 0, }, token: '00', spent_by: null, diff --git a/__tests__/__fixtures__/settings-fixture.js b/__tests__/__fixtures__/settings-fixture.js index ab4f7c5a..8f9244ce 100644 --- a/__tests__/__fixtures__/settings-fixture.js +++ b/__tests__/__fixtures__/settings-fixture.js @@ -20,6 +20,7 @@ const defaultConfig = { tokenUid: '', gapLimit: null, confirmFirstAddress: null, + history_sync_mode: 'polling_http_api', }; let config = cloneDeep(defaultConfig); diff --git a/__tests__/decode.test.js b/__tests__/decode.test.js index a836f95c..7fef1feb 100644 --- a/__tests__/decode.test.js +++ b/__tests__/decode.test.js @@ -191,6 +191,7 @@ describe('decode api', () => { type: 'MultiSig', timelock: null, value: 6400, + token_data: 0, }, script: expect.any(String), token: '00', diff --git a/__tests__/history-sync.test.js b/__tests__/history-sync.test.js index fbd29b3b..f5e6340c 100644 --- a/__tests__/history-sync.test.js +++ b/__tests__/history-sync.test.js @@ -11,17 +11,20 @@ describe('history sync', () => { }); afterEach(async () => { - await TestUtils.stopWallet({ walletId }); + await TestUtils.stopWallet({ walletId, pollInterval: 2000 }); }); - it('should start a wallet with default http polling if not configured', async () => { + it('should start a wallet with default xpub streaming if not configured', async () => { + const config = settings._getDefaultConfig(); + delete config.history_sync_mode; + settings._setConfig(config); const response = await TestUtils.request .post('/start') .send({ seedKey: TestUtils.seedKey, 'wallet-id': walletId }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); const wallet = initializedWallets.get(walletId); - expect(wallet.historySyncMode).toEqual(hathorLib.HistorySyncMode.POLLING_HTTP_API); + expect(wallet.historySyncMode).toEqual(hathorLib.HistorySyncMode.XPUB_STREAM_WS); }); it('should start a wallet with configured history sync', async () => { @@ -40,18 +43,18 @@ describe('history sync', () => { it('should use the history sync from the request when provided', async () => { const config = settings._getDefaultConfig(); - config.history_sync_mode = 'manual_stream_ws'; + config.history_sync_mode = 'polling_http_api'; settings._setConfig(config); const response = await TestUtils.request .post('/start') .send({ seedKey: TestUtils.seedKey, 'wallet-id': walletId, - history_sync_mode: 'xpub_stream_ws', + history_sync_mode: 'manual_stream_ws', }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); const wallet = initializedWallets.get(walletId); - expect(wallet.historySyncMode).toEqual(hathorLib.HistorySyncMode.XPUB_STREAM_WS); + expect(wallet.historySyncMode).toEqual(hathorLib.HistorySyncMode.MANUAL_STREAM_WS); }); }); diff --git a/__tests__/integration/configuration/bet.py b/__tests__/integration/configuration/bet.py new file mode 100644 index 00000000..d5f243b4 --- /dev/null +++ b/__tests__/integration/configuration/bet.py @@ -0,0 +1,225 @@ +# +# Copyright (c) Hathor Labs and its affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. +# + +from math import floor +from typing import Optional, TypeAlias + +from hathor.nanocontracts.blueprint import Blueprint +from hathor.nanocontracts.context import Context +from hathor.nanocontracts.exception import NCFail +from hathor.nanocontracts.types import ( + Address, + NCAction, + NCActionType, + SignedData, + Timestamp, + TokenUid, + TxOutputScript, + public, + view, +) + +Result: TypeAlias = str +Amount: TypeAlias = int + + +class InvalidToken(NCFail): + pass + + +class ResultAlreadySet(NCFail): + pass + + +class ResultNotAvailable(NCFail): + pass + + +class WithdrawalNotAllowed(NCFail): + pass + + +class DepositNotAllowed(NCFail): + pass + + +class TooManyActions(NCFail): + pass + + +class TooLate(NCFail): + pass + + +class InsufficientBalance(NCFail): + pass + + +class InvalidOracleSignature(NCFail): + pass + + +class Bet(Blueprint): + """Bet blueprint with final result provided by an oracle. + + The life cycle of contracts using this blueprint is the following: + + 1. [Owner ] Create a contract. + 2. [User 1] `bet(...)` on result A. + 3. [User 2] `bet(...)` on result A. + 4. [User 3] `bet(...)` on result B. + 5. [Oracle] `set_result(...)` as result A. + 6. [User 1] `withdraw(...)` + 7. [User 2] `withdraw(...)` + + Notice that, in the example above, users 1 and 2 won. + """ + + # Total bets per result. + bets_total: dict[Result, Amount] + + # Total bets per (result, address). + bets_address: dict[tuple[Result, Address], Amount] + + # Bets grouped by address. + address_details: dict[Address, dict[Result, Amount]] + + # Amount that has already been withdrawn per address. + withdrawals: dict[Address, Amount] + + # Total bets. + total: Amount + + # Final result. + final_result: Optional[Result] + + # Oracle script to set the final result. + oracle_script: TxOutputScript + + # Maximum timestamp to make a bet. + date_last_bet: Timestamp + + # Token for this bet. + token_uid: TokenUid + + @public + def initialize(self, ctx: Context, oracle_script: TxOutputScript, token_uid: TokenUid, + date_last_bet: Timestamp) -> None: + if len(ctx.actions) != 0: + raise NCFail('must be a single call') + self.oracle_script = oracle_script + self.token_uid = token_uid + self.date_last_bet = date_last_bet + self.final_result = None + self.total = Amount(0) + + @view + def has_result(self) -> bool: + """Return True if the final result has already been set.""" + return bool(self.final_result is not None) + + def fail_if_result_is_available(self) -> None: + """Fail the execution if the final result has already been set.""" + if self.has_result(): + raise ResultAlreadySet + + def fail_if_result_is_not_available(self) -> None: + """Fail the execution if the final result is not available yet.""" + if not self.has_result(): + raise ResultNotAvailable + + def fail_if_invalid_token(self, action: NCAction) -> None: + """Fail the execution if the token is invalid.""" + if action.token_uid != self.token_uid: + token1 = self.token_uid.hex() if self.token_uid else None + token2 = action.token_uid.hex() if action.token_uid else None + raise InvalidToken(f'invalid token ({token1} != {token2})') + + def _get_action(self, ctx: Context) -> NCAction: + """Return the only action available; fails otherwise.""" + if len(ctx.actions) != 1: + raise TooManyActions('only one action supported') + if self.token_uid not in ctx.actions: + raise InvalidToken(f'token different from {self.token_uid.hex()}') + return ctx.actions[self.token_uid] + + @public + def bet(self, ctx: Context, address: Address, score: str) -> None: + """Make a bet.""" + action = self._get_action(ctx) + if action.type != NCActionType.DEPOSIT: + raise WithdrawalNotAllowed('must be deposit') + self.fail_if_result_is_available() + self.fail_if_invalid_token(action) + if ctx.timestamp > self.date_last_bet: + raise TooLate(f'cannot place bets after {self.date_last_bet}') + amount = Amount(action.amount) + self.total = Amount(self.total + amount) + if score not in self.bets_total: + self.bets_total[score] = amount + else: + self.bets_total[score] += amount + key = (score, address) + if key not in self.bets_address: + self.bets_address[key] = amount + else: + self.bets_address[key] += amount + + # Update dict indexed by address + partial = self.address_details.get(address, {}) + partial.update({ + score: self.bets_address[key] + }) + + self.address_details[address] = partial + + @public + def set_result(self, ctx: Context, result: SignedData[Result]) -> None: + """Set final result. This method is called by the oracle.""" + self.fail_if_result_is_available() + if not result.checksig(self.oracle_script): + raise InvalidOracleSignature + self.final_result = result.data + + @public + def withdraw(self, ctx: Context) -> None: + """Withdraw tokens after the final result is set.""" + action = self._get_action(ctx) + if action.type != NCActionType.WITHDRAWAL: + raise DepositNotAllowed('action must be withdrawal') + self.fail_if_result_is_not_available() + self.fail_if_invalid_token(action) + address = Address(ctx.address) + allowed = self.get_max_withdrawal(address) + if action.amount > allowed: + raise InsufficientBalance(f'withdrawal amount is greater than available (max: {allowed})') + if address not in self.withdrawals: + self.withdrawals[address] = action.amount + else: + self.withdrawals[address] += action.amount + + @view + def get_max_withdrawal(self, address: Address) -> Amount: + """Return the maximum amount available for withdrawal.""" + total = self.get_winner_amount(address) + withdrawals = self.withdrawals.get(address, Amount(0)) + return total - withdrawals + + @view + def get_winner_amount(self, address: Address) -> Amount: + """Return how much an address has won.""" + self.fail_if_result_is_not_available() + if self.final_result not in self.bets_total: + return Amount(0) + result_total = self.bets_total[self.final_result] + if result_total == 0: + return Amount(0) + address_total = self.bets_address.get((self.final_result, address), 0) + percentage = address_total / result_total + return Amount(floor(percentage * self.total)) + +__blueprint__ = Bet diff --git a/__tests__/integration/configuration/privnet.yml b/__tests__/integration/configuration/privnet.yml index 45bcef74..015f8a20 100644 --- a/__tests__/integration/configuration/privnet.yml +++ b/__tests__/integration/configuration/privnet.yml @@ -15,13 +15,16 @@ ENABLE_PEER_WHITELIST: false # Genesis stuff GENESIS_OUTPUT_SCRIPT: 76a91466665b27f7dbc4c8c089d2f686c170c74d66f0b588ac GENESIS_BLOCK_TIMESTAMP: 1643902665 -GENESIS_BLOCK_NONCE: 4784939 -GENESIS_BLOCK_HASH: 00000334a21fbb58b4db8d7ff282d018e03e2977abd3004cf378fb1d677c3967 +GENESIS_BLOCK_NONCE: 2518838 +GENESIS_BLOCK_HASH: 0000076d916e83de99f9623db91bffe6649606427b848fa0b8e0cbb72c9e87cc GENESIS_TX1_NONCE: 0 GENESIS_TX1_HASH: 54165cef1fd4cf2240d702b8383c307c822c16ca407f78014bdefa189a7571c2 GENESIS_TX2_NONCE: 0 GENESIS_TX2_HASH: 039906854ce6309b3180945f2a23deb9edff369753f7082e19053f5ac11bfbae +GENESIS_TOKEN_UNITS: 92233720368547758 +GENESIS_TOKENS: 9223372036854775800 + MIN_TX_WEIGHT_K: 0 MIN_TX_WEIGHT_COEFFICIENT: 0 MIN_TX_WEIGHT: 1 @@ -30,7 +33,10 @@ REWARD_SPEND_MIN_BLOCKS: 1 CHECKPOINTS: [] ENABLE_NANO_CONTRACTS: true +ENABLE_ON_CHAIN_BLUEPRINTS: true +NC_ON_CHAIN_BLUEPRINT_ALLOWED_ADDRESSES: + - WjnxnM677xdDLek6e5B3BtwxYHTQ6tzXmF BLUEPRINTS: 3cb032600bdf7db784800e4ea911b10676fa2f67591f82bb62628c234e771595: Bet -extends: mainnet.yml \ No newline at end of file +extends: mainnet.yml diff --git a/__tests__/integration/configuration/settings-fixture.js b/__tests__/integration/configuration/settings-fixture.js index 1024e566..ce27f7d0 100644 --- a/__tests__/integration/configuration/settings-fixture.js +++ b/__tests__/integration/configuration/settings-fixture.js @@ -34,6 +34,7 @@ const defaultConfig = { connectionTimeout: 30000, allowPassphrase: false, confirmFirstAddress: false, + history_sync_mode: 'polling_http_api', }; let config = cloneDeep(defaultConfig); diff --git a/__tests__/integration/configuration/test-constants.js b/__tests__/integration/configuration/test-constants.js index dc643325..6759b60f 100644 --- a/__tests__/integration/configuration/test-constants.js +++ b/__tests__/integration/configuration/test-constants.js @@ -60,6 +60,9 @@ export const WALLET_CONSTANTS = { 'WgWfrJqAgS3RwzXMMz8fywidQAUx6a5smc' ] }, + ocb: { + seed: 'bicycle dice amused car lock outdoor auto during nest accident soon sauce slot enact hand they member source job forward vibrant lab catch coach', // The wallet that can sign on chain blueprint txs with its address at index 0 + }, }; export const TOKEN_DATA = { diff --git a/__tests__/integration/create-token.test.js b/__tests__/integration/create-token.test.js index 03c17af8..165f323b 100644 --- a/__tests__/integration/create-token.test.js +++ b/__tests__/integration/create-token.test.js @@ -196,6 +196,51 @@ describe('create token', () => { expect(tkaBalance.available).toBe(100); // The newly minted TKA tokens }); + it('should create a token with large value', async () => { + const largeWallet1 = WalletHelper.getPrecalculatedWallet('large-wallet1'); + await WalletHelper.startMultipleWalletsForTest([largeWallet1]); + + let wallet1balance = await largeWallet1.getBalance(); + expect(wallet1balance.available).toStrictEqual(0); + + const amount = 2n ** 63n; // This is the maximum output value + const depositAmount = tokensUtils.getDepositAmount(amount); + + // The deposit amount contains a slight precision loss, but this is expected and compatible with + // the full node, in Python. See the docstring in the `getDepositAmount` function in the + // wallet-lib for more info. + expect(depositAmount).toStrictEqual(92233720368547760n); + + await largeWallet1.injectFunds(depositAmount.toString()); + wallet1balance = await largeWallet1.getBalance(); + expect(wallet1balance.available).toStrictEqual(depositAmount); + + const response = await TestUtils.request + .post('/wallet/create-token') + .send({ + name: tokenA.name, + symbol: tokenA.symbol, + amount: amount.toString(), + }) + .set({ 'x-wallet-id': largeWallet1.walletId }); + + expect(response.body.success).toBe(true); + expect(response.body.hash).toBeDefined(); + expect(response.body.configurationString) + .toBe(tokensUtils.getConfigurationString(response.body.hash, tokenA.name, tokenA.symbol)); + + const configStringResponse = await TestUtils.getConfigurationString(response.body.hash); + expect(response.body.success).toBe(true); + expect(response.body.configurationString).toBe(configStringResponse.configurationString); + + await TestUtils.waitForTxReceived(largeWallet1.walletId, response.body.hash); + + const htrBalance = await largeWallet1.getBalance(); + const tkaBalance = await largeWallet1.getBalance(response.body.hash); + expect(htrBalance.available).toBe(0); + expect(tkaBalance.available).toBe(amount); + }); + it('should send the created tokens to the correct address', async () => { const amountTokens = getRandomInt(100, 200); const response = await TestUtils.request diff --git a/__tests__/integration/docker-compose.yml b/__tests__/integration/docker-compose.yml index f3ca2262..64372d0f 100644 --- a/__tests__/integration/docker-compose.yml +++ b/__tests__/integration/docker-compose.yml @@ -6,7 +6,7 @@ services: fullnode: image: - ${HATHOR_LIB_INTEGRATION_TESTS_FULLNODE_IMAGE:-hathornetwork/hathor-core:experimental-nano-sdk-v1.5-rc4} + ${HATHOR_LIB_INTEGRATION_TESTS_FULLNODE_IMAGE:-hathornetwork/hathor-core:experimental-nano-testnet-v1.7.3} command: [ "run_node", "--listen", "tcp:40404", @@ -15,8 +15,8 @@ services: "--wallet-index", "--allow-mining-without-peers", "--unsafe-mode", "nano-testnet-alpha", - "--memory-storage", - "--nc-history-index", + "--data", "./tmp", + "--nc-indices", ] environment: HATHOR_CONFIG_YAML: privnet/conf/privnet.yml @@ -29,12 +29,18 @@ services: target: /privnet/conf networks: - hathor-privnet + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; import json; r = urllib.request.urlopen('http://localhost:8080/v1a/status'); body = json.loads(r.read()); assert body['server']['state'] == 'READY'"] + interval: 5s + timeout: 10s + retries: 10 tx-mining-service: image: ${HATHOR_LIB_INTEGRATION_TESTS_TXMINING_IMAGE:-hathornetwork/tx-mining-service} depends_on: - - fullnode + fullnode: + condition: service_healthy ports: - "8034:8034" # Not mandatory to keep this port open, but helpful for developer machine debugging - "8035:8035" diff --git a/__tests__/integration/nano-contracts.test.js b/__tests__/integration/nano-contracts.test.js index 28ebb0c8..f65e09c8 100644 --- a/__tests__/integration/nano-contracts.test.js +++ b/__tests__/integration/nano-contracts.test.js @@ -1,48 +1,49 @@ +import fs from 'fs'; import { Address, P2PKH, bufferUtils } from '@hathor/wallet-lib'; import { isEmpty } from 'lodash'; import { TestUtils } from './utils/test-utils-integration'; -import { HATHOR_TOKEN_ID } from './configuration/test-constants'; +import { HATHOR_TOKEN_ID, WALLET_CONSTANTS } from './configuration/test-constants'; import { WalletHelper } from './utils/wallet-helper'; import { initializedWallets } from '../../src/services/wallets.service'; describe('nano contract routes', () => { - let wallet; - let libWalletObject; + let walletNano; + const builtInBlueprintId = '3cb032600bdf7db784800e4ea911b10676fa2f67591f82bb62628c234e771595'; beforeAll(async () => { try { // A random HTR value for the first wallet - wallet = WalletHelper.getPrecalculatedWallet('nano-contracts'); - await WalletHelper.startMultipleWalletsForTest([wallet]); - libWalletObject = initializedWallets.get(wallet.walletId); - await wallet.injectFunds(1000); + walletNano = WalletHelper.getPrecalculatedWallet('nano-contracts'); + await WalletHelper.startMultipleWalletsForTest([walletNano]); + await walletNano.injectFunds(1000); } catch (err) { TestUtils.logError(err.stack); } }); afterAll(async () => { - await wallet.stop(); + await walletNano.stop(); }); - const checkTxValid = async txId => { + const checkTxValid = async (txId, wallet) => { expect(txId).toBeDefined(); await TestUtils.waitForTxReceived(wallet.walletId, txId); // We need to wait for the tx to get a first block, so we guarantee it was executed await TestUtils.waitTxConfirmed(wallet.walletId, txId); // Now we query the transaction from the full node to double check it's still valid // after the nano execution and it already has a first block, so it was really executed + const libWalletObject = initializedWallets.get(wallet.walletId); const txAfterExecution = await libWalletObject.getFullTxById(txId); expect(isEmpty(txAfterExecution.meta.voided_by)).toBe(true); expect(isEmpty(txAfterExecution.meta.first_block)).not.toBeNull(); }; - it('bet methods', async () => { + const executeTests = async (wallet, blueprintId) => { const address0 = await wallet.getAddressAt(0); const address1 = await wallet.getAddressAt(1); const dateLastBet = parseInt(Date.now().valueOf() / 1000, 10) + 6000; // Now + 6000 seconds + const libWalletObject = initializedWallets.get(wallet.walletId); const network = libWalletObject.getNetworkObject(); - const blueprintId = '3cb032600bdf7db784800e4ea911b10676fa2f67591f82bb62628c234e771595'; // Create NC const response = await TestUtils.request @@ -73,7 +74,7 @@ describe('nano contract routes', () => { expect(responseTx1.status).toBe(200); expect(responseTx1.body.success).toBe(true); const tx1 = responseTx1.body; - await checkTxValid(tx1.hash); + await checkTxValid(tx1.hash, wallet); // Bet 100 to address 2 const address2 = await wallet.getAddressAt(2); @@ -101,7 +102,7 @@ describe('nano contract routes', () => { expect(responseBet.status).toBe(200); expect(responseBet.body.success).toBe(true); const txBet = responseBet.body; - await checkTxValid(txBet.hash); + await checkTxValid(txBet.hash, wallet); // Bet 200 to address 3 const address3 = await wallet.getAddressAt(3); @@ -129,7 +130,7 @@ describe('nano contract routes', () => { expect(responseBet2.status).toBe(200); expect(responseBet2.body.success).toBe(true); const txBet2 = responseBet2.body; - await checkTxValid(txBet2.hash); + await checkTxValid(txBet2.hash, wallet); // Get nc history const txIds = [tx1.hash, txBet.hash, txBet2.hash]; @@ -193,7 +194,7 @@ describe('nano contract routes', () => { }) .set({ 'x-wallet-id': wallet.walletId }); const txSetResult = responseSetResult.body; - await checkTxValid(txSetResult.hash); + await checkTxValid(txSetResult.hash, wallet); txIds.push(txSetResult.hash); // Try to withdraw to address 2, success @@ -216,7 +217,7 @@ describe('nano contract routes', () => { }) .set({ 'x-wallet-id': wallet.walletId }); const txWithdrawal = responseWithdrawal.body; - await checkTxValid(txWithdrawal.hash); + await checkTxValid(txWithdrawal.hash, wallet); txIds.push(txWithdrawal.hash); // Get state again @@ -313,5 +314,49 @@ describe('nano contract routes', () => { expect(responseHistory5.body.history.length).toBe(2); // When using before, the order comes reverted expect(responseHistory5.body.history).toStrictEqual([history2[1], history2[0]]); + }; + + it('built in bet methods', async () => { + await executeTests(walletNano, builtInBlueprintId); + }); + + it('on chain bet methods', async () => { + // For now the on chain blueprints needs a signature from a specific address + // so we must always generate the same seed + const { seed } = WALLET_CONSTANTS.ocb; + const ocbWallet = new WalletHelper('ocb-wallet', { words: seed }); + await WalletHelper.startMultipleWalletsForTest([ocbWallet]); + const libOcbWalletObject = initializedWallets.get(ocbWallet.walletId); + await ocbWallet.injectFunds(1000); + // We use the address10 as caller of the ocb tx + // so we don't mess with the number of transactions for address0 tests + const address10 = await libOcbWalletObject.getAddressAtIndex(10); + + // Use the bet blueprint code + const code = fs.readFileSync('./__tests__/integration/configuration/bet.py', 'utf8'); + + // First we will have a test case for an error when calling the lib method + // when running with an invalid address + const responseError = await TestUtils.request + .post('/wallet/nano-contracts/create-on-chain-blueprint') + .send({ code, address: '123' }) + .set({ 'x-wallet-id': ocbWallet.walletId }); + + expect(responseError.body.success).toBe(false); + + // Now success + const response = await TestUtils.request + .post('/wallet/nano-contracts/create-on-chain-blueprint') + .send({ code, address: address10 }) + .set({ 'x-wallet-id': ocbWallet.walletId }); + const ocbHash = response.body.hash; + // Wait for the tx to be confirmed, so we can use the on chain blueprint + await TestUtils.waitForTxReceived(ocbWallet.walletId, ocbHash); + await TestUtils.waitTxConfirmed(ocbWallet.walletId, ocbHash); + // We must have one transaction in the address10 now + const address10Meta = await libOcbWalletObject.storage.store.getAddressMeta(address10); + expect(address10Meta.numTransactions).toBe(1); + // Execute the bet blueprint tests + await executeTests(ocbWallet, ocbHash); }); }); diff --git a/__tests__/integration/simple-send-tx.test.js b/__tests__/integration/simple-send-tx.test.js index 86ae125b..fa293ad6 100644 --- a/__tests__/integration/simple-send-tx.test.js +++ b/__tests__/integration/simple-send-tx.test.js @@ -141,6 +141,48 @@ describe('simple-send-tx (HTR)', () => { expect(balance1.available).toBe(800); }); + it('should make a successful transaction with large value', async () => { + const largeWallet1 = WalletHelper.getPrecalculatedWallet('large-wallet1'); + const largeWallet2 = WalletHelper.getPrecalculatedWallet('large-wallet2'); + await WalletHelper.startMultipleWalletsForTest([largeWallet1, largeWallet2]); + + let wallet1balance = await largeWallet1.getBalance(); + expect(wallet1balance.available).toStrictEqual(0); + + let wallet2balance = await largeWallet2.getBalance(); + expect(wallet2balance.available).toStrictEqual(0); + + const value = 2n ** 55n; // Just a large value that would lose precision as a Number + await largeWallet1.injectFunds(value.toString()); + + wallet1balance = await largeWallet1.getBalance(); + expect(wallet1balance.available).toStrictEqual(value); + + const response = await TestUtils.request + .post('/wallet/simple-send-tx') + .send({ + address: await largeWallet2.getAddressAt(0), + value: value.toString(), + }) + .set({ 'x-wallet-id': largeWallet1.walletId }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.outputs).toHaveLength(1); + + await TestUtils.waitForTxReceived(largeWallet1.walletId, response.body.hash); + await TestUtils.waitForTxReceived(largeWallet2.walletId, response.body.hash); + + const addr0 = await largeWallet2.getAddressInfo(0); + expect(addr0.total_amount_available).toStrictEqual(value); + + wallet2balance = await largeWallet2.getBalance(); + expect(wallet2balance.available).toStrictEqual(value); + + wallet1balance = await largeWallet1.getBalance(); + expect(wallet1balance.available).toStrictEqual(0); + }); + it('should make a successful transaction with change address', async () => { const changeAddress = await wallet1.getAddressAt(5); diff --git a/__tests__/integration/utils/test-utils-integration.js b/__tests__/integration/utils/test-utils-integration.js index e2957773..71c9148b 100644 --- a/__tests__/integration/utils/test-utils-integration.js +++ b/__tests__/integration/utils/test-utils-integration.js @@ -1,7 +1,8 @@ /* eslint-disable no-console */ import supertest from 'supertest'; -import { txApi, walletApi, HathorWallet, walletUtils } from '@hathor/wallet-lib'; +import superagent from 'superagent'; +import { txApi, walletApi, HathorWallet, walletUtils, bigIntUtils } from '@hathor/wallet-lib'; import createApp from '../../../src/app'; import { initializedWallets } from '../../../src/services/wallets.service'; import { loggers } from './logger.util'; @@ -54,6 +55,26 @@ export class TestUtils { return reject(err); } + superagent.parse['application/json'] = (res, callback) => { + // reference: https://github.com/ladjs/superagent/blob/2c188904f8181ab760496d2849977dddee9900d1/src/node/parsers/json.js + res.text = ''; + res.setEncoding('utf8'); + res.on('data', chunk => { res.text += chunk; }); + res.on('end', () => { + let body; + let error; + try { + body = res.text && bigIntUtils.JSONBigInt.parse(res.text); + } catch (parseErr) { + error = parseErr; + error.rawResponse = res.text || null; + error.statusCode = res.statusCode; + } finally { + callback(error, body); + } + }); + }; + // Ensures the supertest agent will be bound to the correct express port request = supertest.agent(server); return resolve(); diff --git a/__tests__/nano-contracts/on_chain_blueprint.test.js b/__tests__/nano-contracts/on_chain_blueprint.test.js new file mode 100644 index 00000000..2dc59153 --- /dev/null +++ b/__tests__/nano-contracts/on_chain_blueprint.test.js @@ -0,0 +1,77 @@ +import { SendTransaction } from '@hathor/wallet-lib'; +import TestUtils from '../test-utils'; + +const walletId = 'stub_ocb'; + +describe('create api', () => { + beforeAll(async () => { + await TestUtils.startWallet({ walletId, preCalculatedAddresses: TestUtils.addresses }); + }); + + afterAll(async () => { + await TestUtils.stopWallet({ walletId }); + }); + + it('should return 200 with a valid body', async () => { + const response = await TestUtils.request + .post('/wallet/nano-contracts/create-on-chain-blueprint') + .send({ + code: 'test', + address: 'WewDeXWyvHP7jJTs7tjLoQfoB72LLxJQqN', + }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(200); + expect(response.body.hash).toBeDefined(); + expect(response.body.success).toBe(true); + }); + + it('should fail without required parameter', async () => { + const response = await TestUtils.request + .post('/wallet/nano-contracts/create-on-chain-blueprint') + .send({ + address: 'WewDeXWyvHP7jJTs7tjLoQfoB72LLxJQqN', + }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(400); + + const response2 = await TestUtils.request + .post('/wallet/nano-contracts/create-on-chain-blueprint') + .send({ + code: 'test', + }) + .set({ 'x-wallet-id': walletId }); + expect(response2.status).toBe(400); + }); + + it('should receive an error when trying to do concurrent transactions (lock/unlock behavior)', async () => { + const spy = jest.spyOn(SendTransaction.prototype, 'updateOutputSelected').mockImplementation(async () => { + await new Promise(resolve => { + setTimeout(resolve, 1000); + }); + }); + try { + const promise1 = TestUtils.request + .post('/wallet/nano-contracts/create-on-chain-blueprint') + .send({ + code: 'test', + address: 'WewDeXWyvHP7jJTs7tjLoQfoB72LLxJQqN', + }) + .set({ 'x-wallet-id': walletId }); + const promise2 = TestUtils.request + .post('/wallet/nano-contracts/create-on-chain-blueprint') + .send({ + code: 'test2', + address: 'WewDeXWyvHP7jJTs7tjLoQfoB72LLxJQqN', + }) + .set({ 'x-wallet-id': walletId }); + + const [response1, response2] = await Promise.all([promise1, promise2]); + expect(response1.status).toBe(200); + expect(response1.body.hash).toBeTruthy(); + expect(response2.status).toBe(200); + expect(response2.body.success).toBe(false); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/__tests__/plugins/child.test.js b/__tests__/plugins/child.test.js new file mode 100644 index 00000000..53cf71f2 --- /dev/null +++ b/__tests__/plugins/child.test.js @@ -0,0 +1,40 @@ +import { bigIntUtils } from '@hathor/wallet-lib'; +import { handleMessage } from '../../src/plugins/child'; +import { notificationBus, EVENTBUS_EVENT_NAME } from '../../src/services/notification.service'; + +jest.mock('../../src/services/notification.service', () => ({ + notificationBus: { + emit: jest.fn(), + }, + EVENTBUS_EVENT_NAME: 'eventbus_event', +})); + +describe('handleMessage', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should parse the serialized data and emit the eventbus event', () => { + const mockData = { type: 'custom_event', payload: 'test', bigint: BigInt(Number.MAX_SAFE_INTEGER) + 1n }; + + handleMessage(bigIntUtils.JSONBigInt.stringify(mockData)); + + expect(notificationBus.emit).toHaveBeenCalledWith(EVENTBUS_EVENT_NAME, mockData); + }); + + it('should emit the specific event type if present in the data', () => { + const mockData = { type: 'custom_event', payload: 'test', bigint: BigInt(Number.MAX_SAFE_INTEGER) + 1n }; + + handleMessage(bigIntUtils.JSONBigInt.stringify(mockData)); + + expect(notificationBus.emit).toHaveBeenCalledWith(mockData.type, mockData); + }); + + it('should not emit a specific event type if type is not present in the data', () => { + const mockData = { payload: 'test', bigint: BigInt(Number.MAX_SAFE_INTEGER) + 1n }; + + handleMessage(bigIntUtils.JSONBigInt.stringify(mockData)); + + expect(notificationBus.emit).not.toHaveBeenCalledWith(mockData.type, mockData); + }); +}); diff --git a/__tests__/plugins/debug_plugin.test.js b/__tests__/plugins/debug_plugin.test.js index a50fc60f..8b49617a 100644 --- a/__tests__/plugins/debug_plugin.test.js +++ b/__tests__/plugins/debug_plugin.test.js @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ +import { bigIntUtils } from '@hathor/wallet-lib'; import { eventHandler, getSettings } from '../../src/plugins/hathor_debug'; test('settings', () => { @@ -27,9 +28,9 @@ test('settings', () => { test('event handler', () => { const oldArgs = process.argv; const logSpy = jest.spyOn(console, 'log'); - const smallMsg = { type: 'small', walletId: 'default', foo: 'bar' }; + const smallMsg = { type: 'small', walletId: 'default', foo: 'bar', bigInt: BigInt(Number.MAX_SAFE_INTEGER) + 1n }; const bigMsg = { type: 'big', walletId: 'default' }; - const bigCompleteMsg = { ...bigMsg, message: '' }; + const bigCompleteMsg = { ...bigMsg, message: '', bigInt: BigInt(Number.MAX_SAFE_INTEGER) + 1n }; for (let i = 0; i < 200; i++) { // 200 * 'aaaaa'(length of 5) -> lenght of 1000 bigCompleteMsg.message += 'aaaaa'; @@ -48,7 +49,7 @@ test('event handler', () => { logSpy.mockReset(); // small message: always log eventHandler(smallMsg); - expect(logSpy).toHaveBeenCalledWith(toDebugMessage(JSON.stringify(smallMsg))); + expect(logSpy).toHaveBeenCalledWith(toDebugMessage(bigIntUtils.JSONBigInt.stringify(smallMsg))); logSpy.mockReset(); // big message: should not log eventHandler(bigCompleteMsg); @@ -63,11 +64,13 @@ test('event handler', () => { logSpy.mockReset(); // small message: always log eventHandler(smallMsg); - expect(logSpy).toHaveBeenCalledWith(toDebugMessage(JSON.stringify(smallMsg))); + expect(logSpy).toHaveBeenCalledWith(toDebugMessage(bigIntUtils.JSONBigInt.stringify(smallMsg))); logSpy.mockReset(); // big message: should log the entire message eventHandler(bigCompleteMsg); - expect(logSpy).toHaveBeenCalledWith(toDebugMessage(JSON.stringify(bigCompleteMsg))); + expect(logSpy).toHaveBeenCalledWith( + toDebugMessage(bigIntUtils.JSONBigInt.stringify(bigCompleteMsg)) + ); // debugLong: unexpected value process.argv = [ @@ -78,11 +81,11 @@ test('event handler', () => { logSpy.mockReset(); // small message: always log eventHandler(smallMsg); - expect(logSpy).toHaveBeenCalledWith(toDebugMessage(JSON.stringify(smallMsg))); + expect(logSpy).toHaveBeenCalledWith(toDebugMessage(bigIntUtils.JSONBigInt.stringify(smallMsg))); logSpy.mockReset(); // big message: should log partially eventHandler(bigCompleteMsg); - expect(logSpy).toHaveBeenCalledWith(toDebugMessage(JSON.stringify(bigMsg))); + expect(logSpy).toHaveBeenCalledWith(toDebugMessage(bigIntUtils.JSONBigInt.stringify(bigMsg))); // debugLong: default (should be the same as unexpected) process.argv = [ @@ -92,11 +95,11 @@ test('event handler', () => { logSpy.mockReset(); // small message: always log eventHandler(smallMsg); - expect(logSpy).toHaveBeenCalledWith(toDebugMessage(JSON.stringify(smallMsg))); + expect(logSpy).toHaveBeenCalledWith(toDebugMessage(bigIntUtils.JSONBigInt.stringify(smallMsg))); logSpy.mockReset(); // big message: should log partially eventHandler(bigCompleteMsg); - expect(logSpy).toHaveBeenCalledWith(toDebugMessage(JSON.stringify(bigMsg))); + expect(logSpy).toHaveBeenCalledWith(toDebugMessage(bigIntUtils.JSONBigInt.stringify(bigMsg))); // Restore original argv state process.argv = oldArgs; diff --git a/__tests__/plugins/rabbitmq_plugin.test.js b/__tests__/plugins/rabbitmq_plugin.test.js index d4c20758..c1224e2f 100644 --- a/__tests__/plugins/rabbitmq_plugin.test.js +++ b/__tests__/plugins/rabbitmq_plugin.test.js @@ -5,34 +5,131 @@ * LICENSE file in the root directory of this source tree. */ +import { bigIntUtils } from '@hathor/wallet-lib'; import { eventHandlerFactory, getSettings } from '../../src/plugins/hathor_rabbitmq'; -test('settings', () => { - const oldArgs = process.argv; - process.argv = [ - 'node', // not used but a value is required at this index - 'a_script_file.js', // not used but a value is required at this index - '--plugin_rabbitmq_url', 'test-url', - '--plugin_rabbitmq_queue', 'test-queue', - ]; - const settings = getSettings(); - expect(settings).toMatchObject({ - url: 'test-url', - queue: 'test-queue', - }); - - // Restore original argv state - process.argv = oldArgs; +describe('RabbitMQ plugin settings', () => { + it('should throw an error if no settings are provided', () => { + const oldArgs = process.argv; + process.argv = [ + 'node', // not used but a value is required at this index + 'a_script_file.js', // not used but a value is required at this index + ]; + expect(() => getSettings()).toThrow('You must provide a RabbitMQ URL'); + process.argv = oldArgs; + }); + + it('should throw an error if no queue or exchange is provided', () => { + const oldArgs = process.argv; + process.argv = [ + 'node', // not used but a value is required at this index + 'a_script_file.js', // not used but a value is required at this index + '--plugin_rabbitmq_url', 'test-url', + ]; + expect(() => getSettings()).toThrow('You must provide either a RabbitMQ queue or exchange'); + process.argv = oldArgs; + }); + + it('should throw an error if both queue and exchange are provided', () => { + const oldArgs = process.argv; + process.argv = [ + 'node', // not used but a value is required at this index + 'a_script_file.js', // not used but a value is required at this index + '--plugin_rabbitmq_url', 'test-url', + '--plugin_rabbitmq_queue', 'test-queue', + '--plugin_rabbitmq_exchange', 'test-exchange', + ]; + expect(() => getSettings()).toThrow('You must provide either a RabbitMQ queue or exchange, not both'); + process.argv = oldArgs; + }); + + it('should throw an error if exchange is provided without routing key', () => { + const oldArgs = process.argv; + process.argv = [ + 'node', // not used but a value is required at this index + 'a_script_file.js', // not used but a value is required at this index + '--plugin_rabbitmq_url', 'test-url', + '--plugin_rabbitmq_exchange', 'test-exchange', + ]; + expect(() => getSettings()).toThrow('You must provide a RabbitMQ routing key if you provide exchange. A blank routing key is acceptable though.'); + process.argv = oldArgs; + }); + + it('should return the settings if everything is correct with queue configuration', () => { + const oldArgs = process.argv; + process.argv = [ + 'node', // not used but a value is required at this index + 'a_script_file.js', // not used but a value is required at this index + '--plugin_rabbitmq_url', 'test-url', + '--plugin_rabbitmq_queue', 'test-queue', + ]; + const settings = getSettings(); + expect(settings).toMatchObject({ + url: 'test-url', + queue: 'test-queue', + }); + process.argv = oldArgs; + }); + + it('should return the settings if everything is correct with exchange and routing key', () => { + const oldArgs = process.argv; + process.argv = [ + 'node', // not used but a value is required at this index + 'a_script_file.js', // not used but a value is required at this index + '--plugin_rabbitmq_url', 'test-url', + '--plugin_rabbitmq_exchange', 'test-exchange', + '--plugin_rabbitmq_routing_key', 'test-routing-key', + ]; + const settings = getSettings(); + expect(settings).toMatchObject({ + url: 'test-url', + exchange: 'test-exchange', + routingKey: 'test-routing-key', + }); + process.argv = oldArgs; + }); + + it('should return the settings if everything is correct with exchange and a blank routing key', () => { + const oldArgs = process.argv; + process.argv = [ + 'node', // not used but a value is required at this index + 'a_script_file.js', // not used but a value is required at this index + '--plugin_rabbitmq_url', 'test-url', + '--plugin_rabbitmq_exchange', 'test-exchange', + '--plugin_rabbitmq_routing_key', '', + ]; + const settings = getSettings(); + expect(settings).toMatchObject({ + url: 'test-url', + exchange: 'test-exchange', + routingKey: '', + }); + process.argv = oldArgs; + }); }); -test('event handler', () => { - const channelMock = { - sendToQueue: jest.fn(), - }; - const mockedSettings = { queue: 'test-queue' }; - const evHandler = eventHandlerFactory(channelMock, mockedSettings); - const data = { test: 'event' }; +describe('RabbitMQ plugin event handler', () => { + it('should return a function that sends to a queue', () => { + const channelMock = { + sendToQueue: jest.fn(), + }; + const mockedSettings = { queue: 'test-queue' }; + const evHandler = eventHandlerFactory(channelMock, mockedSettings); + const data = { test: 'event' }; - evHandler(data); - expect(channelMock.sendToQueue).toHaveBeenCalledWith('test-queue', Buffer.from(JSON.stringify(data))); + evHandler(data); + expect(channelMock.sendToQueue).toHaveBeenCalledWith('test-queue', Buffer.from(bigIntUtils.JSONBigInt.stringify(data))); + }); + + it('should return a function that publishes to an exchange', () => { + const channelMock = { + publish: jest.fn(), + }; + const mockedSettings = { exchange: 'test-exchange', routingKey: 'test-routing-key' }; + const evHandler = eventHandlerFactory(channelMock, mockedSettings); + const data = { test: 'event' }; + + evHandler(data); + expect(channelMock.publish).toHaveBeenCalledWith('test-exchange', 'test-routing-key', Buffer.from(bigIntUtils.JSONBigInt.stringify(data))); + }); }); diff --git a/__tests__/plugins/sqs_plugin.test.js b/__tests__/plugins/sqs_plugin.test.js index ccbc9fc0..56b8139b 100644 --- a/__tests__/plugins/sqs_plugin.test.js +++ b/__tests__/plugins/sqs_plugin.test.js @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ +import { bigIntUtils } from '@hathor/wallet-lib'; import { eventHandlerFactory, getSettings } from '../../src/plugins/hathor_sqs'; test('settings', () => { @@ -47,11 +48,11 @@ test('event handler', () => { }; const mockedSettings = { queueUrl: 'test-queue' }; const evHandler = eventHandlerFactory(sqsMock, mockedSettings); - const data = { test: 'event' }; + const data = { test: 'event', bigInt: BigInt(Number.MAX_SAFE_INTEGER) + 1n }; evHandler(data); expect(sqsMock.sendMessage).toHaveBeenCalledWith({ QueueUrl: mockedSettings.queueUrl, - MessageBody: JSON.stringify(data), + MessageBody: bigIntUtils.JSONBigInt.stringify(data), }, expect.anything()); }); diff --git a/__tests__/plugins/ws_plugin.test.js b/__tests__/plugins/ws_plugin.test.js index 178df4f7..9828ba6b 100644 --- a/__tests__/plugins/ws_plugin.test.js +++ b/__tests__/plugins/ws_plugin.test.js @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ +import { bigIntUtils } from '@hathor/wallet-lib'; import { getSockets, eventHandler, connectionHandler, getSettings } from '../../src/plugins/hathor_websocket'; test('settings', () => { @@ -55,9 +56,10 @@ test('event handler', () => { connectionHandler(socket1); connectionHandler(socket2); - eventHandler({ foo: 'bar' }); - expect(socket1.send).toHaveBeenCalledWith('{"foo":"bar"}'); - expect(socket2.send).toHaveBeenCalledWith('{"foo":"bar"}'); + const data = { foo: 'bar', bigInt: BigInt(Number.MAX_SAFE_INTEGER) + 1n }; + eventHandler(data); + expect(socket1.send).toHaveBeenCalledWith(bigIntUtils.JSONBigInt.stringify(data)); + expect(socket2.send).toHaveBeenCalledWith(bigIntUtils.JSONBigInt.stringify(data)); // simulate disconnections socket1.cb(); diff --git a/__tests__/test-utils.js b/__tests__/test-utils.js index a5c3d9a4..600cc40d 100644 --- a/__tests__/test-utils.js +++ b/__tests__/test-utils.js @@ -135,6 +135,7 @@ class TestUtils { exitIfClosed = false, retries = 3, firstAddress = null, + pollInterval = 500, } = {}) { for (let i = 0; i < retries; i++) { const res = await TestUtils.walletStatus({ walletId, firstAddress }); @@ -152,7 +153,7 @@ class TestUtils { return false; } await new Promise(resolve => { - setTimeout(resolve, 500); + setTimeout(resolve, pollInterval); }); } TestUtils.logger.debug('[TestUtil:waitReady] too many attempts', { walletId }); @@ -187,8 +188,8 @@ class TestUtils { await TestUtils.waitReady({ walletId, retries: 10 }); } - static async stopWallet({ walletId = WALLET_ID } = {}) { - const isReady = await TestUtils.waitReady({ walletId, exitIfClosed: true }); + static async stopWallet({ walletId = WALLET_ID, pollInterval = 500 } = {}) { + const isReady = await TestUtils.waitReady({ walletId, exitIfClosed: true, pollInterval }); if (!isReady) { TestUtils.logger.debug('[TestUtil:stopWallet] wallet is already stopped', { walletId }); return; diff --git a/docs/multisig-wallets.md b/docs/multisig-wallets.md index 0382eb65..81220c39 100644 --- a/docs/multisig-wallets.md +++ b/docs/multisig-wallets.md @@ -44,23 +44,35 @@ The order of the pubkeys is not important. ## Collect pubkeys -Configure your wallet normally and use the `/multisig-pubkey` to get your pubkey. +There are two ways for getting the pubkey. + +1. Configure your wallet normally and use the `/multisig-pubkey` to get your pubkey. +2. Use the `make multisig_xpub_from_seed` command to generate the pubkey. + This public key will be shared among all participants and will be used in the configuration file in the pubkeys array. -You don't need to start the wallet yet. ### POST /multisig-pubkey +You don't need to start the wallet before sending this request, just have the seedKey configured in your configuration file. + Parameters: `seedKey`: Parameter to define which seed (from the object seeds in the config file) will be used to generate the pubkey. `passphrase`: Optional parameter to generate the pubkey with a passphrase. If not sent we use empty string. Should be the same when starting the wallet later. - ```bash $ curl -X POST --data-urlencode "passphrase=123" --data "seedKey=default" http://localhost:8000/multisig-pubkey {"success":true,"xpubkey":"xpub..."} ``` +### make multisig_xpub_from_seed + +Just run the command, passing the seed key as argument. This method does not support passphrase yet, use the previous method if you need it. + +```bash +make multisig_xpub_from_seed seed='' +``` + ## Start a MultiSig Wallet Same as the start on [README.md](./README.md), but include the parameter `"multisig": true` to the request body. diff --git a/docs/plugins.md b/docs/plugins.md index 44feb1e2..fbec0d39 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -134,3 +134,18 @@ The `custom_plugin.js` file will have the plugin logic. To configure custom plugins you need to add the plugin id to the [enabled plugins](#configuration) and for each custom plugin there should be 2 new variables, the `--plugin__file` (or `HEADLESS_PLUGIN__FILE`) and `--plugin__name` (or `HEADLESS_PLUGIN__NAME`). Any configuration of the plugin itself will be made outside the config module. + +### BigInt support + +We have support for BigInt on the headless, which means it's possible that data sent to the plugins will contain BigInt values. + +In case your plugin needs to serialize data to send it upstream to some other tool, you may want to use our serialization tools available at the `wallet-lib` in order to guarantee the correct serialization of BigInt data. + +They can be used like so: + +```javascript +import { bigIntUtils } from 'wallet-lib'; + +const serializedData = bigIntUtils.JSONBigInt.stringify(data); +const deserializedData = bigIntUtils.JSONBigInt.parse(serializedData); +``` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 56e53ce0..b4d3f551 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@dinamonetworks/hsm-dinamo": "4.9.1", "@hathor/healthcheck-lib": "0.1.0", - "@hathor/wallet-lib": "2.0.1", + "@hathor/wallet-lib": "2.1.1", "axios": "1.7.7", "express": "4.18.2", "express-validator": "6.10.0", @@ -39,7 +39,8 @@ "jest": "29.7.0", "mock-socket": "9.3.1", "nodemon": "3.1.0", - "supertest": "6.3.4" + "superagent": "9.0.2", + "supertest": "7.0.0" }, "engines": { "node": ">=22.0.0", @@ -2170,9 +2171,9 @@ "integrity": "sha512-Oi223+iKye5cmPyMIqp64E/ZP+in0JndN/s9uEigmXxt6wRhwciCPbzSY4S2oicy1uNqhv7lLdyUc3O/P3sCzQ==" }, "node_modules/@hathor/wallet-lib": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@hathor/wallet-lib/-/wallet-lib-2.0.1.tgz", - "integrity": "sha512-wqqQsZP6p4cXlkDc/kZuOkZoh1XZprEjR0VkJvdy5IvgQtjYvHv0Fklz3EAhhh/zBPIeytD1wD0AvaRzhP6fEQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hathor/wallet-lib/-/wallet-lib-2.1.1.tgz", + "integrity": "sha512-kdoMpTST4OSNMdL1eEw7pcVsdjfiMNkZ74/34Yj7mp0JTqtAQsGpSh7lVRU/7CnliHvF7hL1JUPARTir0rV5aA==", "license": "MIT", "dependencies": { "abstract-level": "1.0.4", @@ -3692,7 +3693,8 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/async": { "version": "3.2.5", @@ -4952,6 +4954,7 @@ "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", "dev": true, + "license": "ISC", "dependencies": { "asap": "^2.0.0", "wrappy": "1" @@ -6200,15 +6203,15 @@ } }, "node_modules/formidable": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", - "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz", + "integrity": "sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==", "dev": true, + "license": "MIT", "dependencies": { "dezalgo": "^1.0.4", - "hexoid": "^1.0.0", - "once": "^1.4.0", - "qs": "^6.11.0" + "hexoid": "^2.0.0", + "once": "^1.4.0" }, "funding": { "url": "https://ko-fi.com/tunnckoCore/commissions" @@ -6518,10 +6521,11 @@ } }, "node_modules/hexoid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", - "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-2.0.0.tgz", + "integrity": "sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -10805,24 +10809,24 @@ } }, "node_modules/superagent": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", - "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", + "integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==", "dev": true, + "license": "MIT", "dependencies": { "component-emitter": "^1.3.0", "cookiejar": "^2.1.4", "debug": "^4.3.4", "fast-safe-stringify": "^2.1.1", "form-data": "^4.0.0", - "formidable": "^2.1.2", + "formidable": "^3.5.1", "methods": "^1.1.2", "mime": "2.6.0", - "qs": "^6.11.0", - "semver": "^7.3.8" + "qs": "^6.11.0" }, "engines": { - "node": ">=6.4.0 <13 || >=14" + "node": ">=14.18.0" } }, "node_modules/superagent/node_modules/debug": { @@ -10860,32 +10864,18 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, - "node_modules/superagent/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/supertest": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", - "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.0.0.tgz", + "integrity": "sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==", "dev": true, + "license": "MIT", "dependencies": { "methods": "^1.1.2", - "superagent": "^8.1.2" + "superagent": "^9.0.1" }, "engines": { - "node": ">=6.4.0" + "node": ">=14.18.0" } }, "node_modules/supports-color": { diff --git a/package.json b/package.json index 4c9a9a4e..2bc5020d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dependencies": { "@dinamonetworks/hsm-dinamo": "4.9.1", "@hathor/healthcheck-lib": "0.1.0", - "@hathor/wallet-lib": "2.0.1", + "@hathor/wallet-lib": "2.1.1", "axios": "1.7.7", "express": "4.18.2", "express-validator": "6.10.0", @@ -68,6 +68,7 @@ "jest": "29.7.0", "mock-socket": "9.3.1", "nodemon": "3.1.0", - "supertest": "6.3.4" + "supertest": "7.0.0", + "superagent": "9.0.2" } } diff --git a/scripts/generate_wallets.js b/scripts/generate_wallets.js index e376ea37..068ccfd0 100644 --- a/scripts/generate_wallets.js +++ b/scripts/generate_wallets.js @@ -124,4 +124,5 @@ if (walletsArray) { }); } else { console.log(generatedWallet); + process.exit(1); } diff --git a/scripts/generate_words.js b/scripts/generate_words.js index d1fab2a2..05bb71a4 100644 --- a/scripts/generate_words.js +++ b/scripts/generate_words.js @@ -7,6 +7,16 @@ import { walletUtils } from '@hathor/wallet-lib'; -const words = walletUtils.generateWalletWords(); +function main() { + const words = walletUtils.generateWalletWords(); -console.log(words); + console.log(words); +} + +try { + main(); + process.exit(0); +} catch (err) { + console.error(err); + process.exit(1); +} diff --git a/scripts/get_multisig_xpub_from_seed.js b/scripts/get_multisig_xpub_from_seed.js new file mode 100644 index 00000000..c98ac6d2 --- /dev/null +++ b/scripts/get_multisig_xpub_from_seed.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import hathorLib from '@hathor/wallet-lib'; + +function main() { + /** + * argv contains the cli arguments that made this script run, including the interpreter + * and script filename so to get the actual seed passed via cli we need to remove the + * other arguments. + * argv = ["babel-node", "get_xpub_from_seed.js", "word0", ..., "wordLast"]; + * We need to remove the first 2 and join the other arguments into a single string + * separated by spaces + */ + const seed = process.argv.slice(2).join(' '); + const xpubkey = hathorLib.walletUtils.getMultiSigXPubFromWords(seed); + + // Print the xpubkey + console.log(xpubkey); +} + +try { + main(); + process.exit(0); +} catch (err) { + console.error(err); + process.exit(1); +} diff --git a/scripts/get_xpub_from_seed.js b/scripts/get_xpub_from_seed.js index a5df24ff..8119fba7 100644 --- a/scripts/get_xpub_from_seed.js +++ b/scripts/get_xpub_from_seed.js @@ -7,18 +7,28 @@ import hathorLib from '@hathor/wallet-lib'; -/** - * argv contains the cli arguments that made this script run, including the interpreter - * and script filename so to get the actual seed passed via cli we need to remove the - * other arguments. - * argv = ["babel-node", "get_xpub_from_seed.js", "word0", ..., "wordLast"]; - * We need to remove the first 2 and join the other arguments into a single string - * separated by spaces - */ -const seed = process.argv.slice(2).join(' '); -const hdprivkeyRoot = hathorLib.walletUtils.getXPrivKeyFromSeed(seed); -// `accountDerivationIndex` will make the util method derive to the change path -const hdprivkey = hathorLib.walletUtils.deriveXpriv(hdprivkeyRoot, '0\'/0'); +function main() { + /** + * argv contains the cli arguments that made this script run, including the interpreter + * and script filename so to get the actual seed passed via cli we need to remove the + * other arguments. + * argv = ["babel-node", "get_xpub_from_seed.js", "word0", ..., "wordLast"]; + * We need to remove the first 2 and join the other arguments into a single string + * separated by spaces + */ + const seed = process.argv.slice(2).join(' '); + const hdprivkeyRoot = hathorLib.walletUtils.getXPrivKeyFromSeed(seed); + // `accountDerivationIndex` will make the util method derive to the change path + const hdprivkey = hathorLib.walletUtils.deriveXpriv(hdprivkeyRoot, '0\'/0'); + + // Print the xpubkey + console.log(hdprivkey.xpubkey); +} -// Print the xpubkey -console.log(hdprivkey.xpubkey); +try { + main(); + process.exit(0); +} catch (err) { + console.error(err); + process.exit(1); +} diff --git a/scripts/github/docker.py b/scripts/github/docker.py index db4c869a..3024200c 100644 --- a/scripts/github/docker.py +++ b/scripts/github/docker.py @@ -19,6 +19,8 @@ def prep_tags(environ: Dict): ref = environ.get('GITHUB_REF') sha = environ.get('GITHUB_SHA') tags = set() + # These are used to deploy images with the RabbitMQ plugin included + rabbitmq_tags = set() if ref.startswith('refs/tags/'): git_tag = ref[10:] @@ -39,10 +41,14 @@ def prep_tags(environ: Dict): tags.add(base_ecr_tag + version) tags.add(base_ecr_tag + '{}-{}'.format(sha, timestamp)) tags.add(base_ecr_tag + 'latest') + rabbitmq_tags.add(base_ecr_tag + version + '-rabbitmq') + rabbitmq_tags.add(base_ecr_tag + 'latest-rabbitmq') tags.add(base_dockerhub_tag + version) tags.add(base_dockerhub_tag + '{}-{}'.format(sha, timestamp)) tags.add(base_dockerhub_tag + 'latest') + rabbitmq_tags.add(base_dockerhub_tag + version + '-rabbitmq') + rabbitmq_tags.add(base_dockerhub_tag + 'latest-rabbitmq') elif ref == 'refs/heads/master': # A push to master creates a staging tag tags.add(base_ecr_tag + 'staging-{}-{}'.format(sha, timestamp)) @@ -53,7 +59,7 @@ def prep_tags(environ: Dict): # XXX: We currently do not run on other branches tags.add(base_ecr_tag + 'dev-{}-{}'.format(sha, timestamp)) - return tags + return tags, rabbitmq_tags def print_output(output: Dict): outputs = ['{}={}\n'.format(k, v) for k, v in output.items()] @@ -61,6 +67,8 @@ def print_output(output: Dict): f.writelines(outputs) if __name__ == '__main__': - tags = prep_tags(os.environ) + tags, rabbitmq_tags = prep_tags(os.environ) if tags: print_output({'tags': ','.join(tags)}) + if rabbitmq_tags: + print_output({'rabbitmq_tags': ','.join(rabbitmq_tags)}) diff --git a/setupTests-integration.js b/setupTests-integration.js index 905afe63..bc3936ef 100644 --- a/setupTests-integration.js +++ b/setupTests-integration.js @@ -88,6 +88,7 @@ beforeAll(async () => { // The downside of that is that we don't get logs, however is the only // way for now. We should stop using jasmine soon (and change for jest-circus) // when we do some package upgrades + console.error(err); process.exit(1); } }); diff --git a/src/api-docs.js b/src/api-docs.js index 72973015..cc4067f5 100644 --- a/src/api-docs.js +++ b/src/api-docs.js @@ -4307,6 +4307,84 @@ const defaultApiDocs = { }, }, }, + '/wallet/nano-contracts/create-on-chain-blueprint': { + post: { + operationId: 'ocbCreate', + summary: 'Create an on chain blueprint transaction.', + parameters: [ + { $ref: '#/components/parameters/XWalletIdParameter' }, + ], + requestBody: { + description: 'Data to create the on chain blueprint.', + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['code', 'address'], + properties: { + code: { + type: 'string', + description: 'Blueprint code.' + }, + address: { + type: 'string', + description: 'Address caller that will sign the on chain blueprint transaction.' + }, + }, + }, + examples: { + data: { + summary: 'Data to create the on chain blueprint', + value: { + code: 'class TestBlueprint:\n\n def x():\n pass\n\n__blueprint__ = TestBlueprint\n', + address: 'H8bt9nYhUNJHg7szF32CWWi1eB8PyYZnbt', + } + } + } + } + } + }, + responses: { + 200: { + description: 'Create the on chain blueprint', + content: { + 'application/json': { + examples: { + success: { + summary: 'Success', + value: { + success: true, + inputs: [], + outputs: [], + signalBits: 0, + version: 6, + weight: 21.99328529001309, + nonce: 782869, + timestamp: 1740594655, + parents: [ + '0008f0e9dbe6e4bbc3a85fce7494fee70011b9c7e72f5276daa2a235355ac013', + '008d81d9d58a43fd9649f33483d804a4417247b4d4e4e01d64406c4177fee0c2' + ], + tokens: [], + hash: '000001b28c9dcffde620193906952714401d9208569b5aa923ec18ace525a86a', + code: { + kind: 'python+gzip', + content: { + type: 'Buffer', + data: [] + } + } + } + }, + ...commonExamples.xWalletIdErrResponseExamples, + }, + }, + }, + }, + }, + }, + }, '/configuration-string': { get: { operationId: 'getTokenConfigurationString', diff --git a/src/controllers/wallet/nano-contracts.controller.js b/src/controllers/wallet/nano-contracts.controller.js index 9eec5088..fb63c0e3 100644 --- a/src/controllers/wallet/nano-contracts.controller.js +++ b/src/controllers/wallet/nano-contracts.controller.js @@ -199,6 +199,40 @@ async function getOracleSignedResult(req, res) { } } +/** + * Create an on chain blueprint transaction + */ +async function createOnChainBlueprint(req, res) { + const validationResult = parametersValidation(req); + if (!validationResult.success) { + res.status(400).json(validationResult); + return; + } + + const unlock = lockSendTx(req.walletId); + if (unlock === null) { + // TODO: return status code 423 + // we should do this refactor in the future for all APIs + res.send({ success: false, error: cantSendTxErrorMessage }); + return; + } + + const { wallet } = req; + const { code, address } = req.body; + + try { + /** @type {import('@hathor/wallet-lib').SendTransaction} */ + const sendTransaction = await wallet.createOnChainBlueprintTransaction(code, address); + const tx = await runSendTransaction(sendTransaction, unlock); + res.send({ success: true, ...mapTxReturn(tx) }); + } catch (err) { + // The unlock method should be always called. `runSendTransaction` method + // already calls unlock, so we can manually call it only in the catch block. + unlock(); + res.send({ success: false, error: err.message }); + } +} + module.exports = { getState, getHistory, @@ -206,4 +240,5 @@ module.exports = { executeNanoContractMethod, getOracleData, getOracleSignedResult, + createOnChainBlueprint, }; diff --git a/src/index.js b/src/index.js index f70ce371..af4075f1 100644 --- a/src/index.js +++ b/src/index.js @@ -7,7 +7,7 @@ /* istanbul ignore file */ import { fork } from 'child_process'; -import { config as hathorLibConfig } from '@hathor/wallet-lib'; +import { config as hathorLibConfig, bigIntUtils } from '@hathor/wallet-lib'; import createApp from './app'; import { EVENTBUS_EVENT_NAME, notificationBus } from './services/notification.service'; @@ -71,7 +71,7 @@ async function startHeadless() { return; } // Sending event data - child.send(data); + child.send(bigIntUtils.JSONBigInt.stringify(data)); }); } diff --git a/src/plugins/child.js b/src/plugins/child.js index 665bbe48..2a657f0d 100644 --- a/src/plugins/child.js +++ b/src/plugins/child.js @@ -6,6 +6,7 @@ */ import path from 'path'; +import { bigIntUtils } from '@hathor/wallet-lib'; import settings from '../settings'; import { notificationBus, EVENTBUS_EVENT_NAME } from '../services/notification.service'; @@ -114,6 +115,16 @@ export const main = async () => { } }; +// We export this just for testing purposes +export const handleMessage = serializedData => { + const data = bigIntUtils.JSONBigInt.parse(serializedData); + // Repeat notifications from main process to local notification service + notificationBus.emit(EVENTBUS_EVENT_NAME, data); + if (data.type) { + notificationBus.emit(data.type, data); + } +}; + if (process.env.NODE_ENV !== 'test') { process.on('disconnect', () => { // If parent disconnects, we must exit to avoid running indefinetly @@ -121,13 +132,7 @@ if (process.env.NODE_ENV !== 'test') { process.exit(127); }); - process.on('message', data => { - // Repeat notifications from main process to local notification service - notificationBus.emit(EVENTBUS_EVENT_NAME, data); - if (data.type) { - notificationBus.emit(data.type, data); - } - }); + process.on('message', handleMessage); console.log('[child_process] startup'); main(); diff --git a/src/plugins/hathor_debug.js b/src/plugins/hathor_debug.js index f34d241d..820dc9a3 100644 --- a/src/plugins/hathor_debug.js +++ b/src/plugins/hathor_debug.js @@ -4,6 +4,8 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ +import { bigIntUtils } from '@hathor/wallet-lib'; + let debugLong; /* istanbul ignore next */ @@ -38,7 +40,7 @@ function debugLog(data) { } export function eventHandler(data) { - const message = JSON.stringify(data); + const message = bigIntUtils.JSONBigInt.stringify(data); if (message.length < 1000) { debugLog(message); return; diff --git a/src/plugins/hathor_rabbitmq.js b/src/plugins/hathor_rabbitmq.js index 09a7fb43..63af4768 100644 --- a/src/plugins/hathor_rabbitmq.js +++ b/src/plugins/hathor_rabbitmq.js @@ -6,6 +6,9 @@ */ /* istanbul ignore next */ + +import { bigIntUtils } from '@hathor/wallet-lib'; + async function checkDeps() { const requiredDeps = { yargs: '^17.7.2', @@ -33,13 +36,46 @@ export function getSettings() { || process.env.HEADLESS_PLUGIN_RABBITMQ_URL; const queue = argv.plugin_rabbitmq_queue || process.env.HEADLESS_PLUGIN_RABBITMQ_QUEUE; + const exchange = argv.plugin_rabbitmq_exchange + || process.env.HEADLESS_PLUGIN_RABBITMQ_EXCHANGE; + const routingKey = argv.plugin_rabbitmq_routing_key !== undefined + ? argv.plugin_rabbitmq_routing_key + : process.env.HEADLESS_PLUGIN_RABBITMQ_ROUTING_KEY; + + if (url === undefined) { + throw new Error('You must provide a RabbitMQ URL'); + } + + if (queue === undefined && exchange === undefined) { + throw new Error('You must provide either a RabbitMQ queue or exchange'); + } - return { url, queue }; + if (queue !== undefined && exchange !== undefined) { + throw new Error('You must provide either a RabbitMQ queue or exchange, not both'); + } + + if (exchange !== undefined && routingKey === undefined) { + throw new Error('You must provide a RabbitMQ routing key if you provide exchange. A blank routing key is acceptable though.'); + } + + return { url, queue, exchange, routingKey }; } export function eventHandlerFactory(channel, settings) { + if (settings.exchange !== undefined) { + return data => { + // Check https://amqp-node.github.io/amqplib/channel_api.html#channel_publish + channel.publish( + settings.exchange, + settings.routingKey, + Buffer.from(bigIntUtils.JSONBigInt.stringify(data)) + ); + }; + } + return data => { - channel.sendToQueue(settings.queue, Buffer.from(JSON.stringify(data))); + // Check https://amqp-node.github.io/amqplib/channel_api.html#channel_sendToQueue + channel.sendToQueue(settings.queue, Buffer.from(bigIntUtils.JSONBigInt.stringify(data))); }; } diff --git a/src/plugins/hathor_sqs.js b/src/plugins/hathor_sqs.js index a277e6a1..872d2c8b 100644 --- a/src/plugins/hathor_sqs.js +++ b/src/plugins/hathor_sqs.js @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +import { bigIntUtils } from '@hathor/wallet-lib'; + /* istanbul ignore next */ async function checkDeps() { const requiredDeps = { @@ -53,7 +55,7 @@ export function eventHandlerFactory(sqs, settings) { return data => { const params = { QueueUrl: settings.queueUrl, - MessageBody: JSON.stringify(data), + MessageBody: bigIntUtils.JSONBigInt.stringify(data), }; sqs.sendMessage(params, err => { if (err) { diff --git a/src/plugins/hathor_websocket.js b/src/plugins/hathor_websocket.js index 2950abfe..d713fe6c 100644 --- a/src/plugins/hathor_websocket.js +++ b/src/plugins/hathor_websocket.js @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +import { bigIntUtils } from '@hathor/wallet-lib'; + /* istanbul ignore next */ async function checkDeps() { const requiredDeps = { @@ -60,7 +62,7 @@ export function connectionHandler(socket) { export function eventHandler(data) { // Broadcast to all live connections for (const socket of sockets) { - socket.send(JSON.stringify(data)); + socket.send(bigIntUtils.JSONBigInt.stringify(data)); } } diff --git a/src/routes/wallet/nano-contracts.routes.js b/src/routes/wallet/nano-contracts.routes.js index ce6377bc..16342a88 100644 --- a/src/routes/wallet/nano-contracts.routes.js +++ b/src/routes/wallet/nano-contracts.routes.js @@ -11,6 +11,7 @@ const { getState, getHistory, createNanoContract, + createOnChainBlueprint, executeNanoContractMethod, getOracleData, getOracleSignedResult @@ -99,4 +100,15 @@ nanoContractRouter.post( executeNanoContractMethod, ); +/** + * POST request to create an on chain blueprint tx + * For the docs, see api-docs.js + */ +nanoContractRouter.post( + '/create-on-chain-blueprint', + body('code').isString(), + body('address').isString(), + createOnChainBlueprint, +); + module.exports = nanoContractRouter; diff --git a/src/services/wallets.service.js b/src/services/wallets.service.js index cfa223a8..5a0fedbc 100644 --- a/src/services/wallets.service.js +++ b/src/services/wallets.service.js @@ -104,35 +104,27 @@ async function startWallet(walletId, walletConfig, config, options = {}) { const wallet = new HathorWallet(hydratedWalletConfig); setupWalletStateLogs(wallet, logger); - if (options?.historySyncMode || config.history_sync_mode) { - // POLLING_HTTP_API is the default case if something invalid is configured - // this will be kept. - let mode = HistorySyncMode.POLLING_HTTP_API; - // Use from options first and if not configured, use from config - const strMode = options?.historySyncMode || config.history_sync_mode; - switch (strMode) { - case 'xpub_stream_ws': - mode = HistorySyncMode.XPUB_STREAM_WS; - break; - case 'manual_stream_ws': - mode = HistorySyncMode.MANUAL_STREAM_WS; - break; - default: - break; - } - - if (hydratedWalletConfig.multisig) { - // XXX: Multisig is not supported on streaming yet - mode = HistorySyncMode.POLLING_HTTP_API; - } - if (hydratedWalletConfig.scanPolicy?.policy && hydratedWalletConfig.scanPolicy?.policy !== 'gap-limit') { - // XXX: currently only gap-limit can use streaming modes - mode = HistorySyncMode.POLLING_HTTP_API; - } - // eslint-disable-next-line no-console - console.log(`Configuring wallet ${sanitizeLogInput(walletId)} for history sync mode: ${mode}`); - wallet.setHistorySyncMode(mode); + // Will try to use the options.historySyncMode then config.history_sync_mode + const configMode = { + polling_http_api: HistorySyncMode.POLLING_HTTP_API, + xpub_stream_ws: HistorySyncMode.XPUB_STREAM_WS, + manual_stream_ws: HistorySyncMode.MANUAL_STREAM_WS, + }[options?.historySyncMode || config.history_sync_mode]; + + // XPUB_STREAM_WS is the default case if nothing was configured. + let mode = configMode || HistorySyncMode.XPUB_STREAM_WS; + + if (hydratedWalletConfig.multisig) { + // XXX: Multisig is not supported on streaming yet + mode = HistorySyncMode.POLLING_HTTP_API; } + if (hydratedWalletConfig.scanPolicy?.policy && hydratedWalletConfig.scanPolicy?.policy !== 'gap-limit') { + // XXX: currently only gap-limit can use streaming modes + mode = HistorySyncMode.POLLING_HTTP_API; + } + // eslint-disable-next-line no-console + console.log(`Configuring wallet ${sanitizeLogInput(walletId)} for history sync mode: ${mode}`); + wallet.setHistorySyncMode(mode); if (config.gapLimit) { // XXX: The gap limit is now a per-wallet configuration