diff --git a/.env.test b/.env.test index 4b0ab983..88f72661 100644 --- a/.env.test +++ b/.env.test @@ -43,3 +43,11 @@ MAX_FEE_AMOUNT_ALLOWED=5000000 #Dust value (Satoshi) BURN_DUST_VALUE=2000 + +# Atlas SWAP events +ATLAS_EVENTS_ENABLED=false +ATLAS_SQS_QUEUE_URL='http://localhost:4566/000000000000/atlas-swap-events.fifo' +ATLAS_SQS_ENDPOINT='http://localhost:4566' +AWS_REGION='us-east-1' +AWS_ACCESS_KEY_ID='test' +AWS_SECRET_ACCESS_KEY='test' diff --git a/.eslintignore b/.eslintignore index dc3b7ccb..78809c07 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,4 +3,6 @@ dist/ coverage/ .eslintrc.js __tests__/ -mongo-init.js \ No newline at end of file +mongo-init.js +ci/ +tools/ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61f4287c..ec3b48a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,3 +30,51 @@ jobs: npm run test:all npm run eslint npm run only-coverage + + integration-tests: + runs-on: ubuntu-latest + services: + localstack: + image: localstack/localstack:3 + ports: ['4566:4566'] + env: + SERVICES: sqs + options: >- + --health-cmd "curl -f http://localhost:4566/_localstack/health" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup NodeJS + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Generate .env file + run: | + cp .env.test .env + + - name: Install dependencies + run: npm ci + + # Service containers do not run LocalStack's init/ready.d hooks, so the + # queue is created explicitly instead of reusing ci/localstack-init. + - name: Create SQS queue + run: node ci/create-atlas-queue.js + env: + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + AWS_REGION: us-east-1 + ATLAS_SQS_ENDPOINT: http://localhost:4566 + + - name: Run integration tests + run: npm run integration-test + env: + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + AWS_REGION: us-east-1 + ATLAS_SQS_ENDPOINT: http://localhost:4566 diff --git a/Dockerfile b/Dockerfile index 9dc02af9..fb1bd29e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,4 +12,8 @@ COPY --chown=node:node . ./ RUN npm run build -CMD ["node", "."] +# API and daemon are deployed as separate processes. APP_MODE keeps the +# default behaviour (both in one process) while letting the orchestrator +# select API or DAEMON explicitly instead of relying on an omitted flag. +ENV APP_MODE=ALL +CMD ["sh", "-c", "node . --appmode=$APP_MODE"] diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md index a9b1e8d9..272d0770 100644 --- a/ENV_VARIABLES.md +++ b/ENV_VARIABLES.md @@ -24,7 +24,7 @@ This table was created to guide and centralize the **environment variables** nec |FEE_PER_KB_SLOW_MIN |100 |'Fee per kb slow' | |BURN_DUST_VALUE |2000 |'Burn dust value' | |BTC_CONFIRMATIONS |100 |'testnet or mainnet' | -|NETWORK |`testnet or mainnet` |'testnet or mainnet' | +|NETWORK |`testnet or mainnet` |'testnet or mainnet. Required: the daemon refuses to start with any other value' | |BLOCKBOOK_URL | |'Blockbook url' | |MAX_AMOUNT_ALLOWED_IN_SATOSHI | |'Pegin Pegout max allowed in satoshis' | |LOG_FORMAT |`json or pretty` |'Log output format. Defaults to json' | @@ -37,6 +37,80 @@ This table was created to guide and centralize the **environment variables** nec |BACKOFFICE_API_PASSWORD | |'Backoffice service account password. Secret — never commit'| |BACKOFFICE_FLAGS_CACHE_TTL_MS |60000 |'How long retrieved flags are cached before re-fetching'| |BACKOFFICE_HTTP_TIMEOUT_MS |2000 |'Timeout for each backoffice HTTP request'| +|ATLAS_EVENTS_ENABLED |`false` |'Kill switch for Atlas SWAP event publication. Only the literal `true` enables it'| +|ATLAS_SQS_QUEUE_URL | |'URL of the SQS FIFO queue the Atlas events are published to. Required when `ATLAS_EVENTS_ENABLED=true`; the daemon aborts at startup without it'| +|AWS_REGION |`us-east-1` |'AWS region of the Atlas SQS queue' | +|ATLAS_SQS_ENDPOINT |`http://localhost:4566` |'Custom SQS endpoint. Local development and tests only (LocalStack); leave empty in deployments'| + +### Atlas SWAP events + +While `ATLAS_EVENTS_ENABLED=true`, the daemon publishes Atlas SWAP events to the +SQS FIFO queue at `ATLAS_SQS_QUEUE_URL` as it processes Bridge transactions. +**Only the daemon publishes**: the publisher is registered by +`configureDaemonDependencies` and is simply not bound in the API process. + +Peg-out, one event per transition: `swap.created` (RECEIVED), `swap.pending` +(WAITING_FOR_CONFIRMATION), `swap.completed` (RELEASE_BTC) and `swap.rejected` +(REJECTED). `WAITING_FOR_SIGNATURE` publishes nothing: it is an internal +federation sub-state with no equivalent in the v1.0 schema. + +Peg-in, keyed by `btcTxId`, two events per outcome: `LOCKED` publishes +`swap.created` and `swap.completed`, and a rejection publishes `swap.created` +followed by `swap.rejected`. `swap.pending` has no trigger, because the daemon +observes only Rootstock and never sees the deposit on Bitcoin. + +The `swap.completed` of a peg-in carries `duration_ms: null` — the Bitcoin +broadcast time is unknown, and a zero would drag the average duration down — and +`fee: "0.00000000"`, because the Bridge credits the whole amount sent. Its +`destination_tx_hash` is the Rootstock transaction that credited the RBTC. + +Rejection reasons are translated to the names of the rskj enums +(`RejectedPeginReason`, `NonRefundablePeginReason`) in +`models/atlas/atlas-pegin-reasons.ts`, and the raw numbers of both logs travel in +`error_message`. The `error_code` always names the `rejected_pegin` reason, the +root cause present in every branch; the exception is a rejection the Bridge +followed with no refund branch at all, reported as +`PEGIN_REJECTED_NO_REFUND_BRANCH`. **This table has to stay aligned with rskj**: +a value added there falls back to `UNKNOWN` with a `warn`, which degrades well +but only if someone reads the warning. + +`swap_id` and `wallet_address` are normalized — 0x-prefixed and lowercase — in +both flows, so one transaction cannot reach Atlas under two spellings. Bitcoin +addresses are left alone, since base58 is case sensitive. + +The `swap_id` identifies the swap on the chain the funds come from: a peg-out +carries its `originatingRskTxHash`, a peg-in its `btcTxId`. The queue's +`MessageGroupId` is that same `swap_id`, so the transitions of one swap stay +ordered while different swaps are processed in parallel. The queue must have +content based deduplication **disabled**: `MessageDeduplicationId` is the +`event_id`. + +The network travels in the chain ids (`rootstock_testnet` / `bitcoin_testnet`), +derived from `NETWORK`. Because a wrong network would silently contaminate the +analytics database, `NETWORK` is validated when the daemon starts and the daemon +aborts if it is neither `mainnet` nor `testnet`. + +`ATLAS_SQS_QUEUE_URL` is validated the same way, and only while the switch is +on: a blank url would not disable publication, it would fail every send and lose +the events with no retry, so the daemon aborts at startup rather than running +blind. With `ATLAS_EVENTS_ENABLED` off the variable is not read at all. + +Publication happens after the status has been written to Mongo and never fails +the caller: if SQS is unreachable the failure is logged at error level and block +processing continues. Events lost in that window are not recovered. + +Every publication, successful or not, logs one line carrying +`metric: 'atlas_events_published_total'` with `status`, `flow`, `eventType` and +the running `total`. **That field name is the contract with the log aggregator** +— it is what an alert on lost events queries, so it must not be renamed for +style. The counter makes the loss above visible; it does not fix it, and +idempotency by `btcTxId` means a re-sync will not retry a peg-in it already +recorded. + +Credentials come from the standard AWS SDK chain (an IAM role in deployments, +`test`/`test` against LocalStack). `docker compose up` starts a LocalStack +container that creates the queue from `ci/localstack-init`; from inside the +compose network the endpoint host is `localstack`, not `localhost`. ### Backoffice feature flags diff --git a/README.md b/README.md index a586662c..e129ce29 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,20 @@ The running application serves an interactive REST Explorer at `/explorer` and a ## Testing ```sh -npm run unit-test # dist/__tests__/**/*.unit.js -npm run acceptance-test # dist/__tests__/**/*.acceptance.js -npm run test:all # both suites -npm run coverage # nyc report (after a test run) +npm run unit-test # dist/__tests__/**/*.unit.js +npm run acceptance-test # dist/__tests__/**/*.acceptance.js +npm run test:all # both suites +npm run integration-test # dist/__tests__/integration/**/*.integration.js (needs LocalStack) +npm run coverage # nyc report (after a test run) +``` + +`integration-test` is deliberately left out of `test:all` so it does not slow +the fast unit cycle; it runs in its own CI job. It needs the LocalStack SQS +queue up: + +```sh +docker-compose up -d localstack +npm run integration-test ``` ## Fix code style and formatting issues @@ -107,6 +117,7 @@ The project includes Docker Compose configuration for running both the API and M - **API Service**: Runs on port 3000, connects to MongoDB using service name `pp-api-db` - **MongoDB Service**: Runs on ports 27017-27019, automatically initializes with user and database from environment variables +- **LocalStack Service**: Runs on port 4566 and creates the `atlas-swap-events.fifo` SQS queue from `ci/localstack-init`, so the Atlas SWAP events have somewhere to go locally ### Start Database Only (Development) @@ -135,6 +146,23 @@ The API will be accessible at `http://localhost:3000` and will automatically con **Note**: When running in Docker, the API uses `pp-api-db` as the MongoDB host (configured in docker-compose.yml). For local development without Docker, use `localhost`. +### Atlas SWAP events locally + +To watch the daemon publish Atlas events against LocalStack, add to your `.env`: + +``` +ATLAS_EVENTS_ENABLED=true +ATLAS_SQS_ENDPOINT=http://localhost:4566 +ATLAS_SQS_QUEUE_URL=http://localhost:4566/000000000000/atlas-swap-events.fifo +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=test +AWS_SECRET_ACCESS_KEY=test +``` + +From inside the compose network the host is `localstack`, not `localhost`. The +kill switch starts off in every environment; see +[`ENV_VARIABLES.md`](./ENV_VARIABLES.md) for what each event carries. + ## Other useful commands - `npm run openapi-spec`: Generate OpenAPI spec into a file diff --git a/ci/create-atlas-queue.js b/ci/create-atlas-queue.js new file mode 100644 index 00000000..61964bf6 --- /dev/null +++ b/ci/create-atlas-queue.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/** + * Creates the Atlas SWAP events FIFO queue. + * + * LocalStack's `init/ready.d` hooks (see `ci/localstack-init`) do not run when + * LocalStack is started as a GitHub Actions service container, so the queue is + * created explicitly there. Written against the AWS SDK already in the + * dependency tree rather than the AWS CLI, so it behaves the same on a hosted + * runner and under `act`. + */ +const {SQSClient, CreateQueueCommand} = require('@aws-sdk/client-sqs'); + +const region = process.env.AWS_REGION || 'us-east-1'; +const endpoint = process.env.ATLAS_SQS_ENDPOINT || 'http://localhost:4566'; +const queueName = process.env.ATLAS_SQS_QUEUE_NAME || 'atlas-swap-events.fifo'; + +async function main() { + const client = new SQSClient({region, endpoint}); + try { + const {QueueUrl} = await client.send( + new CreateQueueCommand({ + QueueName: queueName, + Attributes: {FifoQueue: 'true', ContentBasedDeduplication: 'false'}, + }), + ); + console.log(QueueUrl); + } finally { + client.destroy(); + } +} + +main().catch(error => { + console.error(error.message); + process.exit(1); +}); diff --git a/ci/localstack-init/01-create-queue.sh b/ci/localstack-init/01-create-queue.sh new file mode 100755 index 00000000..38366d01 --- /dev/null +++ b/ci/localstack-init/01-create-queue.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +awslocal sqs create-queue \ + --queue-name atlas-swap-events.fifo \ + --attributes FifoQueue=true,ContentBasedDeduplication=false diff --git a/docker-compose.yml b/docker-compose.yml index 2192a1e2..3d0565d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,25 @@ services: timeout: 5s retries: 5 start_period: 10s + localstack: + image: localstack/localstack:3 + container_name: pp-api-localstack + ports: + - "4566:4566" + environment: + SERVICES: sqs + AWS_DEFAULT_REGION: us-east-1 + DEBUG: 0 + volumes: + - ./ci/localstack-init:/etc/localstack/init/ready.d + networks: + - pp-api-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s networks: pp-api-network: name: pp-api-network diff --git a/package-lock.json b/package-lock.json index 424168bb..6cc1d6ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "2wp-api", "version": "4.1.0", "dependencies": { + "@aws-sdk/client-sqs": "^3.1119.0", "@loopback/boot": "^8.0.13", "@loopback/core": "^7.0.12", "@loopback/repository": "^8.0.12", @@ -40,6 +41,8 @@ "@types/node": "^20.19.43", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "eslint": "^8.48.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^18.0.0", @@ -61,6 +64,294 @@ "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", "license": "MIT" }, + "node_modules/@aws-sdk/client-sqs": { + "version": "3.1119.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1119.0.tgz", + "integrity": "sha512-UWEOBvXHWItIgCjrAWbBZQ8Masj0pNORpGP1DylA0+VIO4LHCEGXAAquTYceWjcowCKsrhY627tu1xJspdWkAg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.81", + "@aws-sdk/middleware-sdk-sqs": "^3.972.42", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.81", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz", + "integrity": "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-sqs": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.42.tgz", + "integrity": "sha512-D/O6iHAqlm0b430vIGewanSsr5RzaWbSDFlHYdKLWlZb4kaMKBmb44ReNHAFEKj5tNRY8pysw0qfciosB/VcJg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1672,6 +1963,87 @@ "dev": true, "license": "(Unlicense OR Apache-2.0)" }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", + "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@types/big.js": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/@types/big.js/-/big.js-6.2.2.tgz", @@ -2794,6 +3166,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", diff --git a/package.json b/package.json index 0b612181..d934986d 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "posttest": "npm run lint", "test:dev": "lb-nyc mocha --allow-console-logs dist/__tests__/**/*.js && npm run posttest", "unit-test": "npm run pretest && lb-nyc mocha --recursive 'dist/__tests__/**/*.unit.js' --timeout 10000", + "integration-test": "npm run pretest && lb-nyc mocha --recursive 'dist/__tests__/integration/**/*.integration.js' --timeout 30000", "acceptance-test": "npm run pretest && lb-nyc mocha --recursive 'dist/__tests__/**/*.acceptance.js' --timeout 10000 --reporter spec 2>/dev/null", "test:all": "npm run pretest && lb-nyc mocha --recursive 'dist/__tests__/**/*.unit.js' 'dist/__tests__/**/*.acceptance.js' --timeout 10000", "premigrate": "npm run rebuild", @@ -55,6 +56,7 @@ "!*/__tests__" ], "dependencies": { + "@aws-sdk/client-sqs": "^3.1119.0", "@loopback/boot": "^8.0.13", "@loopback/core": "^7.0.12", "@loopback/repository": "^8.0.12", @@ -107,6 +109,8 @@ "@types/node": "^20.19.43", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "eslint": "^8.48.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^18.0.0", diff --git a/schemas/atlas-swap-event.schema.json b/schemas/atlas-swap-event.schema.json new file mode 100644 index 00000000..fdff7f19 --- /dev/null +++ b/schemas/atlas-swap-event.schema.json @@ -0,0 +1,161 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://rootstock.io/schemas/atlas-swap-event.schema.json", + "title": "Atlas SWAP Event", + "description": "Executable contract for the Atlas SWAP Event Schema v1.0 as emitted by the 2wp-api daemon for native peg-in and peg-out.", + "type": "object", + "required": [ + "event_id", + "event_type", + "swap_id", + "swap_type", + "source", + "schema_version", + "emitted_at", + "data" + ], + "additionalProperties": false, + "properties": { + "event_id": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "event_type": { + "type": "string", + "enum": ["swap.created", "swap.pending", "swap.completed", "swap.rejected"] + }, + "swap_id": {"type": "string", "minLength": 1}, + "swap_type": {"type": "string", "const": "powpeg"}, + "source": {"type": "string", "const": "PWP"}, + "schema_version": {"type": "string", "const": "1.0"}, + "emitted_at": {"type": "string", "format": "date-time"}, + "data": {"type": "object"} + }, + "definitions": { + "chainId": { + "type": "string", + "enum": ["rootstock_mainnet", "rootstock_testnet", "bitcoin_mainnet", "bitcoin_testnet"] + }, + "decimalAmount": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]{8}$" + }, + "asset": { + "type": "string", + "enum": ["BTC", "RBTC"] + }, + "errorCode": { + "description": "Peg-out rejection reasons, peg-in rejection reasons named after the rskj RejectedPeginReason enum, the observed absence of a refund branch, and the fallback for a reason rskj added after this schema.", + "type": "string", + "enum": [ + "LOW_AMOUNT", + "CALLER_CONTRACT", + "FEE_ABOVE_VALUE", + "PEGIN_CAP_SURPASSED", + "LEGACY_PEGIN_MULTISIG_SENDER", + "LEGACY_PEGIN_UNDETERMINED_SENDER", + "PEGIN_V1_INVALID_PAYLOAD", + "INVALID_AMOUNT", + "PEGIN_REJECTED_NO_REFUND_BRANCH", + "UNKNOWN" + ] + } + }, + "allOf": [ + { + "if": {"properties": {"event_type": {"const": "swap.created"}}, "required": ["event_type"]}, + "then": { + "properties": { + "data": { + "type": "object", + "required": [ + "provider", + "source_chain", + "destination_chain", + "input_asset", + "output_asset", + "input_amount", + "input_amount_usd", + "wallet_address", + "wallet_type", + "quote_id" + ], + "additionalProperties": false, + "properties": { + "provider": {"type": "string", "const": "powpeg"}, + "source_chain": {"$ref": "#/definitions/chainId"}, + "destination_chain": {"$ref": "#/definitions/chainId"}, + "input_asset": {"$ref": "#/definitions/asset"}, + "output_asset": {"$ref": "#/definitions/asset"}, + "input_amount": {"$ref": "#/definitions/decimalAmount"}, + "input_amount_usd": {"type": "null"}, + "wallet_address": {"type": ["string", "null"], "minLength": 1}, + "wallet_type": {"type": "null"}, + "quote_id": {"type": "null"} + } + } + } + } + }, + { + "if": {"properties": {"event_type": {"const": "swap.pending"}}, "required": ["event_type"]}, + "then": { + "properties": { + "data": { + "type": "object", + "required": ["source_tx_hash", "deposit_address", "expected_confirmations"], + "additionalProperties": false, + "properties": { + "source_tx_hash": {"type": "string", "minLength": 1}, + "deposit_address": {"type": "null"}, + "expected_confirmations": {"type": "integer", "minimum": 0} + } + } + } + } + }, + { + "if": {"properties": {"event_type": {"const": "swap.completed"}}, "required": ["event_type"]}, + "then": { + "properties": { + "data": { + "type": "object", + "required": [ + "destination_tx_hash", + "output_amount", + "output_amount_usd", + "fee", + "duration_ms" + ], + "additionalProperties": false, + "properties": { + "destination_tx_hash": {"type": "string", "minLength": 1}, + "output_amount": {"$ref": "#/definitions/decimalAmount"}, + "output_amount_usd": {"type": "null"}, + "fee": {"$ref": "#/definitions/decimalAmount"}, + "duration_ms": {"type": ["integer", "null"], "minimum": 0} + } + } + } + } + }, + { + "if": {"properties": {"event_type": {"const": "swap.rejected"}}, "required": ["event_type"]}, + "then": { + "properties": { + "data": { + "type": "object", + "required": ["error_category", "error_code", "error_message", "refund_applicable"], + "additionalProperties": false, + "properties": { + "error_category": {"type": "string", "enum": ["validation", "protocol_violation"]}, + "error_code": {"$ref": "#/definitions/errorCode"}, + "error_message": {"type": "string", "minLength": 1}, + "refund_applicable": {"type": "boolean"} + } + } + } + } + } + ] +} diff --git a/src/__tests__/integration/atlas-pegout-events.integration.ts b/src/__tests__/integration/atlas-pegout-events.integration.ts new file mode 100644 index 00000000..5e173409 --- /dev/null +++ b/src/__tests__/integration/atlas-pegout-events.integration.ts @@ -0,0 +1,382 @@ +import {expect} from '@loopback/testlab'; +import { + CreateQueueCommand, + DeleteMessageCommand, + Message, + ReceiveMessageCommand, + SQSClient, +} from '@aws-sdk/client-sqs'; +import Ajv, {ValidateFunction} from 'ajv'; +import addFormats from 'ajv-formats'; +import * as fs from 'fs'; +import * as path from 'path'; +import {randomBytes} from 'crypto'; +import {AtlasEvent, AtlasEventType} from '../../models/atlas/atlas-event.model'; +import {PegoutAtlasEventBuilder} from '../../services/atlas/pegout-atlas-event.builder'; +import {SqsAtlasEventPublisher} from '../../services/atlas/sqs-atlas-event-publisher'; +import { + PegoutStatusDbDataModel, + PegoutStatuses, +} from '../../models/rsk/pegout-status-data-model'; +import { + PeginAtlasEventBuilder, + PeginAtlasEventContext, +} from '../../services/atlas/pegin-atlas-event.builder'; +import { + PeginStatus, + PeginStatusDataModel, +} from '../../models/rsk/pegin-status-data.model'; + +const QUEUE_NAME = 'atlas-swap-events.fifo'; +const SCHEMA_PATH = path.resolve(process.cwd(), 'schemas/atlas-swap-event.schema.json'); + +function withDefault(name: string, value: string): void { + if (!process.env[name]) { + process.env[name] = value; + } +} + +withDefault('NETWORK', 'testnet'); +withDefault('RSK_PEGOUT_MINIMUM_CONFIRMATIONS', '10'); +withDefault('AWS_REGION', 'us-east-1'); +withDefault('AWS_ACCESS_KEY_ID', 'test'); +withDefault('AWS_SECRET_ACCESS_KEY', 'test'); +withDefault('ATLAS_SQS_ENDPOINT', 'http://localhost:4566'); + +function givenSwapId(): string { + return `0x${randomBytes(32).toString('hex')}`; +} + +function givenPegin(swapId: string, status: PeginStatus): PeginStatusDataModel { + const pegin = new PeginStatusDataModel(); + pegin.btcTxId = swapId; + pegin.status = status; + pegin.createdOn = new Date('2024-05-01T10:00:00.000Z'); + pegin.rskTxId = `0x${randomBytes(32).toString('hex')}`; + pegin.rskBlockHeight = 1; + pegin.rskRecipient = '0x2D623170Cb518434af6c02602334610f194818c1'; + return pegin; +} + +function givenPegout( + swapId: string, + status: PegoutStatuses, + data: Partial = {}, +): PegoutStatusDbDataModel { + const pegout = new PegoutStatusDbDataModel(); + pegout.originatingRskTxHash = swapId; + pegout.rskTxHash = swapId; + pegout.rskSenderAddress = '0x40d2878B98A9C5A5b7bc3B2FC0e26dfDefCfe737'; + pegout.btcTxHash = randomBytes(32).toString('hex'); + pegout.createdOn = new Date('2024-05-01T10:00:00.000Z'); + pegout.valueRequestedInSatoshis = 10000000; + pegout.valueInSatoshisToBeReceived = 9995000; + pegout.status = status; + return Object.assign(pegout, data); +} + +describe('Integration: Atlas peg events over SQS', function () { + this.timeout(30000); + + let client: SQSClient; + let publisher: SqsAtlasEventPublisher; + let validate: ValidateFunction; + let queueUrl: string; + + before(async () => { + client = new SQSClient({ + region: process.env.AWS_REGION, + endpoint: process.env.ATLAS_SQS_ENDPOINT, + }); + // Idempotent: the queue already exists when created by the LocalStack init + // hook (docker compose) or by the CI step. + const {QueueUrl} = await client.send(new CreateQueueCommand({ + QueueName: QUEUE_NAME, + Attributes: {FifoQueue: 'true', ContentBasedDeduplication: 'false'}, + })); + queueUrl = QueueUrl!; + process.env.ATLAS_SQS_QUEUE_URL = queueUrl; + publisher = new SqsAtlasEventPublisher(); + + const ajv = new Ajv({allErrors: true, strict: false}); + addFormats(ajv); + validate = ajv.compile(JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'))); + }); + + after(async () => { + await drain(20); + publisher?.destroy(); + client?.destroy(); + }); + + beforeEach(async () => { + await drain(20); + }); + + /** + * Receives and deletes up to `max` messages, returning them in arrival order. + * Deleting as we go releases the FIFO message group so the next message of + * the same peg-out becomes visible. + */ + async function drain(max: number, minimum = 0): Promise { + const messages: Message[] = []; + let emptyPolls = 0; + while (messages.length < max && emptyPolls < 3) { + const {Messages} = await client.send(new ReceiveMessageCommand({ + QueueUrl: queueUrl, + MaxNumberOfMessages: 10, + WaitTimeSeconds: 1, + MessageSystemAttributeNames: ['MessageGroupId', 'MessageDeduplicationId'], + })); + if (!Messages?.length) { + if (messages.length >= minimum && minimum > 0) { + break; + } + emptyPolls++; + continue; + } + emptyPolls = 0; + for (const message of Messages) { + messages.push(message); + await client.send(new DeleteMessageCommand({ + QueueUrl: queueUrl, + ReceiptHandle: message.ReceiptHandle, + })); + } + } + return messages; + } + + function parse(message: Message): AtlasEvent { + return JSON.parse(message.Body!) as AtlasEvent; + } + + function expectValid(event: AtlasEvent): void { + validate(event); + expect(validate.errors ?? []).to.be.empty(); + } + + it('delivers created, pending and completed of one peg-out in order', async () => { + const swapId = givenSwapId(); + const created = PegoutAtlasEventBuilder.build( + givenPegout(swapId, PegoutStatuses.RECEIVED), + )!; + const pending = PegoutAtlasEventBuilder.build( + givenPegout(swapId, PegoutStatuses.WAITING_FOR_CONFIRMATION, { + rskTxHash: `${swapId}_0`, + createdOn: new Date('2024-05-01T10:01:00.000Z'), + }), + )!; + const completed = PegoutAtlasEventBuilder.build( + givenPegout(swapId, PegoutStatuses.RELEASE_BTC, { + rskTxHash: `${swapId}___0`, + createdOn: new Date('2024-05-01T10:03:04.000Z'), + }), + {receivedCreatedOn: new Date('2024-05-01T10:00:00.000Z')}, + )!; + + await publisher.publish(created); + await publisher.publish(pending); + await publisher.publish(completed); + + const messages = await drain(10, 3); + expect(messages).to.have.length(3); + + const events = messages.map(parse); + expect(events.map(event => event.event_type)).to.eql([ + AtlasEventType.SWAP_CREATED, + AtlasEventType.SWAP_PENDING, + AtlasEventType.SWAP_COMPLETED, + ]); + events.forEach(event => { + expect(event.swap_id).to.equal(swapId); + expectValid(event); + }); + messages.forEach(message => { + expect(message.Attributes?.MessageGroupId).to.equal(swapId); + }); + }); + + it('uses a distinct message group per peg-out', async () => { + const firstSwapId = givenSwapId(); + const secondSwapId = givenSwapId(); + + await publisher.publish( + PegoutAtlasEventBuilder.build(givenPegout(firstSwapId, PegoutStatuses.RECEIVED))!, + ); + await publisher.publish( + PegoutAtlasEventBuilder.build(givenPegout(secondSwapId, PegoutStatuses.RECEIVED))!, + ); + + const messages = await drain(10, 2); + expect(messages).to.have.length(2); + + const groupIds = messages.map(message => message.Attributes?.MessageGroupId); + expect(new Set(groupIds).size).to.equal(2); + expect(groupIds.sort()).to.eql([firstSwapId, secondSwapId].sort()); + messages.map(parse).forEach(expectValid); + }); + + it('deduplicates a re-sent event_id inside the FIFO deduplication window', async () => { + const swapId = givenSwapId(); + const event = PegoutAtlasEventBuilder.build(givenPegout(swapId, PegoutStatuses.RECEIVED))!; + + await publisher.publish(event); + await publisher.publish(event); + + const messages = await drain(10); + expect(messages).to.have.length(1); + expect(parse(messages[0]).event_id).to.equal(event.event_id); + expect(messages[0].Attributes?.MessageDeduplicationId).to.equal(event.event_id); + }); + + it('delivers a rejected peg-out as a single message', async () => { + const swapId = givenSwapId(); + const rejected = PegoutAtlasEventBuilder.build( + givenPegout(swapId, PegoutStatuses.REJECTED, {reason: 'LOW_AMOUNT'}), + )!; + + await publisher.publish(rejected); + + const messages = await drain(10, 1); + expect(messages).to.have.length(1); + const event = parse(messages[0]); + expect(event.event_type).to.equal(AtlasEventType.SWAP_REJECTED); + expectValid(event); + }); + + describe('peg-in', () => { + // `pegin_btc` / `lock_btc` report satoshis, unlike the peg-out logs. + const context: PeginAtlasEventContext = { + amountInSatoshis: '50000000', + rskRecipient: '0x2D623170Cb518434af6c02602334610f194818c1', + }; + + it('delivers created then completed for a locked peg-in, in order', async () => { + const swapId = givenSwapId(); + const events = PeginAtlasEventBuilder.build(givenPegin(swapId, PeginStatus.LOCKED), context); + expect(events).to.have.length(2); + + for (const event of events) { + await publisher.publish(event, 'pegin'); + } + + const messages = await drain(5, 2); + expect(messages).to.have.length(2); + + const received = messages.map(parse); + received.forEach(expectValid); + expect(received.map(event => event.event_type)).to.eql([ + AtlasEventType.SWAP_CREATED, + AtlasEventType.SWAP_COMPLETED, + ]); + // Both events of one peg-in share the group, so order is guaranteed. + expect(messages.map(m => m.Attributes?.MessageGroupId)).to.eql([swapId, swapId]); + expect(received.map(event => event.swap_id)).to.eql([swapId, swapId]); + expect(received[0].event_id).to.not.equal(received[1].event_id); + + // The Bridge credits the whole amount: nothing is lost between the two. + const created = received[0].data as {input_amount: string}; + const completed = received[1].data as {output_amount: string; fee: string}; + expect(created.input_amount).to.equal('0.50000000'); + expect(completed.output_amount).to.equal(created.input_amount); + expect(completed.fee).to.equal('0.00000000'); + }); + + // The refundable branch is the one that carries an amount: release_requested + // is the only log of a rejected peg-in that reports what the user sent. + it('delivers created before rejected for a refundable peg-in, with the amount', async () => { + const swapId = givenSwapId(); + const events = PeginAtlasEventBuilder.build( + givenPegin(swapId, PeginStatus.REJECTED_REFUND), + {amountInSatoshis: '50000000', rejectedReason: '4'}, + ); + expect(events).to.have.length(2); + + for (const event of events) { + await publisher.publish(event, 'pegin'); + } + + const messages = await drain(5, 2); + expect(messages).to.have.length(2); + + const received = messages.map(parse); + received.forEach(expectValid); + expect(received.map(event => event.event_type)).to.eql([ + AtlasEventType.SWAP_CREATED, + AtlasEventType.SWAP_REJECTED, + ]); + expect(messages.map(m => m.Attributes?.MessageGroupId)).to.eql([swapId, swapId]); + expect(received[0].event_id).to.not.equal(received[1].event_id); + + expect((received[0].data as {input_amount: string}).input_amount).to.equal('0.50000000'); + const rejected = received[1].data as { + error_code: string; error_category: string; refund_applicable: boolean; + }; + expect(rejected.error_code).to.equal('PEGIN_V1_INVALID_PAYLOAD'); + expect(rejected.error_category).to.equal('validation'); + expect(rejected.refund_applicable).to.be.true(); + }); + + // The Bridge rejected the peg-in and emitted no refund branch at all, so + // the code names that absence rather than a reason it does not have. + it('delivers a rejection the Bridge left with no refund branch', async () => { + const swapId = givenSwapId(); + const events = PeginAtlasEventBuilder.build( + givenPegin(swapId, PeginStatus.REJECTED_NO_REFUND), + {rejectedReason: '2'}, + ); + expect(events).to.have.length(2); + + for (const event of events) { + await publisher.publish(event, 'pegin'); + } + + const messages = await drain(5, 2); + expect(messages).to.have.length(2); + + const received = messages.map(parse); + received.forEach(expectValid); + expect(received.map(event => event.event_type)).to.eql([ + AtlasEventType.SWAP_CREATED, + AtlasEventType.SWAP_REJECTED, + ]); + expect(messages.map(m => m.Attributes?.MessageGroupId)).to.eql([swapId, swapId]); + + const rejected = received[1].data as { + error_code: string; error_category: string; refund_applicable: boolean; + }; + expect(rejected.error_code).to.equal('PEGIN_REJECTED_NO_REFUND_BRANCH'); + // Derived from rejected_pegin reason=2, the only reason it has. + expect(rejected.error_category).to.equal('protocol_violation'); + expect(rejected.refund_applicable).to.be.false(); + }); + + it('delivers created before rejected for a rejected peg-in', async () => { + const swapId = givenSwapId(); + const events = PeginAtlasEventBuilder.build( + givenPegin(swapId, PeginStatus.REJECTED_NO_REFUND), + {rejectedReason: '3', unrefundableReason: '1'}, + ); + expect(events).to.have.length(2); + + for (const event of events) { + await publisher.publish(event, 'pegin'); + } + + const messages = await drain(5, 2); + expect(messages).to.have.length(2); + + const received = messages.map(parse); + received.forEach(expectValid); + expect(received.map(event => event.event_type)).to.eql([ + AtlasEventType.SWAP_CREATED, + AtlasEventType.SWAP_REJECTED, + ]); + // Both transitions of one peg-in share the group, so order is guaranteed. + expect(messages.map(m => m.Attributes?.MessageGroupId)).to.eql([swapId, swapId]); + expect(received[0].event_id).to.not.equal(received[1].event_id); + }); + }); + +}); diff --git a/src/__tests__/unit/daemon-runner.unit.ts b/src/__tests__/unit/daemon-runner.unit.ts new file mode 100644 index 00000000..fbf705c2 --- /dev/null +++ b/src/__tests__/unit/daemon-runner.unit.ts @@ -0,0 +1,29 @@ +import {expect} from '@loopback/testlab'; +import {DaemonRunner} from '../../daemon-runner'; + +describe('DaemonRunner', () => { + const originalNetwork = process.env.NETWORK; + + afterEach(() => { + if (originalNetwork === undefined) { + delete process.env.NETWORK; + } else { + process.env.NETWORK = originalNetwork; + } + }); + + it('refuses to start when NETWORK is not configured', () => { + delete process.env.NETWORK; + expect(() => new DaemonRunner()).to.throw(/NETWORK/); + }); + + it('refuses to start when NETWORK holds an unsupported value', () => { + process.env.NETWORK = 'regtest'; + expect(() => new DaemonRunner()).to.throw(/NETWORK/); + }); + + it('builds when NETWORK is configured', () => { + process.env.NETWORK = 'testnet'; + expect(() => new DaemonRunner()).to.not.throw(); + }); +}); diff --git a/src/__tests__/unit/dependency-injection-handler.unit.ts b/src/__tests__/unit/dependency-injection-handler.unit.ts new file mode 100644 index 00000000..9330997a --- /dev/null +++ b/src/__tests__/unit/dependency-injection-handler.unit.ts @@ -0,0 +1,78 @@ +import {Application} from '@loopback/core'; +import {expect} from '@loopback/testlab'; +import {DependencyInjectionHandler} from '../../dependency-injection-handler'; +import {ConstantsBindings, ServicesBindings} from '../../dependency-injection-bindings'; + +/** + * Atlas SWAP events are emitted only while the daemon processes Bridge + * transactions. These tests keep that rule enforceable: if someone moves a + * daemon-only binding back into the shared list, the API process regains the + * ability to publish and CI turns red here. + */ +const DAEMON_ONLY_BINDINGS = [ + ConstantsBindings.ATLAS_EVENTS_ENABLED, + ServicesBindings.ATLAS_EVENT_PUBLISHER, + ServicesBindings.PEGIN_DATA_PROCESSOR, + ServicesBindings.PEGOUT_DATA_PROCESSOR, + ServicesBindings.RSK_BLOCK_PROCESSOR_PUBLISHER, + ServicesBindings.DAEMON_SERVICE, +]; + +// Bindings the REST API controllers inject, which must stay in the shared list. +const SHARED_BINDINGS = [ + ServicesBindings.BITCOIN_SERVICE, + ServicesBindings.RSK_NODE_SERVICE, + ServicesBindings.BRIDGE_SERVICE, + ServicesBindings.PEGIN_STATUS_DATA_SERVICE, + ServicesBindings.PEGOUT_STATUS_DATA_SERVICE, + ServicesBindings.PEGIN_STATUS_SERVICE, + ServicesBindings.PEGOUT_STATUS_SERVICE, + ServicesBindings.SYNC_STATUS_DATA_SERVICE, + ServicesBindings.FEATURES_SERVICE, + ServicesBindings.BACKOFFICE_FEATURE_FLAGS_SERVICE, +]; + +describe('DependencyInjectionHandler', () => { + + describe('the API process', () => { + let app: Application; + + beforeEach(() => { + app = new Application(); + DependencyInjectionHandler.configureDependencies(app); + }); + + for (const binding of DAEMON_ONLY_BINDINGS) { + it(`does not bind ${binding}`, () => { + expect(app.isBound(binding)).to.be.false(); + }); + } + + for (const binding of SHARED_BINDINGS) { + it(`binds ${binding}`, () => { + expect(app.isBound(binding)).to.be.true(); + }); + } + + it('cannot resolve the Atlas event publisher', async () => { + await expect(app.get(ServicesBindings.ATLAS_EVENT_PUBLISHER)).to.be.rejected(); + }); + }); + + describe('the daemon process', () => { + let app: Application; + + beforeEach(() => { + app = new Application(); + DependencyInjectionHandler.configureDependencies(app); + DependencyInjectionHandler.configureDaemonDependencies(app); + }); + + for (const binding of [...DAEMON_ONLY_BINDINGS, ...SHARED_BINDINGS]) { + it(`binds ${binding}`, () => { + expect(app.isBound(binding)).to.be.true(); + }); + } + }); + +}); diff --git a/src/__tests__/unit/models/atlas/atlas-chain.unit.ts b/src/__tests__/unit/models/atlas/atlas-chain.unit.ts new file mode 100644 index 00000000..ccf45ebd --- /dev/null +++ b/src/__tests__/unit/models/atlas/atlas-chain.unit.ts @@ -0,0 +1,98 @@ +import {expect} from '@loopback/testlab'; +import { + CHAIN_IDS, + assertNetworkConfigured, + resolvePegoutChainIds, + resolvePeginChainIds, +} from '../../../../models/atlas/atlas-chain'; + +describe('Model: atlas-chain', () => { + const originalNetwork = process.env.NETWORK; + + afterEach(() => { + if (originalNetwork === undefined) { + delete process.env.NETWORK; + } else { + process.env.NETWORK = originalNetwork; + } + }); + + it('resolves mainnet chain ids', () => { + process.env.NETWORK = 'mainnet'; + expect(resolvePegoutChainIds()).to.eql({ + sourceChain: CHAIN_IDS.ROOTSTOCK_MAINNET, + destinationChain: CHAIN_IDS.BITCOIN_MAINNET, + }); + }); + + it('resolves testnet chain ids', () => { + process.env.NETWORK = 'testnet'; + expect(resolvePegoutChainIds()).to.eql({ + sourceChain: CHAIN_IDS.ROOTSTOCK_TESTNET, + destinationChain: CHAIN_IDS.BITCOIN_TESTNET, + }); + }); + + it('throws when NETWORK is absent instead of defaulting to testnet', () => { + delete process.env.NETWORK; + expect(() => resolvePegoutChainIds()).to.throw(/NETWORK/); + expect(() => assertNetworkConfigured()).to.throw(/NETWORK/); + }); + + it('throws when NETWORK holds an unsupported value', () => { + process.env.NETWORK = 'regtest'; + expect(() => resolvePegoutChainIds()).to.throw(/NETWORK/); + }); + + it('throws when NETWORK is an empty string', () => { + process.env.NETWORK = ''; + expect(() => resolvePegoutChainIds()).to.throw(/NETWORK/); + }); + + it('always sources from Rootstock and targets Bitcoin', () => { + for (const network of ['mainnet', 'testnet']) { + process.env.NETWORK = network; + const {sourceChain, destinationChain} = resolvePegoutChainIds(); + expect(sourceChain.startsWith('rootstock_')).to.be.true(); + expect(destinationChain.startsWith('bitcoin_')).to.be.true(); + } + }); + + it('returns the configured network from assertNetworkConfigured', () => { + process.env.NETWORK = 'mainnet'; + expect(assertNetworkConfigured()).to.equal('mainnet'); + }); + + describe('peg-in', () => { + it('resolves mainnet chain ids', () => { + process.env.NETWORK = 'mainnet'; + expect(resolvePeginChainIds()).to.eql({ + sourceChain: 'bitcoin_mainnet', + destinationChain: 'rootstock_mainnet', + }); + }); + + it('resolves testnet chain ids', () => { + process.env.NETWORK = 'testnet'; + expect(resolvePeginChainIds()).to.eql({ + sourceChain: 'bitcoin_testnet', + destinationChain: 'rootstock_testnet', + }); + }); + + it('is the mirror image of a peg-out', () => { + process.env.NETWORK = 'testnet'; + const pegout = resolvePegoutChainIds(); + const pegin = resolvePeginChainIds(); + + expect(pegin.sourceChain).to.equal(pegout.destinationChain); + expect(pegin.destinationChain).to.equal(pegout.sourceChain); + }); + + it('throws when NETWORK is absent instead of defaulting to testnet', () => { + delete process.env.NETWORK; + expect(() => resolvePeginChainIds()).to.throw(/NETWORK/); + }); + }); + +}); diff --git a/src/__tests__/unit/models/atlas/atlas-identifiers.unit.ts b/src/__tests__/unit/models/atlas/atlas-identifiers.unit.ts new file mode 100644 index 00000000..0eb8a985 --- /dev/null +++ b/src/__tests__/unit/models/atlas/atlas-identifiers.unit.ts @@ -0,0 +1,58 @@ +import {expect} from '@loopback/testlab'; +import { + normalizeAddress, + normalizeSwapId, +} from '../../../../models/atlas/atlas-identifiers'; + +describe('Model: atlas-identifiers', () => { + + describe('normalizeSwapId', () => { + it('adds the 0x prefix when the log omits it', () => { + expect(normalizeSwapId('1f789f91cb5cb6f76b91f19adcc89233')) + .to.equal('0x1f789f91cb5cb6f76b91f19adcc89233'); + }); + + it('lowercases a mixed-case hash', () => { + expect(normalizeSwapId('0x1F789F91CB5CB6F76B91F19ADCC89233')) + .to.equal('0x1f789f91cb5cb6f76b91f19adcc89233'); + }); + + it('leaves an already normalized hash untouched', () => { + const hash = '0x1f789f91cb5cb6f76b91f19adcc89233f3447d7228d8798c4e94ef09fd6d8950'; + + expect(normalizeSwapId(hash)).to.equal(hash); + }); + + it('throws on an empty value rather than emitting an empty swap_id', () => { + expect(() => normalizeSwapId('')).to.throw(/swap_id/); + expect(() => normalizeSwapId(' ')).to.throw(/swap_id/); + expect(() => normalizeSwapId(undefined)).to.throw(/swap_id/); + expect(() => normalizeSwapId(null)).to.throw(/swap_id/); + }); + }); + + describe('normalizeAddress', () => { + it('lowercases a checksummed Rootstock address', () => { + expect(normalizeAddress('0x2D623170Cb518434af6c02602334610f194818c1')) + .to.equal('0x2d623170cb518434af6c02602334610f194818c1'); + }); + + // base58 is case sensitive: lowercasing a Bitcoin address destroys it. + it('leaves a Bitcoin address untouched', () => { + for (const address of [ + 'mfWxJ45yp2SFn7UciZyNpvDKrzbhyfKrY8', + '2N6JWYUb6Li4Kux6UB2eihT7n3rm3YX97uv', + 'tb1qEXAMPLEmixedCase', + ]) { + expect(normalizeAddress(address)).to.equal(address); + } + }); + + it('keeps a missing address null instead of inventing one', () => { + expect(normalizeAddress(undefined)).to.be.null(); + expect(normalizeAddress(null)).to.be.null(); + expect(normalizeAddress('')).to.be.null(); + }); + }); + +}); diff --git a/src/__tests__/unit/models/atlas/atlas-pegin-reasons.unit.ts b/src/__tests__/unit/models/atlas/atlas-pegin-reasons.unit.ts new file mode 100644 index 00000000..bfe14c4c --- /dev/null +++ b/src/__tests__/unit/models/atlas/atlas-pegin-reasons.unit.ts @@ -0,0 +1,77 @@ +import {expect} from '@loopback/testlab'; +import { + NON_REFUNDABLE_PEGIN_REASONS, + REJECTED_PEGIN_REASONS, + UNKNOWN_REASON_NAME, + errorCategoryOf, + nonRefundablePeginReasonName, + rejectedPeginReasonName, +} from '../../../../models/atlas/atlas-pegin-reasons'; + +describe('Model: atlas-pegin-reasons', () => { + + // The names come from rskj's RejectedPeginReason, verified against + // rsksmart/rskj@161c3f1. A value rskj adds later falls back to UNKNOWN. + it('maps every RejectedPeginReason value to its rskj name', () => { + expect(REJECTED_PEGIN_REASONS).to.eql({ + '1': 'PEGIN_CAP_SURPASSED', + '2': 'LEGACY_PEGIN_MULTISIG_SENDER', + '3': 'LEGACY_PEGIN_UNDETERMINED_SENDER', + '4': 'PEGIN_V1_INVALID_PAYLOAD', + '5': 'INVALID_AMOUNT', + }); + for (const [reason, name] of Object.entries(REJECTED_PEGIN_REASONS)) { + expect(rejectedPeginReasonName(reason)).to.equal(name); + } + }); + + it('maps every NonRefundablePeginReason value to its rskj name', () => { + expect(NON_REFUNDABLE_PEGIN_REASONS).to.eql({ + '1': 'LEGACY_PEGIN_UNDETERMINED_SENDER', + '2': 'PEGIN_V1_REFUND_ADDRESS_NOT_SET', + '3': 'INVALID_AMOUNT', + '4': 'OUTPUTS_SENT_TO_DIFFERENT_TYPES_OF_FEDS', + }); + for (const [reason, name] of Object.entries(NON_REFUNDABLE_PEGIN_REASONS)) { + expect(nonRefundablePeginReasonName(reason)).to.equal(name); + } + }); + + it('categorizes INVALID_AMOUNT as validation, not protocol_violation', () => { + expect(errorCategoryOf('INVALID_AMOUNT')).to.equal('validation'); + }); + + it('categorizes an undetermined or multisig sender as protocol_violation', () => { + expect(errorCategoryOf('LEGACY_PEGIN_UNDETERMINED_SENDER')).to.equal('protocol_violation'); + expect(errorCategoryOf('LEGACY_PEGIN_MULTISIG_SENDER')).to.equal('protocol_violation'); + }); + + it('categorizes a payload or cap rejection as validation', () => { + expect(errorCategoryOf('PEGIN_V1_INVALID_PAYLOAD')).to.equal('validation'); + expect(errorCategoryOf('PEGIN_CAP_SURPASSED')).to.equal('validation'); + }); + + it('falls back to UNKNOWN and validation for a reason rskj does not have yet', () => { + for (const reason of ['6', '99', '', undefined]) { + expect(rejectedPeginReasonName(reason)).to.equal(UNKNOWN_REASON_NAME); + } + expect(errorCategoryOf(UNKNOWN_REASON_NAME)).to.equal('validation'); + }); + + it('reports no name at all when the unrefundable log is absent', () => { + expect(nonRefundablePeginReasonName(undefined)).to.be.undefined(); + expect(nonRefundablePeginReasonName('')).to.be.undefined(); + }); + + it('names an unrefundable reason rskj does not have yet as UNKNOWN', () => { + expect(nonRefundablePeginReasonName('9')).to.equal(UNKNOWN_REASON_NAME); + }); + + // This is the bug the translation table exists to prevent: the two logs carry + // different enums in the same position, so a bare number is meaningless. + it('keeps the two enums apart: reason 3 differs per event name', () => { + expect(rejectedPeginReasonName('3')).to.equal('LEGACY_PEGIN_UNDETERMINED_SENDER'); + expect(nonRefundablePeginReasonName('3')).to.equal('INVALID_AMOUNT'); + }); + +}); diff --git a/src/__tests__/unit/services/atlas/atlas-event-metrics.unit.ts b/src/__tests__/unit/services/atlas/atlas-event-metrics.unit.ts new file mode 100644 index 00000000..1b9245e0 --- /dev/null +++ b/src/__tests__/unit/services/atlas/atlas-event-metrics.unit.ts @@ -0,0 +1,110 @@ +import {expect, sinon} from '@loopback/testlab'; +import {AtlasEventType} from '../../../../models/atlas/atlas-event.model'; +import { + ATLAS_EVENTS_PUBLISHED_METRIC, + AtlasEventMetrics, +} from '../../../../services/atlas/atlas-event-metrics'; +import {Logger} from '../../../../utils/logger'; + +type StubbedLogger = Logger & { + info: sinon.SinonStub; + warn: sinon.SinonStub; + error: sinon.SinonStub; + debug: sinon.SinonStub; +}; + +const givenLogger = (): StubbedLogger => ( { + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), +}); + +const linesOf = (logger: StubbedLogger): Record[] => + [...logger.info.getCalls(), ...logger.warn.getCalls()] + .map(call => call.args[0] as Record); + +describe('Service: AtlasEventMetrics', () => { + + it('counts a success and a failure separately per flow and event type', () => { + const metrics = new AtlasEventMetrics(givenLogger()); + + metrics.recordSuccess(AtlasEventType.SWAP_CREATED, 'pegin'); + metrics.recordSuccess(AtlasEventType.SWAP_CREATED, 'pegin'); + metrics.recordSuccess(AtlasEventType.SWAP_CREATED, 'pegout'); + metrics.recordSuccess(AtlasEventType.SWAP_COMPLETED, 'pegin'); + metrics.recordFailure(AtlasEventType.SWAP_CREATED, 'pegin'); + + expect(metrics.total('success', AtlasEventType.SWAP_CREATED, 'pegin')).to.equal(2); + expect(metrics.total('success', AtlasEventType.SWAP_CREATED, 'pegout')).to.equal(1); + expect(metrics.total('success', AtlasEventType.SWAP_COMPLETED, 'pegin')).to.equal(1); + expect(metrics.total('failure', AtlasEventType.SWAP_CREATED, 'pegin')).to.equal(1); + expect(metrics.total('failure', AtlasEventType.SWAP_COMPLETED, 'pegout')).to.equal(0); + }); + + it('emits one log line per publication with a stable metric field', () => { + const logger = givenLogger(); + const metrics = new AtlasEventMetrics(logger); + + metrics.recordSuccess(AtlasEventType.SWAP_CREATED, 'pegin'); + metrics.recordFailure(AtlasEventType.SWAP_REJECTED, 'pegout'); + + const lines = linesOf(logger); + expect(lines).to.have.length(2); + expect(lines[0]).to.containEql({ + metric: ATLAS_EVENTS_PUBLISHED_METRIC, + status: 'success', + flow: 'pegin', + eventType: 'swap.created', + total: 1, + }); + expect(lines[1]).to.containEql({ + metric: ATLAS_EVENTS_PUBLISHED_METRIC, + status: 'failure', + flow: 'pegout', + eventType: 'swap.rejected', + total: 1, + }); + }); + + // The field name is the contract with the log aggregator: it is what an alert + // queries, so renaming it for style would silently break the alert. + it('names the metric atlas_events_published_total', () => { + expect(ATLAS_EVENTS_PUBLISHED_METRIC).to.equal('atlas_events_published_total'); + }); + + it('reports the running total, not just the last publication', () => { + const logger = givenLogger(); + const metrics = new AtlasEventMetrics(logger); + + for (let i = 0; i < 3; i++) { + metrics.recordSuccess(AtlasEventType.SWAP_PENDING, 'pegout'); + } + + expect(linesOf(logger).map(line => line.total)).to.eql([1, 2, 3]); + }); + + it('never throws, whatever the logger does', () => { + const logger = givenLogger(); + logger.info.throws(new Error('log pipeline is down')); + logger.warn.throws(new Error('log pipeline is down')); + const metrics = new AtlasEventMetrics(logger); + + expect(() => metrics.recordSuccess(AtlasEventType.SWAP_CREATED, 'pegin')).to.not.throw(); + expect(() => metrics.recordFailure(AtlasEventType.SWAP_CREATED, 'pegin')).to.not.throw(); + + // The count still advanced: the counter does not depend on the logger. + expect(metrics.total('success', AtlasEventType.SWAP_CREATED, 'pegin')).to.equal(1); + expect(metrics.total('failure', AtlasEventType.SWAP_CREATED, 'pegin')).to.equal(1); + }); + + it('records an unspecified flow without losing the publication', () => { + const metrics = new AtlasEventMetrics(givenLogger()); + + metrics.recordSuccess(AtlasEventType.SWAP_CREATED); + + expect(metrics.total('success', AtlasEventType.SWAP_CREATED)).to.equal(1); + expect(metrics.total('success', AtlasEventType.SWAP_CREATED, 'pegin')).to.equal(0); + }); + +}); diff --git a/src/__tests__/unit/services/atlas/pegin-atlas-event.builder.unit.ts b/src/__tests__/unit/services/atlas/pegin-atlas-event.builder.unit.ts new file mode 100644 index 00000000..70cdea04 --- /dev/null +++ b/src/__tests__/unit/services/atlas/pegin-atlas-event.builder.unit.ts @@ -0,0 +1,545 @@ +import {expect} from '@loopback/testlab'; +import Ajv, {ValidateFunction} from 'ajv'; +import addFormats from 'ajv-formats'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + ATLAS_SCHEMA_VERSION, + ATLAS_SOURCE, + ATLAS_SWAP_TYPE, + AtlasEventType, + SwapCompletedData, + SwapCreatedData, + SwapRejectedData, +} from '../../../../models/atlas/atlas-event.model'; +import { + PeginAtlasEventBuilder, + PeginAtlasEventContext, +} from '../../../../services/atlas/pegin-atlas-event.builder'; +import { + PeginStatus, + PeginStatusDataModel, +} from '../../../../models/rsk/pegin-status-data.model'; +import ExtendedBridgeTx from '../../../../services/extended-bridge-tx'; + +const SCHEMA_PATH = path.resolve(process.cwd(), 'schemas/atlas-swap-event.schema.json'); + +const btcTxId = '0x1f789f91cb5cb6f76b91f19adcc89233f3447d7228d8798c4e94ef09fd6d8950'; +const createdOn = new Date('2024-05-01T10:00:00.000Z'); +const receiver = '0x2D623170Cb518434af6c02602334610f194818c1'; + +function givenPegin(status: PeginStatus): PeginStatusDataModel { + const pegin = new PeginStatusDataModel(); + pegin.btcTxId = btcTxId; + pegin.status = status; + pegin.createdOn = createdOn; + pegin.rskTxId = '0xd2852f38fedf1915978715b8a0dc0670040ac4e9065989c810a5bf29c1e006fb'; + pegin.rskBlockHeight = 1; + pegin.rskRecipient = receiver; + return pegin; +} + +/** + * A `registerBtcTransaction` transaction carrying exactly `events`, which is + * all `extractContext` reads. + */ +function givenTx(events: Array<{name: string; arguments: Record}>) { + return { + txHash: '0xd2852f38fedf1915978715b8a0dc0670040ac4e9065989c810a5bf29c1e006fb', + blockNumber: 1, + createdOn: createdOn, + events, + }; +} + +describe('Service: PeginAtlasEventBuilder', () => { + const originalNetwork = process.env.NETWORK; + let validate: ValidateFunction; + + before(() => { + const ajv = new Ajv({allErrors: true}); + addFormats(ajv); + validate = ajv.compile(JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'))); + }); + + beforeEach(() => { + process.env.NETWORK = 'testnet'; + }); + + after(() => { + if (originalNetwork === undefined) { + delete process.env.NETWORK; + } else { + process.env.NETWORK = originalNetwork; + } + }); + + const expectValid = (event: unknown) => { + if (!validate(event)) { + throw new Error(`event does not match the schema: ${JSON.stringify(validate.errors)}`); + } + }; + + describe('LOCKED', () => { + const context: PeginAtlasEventContext = { + amountInSatoshis: '50000000', + rskRecipient: receiver, + }; + + it('builds swap.created then swap.completed for a LOCKED pegin', () => { + const events = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.LOCKED), context); + + expect(events).to.have.length(2); + expect(events[0].event_type).to.equal(AtlasEventType.SWAP_CREATED); + expect(events[1].event_type).to.equal(AtlasEventType.SWAP_COMPLETED); + expect(events[0].swap_id).to.equal(events[1].swap_id); + }); + + // A peg-in is credited in the same Rootstock transaction the daemon is + // reading, so the destination tx is that one, not the Bitcoin deposit. + it('points destination_tx_hash at the Rootstock transaction, normalized', () => { + const pegin = givenPegin(PeginStatus.LOCKED); + pegin.rskTxId = '0xD2852F38FEDF1915978715B8A0DC0670040AC4E9065989C810A5BF29C1E006FB'; + + const [, completed] = PeginAtlasEventBuilder.build(pegin, context); + + expect((completed.data as SwapCompletedData).destination_tx_hash) + .to.equal('0xd2852f38fedf1915978715b8a0dc0670040ac4e9065989c810a5bf29c1e006fb'); + }); + + it('reports the whole amount as output_amount and a zero fee', () => { + const [created, completed] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.LOCKED), + context, + ); + const completedData = completed.data as SwapCompletedData; + + // The Bridge credits the full amount that was sent; there is no peg-in + // fee to subtract. + expect(completedData.output_amount).to.equal('0.50000000'); + expect(completedData.output_amount).to.equal((created.data as SwapCreatedData).input_amount); + expect(completedData.fee).to.equal('0.00000000'); + expect(completedData.output_amount_usd).to.be.null(); + }); + + // The daemon only sees Rootstock: when the deposit was broadcast on Bitcoin + // is unknown, and a zero would drag the average duration down. + it('leaves duration_ms null: the Bitcoin broadcast time is unknown', () => { + const [, completed] = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.LOCKED), context); + + expect((completed.data as SwapCompletedData).duration_ms).to.be.null(); + }); + + it('gives the two events distinct event_ids', () => { + const [created, completed] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.LOCKED), + context, + ); + + expect(created.event_id).to.not.equal(completed.event_id); + }); + + it('fills the envelope from the persisted status', () => { + const [event] = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.LOCKED), context); + + expect(event.swap_id).to.equal(btcTxId); + expect(event.swap_type).to.equal(ATLAS_SWAP_TYPE); + expect(event.source).to.equal(ATLAS_SOURCE); + expect(event.schema_version).to.equal(ATLAS_SCHEMA_VERSION); + expect(event.emitted_at).to.equal('2024-05-01T10:00:00.000Z'); + }); + + it('points the chains from Bitcoin to Rootstock', () => { + const [event] = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.LOCKED), context); + const data = event.data as SwapCreatedData; + + expect(data.source_chain).to.equal('bitcoin_testnet'); + expect(data.destination_chain).to.equal('rootstock_testnet'); + expect(data.input_asset).to.equal('BTC'); + expect(data.output_asset).to.equal('RBTC'); + }); + + it('uses mainnet chain ids when NETWORK is mainnet', () => { + process.env.NETWORK = 'mainnet'; + const [event] = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.LOCKED), context); + const data = event.data as SwapCreatedData; + + expect(data.source_chain).to.equal('bitcoin_mainnet'); + expect(data.destination_chain).to.equal('rootstock_mainnet'); + }); + + it('leaves every *_usd field, wallet_type and quote_id null', () => { + const [event] = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.LOCKED), context); + const data = event.data as SwapCreatedData; + + expect(data.input_amount_usd).to.be.null(); + expect(data.wallet_type).to.be.null(); + expect(data.quote_id).to.be.null(); + }); + + it('validates both against the JSON Schema', () => { + const events = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.LOCKED), context); + + expect(events).to.have.length(2); + events.forEach(expectValid); + }); + }); + + describe('amount conversion', () => { + const amountOf = (amountInSatoshis?: string) => { + const [event] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.LOCKED), + {amountInSatoshis, rskRecipient: receiver}, + ); + return (event.data as SwapCreatedData).input_amount; + }; + + // `pegin_btc` / `lock_btc` report satoshis, unlike the peg-out logs which + // report weis. Verified on testnet: block 7140002 logged amount=50000000 + // and credited the receiver 0.5 RBTC. + it('reads the peg-in amount as satoshis, not weis', () => { + expect(amountOf('50000000')).to.equal('0.50000000'); + }); + + it('renders a single satoshi', () => { + expect(amountOf('1')).to.equal('0.00000001'); + }); + + it('renders a whole unit without losing precision', () => { + expect(amountOf('100000000')).to.equal('1.00000000'); + }); + + it('renders a missing amount as zero', () => { + expect(amountOf(undefined)).to.equal('0.00000000'); + }); + + it('does not collapse a realistic peg-in to zero', () => { + for (const satoshis of ['50000000', '500000', '510000']) { + expect(amountOf(satoshis)).to.not.equal('0.00000000'); + } + }); + }); + + describe('rejections', () => { + it('emits swap.created before swap.rejected', () => { + const events = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.REJECTED_REFUND), {}); + + expect(events).to.have.length(2); + expect(events[0].event_type).to.equal(AtlasEventType.SWAP_CREATED); + expect(events[1].event_type).to.equal(AtlasEventType.SWAP_REJECTED); + expect(events[0].swap_id).to.equal(events[1].swap_id); + }); + + it('marks a refundable rejection as such', () => { + const [, rejected] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_REFUND), + {rejectedReason: '3'}, + ); + const data = rejected.data as SwapRejectedData; + + expect(data.refund_applicable).to.be.true(); + expect(data.error_category).to.equal('protocol_violation'); + expect(data.error_code).to.equal('LEGACY_PEGIN_UNDETERMINED_SENDER'); + }); + + it('marks an unrefundable rejection as terminal', () => { + const [, rejected] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + {rejectedReason: '5', unrefundableReason: '3'}, + ); + const data = rejected.data as SwapRejectedData; + + expect(data.refund_applicable).to.be.false(); + expect(data.error_category).to.equal('validation'); + expect(data.error_code).to.equal('INVALID_AMOUNT'); + }); + + it('falls back to UNKNOWN when the Bridge reason is absent', () => { + const [, refundable] = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.REJECTED_REFUND), {}); + const [, terminal] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + {unrefundableReason: '1'}, + ); + + expect((refundable.data as SwapRejectedData).error_code).to.equal('UNKNOWN'); + expect((refundable.data as SwapRejectedData).error_category).to.equal('validation'); + expect((terminal.data as SwapRejectedData).error_code).to.equal('UNKNOWN'); + }); + + it('uses the rejected_pegin reason as the error_code in both branches', () => { + const [, refundable] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_REFUND), + {rejectedReason: '4'}, + ); + // The unrefundable reason names a different enum value for the same + // number; the code must still come from rejected_pegin, the root cause. + const [, terminal] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + {rejectedReason: '4', unrefundableReason: '3'}, + ); + + expect((refundable.data as SwapRejectedData).error_code).to.equal('PEGIN_V1_INVALID_PAYLOAD'); + expect((terminal.data as SwapRejectedData).error_code).to.equal('PEGIN_V1_INVALID_PAYLOAD'); + }); + + it('keeps both raw reason numbers in the error_message', () => { + const [, rejected] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + {rejectedReason: '5', unrefundableReason: '3'}, + ); + const {error_message: message} = rejected.data as SwapRejectedData; + + expect(message).to.match(/rejected_pegin reason=5/); + expect(message).to.match(/unrefundable_pegin reason=3/); + }); + + it('names the unrefundable reason in the error_message', () => { + const [, rejected] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + {rejectedReason: '5', unrefundableReason: '2'}, + ); + const {error_message: message} = rejected.data as SwapRejectedData; + + expect(message).to.match(/PEGIN_V1_REFUND_ADDRESS_NOT_SET/); + expect(message).to.match(/not refundable/); + }); + + it('validates every error_code against the schema enum', () => { + for (const rejectedReason of ['1', '2', '3', '4', '5', '6', undefined]) { + PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_REFUND), + {rejectedReason}, + ).forEach(expectValid); + + for (const unrefundableReason of ['1', '2', '3', '4', '9']) { + PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + {rejectedReason, unrefundableReason}, + ).forEach(expectValid); + } + } + }); + + it('travels without amount or wallet, which the rejection logs do not carry', () => { + const [created] = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.REJECTED_REFUND), {}); + const data = created.data as SwapCreatedData; + + expect(data.input_amount).to.equal('0.00000000'); + expect(data.wallet_address).to.be.null(); + }); + + it('validates both events against the JSON Schema', () => { + PeginAtlasEventBuilder.build(givenPegin(PeginStatus.REJECTED_REFUND), {rejectedReason: '3'}) + .forEach(expectValid); + PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + {rejectedReason: '5', unrefundableReason: '3'}, + ).forEach(expectValid); + }); + }); + + it('builds no event for an unknown status', () => { + const pegin = givenPegin('SOMETHING_ELSE' as PeginStatus); + + expect(PeginAtlasEventBuilder.build(pegin, {})).to.be.empty(); + }); + + it('generates a distinct event_id per event', () => { + const first = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.LOCKED), {}); + const second = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.LOCKED), {}); + + expect(first[0].event_id).to.not.equal(second[0].event_id); + }); + + it('throws when NETWORK is not configured instead of guessing the network', () => { + delete process.env.NETWORK; + + expect(() => PeginAtlasEventBuilder.build(givenPegin(PeginStatus.LOCKED), {})).to.throw(/NETWORK/); + }); + + describe('rejection with no refund branch', () => { + // The Bridge logged the rejection and then no refund branch at all: no + // release_requested, no unrefundable_pegin. The code names what was + // observed, not the suspected cause, which the logs do not carry. + it('uses PEGIN_REJECTED_NO_REFUND_BRANCH when the unrefundable reason is absent', () => { + const [, rejected] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + {rejectedReason: '3'}, + ); + const data = rejected.data as SwapRejectedData; + + expect(data.error_code).to.equal('PEGIN_REJECTED_NO_REFUND_BRANCH'); + expect(data.refund_applicable).to.be.false(); + expect(data.error_message).to.match(/no refund branch/i); + expect(data.error_message).to.match(/rejected_pegin reason=3/); + }); + + it('derives the category from the rejected_pegin reason it does have', () => { + const senderViolation = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + {rejectedReason: '2'}, + )[1].data as SwapRejectedData; + const amountRejection = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + {rejectedReason: '5'}, + )[1].data as SwapRejectedData; + + expect(senderViolation.error_category).to.equal('protocol_violation'); + expect(amountRejection.error_category).to.equal('validation'); + expect(senderViolation.error_code).to.equal('PEGIN_REJECTED_NO_REFUND_BRANCH'); + expect(amountRejection.error_code).to.equal('PEGIN_REJECTED_NO_REFUND_BRANCH'); + }); + + it('keeps naming the reason when the unrefundable log is present', () => { + const [, rejected] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + {rejectedReason: '3', unrefundableReason: '1'}, + ); + + expect((rejected.data as SwapRejectedData).error_code) + .to.equal('LEGACY_PEGIN_UNDETERMINED_SENDER'); + }); + + it('validates against the JSON Schema', () => { + for (const rejectedReason of ['1', '2', '3', '4', '5', '7', undefined]) { + PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + {rejectedReason}, + ).forEach(expectValid); + } + }); + }); + + describe('extractContext', () => { + const peginBtc = (amount: string) => ({ + name: 'pegin_btc', + arguments: {receiver, btcTxHash: btcTxId, amount, protocolVersion: '1'}, + }); + + const lockBtc = (amount: string) => ({ + name: 'lock_btc', + arguments: { + receiver, + senderBtcAddress: 'mfWxJ45yp2SFn7UciZyNpvDKrzbhyfKrY8', + btcTxHash: btcTxId, + amount, + }, + }); + + const rejectedPegin = (reason: string) => ({ + name: 'rejected_pegin', + arguments: {btcTxHash: btcTxId, reason}, + }); + + const releaseRequested = (amount: string) => ({ + name: 'release_requested', + arguments: { + rskTxHash: '0xd2852f38fedf1915978715b8a0dc0670040ac4e9065989c810a5bf29c1e006fb', + btcTxHash: btcTxId, + amount, + }, + }); + + const unrefundablePegin = (reason: string) => ({ + name: 'unrefundable_pegin', + arguments: {btcTxHash: btcTxId, reason}, + }); + + // `release_requested.amount` is `computeTotalAmountSent(btcTx)` on the + // Bridge side: what the user sent to the federation, in satoshis. It is the + // only place a refundable rejection reports an amount at all. + it('reads the amount from release_requested when there is no pegin_btc', () => { + const context = PeginAtlasEventBuilder.extractContext( + givenTx([rejectedPegin('4'), releaseRequested('50000000')]), + ); + + expect(context.amountInSatoshis).to.equal('50000000'); + }); + + it('prefers the pegin_btc amount over release_requested when both are present', () => { + const context = PeginAtlasEventBuilder.extractContext( + givenTx([peginBtc('50000000'), releaseRequested('1')]), + ); + + expect(context.amountInSatoshis).to.equal('50000000'); + }); + + it('prefers the lock_btc amount over release_requested when both are present', () => { + const context = PeginAtlasEventBuilder.extractContext( + givenTx([lockBtc('50000000'), releaseRequested('1')]), + ); + + expect(context.amountInSatoshis).to.equal('50000000'); + }); + + it('reads release_requested.amount as satoshis, not weis', () => { + const [created] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_REFUND), + PeginAtlasEventBuilder.extractContext( + givenTx([rejectedPegin('4'), releaseRequested('50000000')]), + ), + ); + + expect((created.data as SwapCreatedData).input_amount).to.equal('0.50000000'); + }); + + it('still reports zero for an unrefundable rejection, which carries no amount', () => { + const context = PeginAtlasEventBuilder.extractContext( + givenTx([rejectedPegin('5'), unrefundablePegin('3')]), + ); + const [created] = PeginAtlasEventBuilder.build( + givenPegin(PeginStatus.REJECTED_NO_REFUND), + context, + ); + + expect(context.amountInSatoshis).to.be.undefined(); + expect((created.data as SwapCreatedData).input_amount).to.equal('0.00000000'); + }); + + it('reports both reasons when the Bridge emitted both logs', () => { + const context = PeginAtlasEventBuilder.extractContext( + givenTx([rejectedPegin('5'), unrefundablePegin('3')]), + ); + + expect(context.rejectedReason).to.equal('5'); + expect(context.unrefundableReason).to.equal('3'); + }); + }); + + describe('identifier normalization', () => { + it('normalizes the swap_id of every event it builds', () => { + const pegin = givenPegin(PeginStatus.REJECTED_REFUND); + pegin.btcTxId = '1F789F91CB5CB6F76B91F19ADCC89233F3447D7228D8798C4E94EF09FD6D8950'; + + const events = PeginAtlasEventBuilder.build(pegin, {rejectedReason: '3'}); + + expect(events).to.have.length(2); + events.forEach(event => { + expect(event.swap_id).to.equal( + '0x1f789f91cb5cb6f76b91f19adcc89233f3447d7228d8798c4e94ef09fd6d8950', + ); + }); + }); + + it('normalizes the Rootstock recipient used as wallet_address', () => { + const [event] = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.LOCKED), { + amountInSatoshis: '50000000', + rskRecipient: '0x2D623170Cb518434af6c02602334610f194818c1', + }); + + expect((event.data as SwapCreatedData).wallet_address) + .to.equal('0x2d623170cb518434af6c02602334610f194818c1'); + }); + + it('leaves a Bitcoin sender address untouched, since base58 is case sensitive', () => { + const [event] = PeginAtlasEventBuilder.build(givenPegin(PeginStatus.LOCKED), { + amountInSatoshis: '50000000', + senderBtcAddress: 'mfWxJ45yp2SFn7UciZyNpvDKrzbhyfKrY8', + }); + + expect((event.data as SwapCreatedData).wallet_address) + .to.equal('mfWxJ45yp2SFn7UciZyNpvDKrzbhyfKrY8'); + }); + }); + +}); diff --git a/src/__tests__/unit/services/atlas/pegout-atlas-event.builder.unit.ts b/src/__tests__/unit/services/atlas/pegout-atlas-event.builder.unit.ts new file mode 100644 index 00000000..87741670 --- /dev/null +++ b/src/__tests__/unit/services/atlas/pegout-atlas-event.builder.unit.ts @@ -0,0 +1,361 @@ +import {expect} from '@loopback/testlab'; +import Ajv, {ValidateFunction} from 'ajv'; +import addFormats from 'ajv-formats'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + ATLAS_SCHEMA_VERSION, + ATLAS_SOURCE, + ATLAS_SWAP_TYPE, + AtlasEvent, + AtlasEventType, + SwapCompletedData, + SwapCreatedData, + SwapPendingData, + SwapRejectedData, +} from '../../../../models/atlas/atlas-event.model'; +import {PegoutAtlasEventBuilder} from '../../../../services/atlas/pegout-atlas-event.builder'; +import { + PegoutStatusDbDataModel, + PegoutStatuses, + RejectedPegoutReason, +} from '../../../../models/rsk/pegout-status-data-model'; + +const SCHEMA_PATH = path.resolve(process.cwd(), 'schemas/atlas-swap-event.schema.json'); + +const originatingRskTxHash = '0x8e0b47b0c60f7e02b41ee1b7d4f0d4e3f9a1c2b3d4e5f60718293a4b5c6d7e8f'; +const rskSenderAddress = '0x40d2878B98A9C5A5b7bc3B2FC0e26dfDefCfe737'; +const btcTxHash = '0d3b1a1c4a3f8e6d5c4b3a29180706f5e4d3c2b1a09f8e7d6c5b4a3928170605'; +const receivedCreatedOn = new Date('2024-05-01T10:00:00.000Z'); + +function givenPegout(data: Partial): PegoutStatusDbDataModel { + const pegout = new PegoutStatusDbDataModel(); + pegout.originatingRskTxHash = originatingRskTxHash; + pegout.rskTxHash = originatingRskTxHash; + pegout.rskSenderAddress = rskSenderAddress; + pegout.createdOn = receivedCreatedOn; + pegout.valueRequestedInSatoshis = 10000000; + return Object.assign(pegout, data); +} + +describe('Service: PegoutAtlasEventBuilder', () => { + const originalNetwork = process.env.NETWORK; + const originalConfirmations = process.env.RSK_PEGOUT_MINIMUM_CONFIRMATIONS; + let validate: ValidateFunction; + + before(() => { + const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8')); + const ajv = new Ajv({allErrors: true, strict: false}); + addFormats(ajv); + validate = ajv.compile(schema); + }); + + beforeEach(() => { + process.env.NETWORK = 'testnet'; + process.env.RSK_PEGOUT_MINIMUM_CONFIRMATIONS = '10'; + }); + + after(() => { + process.env.NETWORK = originalNetwork; + process.env.RSK_PEGOUT_MINIMUM_CONFIRMATIONS = originalConfirmations; + }); + + function expectValidAgainstSchema(event: AtlasEvent | null) { + expect(event).to.not.be.null(); + const valid = validate(event); + expect(validate.errors ?? []).to.be.empty(); + expect(valid).to.be.true(); + } + + describe('swap.created', () => { + const pegout = () => givenPegout({status: PegoutStatuses.RECEIVED}); + + it('builds the full payload from a RECEIVED status', () => { + const event = PegoutAtlasEventBuilder.build(pegout())!; + expect(event.event_type).to.equal(AtlasEventType.SWAP_CREATED); + expect(event.swap_id).to.equal(originatingRskTxHash); + expect(event.swap_type).to.equal(ATLAS_SWAP_TYPE); + expect(event.source).to.equal(ATLAS_SOURCE); + expect(event.schema_version).to.equal(ATLAS_SCHEMA_VERSION); + expect(event.emitted_at).to.equal(receivedCreatedOn.toISOString()); + expect(event.data as SwapCreatedData).to.eql({ + provider: 'powpeg', + source_chain: 'rootstock_testnet', + destination_chain: 'bitcoin_testnet', + input_asset: 'RBTC', + output_asset: 'BTC', + input_amount: '0.10000000', + input_amount_usd: null, + wallet_address: rskSenderAddress.toLowerCase(), + wallet_type: null, + quote_id: null, + }); + }); + + it('uses mainnet chain ids when NETWORK is mainnet', () => { + process.env.NETWORK = 'mainnet'; + const data = PegoutAtlasEventBuilder.build(pegout())!.data as SwapCreatedData; + expect(data.source_chain).to.equal('rootstock_mainnet'); + expect(data.destination_chain).to.equal('bitcoin_mainnet'); + }); + + it('validates against the JSON Schema', () => { + expectValidAgainstSchema(PegoutAtlasEventBuilder.build(pegout())); + }); + }); + + describe('swap.pending', () => { + const pegout = () => givenPegout({ + status: PegoutStatuses.WAITING_FOR_CONFIRMATION, + rskTxHash: `${originatingRskTxHash}_0`, + createdOn: new Date('2024-05-01T10:01:00.000Z'), + }); + + it('reads expected_confirmations from the environment', () => { + process.env.RSK_PEGOUT_MINIMUM_CONFIRMATIONS = '4000'; + const event = PegoutAtlasEventBuilder.build(pegout())!; + expect(event.event_type).to.equal(AtlasEventType.SWAP_PENDING); + expect(event.data as SwapPendingData).to.eql({ + source_tx_hash: originatingRskTxHash, + deposit_address: null, + expected_confirmations: 4000, + }); + }); + + it('falls back to zero confirmations when the variable is unset', () => { + delete process.env.RSK_PEGOUT_MINIMUM_CONFIRMATIONS; + const data = PegoutAtlasEventBuilder.build(pegout())!.data as SwapPendingData; + expect(data.expected_confirmations).to.equal(0); + }); + + // The schema types the field as an integer with minimum 0, so a negative + // value is as invalid as a missing one and is treated the same way. + it('falls back to zero confirmations when the variable is negative', () => { + for (const value of ['-1', '-4000']) { + process.env.RSK_PEGOUT_MINIMUM_CONFIRMATIONS = value; + const event = PegoutAtlasEventBuilder.build(pegout())!; + expect((event.data as SwapPendingData).expected_confirmations).to.equal(0); + expectValidAgainstSchema(event); + } + }); + + it('validates against the JSON Schema', () => { + expectValidAgainstSchema(PegoutAtlasEventBuilder.build(pegout())); + }); + }); + + describe('swap.completed', () => { + const completedOn = new Date(receivedCreatedOn.getTime() + 184000); + const pegout = () => givenPegout({ + status: PegoutStatuses.RELEASE_BTC, + rskTxHash: `${originatingRskTxHash}___0`, + btcTxHash, + createdOn: completedOn, + valueRequestedInSatoshis: 10000000, + valueInSatoshisToBeReceived: 9995000, + }); + + it('computes output_amount, fee and duration_ms', () => { + const event = PegoutAtlasEventBuilder.build(pegout(), {receivedCreatedOn})!; + expect(event.event_type).to.equal(AtlasEventType.SWAP_COMPLETED); + expect(event.data as SwapCompletedData).to.eql({ + destination_tx_hash: btcTxHash, + output_amount: '0.09995000', + output_amount_usd: null, + fee: '0.00005000', + duration_ms: 184000, + }); + }); + + it('leaves duration_ms null when the RECEIVED timestamp is unknown', () => { + const data = PegoutAtlasEventBuilder.build(pegout())!.data as SwapCompletedData; + expect(data.duration_ms).to.be.null(); + }); + + // decimalAmount admits no minus sign, so a received value above the + // requested one would otherwise emit an event Atlas rejects. + it('reports a zero fee when the output pays more than was requested', () => { + const event = PegoutAtlasEventBuilder.build( + givenPegout({ + status: PegoutStatuses.RELEASE_BTC, + rskTxHash: `${originatingRskTxHash}___0`, + btcTxHash, + createdOn: completedOn, + valueRequestedInSatoshis: 9995000, + valueInSatoshisToBeReceived: 10000000, + }), + {receivedCreatedOn}, + )!; + + const data = event.data as SwapCompletedData; + expect(data.fee).to.equal('0.00000000'); + expect(data.output_amount).to.equal('0.10000000'); + expectValidAgainstSchema(event); + }); + + it('validates against the JSON Schema', () => { + expectValidAgainstSchema(PegoutAtlasEventBuilder.build(pegout(), {receivedCreatedOn})); + }); + }); + + describe('swap.rejected', () => { + const reasons: RejectedPegoutReason[] = ['LOW_AMOUNT', 'CALLER_CONTRACT', 'FEE_ABOVE_VALUE']; + + reasons.forEach(reason => { + it(`maps the ${reason} rejection reason`, () => { + const event = PegoutAtlasEventBuilder.build( + givenPegout({status: PegoutStatuses.REJECTED, reason}), + )!; + expect(event.event_type).to.equal(AtlasEventType.SWAP_REJECTED); + expect(event.data as SwapRejectedData).to.eql({ + error_category: 'validation', + error_code: reason, + error_message: 'Pegout request rejected by the Bridge', + refund_applicable: false, + }); + expectValidAgainstSchema(event); + }); + }); + + it('falls back to UNKNOWN when the Bridge reason is not recognized', () => { + const event = PegoutAtlasEventBuilder.build( + givenPegout({status: PegoutStatuses.REJECTED}), + )!; + expect((event.data as SwapRejectedData).error_code).to.equal('UNKNOWN'); + expectValidAgainstSchema(event); + }); + }); + + describe('out of scope statuses', () => { + [ + PegoutStatuses.WAITING_FOR_SIGNATURE, + PegoutStatuses.SIGNED, + PegoutStatuses.PENDING, + PegoutStatuses.NOT_FOUND, + PegoutStatuses.NOT_PEGOUT_TX, + ].forEach(status => { + it(`builds no event for ${status}`, () => { + expect(PegoutAtlasEventBuilder.build(givenPegout({status}))).to.be.null(); + }); + }); + }); + + describe('swap_id regression', () => { + const mutatedHashes = [ + {suffix: '_0', status: PegoutStatuses.WAITING_FOR_CONFIRMATION}, + {suffix: '__1', status: PegoutStatuses.WAITING_FOR_SIGNATURE}, + {suffix: '___2', status: PegoutStatuses.RELEASE_BTC}, + ]; + + mutatedHashes.forEach(({suffix, status}) => { + it(`keeps swap_id as originatingRskTxHash when rskTxHash ends in ${suffix}`, () => { + const pegout = givenPegout({ + status, + rskTxHash: `${originatingRskTxHash}${suffix}`, + btcTxHash, + valueInSatoshisToBeReceived: 9995000, + }); + const event = PegoutAtlasEventBuilder.build(pegout); + if (event === null) { + // WAITING_FOR_SIGNATURE is deliberately out of scope. + expect(status).to.equal(PegoutStatuses.WAITING_FOR_SIGNATURE); + return; + } + expect(event.swap_id).to.equal(originatingRskTxHash); + expect(event.swap_id).to.not.containEql(suffix); + }); + }); + }); + + describe('amount conversion', () => { + it('renders 12345678 satoshis with eight decimals', () => { + expect(PegoutAtlasEventBuilder.toDecimalAmount(12345678)).to.equal('0.12345678'); + }); + + it('renders zero with eight decimals', () => { + expect(PegoutAtlasEventBuilder.toDecimalAmount(0)).to.equal('0.00000000'); + }); + + it('renders a single satoshi without losing precision', () => { + expect(PegoutAtlasEventBuilder.toDecimalAmount(1)).to.equal('0.00000001'); + }); + + it('renders large values without losing precision', () => { + expect(PegoutAtlasEventBuilder.toDecimalAmount(2100000000000000)).to.equal('21000000.00000000'); + expect(PegoutAtlasEventBuilder.toDecimalAmount(999999999999999)).to.equal('9999999.99999999'); + }); + + it('treats a missing amount as zero', () => { + expect(PegoutAtlasEventBuilder.toDecimalAmount(undefined)).to.equal('0.00000000'); + }); + }); + + describe('usd fields', () => { + it('leaves every *_usd field null', () => { + const events = [ + PegoutAtlasEventBuilder.build(givenPegout({status: PegoutStatuses.RECEIVED}))!, + PegoutAtlasEventBuilder.build(givenPegout({ + status: PegoutStatuses.RELEASE_BTC, + btcTxHash, + valueInSatoshisToBeReceived: 9995000, + }))!, + ]; + for (const event of events) { + const data = event.data as unknown as Record; + for (const key of Object.keys(data).filter(k => k.endsWith('_usd'))) { + expect(data[key]).to.be.null(); + } + } + }); + }); + + describe('identifier normalization', () => { + it('normalizes the swap_id of every event it builds', () => { + const statuses = [ + PegoutStatuses.RECEIVED, + PegoutStatuses.WAITING_FOR_CONFIRMATION, + PegoutStatuses.RELEASE_BTC, + PegoutStatuses.REJECTED, + ]; + + for (const status of statuses) { + const event = PegoutAtlasEventBuilder.build(givenPegout({ + status, + originatingRskTxHash: originatingRskTxHash.toUpperCase().replace('0X', ''), + btcTxHash, + valueInSatoshisToBeReceived: 9995000, + }))!; + + expect(event.swap_id).to.equal(originatingRskTxHash); + } + }); + + it('normalizes the rskSenderAddress used as wallet_address', () => { + const event = PegoutAtlasEventBuilder.build(givenPegout({ + status: PegoutStatuses.RECEIVED, + rskSenderAddress: '0x40d2878B98A9C5A5b7bc3B2FC0e26dfDefCfe737', + }))!; + + expect((event.data as SwapCreatedData).wallet_address) + .to.equal('0x40d2878b98a9c5a5b7bc3b2fc0e26dfdefcfe737'); + }); + + // A Bitcoin txid is not 0x-prefixed hex and base58/hex case is not ours to + // change: destination_tx_hash travels exactly as the Bridge reported it. + it('leaves the Bitcoin destination_tx_hash untouched', () => { + const event = PegoutAtlasEventBuilder.build(givenPegout({ + status: PegoutStatuses.RELEASE_BTC, + btcTxHash, + valueInSatoshisToBeReceived: 9995000, + }))!; + + expect((event.data as SwapCompletedData).destination_tx_hash).to.equal(btcTxHash); + }); + }); + + it('generates a distinct event_id per event', () => { + const first = PegoutAtlasEventBuilder.build(givenPegout({status: PegoutStatuses.RECEIVED}))!; + const second = PegoutAtlasEventBuilder.build(givenPegout({status: PegoutStatuses.RECEIVED}))!; + expect(first.event_id).to.not.equal(second.event_id); + }); +}); diff --git a/src/__tests__/unit/services/atlas/sqs-atlas-event-publisher.unit.ts b/src/__tests__/unit/services/atlas/sqs-atlas-event-publisher.unit.ts new file mode 100644 index 00000000..5d17dd52 --- /dev/null +++ b/src/__tests__/unit/services/atlas/sqs-atlas-event-publisher.unit.ts @@ -0,0 +1,283 @@ +import {Application} from '@loopback/core'; +import {expect, sinon} from '@loopback/testlab'; +import {SQSClient} from '@aws-sdk/client-sqs'; +import { + ATLAS_SCHEMA_VERSION, + ATLAS_SOURCE, + ATLAS_SWAP_TYPE, + AtlasEvent, + AtlasEventType, +} from '../../../../models/atlas/atlas-event.model'; +import {isAtlasEventsEnabled} from '../../../../services/atlas/atlas-event-publisher'; +import { + SqsAtlasEventPublisher, + assertQueueUrlConfigured, +} from '../../../../services/atlas/sqs-atlas-event-publisher'; +import {NoopAtlasEventPublisher} from '../../../../services/atlas/noop-atlas-event-publisher'; +import {DependencyInjectionHandler} from '../../../../dependency-injection-handler'; +import {ConstantsBindings, ServicesBindings} from '../../../../dependency-injection-bindings'; + +const sandbox = sinon.createSandbox(); + +const QUEUE_URL = 'http://localhost:4566/000000000000/atlas-swap-events.fifo'; + +const event: AtlasEvent = { + event_id: '2b0a2f8c-5a4a-4a6f-8a4c-6d1b2f3a4c5d', + event_type: AtlasEventType.SWAP_CREATED, + swap_id: '0x8e0b47b0c60f7e02b41ee1b7d4f0d4e3f9a1c2b3d4e5f60718293a4b5c6d7e8f', + swap_type: ATLAS_SWAP_TYPE, + source: ATLAS_SOURCE, + schema_version: ATLAS_SCHEMA_VERSION, + emitted_at: '2024-05-01T10:00:00.000Z', + data: { + provider: 'powpeg', + source_chain: 'rootstock_testnet', + destination_chain: 'bitcoin_testnet', + input_asset: 'RBTC', + output_asset: 'BTC', + input_amount: '0.10000000', + input_amount_usd: null, + wallet_address: '0x40d2878B98A9C5A5b7bc3B2FC0e26dfDefCfe737', + wallet_type: null, + quote_id: null, + }, +}; + +describe('Service: SqsAtlasEventPublisher', () => { + const originalEnv = { + queueUrl: process.env.ATLAS_SQS_QUEUE_URL, + endpoint: process.env.ATLAS_SQS_ENDPOINT, + region: process.env.AWS_REGION, + enabled: process.env.ATLAS_EVENTS_ENABLED, + }; + + beforeEach(() => { + process.env.ATLAS_SQS_QUEUE_URL = QUEUE_URL; + process.env.ATLAS_SQS_ENDPOINT = 'http://localhost:4566'; + process.env.AWS_REGION = 'us-east-1'; + }); + + afterEach(() => { + sandbox.restore(); + restore('ATLAS_SQS_QUEUE_URL', originalEnv.queueUrl); + restore('ATLAS_SQS_ENDPOINT', originalEnv.endpoint); + restore('AWS_REGION', originalEnv.region); + restore('ATLAS_EVENTS_ENABLED', originalEnv.enabled); + }); + + function restore(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + + it('sends the event to the configured queue as a FIFO message', async () => { + const send = sandbox.stub(SQSClient.prototype, 'send').resolves({MessageId: 'id'} as never); + const publisher = new SqsAtlasEventPublisher(); + + await publisher.publish(event); + + sinon.assert.calledOnce(send); + const {input} = send.firstCall.args[0] as unknown as {input: Record}; + expect(input.QueueUrl).to.equal(QUEUE_URL); + expect(input.MessageGroupId).to.equal(event.swap_id); + expect(input.MessageDeduplicationId).to.equal(event.event_id); + }); + + it('sends a parseable body that preserves the whole envelope', async () => { + const send = sandbox.stub(SQSClient.prototype, 'send').resolves({MessageId: 'id'} as never); + const publisher = new SqsAtlasEventPublisher(); + + await publisher.publish(event); + + const {input} = send.firstCall.args[0] as unknown as {input: Record}; + expect(JSON.parse(input.MessageBody)).to.eql(event); + }); + + it('resolves and does not propagate when SQS rejects', async () => { + sandbox.stub(SQSClient.prototype, 'send').rejects(new Error('queue unavailable')); + const publisher = new SqsAtlasEventPublisher(); + + await publisher.publish(event); + }); + + describe('publication metric', () => { + it('records a success when SQS accepts the message', async () => { + sandbox.stub(SQSClient.prototype, 'send').resolves({MessageId: 'id'} as never); + const publisher = new SqsAtlasEventPublisher(); + + await publisher.publish(event, 'pegout'); + + expect(publisher.metrics.total('success', event.event_type, 'pegout')).to.equal(1); + expect(publisher.metrics.total('failure', event.event_type, 'pegout')).to.equal(0); + publisher.destroy(); + }); + + it('records a failure when SQS rejects it, and still does not throw', async () => { + sandbox.stub(SQSClient.prototype, 'send').rejects(new Error('queue unavailable')); + const publisher = new SqsAtlasEventPublisher(); + + await publisher.publish(event, 'pegin'); + + expect(publisher.metrics.total('failure', event.event_type, 'pegin')).to.equal(1); + expect(publisher.metrics.total('success', event.event_type, 'pegin')).to.equal(0); + publisher.destroy(); + }); + + // Counters are keyed by flow and event type, so a peg-in rejection must not + // land in the peg-out bucket, nor be confused with a swap.created. + it('counts a peg-in rejection under its own flow and event type', async () => { + sandbox.stub(SQSClient.prototype, 'send').resolves({MessageId: 'id'} as never); + const publisher = new SqsAtlasEventPublisher(); + const rejection = { + ...event, + event_id: '7c1f0f4e-2b3a-4d5c-8e6f-9a0b1c2d3e4f', + event_type: AtlasEventType.SWAP_REJECTED, + data: { + error_category: 'validation', + error_code: 'PEGIN_V1_INVALID_PAYLOAD', + error_message: 'Peg-in rejected by the Bridge: PEGIN_V1_INVALID_PAYLOAD', + refund_applicable: true, + }, + } as AtlasEvent; + + await publisher.publish(rejection, 'pegin'); + + expect(publisher.metrics.total('success', AtlasEventType.SWAP_REJECTED, 'pegin')).to.equal(1); + expect(publisher.metrics.total('success', AtlasEventType.SWAP_REJECTED, 'pegout')).to.equal(0); + expect(publisher.metrics.total('success', AtlasEventType.SWAP_CREATED, 'pegin')).to.equal(0); + publisher.destroy(); + }); + + it('records a failed peg-in rejection as a loss, not a success', async () => { + sandbox.stub(SQSClient.prototype, 'send').rejects(new Error('queue unavailable')); + const publisher = new SqsAtlasEventPublisher(); + const rejection = {...event, event_type: AtlasEventType.SWAP_REJECTED} as AtlasEvent; + + await publisher.publish(rejection, 'pegin'); + + expect(publisher.metrics.total('failure', AtlasEventType.SWAP_REJECTED, 'pegin')).to.equal(1); + expect(publisher.metrics.total('success', AtlasEventType.SWAP_REJECTED, 'pegin')).to.equal(0); + publisher.destroy(); + }); + + // A metric named published_total must not count events that were never + // published: with the flag off nothing reaches the queue and nothing counts. + it('counts nothing when the Noop publisher discards the event', async () => { + const publisher = new NoopAtlasEventPublisher(); + + await publisher.publish(event, 'pegin'); + + expect(publisher.metrics.total('success', event.event_type, 'pegin')).to.equal(0); + expect(publisher.metrics.total('failure', event.event_type, 'pegin')).to.equal(0); + }); + }); + + it('does not touch SQS when the Noop publisher is used', async () => { + const send = sandbox.stub(SQSClient.prototype, 'send').resolves({} as never); + + await new NoopAtlasEventPublisher().publish(event); + + sinon.assert.notCalled(send); + }); + + describe('feature flag', () => { + it('is off unless ATLAS_EVENTS_ENABLED is exactly "true"', () => { + for (const value of ['false', 'TRUE', '1', 'yes', '']) { + process.env.ATLAS_EVENTS_ENABLED = value; + expect(isAtlasEventsEnabled()).to.be.false(); + } + delete process.env.ATLAS_EVENTS_ENABLED; + expect(isAtlasEventsEnabled()).to.be.false(); + process.env.ATLAS_EVENTS_ENABLED = 'true'; + expect(isAtlasEventsEnabled()).to.be.true(); + }); + + it('binds the Noop publisher when disabled', async () => { + process.env.ATLAS_EVENTS_ENABLED = 'false'; + const send = sandbox.stub(SQSClient.prototype, 'send').resolves({} as never); + const app = new Application(); + DependencyInjectionHandler.configureDaemonDependencies(app); + + expect(await app.get(ConstantsBindings.ATLAS_EVENTS_ENABLED)).to.be.false(); + const publisher = await app.get(ServicesBindings.ATLAS_EVENT_PUBLISHER); + expect(publisher).to.be.instanceOf(NoopAtlasEventPublisher); + + await publisher.publish(event); + sinon.assert.notCalled(send); + }); + + it('binds the SQS publisher when enabled', async () => { + process.env.ATLAS_EVENTS_ENABLED = 'true'; + const app = new Application(); + DependencyInjectionHandler.configureDaemonDependencies(app); + + expect(await app.get(ConstantsBindings.ATLAS_EVENTS_ENABLED)).to.be.true(); + const publisher = await app.get(ServicesBindings.ATLAS_EVENT_PUBLISHER); + expect(publisher).to.be.instanceOf(SqsAtlasEventPublisher); + publisher.destroy(); + }); + }); + + // An empty queue url does not disable publication, it breaks it: every send + // would fail, be swallowed by publish(), and the events lost with no retry. + // A daemon with the switch on and no queue is misconfigured, so it aborts. + describe('queue url configuration', () => { + it('throws when ATLAS_SQS_QUEUE_URL is unset', () => { + delete process.env.ATLAS_SQS_QUEUE_URL; + + expect(() => new SqsAtlasEventPublisher()).to.throw(/ATLAS_SQS_QUEUE_URL is not set/); + }); + + it('throws when ATLAS_SQS_QUEUE_URL is empty or blank', () => { + for (const value of ['', ' ', '\t']) { + process.env.ATLAS_SQS_QUEUE_URL = value; + expect(() => new SqsAtlasEventPublisher()).to.throw(/ATLAS_SQS_QUEUE_URL is not set/); + } + }); + + it('returns the trimmed url when it is configured', () => { + process.env.ATLAS_SQS_QUEUE_URL = ` ${QUEUE_URL} `; + + expect(assertQueueUrlConfigured()).to.equal(QUEUE_URL); + }); + + // The failure has to surface where the daemon starts, not on the first + // peg-out hours later. + it('fails the daemon binding when enabled without a queue url', () => { + process.env.ATLAS_EVENTS_ENABLED = 'true'; + delete process.env.ATLAS_SQS_QUEUE_URL; + const app = new Application(); + DependencyInjectionHandler.configureDaemonDependencies(app); + + return expect( + app.get(ServicesBindings.ATLAS_EVENT_PUBLISHER), + ).to.be.rejectedWith(/ATLAS_SQS_QUEUE_URL is not set/); + }); + + // With the switch off no queue is needed, so a missing url must not stop + // the daemon from booting. + it('does not require a queue url while the switch is off', async () => { + process.env.ATLAS_EVENTS_ENABLED = 'false'; + delete process.env.ATLAS_SQS_QUEUE_URL; + const app = new Application(); + DependencyInjectionHandler.configureDaemonDependencies(app); + + const publisher = await app.get(ServicesBindings.ATLAS_EVENT_PUBLISHER); + expect(publisher).to.be.instanceOf(NoopAtlasEventPublisher); + }); + }); + + // publish() is documented never to reject. With the feature off the logger is + // the only thing that can fail, and it must not take peg processing down. + it('resolves even if the logger throws while discarding the event', async () => { + const publisher = new NoopAtlasEventPublisher(); + sandbox + .stub(publisher['logger'], 'debug') + .throws(new Error('log pipeline unavailable')); + + await publisher.publish(event, 'pegin'); + }); +}); diff --git a/src/__tests__/unit/services/daemon.service.unit.ts b/src/__tests__/unit/services/daemon.service.unit.ts index a1bb9d86..ff762869 100644 --- a/src/__tests__/unit/services/daemon.service.unit.ts +++ b/src/__tests__/unit/services/daemon.service.unit.ts @@ -9,6 +9,7 @@ import {BridgeService} from '../../../services/bridge.service'; import {PeginStatusMongoDbDataService} from '../../../services/pegin-status-data-services/pegin-status-mongo.service'; import {PeginDataProcessor} from '../../../services/pegin-data.processor'; import {PegoutDataProcessor} from '../../../services/pegout-data.processor'; +import { NoopAtlasEventPublisher } from '../../../services/atlas/noop-atlas-event-publisher'; import RskBlockProcessorPublisher from '../../../services/rsk-block-processor-publisher'; import {RskChainSyncService, RskChainSyncSubscriber} from '../../../services/rsk-chain-sync.service'; import {getRandomHash} from '../../helper'; @@ -39,8 +40,8 @@ describe('Service: DaemonService', () => { mockedPeginStatusDataService, mockedRskSyncChainService, "0", - new PeginDataProcessor(mockedPeginStatusDataService), - new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService) + new PeginDataProcessor(mockedPeginStatusDataService, new NoopAtlasEventPublisher()), + new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService, new NoopAtlasEventPublisher()) ); await daemonService.start(); @@ -71,8 +72,8 @@ describe('Service: DaemonService', () => { mockedPeginStatusDataService, mockedRskSyncChainService, "0", - new PeginDataProcessor(mockedPeginStatusDataService), - new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService) + new PeginDataProcessor(mockedPeginStatusDataService, new NoopAtlasEventPublisher()), + new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService, new NoopAtlasEventPublisher()) ); clock.tick(1); @@ -135,8 +136,8 @@ describe('Service: DaemonService', () => { mockedPeginStatusDataService, mockedRskSyncChainService, "0", - new PeginDataProcessor(mockedPeginStatusDataService), - new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService) + new PeginDataProcessor(mockedPeginStatusDataService, new NoopAtlasEventPublisher()), + new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService, new NoopAtlasEventPublisher()) ); await daemonService.start(); diff --git a/src/__tests__/unit/services/node-bridge-data.provider.unit.ts b/src/__tests__/unit/services/node-bridge-data.provider.unit.ts index 11f2f299..ccde4d87 100644 --- a/src/__tests__/unit/services/node-bridge-data.provider.unit.ts +++ b/src/__tests__/unit/services/node-bridge-data.provider.unit.ts @@ -7,6 +7,7 @@ import ExtendedBridgeTx from '../../../services/extended-bridge-tx' import FilteredBridgeTransactionProcessor from '../../../services/filtered-bridge-transaction-processor'; import {BRIDGE_METHODS, getBridgeSignature} from '../../../utils/bridge-utils'; import { PeginDataProcessor } from '../../../services/pegin-data.processor'; +import { NoopAtlasEventPublisher } from '../../../services/atlas/noop-atlas-event-publisher'; import { RskBlock } from '../../../models/rsk/rsk-block.model'; import { RskTransaction } from '../../../models/rsk/rsk-transaction.model'; import { PeginStatusDataService } from '../../../services/pegin-status-data-services/pegin-status-data.service'; @@ -50,7 +51,7 @@ describe('Service: NodeBridgeDataProvider', () => { mockedPeginStatusDataService.stop = sinon.stub(); const bridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; const thisService = new NodeBridgeDataProvider(bridgeService); - const peginDataProcessorSubscriber = new PeginDataProcessor(mockedPeginStatusDataService) as FilteredBridgeTransactionProcessor; + const peginDataProcessorSubscriber = new PeginDataProcessor(mockedPeginStatusDataService, new NoopAtlasEventPublisher()) as FilteredBridgeTransactionProcessor; expect(thisService.getSubscribers()).to.be.empty; // Adds a subscriber @@ -71,7 +72,7 @@ describe('Service: NodeBridgeDataProvider', () => { mockedPeginStatusDataService.stop = sinon.stub(); const bridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; const thisService = new NodeBridgeDataProvider(bridgeService); - const peginDataProcessorSubscriber = new PeginDataProcessor(mockedPeginStatusDataService) as FilteredBridgeTransactionProcessor; + const peginDataProcessorSubscriber = new PeginDataProcessor(mockedPeginStatusDataService, new NoopAtlasEventPublisher()) as FilteredBridgeTransactionProcessor; expect(thisService.getSubscribers()).to.be.empty; // Adds the same subscriber multiple times @@ -99,8 +100,8 @@ describe('Service: NodeBridgeDataProvider', () => { const bridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; const thisService = new NodeBridgeDataProvider(bridgeService); - const peginDataProcessorSubscriber1 = new PeginDataProcessor(mockedPeginStatusDataService) as FilteredBridgeTransactionProcessor; - const peginDataProcessorSubscriber2 = new PeginDataProcessor(mockedPeginStatusDataService) as FilteredBridgeTransactionProcessor; + const peginDataProcessorSubscriber1 = new PeginDataProcessor(mockedPeginStatusDataService, new NoopAtlasEventPublisher()) as FilteredBridgeTransactionProcessor; + const peginDataProcessorSubscriber2 = new PeginDataProcessor(mockedPeginStatusDataService, new NoopAtlasEventPublisher()) as FilteredBridgeTransactionProcessor; expect(thisService.getSubscribers()).to.be.empty; diff --git a/src/__tests__/unit/services/pegin-data.processor.unit.ts b/src/__tests__/unit/services/pegin-data.processor.unit.ts index 3a710038..8ac92a61 100644 --- a/src/__tests__/unit/services/pegin-data.processor.unit.ts +++ b/src/__tests__/unit/services/pegin-data.processor.unit.ts @@ -8,6 +8,17 @@ import ExtendedBridgeTx from '../../../services/extended-bridge-tx'; import {Transaction} from '@rsksmart/bridge-transaction-parser'; import {bridge} from '@rsksmart/rsk-precompiled-abis'; import {ExtendedBridgeEvent} from "../../../models/types/bridge-transaction-parser"; +import {AtlasEventPublisher} from '../../../services/atlas/atlas-event-publisher'; +import {AtlasEventMetrics} from '../../../services/atlas/atlas-event-metrics'; +import {AtlasEvent, AtlasEventType, SwapCreatedData, SwapRejectedData} from '../../../models/atlas/atlas-event.model'; + +type StubbedAtlasEventPublisher = AtlasEventPublisher & {publish: sinon.SinonStub}; + +const givenAtlasEventPublisher = (): StubbedAtlasEventPublisher => + ({publish: sinon.stub().resolves(), metrics: new AtlasEventMetrics()}); + +const publishedEvents = (publisher: StubbedAtlasEventPublisher): AtlasEvent[] => + publisher.publish.getCalls().map(call => call.args[0] as AtlasEvent); const btcTxHash = '0x1f789f91cb5cb6f76b91f19adcc89233f3447d7228d8798c4e94ef09fd6d8950'; const rskTxHash = '0xd2852f38fedf1915978715b8a0dc0670040ac4e9065989c810a5bf29c1e006fb'; @@ -109,7 +120,7 @@ describe('Service: PeginDataProcessor', () => { mockedPeginStatusDataService.start = sinon.stub(); mockedPeginStatusDataService.stop = sinon.stub(); const extendedBridgeTx: ExtendedBridgeTx = {}; - const thisService = new PeginDataProcessor(mockedPeginStatusDataService); + const thisService = new PeginDataProcessor(mockedPeginStatusDataService, givenAtlasEventPublisher()); const result = thisService.parse(extendedBridgeTx); expect(result).to.be.null; }); @@ -119,7 +130,7 @@ describe('Service: PeginDataProcessor', () => { mockedPeginStatusDataService.start = sinon.stub(); mockedPeginStatusDataService.stop = sinon.stub(); const extendedBridgeTx: ExtendedBridgeTx = {events: [{name: 'random'}]}; - const thisService = new PeginDataProcessor(mockedPeginStatusDataService); + const thisService = new PeginDataProcessor(mockedPeginStatusDataService, givenAtlasEventPublisher()); const result = thisService.parse(extendedBridgeTx); expect(result).to.be.null; }); @@ -154,7 +165,7 @@ describe('Service: PeginDataProcessor', () => { events: bridgeTransaction.events }; - const thisService = new PeginDataProcessor(mockedPeginStatusDataService); + const thisService = new PeginDataProcessor(mockedPeginStatusDataService, givenAtlasEventPublisher()); const result = thisService.parse(extendedBridgeTx); expect(result).to.be.instanceOf(PeginStatusDataModel); @@ -197,7 +208,7 @@ describe('Service: PeginDataProcessor', () => { events: bridgeTransaction.events }; - const thisService = new PeginDataProcessor(mockedPeginStatusDataService); + const thisService = new PeginDataProcessor(mockedPeginStatusDataService, givenAtlasEventPublisher()); const result = thisService.parse(extendedBridgeTx); expect(result).to.be.instanceOf(PeginStatusDataModel); @@ -210,7 +221,7 @@ describe('Service: PeginDataProcessor', () => { } }); - it('parses a transaction with just a REJECTED_PEGIN log as null (should never happen :))', () => { + it('parses a rejected_pegin with neither companion log as REJECTED_NO_REFUND', () => { const mockedPeginStatusDataService = {}; mockedPeginStatusDataService.start = sinon.stub(); mockedPeginStatusDataService.stop = sinon.stub(); @@ -240,10 +251,16 @@ describe('Service: PeginDataProcessor', () => { events: bridgeTransaction.events }; - const thisService = new PeginDataProcessor(mockedPeginStatusDataService); + const thisService = new PeginDataProcessor(mockedPeginStatusDataService, givenAtlasEventPublisher()); const result = thisService.parse(extendedBridgeTx); - expect(result).to.be.null; + // The Bridge rejected the peg-in and emitted no refund branch at all. The + // user's funds are not coming back, so the honest status is the same one a + // declared unrefundable pegin gets, rather than no status at all. + expect(result).to.not.be.null(); + expect(result!.status).to.equal(PeginStatus.REJECTED_NO_REFUND); + expect(result!.btcTxId).to.equal(btcTxHash); + expect(result!.rskTxId).to.equal(rskTxHash); }); it('parses a transaction with REJECTED_PEGIN and RELEASE_REQUESTED event logs as a rejected pegin with refund', () => { @@ -276,7 +293,7 @@ describe('Service: PeginDataProcessor', () => { events: bridgeTransaction.events }; - const thisService = new PeginDataProcessor(mockedPeginStatusDataService); + const thisService = new PeginDataProcessor(mockedPeginStatusDataService, givenAtlasEventPublisher()); const result = thisService.parse(extendedBridgeTx); expect(result).to.be.instanceOf(PeginStatusDataModel); @@ -317,7 +334,7 @@ describe('Service: PeginDataProcessor', () => { events: bridgeTransaction.events }; - const thisService = new PeginDataProcessor(mockedPeginStatusDataService); + const thisService = new PeginDataProcessor(mockedPeginStatusDataService, givenAtlasEventPublisher()); const result = thisService.parse(extendedBridgeTx); expect(result).to.be.instanceOf(PeginStatusDataModel); @@ -330,7 +347,7 @@ describe('Service: PeginDataProcessor', () => { it('returns filters', () => { const mockedPeginStatusDataService = {}; - const thisService = new PeginDataProcessor(mockedPeginStatusDataService); + const thisService = new PeginDataProcessor(mockedPeginStatusDataService, givenAtlasEventPublisher()); expect(thisService.getFilters()).to.be.Array; expect(thisService.getFilters()).to.not.be.empty; expect(thisService.getFilters().length).to.equal(1); @@ -366,7 +383,7 @@ describe('Service: PeginDataProcessor', () => { events: bridgeTransaction.events }; - const thisService = new PeginDataProcessor(mockedPeginStatusDataService); + const thisService = new PeginDataProcessor(mockedPeginStatusDataService, givenAtlasEventPublisher()); await thisService.process(extendedBridgeTx); sinon.assert.calledOnce(mockedPeginStatusDataService.set); }); @@ -402,7 +419,7 @@ describe('Service: PeginDataProcessor', () => { const foundPegin: PeginStatusDataModel = {}; mockedPeginStatusDataService.getById.resolves(foundPegin); - const thisService = new PeginDataProcessor(mockedPeginStatusDataService); + const thisService = new PeginDataProcessor(mockedPeginStatusDataService, givenAtlasEventPublisher()); await thisService.process(extendedBridgeTx); sinon.assert.neverCalledWith(mockedPeginStatusDataService.set); }); @@ -436,10 +453,347 @@ describe('Service: PeginDataProcessor', () => { events: bridgeTransaction.events }; - const thisService = new PeginDataProcessor(mockedPeginStatusDataService); + const thisService = new PeginDataProcessor(mockedPeginStatusDataService, givenAtlasEventPublisher()); await thisService.process(extendedBridgeTx); sinon.assert.neverCalledWith(mockedPeginStatusDataService.getById); sinon.assert.neverCalledWith(mockedPeginStatusDataService.set); }); + describe('Atlas events', () => { + const originalNetwork = process.env.NETWORK; + const receiver = '0x2D623170Cb518434af6c02602334610f194818c1'; + const senderBtcAddress = 'mfWxJ45yp2SFn7UciZyNpvDKrzbhyfKrY8'; + // 0.5 BTC in satoshis: `pegin_btc` and `lock_btc` report satoshis, unlike + // the peg-out logs which report weis. Verified on testnet at block 7140002, + // where amount=50000000 credited the receiver 0.5 RBTC. + const halfBtcInSatoshis = '50000000'; + + beforeEach(() => { + process.env.NETWORK = 'testnet'; + }); + + after(() => { + if (originalNetwork === undefined) { + delete process.env.NETWORK; + } else { + process.env.NETWORK = originalNetwork; + } + }); + + const givenPeginBtcEvent = (): ExtendedBridgeEvent => ( { + name: 'pegin_btc', + signature: '0x44cdc782a38244afd68336ab92a0b39f864d6c0b2a50fa1da58cafc93cd2ae5a', + arguments: {receiver, btcTxHash, amount: halfBtcInSatoshis, protocolVersion: '1'}, + }); + + const givenLockBtcEvent = (): ExtendedBridgeEvent => ( { + name: 'lock_btc', + signature: '0xec2232bdbe54a92238ce7a6b45d53fb31f919496c6abe1554be1cc8eddb6600a', + arguments: {receiver, senderBtcAddress, btcTxHash, amount: halfBtcInSatoshis}, + }); + + const givenTx = (events: ExtendedBridgeEvent[]): ExtendedBridgeTx => ( { + sender: '0x4495768E683423a4299D6a7f02A0689a6ff5a0A4', + blockTimestamp: 1626736729000, + blockHash, + txHash: rskTxHash, + createdOn: new Date('2024-05-01T10:00:00.000Z'), + blockNumber: 1, + to: bridge.address, + method: { + name: 'registerBtcTransaction', + signature: '0x43dc0656', + arguments: getMockedRegisterBtcTransactionMethodArgs(), + }, + events, + }); + + const givenProcessor = () => { + const dataService = + sinon.createStubInstance(PeginStatusMongoDbDataService) as SinonStubbedInstance; + const publisher = givenAtlasEventPublisher(); + return {dataService, publisher, processor: new PeginDataProcessor(dataService, publisher)}; + }; + + it('publishes exactly two events for a LOCKED pegin, in order', async () => { + const {dataService, publisher, processor} = givenProcessor(); + + await processor.process(givenTx([givenPeginBtcEvent()])); + + sinon.assert.calledTwice(publisher.publish); + const [event, completed] = publishedEvents(publisher); + expect(event.event_type).to.equal(AtlasEventType.SWAP_CREATED); + expect(completed.event_type).to.equal(AtlasEventType.SWAP_COMPLETED); + expect(completed.swap_id).to.equal(event.swap_id); + expect(event.swap_id).to.equal(btcTxHash); + expect(event.emitted_at).to.equal('2024-05-01T10:00:00.000Z'); + + const data = event.data as SwapCreatedData; + expect(data.source_chain).to.equal('bitcoin_testnet'); + expect(data.destination_chain).to.equal('rootstock_testnet'); + expect(data.input_asset).to.equal('BTC'); + expect(data.output_asset).to.equal('RBTC'); + expect(data.input_amount).to.equal('0.50000000'); + expect(data.wallet_address).to.equal(receiver.toLowerCase()); + + sinon.assert.callOrder(dataService.set, publisher.publish); + }); + + it('prefers the Bitcoin sender address when the log is lock_btc', async () => { + const {publisher, processor} = givenProcessor(); + + await processor.process(givenTx([givenLockBtcEvent()])); + + const [event] = publishedEvents(publisher); + expect((event.data as SwapCreatedData).wallet_address).to.equal(senderBtcAddress); + }); + + it('publishes swap.created then swap.rejected for a refundable rejection', async () => { + const {dataService, publisher, processor} = givenProcessor(); + + await processor.process( + givenTx([getMockedRejectedPeginEvent(), getMockedReleaseRequestedEvent()]), + ); + + sinon.assert.calledTwice(publisher.publish); + const [created, rejected] = publishedEvents(publisher); + expect(created.event_type).to.equal(AtlasEventType.SWAP_CREATED); + expect(rejected.event_type).to.equal(AtlasEventType.SWAP_REJECTED); + expect(created.swap_id).to.equal(btcTxHash); + expect(rejected.swap_id).to.equal(btcTxHash); + + // release_requested carries the amount the user sent; no log in this + // branch carries an address. + const createdData = created.data as SwapCreatedData; + expect(createdData.input_amount).to.equal('0.00001000'); + expect(createdData.wallet_address).to.be.null(); + + // rejected_pegin reason=3 is LEGACY_PEGIN_UNDETERMINED_SENDER in rskj. + const rejectedData = rejected.data as SwapRejectedData; + expect(rejectedData.refund_applicable).to.be.true(); + expect(rejectedData.error_category).to.equal('protocol_violation'); + expect(rejectedData.error_code).to.equal('LEGACY_PEGIN_UNDETERMINED_SENDER'); + expect(rejectedData.error_message).to.match(/rejected_pegin reason=3/); + + sinon.assert.callOrder(dataService.set, publisher.publish); + }); + + it('publishes the amount the user sent for a refundable rejection', async () => { + const releaseRequested = ( { + name: 'release_requested', + signature: '0x7a7c29481528ac8c2b2e93aee658fddd4dc15304fa723a5c2b88514557bcc790', + arguments: {btcTxHash, rskTxHash, amount: halfBtcInSatoshis}, + }); + const {publisher, processor} = givenProcessor(); + + await processor.process(givenTx([getMockedRejectedPeginEvent(), releaseRequested])); + + const [created] = publishedEvents(publisher); + expect((created.data as SwapCreatedData).input_amount).to.equal('0.50000000'); + }); + + it('reports zero for an unrefundable rejection, which carries no amount', async () => { + const {publisher, processor} = givenProcessor(); + + await processor.process( + givenTx([getMockedRejectedPeginEvent(), getMockedUnrefundablePeginEvent()]), + ); + + const [created] = publishedEvents(publisher); + expect((created.data as SwapCreatedData).input_amount).to.equal('0.00000000'); + }); + + it('publishes swap.created then a terminal swap.rejected for an unrefundable pegin', async () => { + const {publisher, processor} = givenProcessor(); + + await processor.process( + givenTx([getMockedRejectedPeginEvent(), getMockedUnrefundablePeginEvent()]), + ); + + sinon.assert.calledTwice(publisher.publish); + const [created, rejected] = publishedEvents(publisher); + expect(created.event_type).to.equal(AtlasEventType.SWAP_CREATED); + + // The code names the root cause, rejected_pegin reason=3, while the + // unrefundable reason=1 only explains why no refund was issued. + const rejectedData = rejected.data as SwapRejectedData; + expect(rejectedData.refund_applicable).to.be.false(); + expect(rejectedData.error_category).to.equal('protocol_violation'); + expect(rejectedData.error_code).to.equal('LEGACY_PEGIN_UNDETERMINED_SENDER'); + expect(rejectedData.error_message).to.match(/unrefundable_pegin reason=1/); + expect(rejectedData.error_message).to.match(/not refundable/); + }); + + describe('rejection with no refund branch', () => { + const givenNoRefundBranchTx = () => givenTx([getMockedRejectedPeginEvent()]); + + it('persists that pegin so the user gets a status', async () => { + const {dataService, processor} = givenProcessor(); + + await processor.process(givenNoRefundBranchTx()); + + sinon.assert.calledOnce(dataService.set); + const [persisted] = dataService.set.firstCall.args as [PeginStatusDataModel]; + expect(persisted.status).to.equal(PeginStatus.REJECTED_NO_REFUND); + }); + + it('publishes swap.created then swap.rejected for it', async () => { + const {publisher, processor} = givenProcessor(); + + await processor.process(givenNoRefundBranchTx()); + + sinon.assert.calledTwice(publisher.publish); + const [created, rejected] = publishedEvents(publisher); + expect(created.event_type).to.equal(AtlasEventType.SWAP_CREATED); + expect(rejected.event_type).to.equal(AtlasEventType.SWAP_REJECTED); + + const rejectedData = rejected.data as SwapRejectedData; + expect(rejectedData.error_code).to.equal('PEGIN_REJECTED_NO_REFUND_BRANCH'); + expect(rejectedData.refund_applicable).to.be.false(); + }); + + it('warns that the Bridge emitted no refund branch', async () => { + const {processor} = givenProcessor(); + const warn = sinon.spy(processor.logger, 'warn'); + + await processor.process(givenNoRefundBranchTx()); + + sinon.assert.called(warn); + expect(JSON.stringify(warn.getCalls().map(call => call.args))) + .to.match(/refund branch/i); + }); + }); + + // The flow cannot be derived from the envelope, so the processor has to + // pass it for the publication metric to be broken down by peg. + it('tells the publisher these events are peg-ins', async () => { + const {publisher, processor} = givenProcessor(); + + await processor.process(givenTx([givenPeginBtcEvent()])); + + sinon.assert.called(publisher.publish); + publisher.publish.getCalls().forEach(call => { + expect(call.args[1]).to.equal('pegin'); + }); + }); + + it('tells the publisher a rejection is a peg-in too', async () => { + const {publisher, processor} = givenProcessor(); + + await processor.process( + givenTx([getMockedRejectedPeginEvent(), getMockedUnrefundablePeginEvent()]), + ); + + sinon.assert.calledTwice(publisher.publish); + const [created, rejected] = publisher.publish.getCalls(); + expect(created.args[1]).to.equal('pegin'); + expect(rejected.args[1]).to.equal('pegin'); + expect((rejected.args[0] as AtlasEvent).event_type).to.equal(AtlasEventType.SWAP_REJECTED); + }); + + it('gives every event its own event_id', async () => { + const {publisher, processor} = givenProcessor(); + + await processor.process( + givenTx([getMockedRejectedPeginEvent(), getMockedReleaseRequestedEvent()]), + ); + + const [created, rejected] = publishedEvents(publisher); + expect(created.event_id).to.not.equal(rejected.event_id); + }); + + describe('when one event of a pair cannot be delivered', () => { + const givenRejectionTx = () => + givenTx([getMockedRejectedPeginEvent(), getMockedUnrefundablePeginEvent()]); + + // This is what actually happens in production: SqsAtlasEventPublisher + // swallows a transport failure and counts it, so the loop keeps going and + // the rejection still reaches the queue even if the created event was lost. + it('still publishes the rejection when the transport drops the created event', async () => { + const {publisher, processor} = givenProcessor(); + publisher.publish.callsFake(async (event: AtlasEvent, flow: 'pegin' | 'pegout') => { + if (event.event_type === AtlasEventType.SWAP_CREATED) { + publisher.metrics.recordFailure(event.event_type, flow); + return; + } + publisher.metrics.recordSuccess(event.event_type, flow); + }); + + await processor.process(givenRejectionTx()); + + sinon.assert.calledTwice(publisher.publish); + expect(publisher.metrics.total('failure', AtlasEventType.SWAP_CREATED, 'pegin')).to.equal(1); + expect(publisher.metrics.total('success', AtlasEventType.SWAP_REJECTED, 'pegin')).to.equal(1); + }); + + // A publisher that rejects is violating its interface contract. The pair + // is then truncated on purpose: a swap.rejected with no swap.created + // would reach Atlas for a swap it never opened a row for, which is worse + // than the swap being absent and the failure logged. + it('stops at the first event a publisher throws on, rather than emitting a rejection with no created', async () => { + const {dataService, publisher, processor} = givenProcessor(); + const error = sinon.spy(processor.logger, 'error'); + publisher.publish.onFirstCall().rejects(new Error('publisher violated its contract')); + + await processor.process(givenRejectionTx()); + + sinon.assert.calledOnce(publisher.publish); + expect(publishedEvents(publisher).map(e => e.event_type)) + .to.eql([AtlasEventType.SWAP_CREATED]); + // The status stays written: analytics never roll back a peg-in. + sinon.assert.calledOnce(dataService.set); + sinon.assert.called(error); + expect(JSON.stringify(error.getCalls().map(call => call.args))) + .to.match(/Could not build or publish/); + }); + }); + + it('does not publish when the status could not be saved', async () => { + const {dataService, publisher, processor} = givenProcessor(); + dataService.set.rejects(new Error('mongo is down')); + + await processor.process(givenTx([givenPeginBtcEvent()])); + + sinon.assert.notCalled(publisher.publish); + }); + + it('keeps the status persisted when publishing fails', async () => { + const {dataService, publisher, processor} = givenProcessor(); + publisher.publish.rejects(new Error('sqs is down')); + + await processor.process(givenTx([givenPeginBtcEvent()])); + + sinon.assert.calledOnce(dataService.set); + sinon.assert.calledOnce(publisher.publish); + }); + + it('does not publish again for a pegin already registered', async () => { + const {dataService, publisher, processor} = givenProcessor(); + dataService.getById.resolves(new PeginStatusDataModel()); + + await processor.process(givenTx([givenPeginBtcEvent()])); + + sinon.assert.notCalled(dataService.set); + sinon.assert.notCalled(publisher.publish); + }); + + it('publishes nothing for a transaction that is not a pegin', async () => { + const {publisher, processor} = givenProcessor(); + + await processor.process(givenTx([])); + + sinon.assert.notCalled(publisher.publish); + }); + + it('does not publish when NETWORK is not configured', async () => { + delete process.env.NETWORK; + const {dataService, publisher, processor} = givenProcessor(); + + await processor.process(givenTx([givenPeginBtcEvent()])); + + sinon.assert.calledOnce(dataService.set); + sinon.assert.notCalled(publisher.publish); + }); + }); + }); diff --git a/src/__tests__/unit/services/pegout-data.processor.unit.ts b/src/__tests__/unit/services/pegout-data.processor.unit.ts index 05c37c35..3604bdd2 100644 --- a/src/__tests__/unit/services/pegout-data.processor.unit.ts +++ b/src/__tests__/unit/services/pegout-data.processor.unit.ts @@ -13,6 +13,10 @@ import * as constants from '../../../constants'; import { BridgeState } from '@rsksmart/bridge-state-data-parser'; import { ExtendedBridgeEvent } from '../../../models/types/bridge-transaction-parser'; import { remove0x, ensure0x } from '../../../utils/hex-utils'; +import * as bitcoin from 'bitcoinjs-lib'; +import { AtlasEventPublisher } from '../../../services/atlas/atlas-event-publisher'; +import { AtlasEventMetrics } from '../../../services/atlas/atlas-event-metrics'; +import { AtlasEvent, AtlasEventType } from '../../../models/atlas/atlas-event.model'; const sandbox = sinon.createSandbox(); @@ -39,8 +43,22 @@ const bridgeState: BridgeState = { const NETWORK = process.env.NETWORK; process.env.RSK_PEGOUT_MINIMUM_CONFIRMATIONS = '10'; +type StubbedAtlasEventPublisher = AtlasEventPublisher & {publish: sinon.SinonStub}; + +const givenAtlasEventPublisher = (): StubbedAtlasEventPublisher => + ({publish: sinon.stub().resolves(), metrics: new AtlasEventMetrics()}); + +let atlasEventPublisher: StubbedAtlasEventPublisher = givenAtlasEventPublisher(); + +const publishedEvents = (publisher: StubbedAtlasEventPublisher): AtlasEvent[] => + publisher.publish.getCalls().map(call => call.args[0] as AtlasEvent); + describe('Service: PegoutDataProcessor', () => { + beforeEach(() => { + atlasEventPublisher = givenAtlasEventPublisher(); + }); + afterEach(() => { sandbox.stub(process.env, 'NETWORK').value(NETWORK); }); @@ -48,7 +66,7 @@ describe('Service: PegoutDataProcessor', () => { it('returns filters', () => { const mockedPegoutStatusDataService = {}; const bridgeService: BridgeService = {}; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService, atlasEventPublisher); expect(thisService.getFilters()).to.be.Array; expect(thisService.getFilters()).to.not.be.empty; expect(thisService.getFilters().length).to.equal(4); @@ -57,7 +75,7 @@ describe('Service: PegoutDataProcessor', () => { it('handles RECEIVED status', async () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const bridgeService: BridgeService = {}; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService, atlasEventPublisher); const rskSenderAddress = '0x40d2878B98A9C5A5b7bc3B2FC0e26dfDefCfe737'; const btcDestinationAddress = '0x09197f6153cb3a91bb51eec373360a1cb3b7c0e0'; const amount = 566666; @@ -126,7 +144,7 @@ describe('Service: PegoutDataProcessor', () => { it('verify method isMethodAccepted returns true for RECEIVED status', async () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const bridgeService: BridgeService = {}; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService, atlasEventPublisher); const rskSenderAddress = '0x40d2878B98A9C5A5b7bc3B2FC0e26dfDefCfe737'; const btcDestinationAddress = '0x09197f6153cb3a91bb51eec373360a1cb3b7c0e0'; const amount = 566666; @@ -242,7 +260,7 @@ describe('Service: PegoutDataProcessor', () => { it('validate accepted methods for method "" ', async () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const bridgeService: BridgeService = {}; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService, atlasEventPublisher); const rskSenderAddress = '0x3A29282d5144cEa68cb33995Ce82212f4B21ccEc'; const btcDestinationAddress = 'mreuQThm58CrYL4WCuY4SmDqiAQzWSy9GR'; const amount = 504237; @@ -290,7 +308,7 @@ describe('Service: PegoutDataProcessor', () => { it('validate accepted methods for a valid method ', async () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const bridgeService: BridgeService = {}; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService, atlasEventPublisher); const rskSenderAddress = '0x3A29282d5144cEa68cb33995Ce82212f4B21ccEc'; const btcDestinationAddress = 'mreuQThm58CrYL4WCuY4SmDqiAQzWSy9GR'; const amount = 504237; @@ -339,7 +357,7 @@ describe('Service: PegoutDataProcessor', () => { it('validate accepted methods for a invalid method ', async () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const bridgeService: BridgeService = {}; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService, atlasEventPublisher); const rskSenderAddress = '0x3A29282d5144cEa68cb33995Ce82212f4B21ccEc'; const btcDestinationAddress = 'mreuQThm58CrYL4WCuY4SmDqiAQzWSy9GR'; const amount = 504237; @@ -387,7 +405,7 @@ describe('Service: PegoutDataProcessor', () => { it('handles REJECTED status', async () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const bridgeService: BridgeService = {}; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, bridgeService, atlasEventPublisher); const rskSenderAddress = '0x3A29282d5144cEa68cb33995Ce82212f4B21ccEc'; const reason = '3'; @@ -446,7 +464,7 @@ describe('Service: PegoutDataProcessor', () => { it('handles RELEASE_REQUEST_RECEIVED status, testnet', async () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const mockedBridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService, atlasEventPublisher); const rskTxSender = '0x1234567890123456789012345678901234567890'; const btcDestinationAddress = 'mgM4vPBnDKa8cKkXki4Bp5nQ7hgTGd4va8'; const amount = 500000; @@ -497,7 +515,7 @@ describe('Service: PegoutDataProcessor', () => { it('handles RELEASE_REJECTED status', async () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const mockedBridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService, atlasEventPublisher); const rskTxHash = '0x3769e1117683faa318c683af5fb763dc03d431580ecf2ad1271ff25bf946fe9c'; const btcTxHash = '0xfbfbc14548d7a352287b5f02199ac909d473333f7c2a072eb4dfda30f97a84e2'; const amount = 500000; @@ -550,7 +568,7 @@ describe('Service: PegoutDataProcessor', () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const mockedBridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService, atlasEventPublisher); const createdOn = new Date(); @@ -631,7 +649,7 @@ describe('Service: PegoutDataProcessor', () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const mockedBridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService, atlasEventPublisher); const createdOn = new Date(); @@ -749,7 +767,7 @@ describe('Service: PegoutDataProcessor', () => { }; const mockedBridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService, atlasEventPublisher); const createdOn = new Date(); mockedBridgeService.getBridgeState.resolves(bridgeState); const getLastByOriginatingRskTxHash = mockedPegoutStatusDataService.getLastByOriginatingRskTxHashNewest as sinon.SinonStub; @@ -863,7 +881,7 @@ describe('Service: PegoutDataProcessor', () => { it('returns same valueInSatoshisToBeReceived when did not find pegout in pegoutsWaitingForConfirmations', async () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const mockedBridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService, atlasEventPublisher); const mockedPegoutStatus = new PegoutStatusDbDataModel(); mockedPegoutStatus.originatingRskTxHash = rskTxHash; mockedPegoutStatus.valueInSatoshisToBeReceived = 1000; @@ -875,7 +893,7 @@ describe('Service: PegoutDataProcessor', () => { it('returns same valueInSatoshisToBeReceived when found a pegout but did not find an output containing the btcRecipientAddress', async () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const mockedBridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService, atlasEventPublisher); const mockedPegoutStatus = new PegoutStatusDbDataModel(); mockedPegoutStatus.originatingRskTxHash = '0x5628682b56ef179e066fd12ee25a84436def371b0a11b45cf1d8308ed06f4698'; mockedPegoutStatus.valueInSatoshisToBeReceived = 1000; @@ -887,7 +905,7 @@ describe('Service: PegoutDataProcessor', () => { it('returns calculated valueInSatoshisToBeReceived when found pegout in pegoutsWaitingForConfirmations', async () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const mockedBridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService, atlasEventPublisher); const mockedPegoutStatus = new PegoutStatusDbDataModel(); mockedPegoutStatus.originatingRskTxHash = '0x5628682b56ef179e066fd12ee25a84436def371b0a11b45cf1d8308ed06f4698'; mockedPegoutStatus.btcRawTransaction = btcRawTx2; @@ -900,7 +918,7 @@ describe('Service: PegoutDataProcessor', () => { it('processIndividualPegout', async () => { const mockedPegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; const mockedBridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; - const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService); + const thisService = new PegoutDataProcessor(mockedPegoutStatusDataService, mockedBridgeService, atlasEventPublisher); const extendedBridgeTx: ExtendedBridgeTx = { sender: '0x4495768E683423a4299D6a7f02A0689a6ff5a0A4', @@ -957,4 +975,347 @@ describe('Service: PegoutDataProcessor', () => { await thisService['processIndividualPegout'](extendedBridgeTx); sinon.assert.calledTwice(mockedPegoutStatusDataService.set); }) + + describe('Atlas events', () => { + const ATLAS_ORIGINATING_RSK_TX_HASH = '0x3ca5051117e635df4e77a66214d3a0805904c1b86357d5c43279d73f7baad8d9'; + const ATLAS_BATCH_RSK_TX_HASH = '0x6843cfeaafe38e1044ec5638877ff766015b44887d32c7aef7daec84aa3af7c5'; + const ATLAS_SENDER = '0x3A29282d5144cEa68cb33995Ce82212f4B21ccEc'; + + let network: string | undefined; + + beforeEach(() => { + network = process.env.NETWORK; + process.env.NETWORK = constants.NETWORK_TESTNET; + }); + + afterEach(() => { + if (network === undefined) { + delete process.env.NETWORK; + } else { + process.env.NETWORK = network; + } + }); + + function givenProcessor() { + const pegoutStatusDataService = sinon.createStubInstance(PegoutStatusMongoDbDataService) as SinonStubbedInstance; + const mockedBridgeService = sinon.createStubInstance(BridgeService) as SinonStubbedInstance & BridgeService; + mockedBridgeService.getBridgeState.resolves(bridgeState); + const publisher = givenAtlasEventPublisher(); + const processor = new PegoutDataProcessor(pegoutStatusDataService, mockedBridgeService, publisher); + // The processor reads the originating tx from an RSK node only to enrich a + // debug log; the unit suite must not reach the network for it. + sinon.stub(processor, 'getTxFromRskTransaction').resolves({valueInWeis: '0'}); + return {pegoutStatusDataService, mockedBridgeService, publisher, processor}; + } + + function givenExtendedBridgeTx( + txHash: string, + events: Array<{name: string; signature: string; arguments: any}>, + createdOn = new Date(), + methodName = '', + ): ExtendedBridgeTx { + return { + sender: '0x4495768E683423a4299D6a7f02A0689a6ff5a0A4', + blockTimestamp: 1626736729000, + blockHash, + txHash, + createdOn, + blockNumber: 2869973, + to: bridge.address, + method: {name: methodName, signature: '', arguments: new Map()}, + events, + }; + } + + function givenReleaseRequestReceivedTx(createdOn = new Date()): ExtendedBridgeTx { + return givenExtendedBridgeTx(ATLAS_ORIGINATING_RSK_TX_HASH, [{ + name: BRIDGE_EVENTS.RELEASE_REQUEST_RECEIVED, + signature: '0x8e04e2f2c246a91202761c435d6a4971bdc7af0617f0c739d900ecd12a6d7266', + arguments: { + sender: ATLAS_SENDER, + btcDestinationAddress: '0x09197f6153cb3a91bb51eec373360a1cb3b7c0e0', + amount: 10000000000000000, + }, + }], createdOn); + } + + function givenReleaseRequestRejectedTx(): ExtendedBridgeTx { + return givenExtendedBridgeTx(ATLAS_ORIGINATING_RSK_TX_HASH, [{ + name: BRIDGE_EVENTS.RELEASE_REQUEST_REJECTED, + signature: '0xb607c3e1fbe6b38cd145b15b837f7b722b199caa60e3057b36c141adee3b75e7', + arguments: {sender: ATLAS_SENDER, amount: 10000000000000000, reason: '1'}, + }]); + } + + it('publishes exactly one swap.created after saving a RECEIVED status', async () => { + const {pegoutStatusDataService, publisher, processor} = givenProcessor(); + + await processor.process(givenReleaseRequestReceivedTx()); + + sinon.assert.calledOnce(publisher.publish); + const [event] = publishedEvents(publisher); + expect(event.event_type).to.equal(AtlasEventType.SWAP_CREATED); + expect(event.swap_id).to.equal(ATLAS_ORIGINATING_RSK_TX_HASH); + sinon.assert.callOrder(pegoutStatusDataService.set, publisher.publish); + }); + + // The flow cannot be derived from the envelope, so the processor has to + // pass it for the publication metric to be broken down by peg. + it('tells the publisher these events are peg-outs', async () => { + const {publisher, processor} = givenProcessor(); + + await processor.process(givenReleaseRequestReceivedTx()); + + sinon.assert.called(publisher.publish); + publisher.publish.getCalls().forEach(call => { + expect(call.args[1]).to.equal('pegout'); + }); + }); + + it('publishes exactly one swap.rejected after saving a REJECTED status', async () => { + const {pegoutStatusDataService, publisher, processor} = givenProcessor(); + + await processor.process(givenReleaseRequestRejectedTx()); + + sinon.assert.calledOnce(publisher.publish); + const [event] = publishedEvents(publisher); + expect(event.event_type).to.equal(AtlasEventType.SWAP_REJECTED); + expect(event.swap_id).to.equal(ATLAS_ORIGINATING_RSK_TX_HASH); + sinon.assert.callOrder(pegoutStatusDataService.set, publisher.publish); + }); + + it('publishes one swap.pending per pegout of a batch, keyed by originatingRskTxHash', async () => { + const {pegoutStatusDataService, publisher, processor} = givenProcessor(); + const originatingHashes = [ + '0xed0b3849b1087653d916f490392b7c7578c4611ef4b0ec1063d6bcd393fb6080', + '0x6205c184b4039891bfeea7c9f1198851dc71906afd677098618cdcb81a17b484', + '0xa7d55089e1339a7ca1fce6d2f8014ad9f03897982f77ca8af756c8ad25903b49', + ]; + + originatingHashes.forEach(originatingRskTxHash => { + const stored = new PegoutStatusDbDataModel(); + stored.originatingRskTxHash = originatingRskTxHash; + stored.rskTxHash = originatingRskTxHash; + stored.rskSenderAddress = ATLAS_SENDER; + stored.btcRecipientAddress = 'mpKPLWXnmqjtXyoqi5yRBYgmF4PswMGj55'; + stored.status = PegoutStatuses.RECEIVED; + stored.isNewestStatus = true; + stored.valueRequestedInSatoshis = 521000; + pegoutStatusDataService.getLastByOriginatingRskTxHashNewest + .withArgs(originatingRskTxHash) + .resolves(stored); + }); + + await processor.process(givenExtendedBridgeTx(ATLAS_BATCH_RSK_TX_HASH, [{ + name: BRIDGE_EVENTS.BATCH_PEGOUT_CREATED, + signature: '0x483d0191cc4e784b04a41f6c4801a0766b43b1fdd0b9e3e6bfdca74e5b05c2eb', + arguments: { + btcTxHash: '0x14b8033bda330b5aba325040188419129c60762e852d7add97f40d14bbdc6931', + releaseRskTxHashes: ensure0x(originatingHashes.map(remove0x).join('')), + }, + }], new Date(), BRIDGE_METHODS.UPDATE_COLLECTIONS)); + + const events = publishedEvents(publisher); + expect(events).to.have.length(3); + events.forEach(event => expect(event.event_type).to.equal(AtlasEventType.SWAP_PENDING)); + const swapIds = events.map(event => event.swap_id); + expect(swapIds).to.eql(originatingHashes); + expect(new Set(swapIds).size).to.equal(3); + sinon.assert.callOrder(pegoutStatusDataService.set, publisher.publish); + }); + + it('publishes one swap.completed after saving a RELEASE_BTC status', async () => { + const {pegoutStatusDataService, publisher, processor} = givenProcessor(); + const receivedCreatedOn = new Date('2024-05-01T10:00:00.000Z'); + const releasedOn = new Date(receivedCreatedOn.getTime() + 184000); + const batchPegoutRskTxHash = 'testhash'; + + const dbPegoutWaitingForSignature = new PegoutStatusDbDataModel(); + dbPegoutWaitingForSignature.rskTxHash = '0x7bdcfca72ea7103a804f9f9013bfb205c8c61fe9deb903a9923e03b80a16bfd2'; + dbPegoutWaitingForSignature.btcRecipientAddress = 'mgM4vPBnDKa8cKkXki4Bp5nQ7hgTGd4va8'; + dbPegoutWaitingForSignature.createdOn = receivedCreatedOn; + dbPegoutWaitingForSignature.originatingRskTxHash = ATLAS_ORIGINATING_RSK_TX_HASH; + dbPegoutWaitingForSignature.rskSenderAddress = ATLAS_SENDER; + dbPegoutWaitingForSignature.status = PegoutStatuses.WAITING_FOR_SIGNATURE; + dbPegoutWaitingForSignature.btcRawTransaction = btcRawTx1; + dbPegoutWaitingForSignature.valueRequestedInSatoshis = 400000; + dbPegoutWaitingForSignature.batchPegoutRskTxHash = batchPegoutRskTxHash; + + const dbPegoutReceived = new PegoutStatusDbDataModel(); + dbPegoutReceived.originatingRskTxHash = ATLAS_ORIGINATING_RSK_TX_HASH; + dbPegoutReceived.status = PegoutStatuses.RECEIVED; + dbPegoutReceived.createdOn = receivedCreatedOn; + + pegoutStatusDataService.getPegoutByRecipientAndCreationTx + .withArgs(dbPegoutWaitingForSignature.btcRecipientAddress, batchPegoutRskTxHash) + .resolves([dbPegoutWaitingForSignature]); + pegoutStatusDataService.getManyByOriginatingRskTxHash + .withArgs(ATLAS_ORIGINATING_RSK_TX_HASH) + .resolves([dbPegoutReceived, dbPegoutWaitingForSignature]); + + await processor.process(givenExtendedBridgeTx( + dbPegoutWaitingForSignature.rskTxHash, + [{ + name: BRIDGE_EVENTS.RELEASE_BTC, + signature: '0x655929b56d5c5a24f81ee80267d5151b9d680e7e703387999922e9070bc98a02', + arguments: {btcRawTransaction: btcRawTx3, releaseRskTxHash: batchPegoutRskTxHash}, + }], + releasedOn, + BRIDGE_METHODS.ADD_SIGNATURE, + )); + + sinon.assert.calledOnce(publisher.publish); + const [event] = publishedEvents(publisher); + expect(event.event_type).to.equal(AtlasEventType.SWAP_COMPLETED); + expect(event.swap_id).to.equal(ATLAS_ORIGINATING_RSK_TX_HASH); + expect(event.emitted_at).to.equal(releasedOn.toISOString()); + expect((event.data).duration_ms).to.equal(184000); + expect((event.data).output_amount).to.equal('0.00393100'); + expect((event.data).fee).to.equal('0.00006900'); + sinon.assert.callOrder(pegoutStatusDataService.set, publisher.publish); + }); + + it('matches each output of a batch that pays one address twice', async () => { + const {pegoutStatusDataService, publisher, processor} = givenProcessor(); + const batchPegoutRskTxHash = 'sharedbatch'; + // btcRawTx3 pays mgM4vPBnDKa8cKkXki4Bp5nQ7hgTGd4va8 at output 0. + const sharedAddress = 'mgM4vPBnDKa8cKkXki4Bp5nQ7hgTGd4va8'; + + const givenBatched = (originating: string, batchPegoutIndex: number) => { + const row = new PegoutStatusDbDataModel(); + row.originatingRskTxHash = originating; + row.rskTxHash = `${originating}__${batchPegoutIndex}`; + row.btcRecipientAddress = sharedAddress; + row.rskSenderAddress = ATLAS_SENDER; + row.status = PegoutStatuses.WAITING_FOR_SIGNATURE; + row.btcRawTransaction = btcRawTx1; + row.valueRequestedInSatoshis = 400000; + row.batchPegoutRskTxHash = batchPegoutRskTxHash; + // Mongo stores batchPegoutIndex as a String even though the model types + // it as a number, so rows read back carry "0"/"1". The matcher must + // cope with what the database actually returns. + row.batchPegoutIndex = String(batchPegoutIndex); + row.createdOn = new Date('2024-05-01T10:00:00.000Z'); + return row; + }; + + const first = givenBatched('0x1111111111111111111111111111111111111111111111111111111111111111', 0); + const second = givenBatched('0x2222222222222222222222222222222222222222222222222222222222222222', 1); + + // Both rows share the recipient address, so the lookup returns two. + pegoutStatusDataService.getPegoutByRecipientAndCreationTx + .withArgs(sharedAddress, batchPegoutRskTxHash) + .resolves([first, second]); + + await processor.process(givenExtendedBridgeTx( + '0x7bdcfca72ea7103a804f9f9013bfb205c8c61fe9deb903a9923e03b80a16bfd2', + [{ + name: BRIDGE_EVENTS.RELEASE_BTC, + signature: '0x655929b56d5c5a24f81ee80267d5151b9d680e7e703387999922e9070bc98a02', + arguments: {btcRawTransaction: btcRawTx3, releaseRskTxHash: batchPegoutRskTxHash}, + }], + new Date('2024-05-01T10:03:04.000Z'), + BRIDGE_METHODS.ADD_SIGNATURE, + )); + + // Output 0 belongs to the row whose batchPegoutIndex is 0. + const events = publishedEvents(publisher); + expect(events).to.have.length(1); + expect(events[0].event_type).to.equal(AtlasEventType.SWAP_COMPLETED); + expect(events[0].swap_id).to.equal(first.originatingRskTxHash); + }); + + it('publishes the canonical Bitcoin txid as destination_tx_hash', async () => { + const {pegoutStatusDataService, publisher, processor} = givenProcessor(); + const batchPegoutRskTxHash = 'testhash'; + + const stored = new PegoutStatusDbDataModel(); + stored.rskTxHash = '0x7bdcfca72ea7103a804f9f9013bfb205c8c61fe9deb903a9923e03b80a16bfd2'; + stored.btcRecipientAddress = 'mgM4vPBnDKa8cKkXki4Bp5nQ7hgTGd4va8'; + stored.createdOn = new Date('2024-05-01T10:00:00.000Z'); + stored.originatingRskTxHash = ATLAS_ORIGINATING_RSK_TX_HASH; + stored.rskSenderAddress = ATLAS_SENDER; + stored.status = PegoutStatuses.WAITING_FOR_SIGNATURE; + stored.btcRawTransaction = btcRawTx1; + stored.valueRequestedInSatoshis = 400000; + stored.batchPegoutRskTxHash = batchPegoutRskTxHash; + + pegoutStatusDataService.getPegoutByRecipientAndCreationTx + .withArgs(stored.btcRecipientAddress, batchPegoutRskTxHash) + .resolves([stored]); + + await processor.process(givenExtendedBridgeTx( + stored.rskTxHash, + [{ + name: BRIDGE_EVENTS.RELEASE_BTC, + signature: '0x655929b56d5c5a24f81ee80267d5151b9d680e7e703387999922e9070bc98a02', + arguments: {btcRawTransaction: btcRawTx3, releaseRskTxHash: batchPegoutRskTxHash}, + }], + new Date('2024-05-01T10:03:04.000Z'), + BRIDGE_METHODS.ADD_SIGNATURE, + )); + + const expectedTxId = bitcoin.Transaction.fromHex(btcRawTx3).getId(); + const internalHash = bitcoin.Transaction.fromHex(btcRawTx3).getHash().toString('hex'); + const [event] = publishedEvents(publisher); + expect((event.data).destination_tx_hash).to.equal(expectedTxId); + expect((event.data).destination_tx_hash).to.not.equal(internalHash); + }); + + it('publishes nothing for a pegout_confirmed transition', async () => { + const {pegoutStatusDataService, publisher, processor} = givenProcessor(); + + const dbPegoutWaitingForConfirmation = new PegoutStatusDbDataModel(); + dbPegoutWaitingForConfirmation.originatingRskTxHash = ATLAS_ORIGINATING_RSK_TX_HASH; + dbPegoutWaitingForConfirmation.rskTxHash = ATLAS_ORIGINATING_RSK_TX_HASH; + dbPegoutWaitingForConfirmation.status = PegoutStatuses.WAITING_FOR_CONFIRMATION; + dbPegoutWaitingForConfirmation.createdOn = new Date(); + pegoutStatusDataService.getManyWaitingForConfirmationNewestCreatedOnBlock + .resolves([dbPegoutWaitingForConfirmation]); + + const pegoutConfirmedEventArgs = new Map(); + pegoutConfirmedEventArgs.set('btcTxHash', ''); + pegoutConfirmedEventArgs.set('pegoutCreationRskBlockNumber', 2869973); + + await processor.process(givenExtendedBridgeTx(ATLAS_ORIGINATING_RSK_TX_HASH, [{ + name: BRIDGE_EVENTS.PEGOUT_CONFIRMED, + signature: '', + arguments: pegoutConfirmedEventArgs, + }])); + + sinon.assert.calledTwice(pegoutStatusDataService.set); + sinon.assert.notCalled(publisher.publish); + }); + + it('does not publish when the status could not be saved', async () => { + const {pegoutStatusDataService, publisher, processor} = givenProcessor(); + pegoutStatusDataService.set.rejects(new Error('mongo is down')); + + await processor.process(givenReleaseRequestReceivedTx()); + + sinon.assert.called(pegoutStatusDataService.set); + sinon.assert.notCalled(publisher.publish); + }); + + it('keeps the status persisted when publishing fails', async () => { + const {pegoutStatusDataService, publisher, processor} = givenProcessor(); + publisher.publish.rejects(new Error('sqs is down')); + + await processor.process(givenReleaseRequestReceivedTx()); + + sinon.assert.calledOnce(pegoutStatusDataService.set); + sinon.assert.calledOnce(publisher.publish); + }); + + it('does not publish when NETWORK is not configured', async () => { + delete process.env.NETWORK; + const {pegoutStatusDataService, publisher, processor} = givenProcessor(); + + await processor.process(givenReleaseRequestReceivedTx()); + + sinon.assert.calledOnce(pegoutStatusDataService.set); + sinon.assert.notCalled(publisher.publish); + }); + }); + }); diff --git a/src/daemon-runner.ts b/src/daemon-runner.ts index dcd5a64c..f49c480d 100644 --- a/src/daemon-runner.ts +++ b/src/daemon-runner.ts @@ -2,14 +2,19 @@ import {Application} from '@loopback/core'; import {ServicesBindings} from './dependency-injection-bindings'; import {DependencyInjectionHandler} from './dependency-injection-handler'; import {DaemonService} from './services/daemon.service'; +import {assertNetworkConfigured} from './models/atlas/atlas-chain'; export class DaemonRunner extends Application { daemonService: DaemonService; constructor() { super(); + assertNetworkConfigured(); DependencyInjectionHandler.configureDependencies(this); + // Daemon-only bindings, the Atlas event publisher among them: emitting + // events is a responsibility of this process and of no other. + DependencyInjectionHandler.configureDaemonDependencies(this); } async start(): Promise { diff --git a/src/dependency-injection-bindings.ts b/src/dependency-injection-bindings.ts index aeedc0d6..1ca7ed14 100644 --- a/src/dependency-injection-bindings.ts +++ b/src/dependency-injection-bindings.ts @@ -8,7 +8,8 @@ export const ConstantsBindings = { MONGO_DB_AUTH_SOURCE: 'constants.mongoDbAuthSource', INITIAL_BLOCK: 'constants.initialBlock', MIN_DEPTH_FOR_SYNC: 'constants.minDepthForSync', - SYNC_INTERVAL_TIME: 'constants.syncIntervalTime' + SYNC_INTERVAL_TIME: 'constants.syncIntervalTime', + ATLAS_EVENTS_ENABLED: 'constants.atlasEventsEnabled' }; export const DatasourcesBindings = { @@ -38,4 +39,5 @@ export const ServicesBindings = { FEATURES_SERVICE: 'services.FeaturesDataService', FLYOVER_SERVICE: 'services.FlyoverService', BACKOFFICE_FEATURE_FLAGS_SERVICE: 'services.BackofficeFeatureFlagsService', + ATLAS_EVENT_PUBLISHER: 'services.AtlasEventPublisher', }; diff --git a/src/dependency-injection-handler.ts b/src/dependency-injection-handler.ts index 7ddb42c5..ae36e2f9 100644 --- a/src/dependency-injection-handler.ts +++ b/src/dependency-injection-handler.ts @@ -1,4 +1,4 @@ -import {Application, BindingScope} from '@loopback/core'; +import {Application, BindingScope, Constructor} from '@loopback/core'; import {TxV2ProviderDataSource} from './datasources'; import {MongoDbDataSource} from './datasources/mongodb.datasource'; import {ConstantsBindings, DatasourcesBindings, ServicesBindings} from './dependency-injection-bindings'; @@ -21,14 +21,76 @@ import {SyncStatusMongoService} from './services/sync-status-mongo.service'; import { PegoutDataProcessor } from './services/pegout-data.processor'; import { FeaturesMongoDbDataService } from './services/features-mongo.service'; import { BackofficeFeatureFlagsService } from './services/backoffice-feature-flags.service'; +import { AtlasEventPublisher, isAtlasEventsEnabled } from './services/atlas/atlas-event-publisher'; +import { SqsAtlasEventPublisher } from './services/atlas/sqs-atlas-event-publisher'; +import { NoopAtlasEventPublisher } from './services/atlas/noop-atlas-event-publisher'; export class DependencyInjectionHandler { + /** + * Bindings shared by every process. `TwpapiApplication` calls only this one, + * so anything registered here is reachable from the REST API. + * + * @param app - The application to configure. + */ public static configureDependencies(app: Application): void { this.configureConstants(app); this.configureDatasources(app); this.configureServices(app); } + /** + * Bindings that belong exclusively to the daemon process, called only by + * `DaemonRunner`. + * + * Atlas SWAP events are emitted while processing Bridge transactions and + * nowhere else, so `ATLAS_EVENT_PUBLISHER` and the two transaction + * processors that use it are registered here and are simply absent from the + * API process: a controller cannot inject what was never bound. + * + * Note none of these classes live under `services/**\/*.service.ts`, so + * `@loopback/boot`'s ServiceBooter does not re-register them in the API. + * + * @param app - The daemon application to configure. + */ + public static configureDaemonDependencies(app: Application): void { + // Read once, so every binding in this method is derived from the same + // evaluation of the kill switch. + const atlasEventsEnabled = isAtlasEventsEnabled(); + app + .bind(ConstantsBindings.ATLAS_EVENTS_ENABLED) + .to(atlasEventsEnabled); + + // The kill switch decides the transport, never the callers: the processors + // always depend on the AtlasEventPublisher interface. + const atlasEventPublisher: Constructor = atlasEventsEnabled + ? SqsAtlasEventPublisher + : NoopAtlasEventPublisher; + app + .bind(ServicesBindings.ATLAS_EVENT_PUBLISHER) + .toClass(atlasEventPublisher) + .inScope(BindingScope.SINGLETON); + + app + .bind(ServicesBindings.PEGIN_DATA_PROCESSOR) + .toClass(PeginDataProcessor) + .inScope(BindingScope.SINGLETON); + + app + .bind(ServicesBindings.PEGOUT_DATA_PROCESSOR) + .toClass(PegoutDataProcessor) + .inScope(BindingScope.SINGLETON); + + app + .bind(ServicesBindings.RSK_BLOCK_PROCESSOR_PUBLISHER) + .toClass(NodeBridgeDataProvider) + .inScope(BindingScope.SINGLETON); + + app + .bind(ServicesBindings.DAEMON_SERVICE) + .toClass(DaemonService) + .inScope(BindingScope.SINGLETON); + } + private static configureConstants(app: Application): void { app .bind(ConstantsBindings.MONGO_DB_USER) @@ -115,31 +177,11 @@ export class DependencyInjectionHandler { .toClass(RskChainSyncService) .inScope(BindingScope.SINGLETON); - app - .bind(ServicesBindings.PEGIN_DATA_PROCESSOR) - .toClass(PeginDataProcessor) - .inScope(BindingScope.SINGLETON); - - app - .bind(ServicesBindings.PEGOUT_DATA_PROCESSOR) - .toClass(PegoutDataProcessor) - .inScope(BindingScope.SINGLETON); - - app - .bind(ServicesBindings.DAEMON_SERVICE) - .toClass(DaemonService) - .inScope(BindingScope.SINGLETON); - app .bind(ServicesBindings.BRIDGE_SERVICE) .toClass(BridgeService) .inScope(BindingScope.SINGLETON); - app - .bind(ServicesBindings.RSK_BLOCK_PROCESSOR_PUBLISHER) - .toClass(NodeBridgeDataProvider) - .inScope(BindingScope.SINGLETON); - app .bind(ServicesBindings.PEGOUT_STATUS_SERVICE) .toClass(PegoutStatusService) @@ -149,7 +191,7 @@ export class DependencyInjectionHandler { .bind(ServicesBindings.UTXO_PROVIDER_SERVICE) .toClass(UtxoProviderProvider) .inScope(BindingScope.SINGLETON); - + app .bind(ServicesBindings.FEATURES_SERVICE) .toClass(FeaturesMongoDbDataService) diff --git a/src/models/atlas/atlas-amount.ts b/src/models/atlas/atlas-amount.ts new file mode 100644 index 00000000..19f4a4e5 --- /dev/null +++ b/src/models/atlas/atlas-amount.ts @@ -0,0 +1,18 @@ +import Big from 'big.js'; + +const SATOSHIS_PER_BTC = 100_000_000; +const AMOUNT_DECIMALS = 8; + +/** + * Formats an amount in satoshis as the fixed 8-decimal string the Atlas SWAP + * schema expects (`"0.12345678"`). + * + * `big.js` is used throughout so large values never lose precision through + * `Number` arithmetic. + * + * @param satoshis - Amount in satoshis. Nullish values are treated as zero. + * @returns The amount in BTC/RBTC as a decimal string. + */ +export function satoshisToDecimalString(satoshis: number | undefined | null): string { + return new Big(satoshis ?? 0).div(SATOSHIS_PER_BTC).toFixed(AMOUNT_DECIMALS); +} diff --git a/src/models/atlas/atlas-chain.ts b/src/models/atlas/atlas-chain.ts new file mode 100644 index 00000000..94f0ddf3 --- /dev/null +++ b/src/models/atlas/atlas-chain.ts @@ -0,0 +1,76 @@ +import * as constants from '../../constants'; + +/** + * Qualified chain identifiers used by the Atlas SWAP event schema. + * The network suffix is always explicit so the analytics side never has to + * guess which chain a swap belongs to. + */ +export const CHAIN_IDS = { + ROOTSTOCK_MAINNET: 'rootstock_mainnet', + ROOTSTOCK_TESTNET: 'rootstock_testnet', + BITCOIN_MAINNET: 'bitcoin_mainnet', + BITCOIN_TESTNET: 'bitcoin_testnet', +} as const; + +export type ChainId = (typeof CHAIN_IDS)[keyof typeof CHAIN_IDS]; + +export interface SwapChainIds { + sourceChain: ChainId; + destinationChain: ChainId; +} + +const SUPPORTED_NETWORKS: string[] = [constants.NETWORK_MAINNET, constants.NETWORK_TESTNET]; + +/** + * Returns the configured `NETWORK`, failing fast when it is missing or not one + * of `mainnet` / `testnet`. + * + * Unlike the rest of the repository this resolver deliberately does **not** + * default to testnet: a mainnet deployment missing the variable would label + * every event as testnet and silently contaminate the analytics database. + * + * @throws Error when `NETWORK` is absent or holds an unsupported value. + */ +export function assertNetworkConfigured(): string { + const network = process.env.NETWORK; + if (!network || !SUPPORTED_NETWORKS.includes(network)) { + throw new Error( + `Atlas events require NETWORK to be exactly '${constants.NETWORK_MAINNET}' or ` + + `'${constants.NETWORK_TESTNET}'. Got '${network ?? ''}'.`, + ); + } + return network; +} + +/** + * Resolves the chain ids of a native peg-out: Rootstock is always the source + * chain and Bitcoin always the destination chain. + * + * @returns The qualified `sourceChain` / `destinationChain` pair for the configured network. + * @throws Error when `NETWORK` is not configured. See {@link assertNetworkConfigured}. + */ +export function resolvePegoutChainIds(): SwapChainIds { + const network = assertNetworkConfigured(); + if (network === constants.NETWORK_MAINNET) { + return { + sourceChain: CHAIN_IDS.ROOTSTOCK_MAINNET, + destinationChain: CHAIN_IDS.BITCOIN_MAINNET, + }; + } + return { + sourceChain: CHAIN_IDS.ROOTSTOCK_TESTNET, + destinationChain: CHAIN_IDS.BITCOIN_TESTNET, + }; +} + +/** + * Resolves the chain ids of a native peg-in, the mirror image of a peg-out: + * Bitcoin is always the source chain and Rootstock always the destination. + * + * @returns The qualified `sourceChain` / `destinationChain` pair for the configured network. + * @throws Error when `NETWORK` is not configured. See {@link assertNetworkConfigured}. + */ +export function resolvePeginChainIds(): SwapChainIds { + const {sourceChain, destinationChain} = resolvePegoutChainIds(); + return {sourceChain: destinationChain, destinationChain: sourceChain}; +} diff --git a/src/models/atlas/atlas-event.model.ts b/src/models/atlas/atlas-event.model.ts new file mode 100644 index 00000000..581e6c9b --- /dev/null +++ b/src/models/atlas/atlas-event.model.ts @@ -0,0 +1,80 @@ +import {ChainId} from './atlas-chain'; + +/** + * Event types of the Atlas SWAP Event Schema v1.0 emitted for native pegs. + * `expired`, `refund_pending`, `refunded`, `claim_pending` and `claimed` do not + * apply to native peg-in / peg-out and are therefore out of scope. + * + * Peg-in never reaches `swap.pending`: the daemon observes Rootstock alone, so + * the deposit on Bitcoin is never seen and there is nothing to report as + * pending. Its other three types are all emitted. + */ +export enum AtlasEventType { + SWAP_CREATED = 'swap.created', + SWAP_PENDING = 'swap.pending', + SWAP_COMPLETED = 'swap.completed', + SWAP_REJECTED = 'swap.rejected', +} + +export const ATLAS_SCHEMA_VERSION = '1.0'; +export const ATLAS_SOURCE = 'PWP'; +export const ATLAS_SWAP_TYPE = 'powpeg'; +export const ATLAS_PROVIDER = 'powpeg'; +/** Assets of the BTC <-> RBTC pair. Which one is input and which is output + * depends on the direction, so each builder pairs them itself. */ +export const ASSET_BTC = 'BTC'; +export const ASSET_RBTC = 'RBTC'; + +export interface SwapCreatedData { + provider: string; + source_chain: ChainId; + destination_chain: ChainId; + input_asset: string; + output_asset: string; + input_amount: string; + input_amount_usd: null; + /** Null when the Bridge log carries no address, as in a rejected peg-in. */ + wallet_address: string | null; + wallet_type: null; + quote_id: null; +} + +export interface SwapPendingData { + source_tx_hash: string; + deposit_address: null; + expected_confirmations: number; +} + +export interface SwapCompletedData { + destination_tx_hash: string; + output_amount: string; + output_amount_usd: null; + fee: string; + duration_ms: number | null; +} + +export interface SwapRejectedData { + error_category: string; + error_code: string; + error_message: string; + refund_applicable: boolean; +} + +export type AtlasEventData = + | SwapCreatedData + | SwapPendingData + | SwapCompletedData + | SwapRejectedData; + +export interface AtlasEvent { + event_id: string; + event_type: AtlasEventType; + /** Always the `originatingRskTxHash`, never the mutated `rskTxHash`. */ + swap_id: string; + swap_type: string; + source: string; + schema_version: string; + /** ISO 8601 UTC timestamp of the Rootstock transaction that caused the transition. */ + emitted_at: string; + data: T; +} diff --git a/src/models/atlas/atlas-identifiers.ts b/src/models/atlas/atlas-identifiers.ts new file mode 100644 index 00000000..2c059adf --- /dev/null +++ b/src/models/atlas/atlas-identifiers.ts @@ -0,0 +1,44 @@ +import {ensure0x} from '../../utils/hex-utils'; + +/** Anything that looks like a 0x-prefixed hex value: a Rootstock hash or address. */ +const PREFIXED_HEX = /^0x[0-9a-fA-F]+$/; + +/** + * Normalizes the identifier used as `swap_id`. + * + * Atlas correlates the events of one swap by string equality, so the same + * transaction reaching the queue as `0xAB…` and `ab…` would split into two + * swaps. Every emitter therefore agrees on one spelling: 0x-prefixed and + * lowercase. + * + * @param value - The hash as it came from the Bridge log or the database. + * @returns The hash, 0x-prefixed and lowercase. + * @throws Error when the value is missing or blank, rather than letting an + * empty `swap_id` reach the queue where it would merge unrelated swaps. + */ +export function normalizeSwapId(value: string | undefined | null): string { + const trimmed = (value ?? '').trim(); + if (trimmed === '') { + throw new Error('Cannot build an Atlas event with an empty swap_id'); + } + return ensure0x(trimmed).toLowerCase(); +} + +/** + * Normalizes an address used as `wallet_address`. + * + * Only 0x-prefixed hex is lowercased, which is what makes this safe to call on + * either flow: a Rootstock address is case insensitive (the mixed case is just + * an EIP-55 checksum), while a Bitcoin address is base58 or bech32 and + * lowercasing it would produce an address that does not exist. + * + * @param value - The address as it came from the Bridge log or the database. + * @returns The normalized address, or `null` when there is none to report. + */ +export function normalizeAddress(value: string | undefined | null): string | null { + const trimmed = (value ?? '').trim(); + if (trimmed === '') { + return null; + } + return PREFIXED_HEX.test(trimmed) ? trimmed.toLowerCase() : trimmed; +} diff --git a/src/models/atlas/atlas-pegin-reasons.ts b/src/models/atlas/atlas-pegin-reasons.ts new file mode 100644 index 00000000..aa9dbe92 --- /dev/null +++ b/src/models/atlas/atlas-pegin-reasons.ts @@ -0,0 +1,122 @@ +import {getLogger, Logger} from '../../utils/logger'; + +const logger: Logger = getLogger('atlasPeginReasons'); + +/** + * `reason` of `rejected_pegin`, mirroring rskj's `RejectedPeginReason`. + * + * Verified against rsksmart/rskj@161c3f1. This table is the only artifact that + * has to stay aligned with rskj: if a value is added there, it lands here as + * {@link UNKNOWN_REASON_NAME} with a warning rather than silently changing + * meaning. See `RejectedPeginReason.java`. + */ +export const REJECTED_PEGIN_REASONS = { + // Unreachable after arrowhead600; if it shows up, something is wrong. + '1': 'PEGIN_CAP_SURPASSED', + '2': 'LEGACY_PEGIN_MULTISIG_SENDER', + '3': 'LEGACY_PEGIN_UNDETERMINED_SENDER', + // Unreadable OP_RETURN payload. + '4': 'PEGIN_V1_INVALID_PAYLOAD', + '5': 'INVALID_AMOUNT', +} as const; + +/** + * `reason` of `unrefundable_pegin`, mirroring rskj's `NonRefundablePeginReason`. + * + * A different enum from {@link REJECTED_PEGIN_REASONS} that happens to share + * the same position in the log: reason `3` means `LEGACY_PEGIN_UNDETERMINED_SENDER` + * in one and `INVALID_AMOUNT` in the other. Translating by number alone, without + * branching on the event name first, is exactly the bug this table prevents. + */ +export const NON_REFUNDABLE_PEGIN_REASONS = { + '1': 'LEGACY_PEGIN_UNDETERMINED_SENDER', + '2': 'PEGIN_V1_REFUND_ADDRESS_NOT_SET', + '3': 'INVALID_AMOUNT', + '4': 'OUTPUTS_SENT_TO_DIFFERENT_TYPES_OF_FEDS', +} as const; + +/** Reported when the Bridge sent a reason this table does not know. */ +export const UNKNOWN_REASON_NAME = 'UNKNOWN'; + +export type AtlasErrorCategory = 'validation' | 'protocol_violation'; + +const VALIDATION: AtlasErrorCategory = 'validation'; +const PROTOCOL_VIOLATION: AtlasErrorCategory = 'protocol_violation'; + +/** + * The only two reasons that describe a sender not honoring the protocol. Every + * other reason — an amount, a cap, an unreadable payload — is the Bridge + * validating the request, which is what `validation` means. + */ +const PROTOCOL_VIOLATIONS: ReadonlySet = new Set([ + 'LEGACY_PEGIN_MULTISIG_SENDER', + 'LEGACY_PEGIN_UNDETERMINED_SENDER', +]); + +/** + * Names the `reason` of a `rejected_pegin` log. + * + * @param reason - The numeric reason as it came in the log. + * @returns The rskj enum name, or {@link UNKNOWN_REASON_NAME} when this table + * does not have that value. + */ +export function rejectedPeginReasonName(reason: string | undefined | null): string { + return nameOf(REJECTED_PEGIN_REASONS, reason, 'rejected_pegin'); +} + +/** + * Names the `reason` of an `unrefundable_pegin` log. + * + * @param reason - The numeric reason as it came in the log. + * @returns The rskj enum name, {@link UNKNOWN_REASON_NAME} when the value is + * unknown, or `undefined` when there was no `unrefundable_pegin` log at all — + * two cases the caller must tell apart. + */ +export function nonRefundablePeginReasonName( + reason: string | undefined | null, +): string | undefined { + if (reason === undefined || reason === null || `${reason}` === '') { + return undefined; + } + return nameOf(NON_REFUNDABLE_PEGIN_REASONS, reason, 'unrefundable_pegin'); +} + +/** + * Classifies a named reason for the `error_category` of `swap.rejected`. + * + * @param reasonName - A name returned by one of the translation functions. + * @returns `protocol_violation` only for the sender-side reasons; `validation` + * for everything else, the unknown fallback included. + */ +export function errorCategoryOf(reasonName: string): AtlasErrorCategory { + return PROTOCOL_VIOLATIONS.has(reasonName) ? PROTOCOL_VIOLATION : VALIDATION; +} + +/** + * Looks a reason up in one table, warning when it is not there. + * + * The warning is the whole degradation strategy: emission never breaks, but a + * value rskj added after this table was written leaves a trace someone can act + * on. + * + * @param table - The translation table of the log being read. + * @param reason - The numeric reason as it came in the log. + * @param eventName - Name of the log, for the warning. + * @returns The name, or {@link UNKNOWN_REASON_NAME}. + */ +function nameOf( + table: Record, + reason: string | undefined | null, + eventName: string, +): string { + const key = reason === undefined || reason === null ? '' : `${reason}`; + const name = table[key]; + if (!name) { + logger.warn( + {method: 'nameOf', event: eventName, reason: key}, + 'Unknown Bridge rejection reason, reporting it as UNKNOWN', + ); + return UNKNOWN_REASON_NAME; + } + return name; +} diff --git a/src/services/atlas/atlas-event-metrics.ts b/src/services/atlas/atlas-event-metrics.ts new file mode 100644 index 00000000..2cb86f4e --- /dev/null +++ b/src/services/atlas/atlas-event-metrics.ts @@ -0,0 +1,124 @@ +import {getLogger, Logger} from '../../utils/logger'; + +/** + * Name of the metric field, and the contract with the log aggregator: this is + * what an alert on lost Atlas events queries. Renaming it for style would + * silently break that alert, so it is pinned by a test and documented in + * `ENV_VARIABLES.md`. + */ +export const ATLAS_EVENTS_PUBLISHED_METRIC = 'atlas_events_published_total'; + +/** Which peg the event belongs to. The envelope does not carry it. */ +export type AtlasEventFlow = 'pegin' | 'pegout'; + +export type AtlasPublicationStatus = 'success' | 'failure'; + +/** Used as a key component when the caller did not say which flow it is. */ +const UNSPECIFIED_FLOW = 'unspecified'; + +/** + * Counts Atlas event publications and logs one line per publication. + * + * This makes the loss described in `ENV_VARIABLES.md` visible: a publisher that + * swallows a transport failure to keep the daemon running leaves no trace of + * how much analytics data went missing. It does **not** recover the events — + * that needs an outbox, or at least an `atlasPublishedAt` flag on the status. + * + * The counter lives in memory and resets with the process, which is enough for + * an aggregator that reads the logged totals. + */ +export class AtlasEventMetrics { + private logger: Logger; + private counters: Map = new Map(); + + constructor(logger: Logger = getLogger('atlasEventMetrics')) { + this.logger = logger; + } + + /** + * Records an event that reached the queue. + * + * @param eventType - `event_type` of the published event. + * @param flow - Which peg it belongs to. + */ + recordSuccess(eventType: string, flow?: AtlasEventFlow): void { + this.record('success', eventType, flow); + } + + /** + * Records an event that did not reach the queue and is now lost. + * + * @param eventType - `event_type` of the event that failed to publish. + * @param flow - Which peg it belongs to. + */ + recordFailure(eventType: string, flow?: AtlasEventFlow): void { + this.record('failure', eventType, flow); + } + + /** + * Reads a running total. + * + * @param status - Whether to count successes or failures. + * @param eventType - `event_type` to count. + * @param flow - Which peg to count, matching how it was recorded. + * @returns The count since this process started, `0` if never recorded. + */ + total(status: AtlasPublicationStatus, eventType: string, flow?: AtlasEventFlow): number { + return this.counters.get(counterKey(status, eventType, flow)) ?? 0; + } + + /** + * Increments the counter and logs the metric line. + * + * The counter is advanced before logging, and logging is guarded: a broken + * log pipeline must not throw into a publisher whose whole contract is not to + * throw, and must not lose the count either. + * + * @param status - Whether the publication succeeded. + * @param eventType - `event_type` of the event. + * @param flow - Which peg it belongs to. + */ + private record( + status: AtlasPublicationStatus, + eventType: string, + flow?: AtlasEventFlow, + ): void { + const key = counterKey(status, eventType, flow); + const total = (this.counters.get(key) ?? 0) + 1; + this.counters.set(key, total); + + const line = { + metric: ATLAS_EVENTS_PUBLISHED_METRIC, + status, + flow: flow ?? UNSPECIFIED_FLOW, + eventType, + total, + }; + try { + if (status === 'failure') { + this.logger.warn(line, 'Atlas event publication failed'); + } else { + this.logger.info(line, 'Atlas event published'); + } + } catch (e) { + // Nothing to do: the count is already recorded and this is a metric. + } + } +} + +/** + * Builds the counter key. One counter per status, flow and event type, so a + * failure of one event type cannot hide behind the successes of another. + * + * @param status - Whether the publication succeeded. + * @param eventType - `event_type` of the event. + * @param flow - Which peg it belongs to. + * @returns The map key. + */ +function counterKey( + status: AtlasPublicationStatus, + eventType: string, + flow?: AtlasEventFlow, +): string { + return `${status}|${flow ?? UNSPECIFIED_FLOW}|${eventType}`; +} diff --git a/src/services/atlas/atlas-event-publisher.ts b/src/services/atlas/atlas-event-publisher.ts new file mode 100644 index 00000000..91a0d683 --- /dev/null +++ b/src/services/atlas/atlas-event-publisher.ts @@ -0,0 +1,37 @@ +import {AtlasEvent} from '../../models/atlas/atlas-event.model'; +import {AtlasEventFlow, AtlasEventMetrics} from './atlas-event-metrics'; + +/** + * Publishes Atlas SWAP events. Consumers depend on this interface and never on + * a concrete transport, so the SQS client can be stubbed in tests and replaced + * later (e.g. by an outbox-backed publisher) without touching the callers. + */ +export interface AtlasEventPublisher { + /** + * Publishes one event. Implementations must never reject: a transport failure + * is logged and swallowed so a peg transition already persisted is not rolled + * back because analytics were unreachable. + * + * @param event - The event to publish. + * @param flow - Which peg the event belongs to. Optional so existing callers + * and implementations keep working; it cannot be derived from the envelope, + * which is why it is passed in. + */ + publish(event: AtlasEvent, flow?: AtlasEventFlow): Promise; + + /** + * Counters of what this publisher published, and of what it lost. Exposed so + * a caller — or a test — can read the totals the logs report. + */ + readonly metrics: AtlasEventMetrics; +} + +/** + * Reads the `ATLAS_EVENTS_ENABLED` kill switch. Anything other than the literal + * `true` keeps publication off, so a typo fails closed. + * + * @returns `true` only when Atlas event publication is explicitly enabled. + */ +export function isAtlasEventsEnabled(): boolean { + return process.env.ATLAS_EVENTS_ENABLED === 'true'; +} diff --git a/src/services/atlas/noop-atlas-event-publisher.ts b/src/services/atlas/noop-atlas-event-publisher.ts new file mode 100644 index 00000000..2a6c5aab --- /dev/null +++ b/src/services/atlas/noop-atlas-event-publisher.ts @@ -0,0 +1,44 @@ +import {AtlasEvent} from '../../models/atlas/atlas-event.model'; +import {getLogger, Logger} from '../../utils/logger'; +import {AtlasEventPublisher} from './atlas-event-publisher'; +import {AtlasEventFlow, AtlasEventMetrics} from './atlas-event-metrics'; + +/** + * Publisher bound while `ATLAS_EVENTS_ENABLED` is off. It builds no SQS client + * and performs no IO, so the feature can ship dark in every environment. + */ +export class NoopAtlasEventPublisher implements AtlasEventPublisher { + /** + * Always empty. A metric named `published_total` must not count events that + * were never published, so discarding leaves the counters at zero. + */ + readonly metrics: AtlasEventMetrics; + private logger: Logger; + + constructor() { + this.logger = getLogger('noopAtlasEventPublisher'); + this.metrics = new AtlasEventMetrics(this.logger); + } + + /** + * Discards the event. + * + * Never rejects, as {@link AtlasEventPublisher.publish} requires: with the + * feature off the only thing that can fail here is the logger, and a broken + * log pipeline must not take peg processing down with it. + * + * @param event - The event that would have been published. + * @param flow - Which peg it would have belonged to. Unused. + */ + async publish(event: AtlasEvent, flow?: AtlasEventFlow): Promise { + try { + this.logger.debug( + {method: 'publish', eventType: event.event_type, swapId: event.swap_id}, + 'Atlas events are disabled, discarding event', + ); + } catch { + // Nothing to report it with, and nothing was at stake: the event was + // going to be discarded either way. + } + } +} diff --git a/src/services/atlas/pegin-atlas-event.builder.ts b/src/services/atlas/pegin-atlas-event.builder.ts new file mode 100644 index 00000000..999d1c9b --- /dev/null +++ b/src/services/atlas/pegin-atlas-event.builder.ts @@ -0,0 +1,316 @@ +import {randomUUID} from 'crypto'; +import { + ASSET_BTC, + ASSET_RBTC, + ATLAS_PROVIDER, + ATLAS_SCHEMA_VERSION, + ATLAS_SOURCE, + ATLAS_SWAP_TYPE, + AtlasEvent, + AtlasEventData, + AtlasEventType, + SwapCompletedData, + SwapCreatedData, + SwapRejectedData, +} from '../../models/atlas/atlas-event.model'; +import {resolvePeginChainIds} from '../../models/atlas/atlas-chain'; +import {satoshisToDecimalString} from '../../models/atlas/atlas-amount'; +import {normalizeAddress, normalizeSwapId} from '../../models/atlas/atlas-identifiers'; +import { + errorCategoryOf, + nonRefundablePeginReasonName, + rejectedPeginReasonName, +} from '../../models/atlas/atlas-pegin-reasons'; +import { + PeginStatus, + PeginStatusDataModel, +} from '../../models/rsk/pegin-status-data.model'; +import {BRIDGE_EVENTS} from '../../utils/bridge-utils'; +import {ExtendedBridgeEvent} from '../../models/types/bridge-transaction-parser'; +import ExtendedBridgeTx from '../extended-bridge-tx'; + +const REJECTED_MESSAGE_PREFIX = 'Peg-in rejected by the Bridge'; +const ABSENT_REASON = 'absent'; +/** + * Reported when the Bridge rejected the peg-in and emitted no refund branch. + * The name describes what was observed, not the cause: the probable one — + * `buildEmptyWalletTo` failing and rskj panicking — is nowhere in the logs. + */ +const NO_REFUND_BRANCH_CODE = 'PEGIN_REJECTED_NO_REFUND_BRANCH'; +const NO_REFUND_BRANCH_CLAUSE = 'the Bridge emitted no refund branch'; +/** The Bridge credits the whole amount sent: a peg-in has no fee to subtract. */ +const NO_FEE = satoshisToDecimalString(0); +const ZERO_AMOUNT = satoshisToDecimalString(0); + +/** + * Data that only exists in the Bridge logs of the transaction being processed. + * + * `PeginStatusDataModel` persists neither the amount nor the addresses, so the + * builder receives them alongside the status instead of the daemon growing new + * columns for them. + */ +export interface PeginAtlasEventContext { + /** + * `amount` **in satoshis**, from `pegin_btc` / `lock_btc` when the peg-in was + * locked, or from `release_requested` when it was rejected with a refund. + * Absent only for an unrefundable rejection, whose logs carry no amount. + * + * Unlike the peg-out logs, whose `amount` is in weis, all three peg-in logs + * report satoshis directly, so no conversion applies here. + */ + amountInSatoshis?: string; + /** Only `lock_btc` carries the Bitcoin address of the sender. */ + senderBtcAddress?: string; + /** `receiver` of `pegin_btc` / `lock_btc`: the destination account on Rootstock. */ + rskRecipient?: string; + /** `reason` of `rejected_pegin`. */ + rejectedReason?: string; + /** `reason` of `unrefundable_pegin`. */ + unrefundableReason?: string; +} + +/** + * Turns a persisted peg-in status into the Atlas SWAP events its transition + * represents. + * + * Unlike peg-out, a peg-in record is written once and never updated, so every + * event of one peg-in is emitted in a single pass over one Bridge transaction: + * `LOCKED` produces `swap.created` and `swap.completed` together, and a + * rejection produces `swap.created` and `swap.rejected`. `swap.pending` has no + * trigger, since the daemon never observes the deposit on Bitcoin. + */ +export class PeginAtlasEventBuilder { + + /** + * Reads from the Bridge logs the fields the persisted status does not keep. + * + * @param extendedBridgeTx - The Bridge transaction being processed. + * @returns The context for {@link PeginAtlasEventBuilder.build}. + */ + public static extractContext(extendedBridgeTx: ExtendedBridgeTx): PeginAtlasEventContext { + const events = (extendedBridgeTx?.events ?? []) as ExtendedBridgeEvent[]; + const byName = (name: string) => events.find(event => event.name === name); + + const lockBtc = byName(BRIDGE_EVENTS.LOCK_BTC); + const peginBtc = byName(BRIDGE_EVENTS.PEGIN_BTC); + const rejected = byName(BRIDGE_EVENTS.REJECTED_PEGIN); + const unrefundable = byName(BRIDGE_EVENTS.UNREFUNDABLE_PEGIN); + const releaseRequested = byName(BRIDGE_EVENTS.RELEASE_REQUESTED); + const locked = lockBtc ?? peginBtc; + + const context: PeginAtlasEventContext = {}; + if (locked) { + context.amountInSatoshis = this.asString(locked.arguments.amount); + context.rskRecipient = this.asString(locked.arguments.receiver); + } else if (releaseRequested) { + // A refundable rejection reports the amount nowhere else. This is + // `computeTotalAmountSent(btcTx)` on the Bridge side: the total the user + // sent to the federation, in satoshis, which is the right input_amount. + // The refund arrives minus the Bitcoin fee, but that difference belongs + // to an outgoing event this schema does not have. + // + // A locked log and a release_requested do not coexist in one peg-in + // transaction; fixing the precedence anyway leaves the behaviour defined + // if they ever do. + context.amountInSatoshis = this.asString(releaseRequested.arguments.amount); + } + if (lockBtc) { + context.senderBtcAddress = this.asString(lockBtc.arguments.senderBtcAddress); + } + if (rejected) { + context.rejectedReason = this.asString(rejected.arguments.reason); + } + if (unrefundable) { + context.unrefundableReason = this.asString(unrefundable.arguments.reason); + } + return context; + } + + /** + * Builds the Atlas events matching the status of `pegin`. + * + * Every in-scope status yields two events, `swap.created` first so the Worker + * has a row carrying chains and assets, then the outcome — `swap.completed` + * or `swap.rejected`. The order of the array is the order they must be + * published in. + * + * @param pegin - The peg-in status just written to the database. + * @param context - Fields read from the Bridge logs of the same transaction. + * @returns The events to publish, empty when the status is out of scope. + */ + public static build( + pegin: PeginStatusDataModel, + context: PeginAtlasEventContext = {}, + ): AtlasEvent[] { + switch (pegin.status) { + case PeginStatus.LOCKED: + return [ + this.envelope(pegin, AtlasEventType.SWAP_CREATED, this.createdData(context)), + this.envelope( + pegin, + AtlasEventType.SWAP_COMPLETED, + this.completedData(pegin, context), + ), + ]; + case PeginStatus.REJECTED_REFUND: + return [ + this.envelope(pegin, AtlasEventType.SWAP_CREATED, this.createdData(context)), + this.envelope(pegin, AtlasEventType.SWAP_REJECTED, this.refundableRejection(context)), + ]; + case PeginStatus.REJECTED_NO_REFUND: + return [ + this.envelope(pegin, AtlasEventType.SWAP_CREATED, this.createdData(context)), + this.envelope(pegin, AtlasEventType.SWAP_REJECTED, this.unrefundableRejection(context)), + ]; + default: + return []; + } + } + + private static envelope( + pegin: PeginStatusDataModel, + eventType: AtlasEventType, + data: AtlasEventData, + ): AtlasEvent { + return { + event_id: randomUUID(), + event_type: eventType, + swap_id: normalizeSwapId(pegin.btcTxId), + swap_type: ATLAS_SWAP_TYPE, + source: ATLAS_SOURCE, + schema_version: ATLAS_SCHEMA_VERSION, + emitted_at: new Date(pegin.createdOn).toISOString(), + data, + }; + } + + private static createdData(context: PeginAtlasEventContext): SwapCreatedData { + const {sourceChain, destinationChain} = resolvePeginChainIds(); + return { + provider: ATLAS_PROVIDER, + source_chain: sourceChain, + destination_chain: destinationChain, + input_asset: ASSET_BTC, + output_asset: ASSET_RBTC, + input_amount: this.inputAmount(context), + input_amount_usd: null, + // `lock_btc` is the only log carrying the user's Bitcoin address; with + // `pegin_btc` the best available is the Rootstock destination account, + // and a rejection carries neither. + wallet_address: normalizeAddress(context.senderBtcAddress ?? context.rskRecipient), + wallet_type: null, + quote_id: null, + }; + } + + /** + * Describes the completion of a peg-in. + * + * `duration_ms` travels null on purpose: the daemon observes Rootstock only, + * so when the deposit was broadcast on Bitcoin is unknown, and a zero would + * pull the average swap duration down instead of leaving it unmeasured. + * + * @param pegin - The peg-in status just persisted. + * @param context - Fields read from the Bridge logs. + * @returns The `swap.completed` payload. + */ + private static completedData( + pegin: PeginStatusDataModel, + context: PeginAtlasEventContext, + ): SwapCompletedData { + return { + // The RBTC is credited by the very Rootstock transaction being processed. + destination_tx_hash: normalizeSwapId(pegin.rskTxId), + output_amount: this.inputAmount(context), + output_amount_usd: null, + fee: NO_FEE, + duration_ms: null, + }; + } + + private static refundableRejection(context: PeginAtlasEventContext): SwapRejectedData { + const reasonName = rejectedPeginReasonName(context.rejectedReason); + return { + error_category: errorCategoryOf(reasonName), + error_code: reasonName, + error_message: this.rejectionMessage(reasonName, context), + refund_applicable: true, + }; + } + + /** + * Describes a rejection whose funds are not coming back, in both of its + * shapes: the Bridge declared the peg-in unrefundable, or it emitted no + * refund branch at all. + * + * The `error_category` always comes from the `rejected_pegin` reason, which + * is the root cause and the only log present in every branch. The + * `error_code` names that same reason, except when there is no refund branch + * to speak of: that absence is the more specific fact, so it takes the code. + * + * @param context - Fields read from the Bridge logs. + * @returns The `swap.rejected` payload. + */ + private static unrefundableRejection(context: PeginAtlasEventContext): SwapRejectedData { + const reasonName = rejectedPeginReasonName(context.rejectedReason); + const declared = nonRefundablePeginReasonName(context.unrefundableReason) !== undefined; + return { + error_category: errorCategoryOf(reasonName), + error_code: declared ? reasonName : NO_REFUND_BRANCH_CODE, + error_message: this.rejectionMessage( + reasonName, + context, + declared ? undefined : NO_REFUND_BRANCH_CLAUSE, + ), + refund_applicable: false, + }; + } + + /** + * Composes the human-readable rejection message. + * + * The raw numbers travel here, in the message, so a reason this build cannot + * name is still recoverable from the event itself without going back to the + * chain. + * + * @param reasonName - The named reason of `rejected_pegin`. + * @param context - Fields read from the Bridge logs. + * @param clause - What to say instead when there is no `unrefundable_pegin` + * reason to report. + * @returns The message for `swap.rejected`. + */ + private static rejectionMessage( + reasonName: string, + context: PeginAtlasEventContext, + clause?: string, + ): string { + const unrefundableName = nonRefundablePeginReasonName(context.unrefundableReason); + const reasons = [`rejected_pegin reason=${context.rejectedReason ?? ABSENT_REASON}`]; + let message = `${REJECTED_MESSAGE_PREFIX}: ${reasonName}`; + if (unrefundableName !== undefined) { + message += ` \u2014 funds not refundable (${unrefundableName})`; + reasons.push(`unrefundable_pegin reason=${context.unrefundableReason}`); + } else if (clause !== undefined) { + message += ` \u2014 ${clause}`; + } + return `${message}. ${reasons.join(', ')}`; + } + + private static inputAmount(context: PeginAtlasEventContext): string { + if (context.amountInSatoshis === undefined) { + // Only an unrefundable rejection gets here: neither `rejected_pegin` nor + // `unrefundable_pegin` carries an amount and there is no other + // transaction to read it from, so zero is the only honest value. + return ZERO_AMOUNT; + } + // `pegin_btc` / `lock_btc` report satoshis. Running these through the + // peg-out helper `fromWeiNumberToSatoshiNumber` divides by 1e10 and + // collapses every realistic peg-in to zero. + return satoshisToDecimalString(Number(context.amountInSatoshis)); + } + + private static asString(value: unknown): string | undefined { + return value === undefined || value === null ? undefined : String(value); + } + +} diff --git a/src/services/atlas/pegout-atlas-event.builder.ts b/src/services/atlas/pegout-atlas-event.builder.ts new file mode 100644 index 00000000..18bcc3e3 --- /dev/null +++ b/src/services/atlas/pegout-atlas-event.builder.ts @@ -0,0 +1,219 @@ +import {randomUUID} from 'crypto'; +import {getLogger, Logger} from '../../utils/logger'; +import { + ASSET_RBTC, + ASSET_BTC, + ATLAS_PROVIDER, + ATLAS_SCHEMA_VERSION, + ATLAS_SOURCE, + ATLAS_SWAP_TYPE, + AtlasEvent, + AtlasEventData, + AtlasEventType, + SwapCompletedData, + SwapCreatedData, + SwapPendingData, + SwapRejectedData, +} from '../../models/atlas/atlas-event.model'; +import {resolvePegoutChainIds} from '../../models/atlas/atlas-chain'; +import {satoshisToDecimalString} from '../../models/atlas/atlas-amount'; +import {normalizeAddress, normalizeSwapId} from '../../models/atlas/atlas-identifiers'; +import { + PegoutStatusDbDataModel, + PegoutStatuses, +} from '../../models/rsk/pegout-status-data-model'; + +const logger: Logger = getLogger('pegoutAtlasEventBuilder'); + +const REJECTION_ERROR_CATEGORY = 'validation'; +const REJECTION_ERROR_MESSAGE = 'Pegout request rejected by the Bridge'; +const UNKNOWN_REJECTION_REASON = 'UNKNOWN'; + +export interface PegoutAtlasEventContext { + /** + * `createdOn` of the `RECEIVED` status of the same peg-out. Only used to + * compute `duration_ms` of `swap.completed`; when absent the field travels null. + */ + receivedCreatedOn?: Date; +} + +/** + * Turns a persisted peg-out status into the Atlas SWAP event that its + * transition represents. + */ +export class PegoutAtlasEventBuilder { + + /** + * Formats an amount in satoshis as a fixed 8-decimal string. + * + * @param satoshis - Amount in satoshis. Nullish values are treated as zero. + * @returns The amount in BTC/RBTC, e.g. `"0.12345678"`. + */ + public static toDecimalAmount(satoshis: number | undefined | null): string { + return satoshisToDecimalString(satoshis); + } + + /** + * Builds the Atlas event matching the status of `pegout`, or `null` when the + * status has no equivalent in the v1.0 schema (e.g. `WAITING_FOR_SIGNATURE`). + * + * @param pegout - The peg-out status just written to the database. + * @param context - Extra data that cannot be derived from `pegout` alone. + * @returns The event to publish, or `null` when the status is out of scope. + */ + public static build( + pegout: PegoutStatusDbDataModel, + context: PegoutAtlasEventContext = {}, + ): AtlasEvent | null { + switch (pegout.status) { + case PegoutStatuses.RECEIVED: + return this.envelope(pegout, AtlasEventType.SWAP_CREATED, this.createdData(pegout)); + case PegoutStatuses.WAITING_FOR_CONFIRMATION: + return this.envelope(pegout, AtlasEventType.SWAP_PENDING, this.pendingData(pegout)); + case PegoutStatuses.RELEASE_BTC: + return this.envelope( + pegout, + AtlasEventType.SWAP_COMPLETED, + this.completedData(pegout, context), + ); + case PegoutStatuses.REJECTED: + return this.envelope(pegout, AtlasEventType.SWAP_REJECTED, this.rejectedData(pegout)); + default: + return null; + } + } + + private static envelope( + pegout: PegoutStatusDbDataModel, + eventType: AtlasEventType, + data: AtlasEventData, + ): AtlasEvent { + return { + event_id: randomUUID(), + event_type: eventType, + // Never `rskTxHash`: the processor mutates it to disambiguate batches. + swap_id: normalizeSwapId(pegout.originatingRskTxHash), + swap_type: ATLAS_SWAP_TYPE, + source: ATLAS_SOURCE, + schema_version: ATLAS_SCHEMA_VERSION, + emitted_at: new Date(pegout.createdOn).toISOString(), + data, + }; + } + + private static createdData(pegout: PegoutStatusDbDataModel): SwapCreatedData { + const {sourceChain, destinationChain} = resolvePegoutChainIds(); + return { + provider: ATLAS_PROVIDER, + source_chain: sourceChain, + destination_chain: destinationChain, + input_asset: ASSET_RBTC, + output_asset: ASSET_BTC, + input_amount: this.toDecimalAmount(pegout.valueRequestedInSatoshis), + input_amount_usd: null, + wallet_address: normalizeAddress(pegout.rskSenderAddress), + wallet_type: null, + quote_id: null, + }; + } + + private static pendingData(pegout: PegoutStatusDbDataModel): SwapPendingData { + return { + source_tx_hash: normalizeSwapId(pegout.originatingRskTxHash), + deposit_address: null, + // Counted in Rootstock blocks, not Bitcoin ones. + expected_confirmations: this.expectedConfirmations(), + }; + } + + private static completedData( + pegout: PegoutStatusDbDataModel, + context: PegoutAtlasEventContext, + ): SwapCompletedData { + const requested = pegout.valueRequestedInSatoshis ?? 0; + const received = pegout.valueInSatoshisToBeReceived ?? 0; + return { + destination_tx_hash: pegout.btcTxHash, + output_amount: this.toDecimalAmount(received), + output_amount_usd: null, + fee: this.toDecimalAmount(this.feeSatoshis(pegout, requested, received)), + duration_ms: this.durationMs(pegout, context), + }; + } + + private static rejectedData(pegout: PegoutStatusDbDataModel): SwapRejectedData { + return { + error_category: REJECTION_ERROR_CATEGORY, + error_code: pegout.reason ?? UNKNOWN_REJECTION_REASON, + error_message: REJECTION_ERROR_MESSAGE, + // The Bridge returns the RBTC to the sender in the rejection transaction + // itself: there is no observable refund branch to wait for. + refund_applicable: false, + }; + } + + private static durationMs( + pegout: PegoutStatusDbDataModel, + {receivedCreatedOn}: PegoutAtlasEventContext, + ): number | null { + if (!receivedCreatedOn) { + return null; + } + const elapsed = new Date(pegout.createdOn).getTime() - new Date(receivedCreatedOn).getTime(); + return Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : null; + } + + /** + * The peg-out fee, in satoshis, never below zero. + * + * `fee` is a `decimalAmount` in the schema and its pattern admits no minus + * sign, so a batch whose output was matched to the wrong peg-out — or a + * corrupted status row — would otherwise produce an event that fails + * validation on the Atlas side. Reporting zero keeps the transition in the + * dataset; the warning is what says the numbers behind it cannot be trusted. + * + * @param pegout - The peg-out the amounts were read from, for the log. + * @param requested - Amount the user asked to release, in satoshis. + * @param received - Amount the Bitcoin output actually pays, in satoshis. + * @returns `requested - received`, or 0 when that difference is negative. + */ + private static feeSatoshis( + pegout: PegoutStatusDbDataModel, + requested: number, + received: number, + ): number { + const fee = requested - received; + if (fee < 0) { + logger.warn( + { + method: 'feeSatoshis', + originatingRskTxHash: pegout.originatingRskTxHash, + requested, + received, + }, + 'Peg-out received more than it requested, reporting a zero fee', + ); + return 0; + } + return fee; + } + + /** + * Confirmations the schema expects a peg-out to wait for, never below zero. + * + * `expected_confirmations` is an `integer` with `minimum: 0`, so a negative + * `RSK_PEGOUT_MINIMUM_CONFIRMATIONS` is as invalid as a missing one and is + * treated the same way. + * + * @returns The configured confirmations, or 0 when unset or not a + * non-negative number. + */ + private static expectedConfirmations(): number { + const configured = parseInt(process.env.RSK_PEGOUT_MINIMUM_CONFIRMATIONS ?? '', 10); + if (!Number.isFinite(configured) || configured < 0) { + return 0; + } + return configured; + } + +} diff --git a/src/services/atlas/sqs-atlas-event-publisher.ts b/src/services/atlas/sqs-atlas-event-publisher.ts new file mode 100644 index 00000000..515b3d64 --- /dev/null +++ b/src/services/atlas/sqs-atlas-event-publisher.ts @@ -0,0 +1,106 @@ +import {SQSClient, SendMessageCommand} from '@aws-sdk/client-sqs'; +import {AtlasEvent} from '../../models/atlas/atlas-event.model'; +import {getLogger, Logger} from '../../utils/logger'; +import {AtlasEventPublisher} from './atlas-event-publisher'; +import {AtlasEventFlow, AtlasEventMetrics} from './atlas-event-metrics'; + +const DEFAULT_AWS_REGION = 'us-east-1'; + +/** + * Returns the configured `ATLAS_SQS_QUEUE_URL`, failing fast when it is missing + * or blank. + * + * An empty url does not disable publication, it breaks it: every `SendMessage` + * would be rejected by the SDK, the failure swallowed by {@link + * SqsAtlasEventPublisher.publish}, and the events lost with no retry. A daemon + * started with the kill switch on and no queue to publish to is misconfigured, + * so it aborts at construction instead of running blind. + * + * @returns The configured queue url. + * @throws Error when `ATLAS_SQS_QUEUE_URL` is absent or blank. + */ +export function assertQueueUrlConfigured(): string { + const queueUrl = process.env.ATLAS_SQS_QUEUE_URL?.trim(); + if (!queueUrl) { + throw new Error( + 'Atlas events are enabled but ATLAS_SQS_QUEUE_URL is not set. Set it to ' + + 'the SQS FIFO queue url, or turn ATLAS_EVENTS_ENABLED off.', + ); + } + return queueUrl; +} + +/** + * Publishes Atlas SWAP events to an SQS FIFO queue. + * + * `MessageGroupId` is the `swap_id`, which keeps the transitions of a single + * peg-out strictly ordered while letting different peg-outs be processed in + * parallel. `MessageDeduplicationId` is the `event_id`, so the queue must have + * content based deduplication disabled. + * + * Constructed only while `ATLAS_EVENTS_ENABLED` is on, which is why the missing + * queue url is fatal here: see {@link assertQueueUrlConfigured}. + */ +export class SqsAtlasEventPublisher implements AtlasEventPublisher { + readonly metrics: AtlasEventMetrics; + private logger: Logger; + private client: SQSClient; + private queueUrl: string; + + constructor() { + this.logger = getLogger('sqsAtlasEventPublisher'); + this.metrics = new AtlasEventMetrics(); + this.queueUrl = assertQueueUrlConfigured(); + this.client = new SQSClient({ + region: process.env.AWS_REGION ?? DEFAULT_AWS_REGION, + // Only set for local development and the integration suite (LocalStack). + ...(process.env.ATLAS_SQS_ENDPOINT ? {endpoint: process.env.ATLAS_SQS_ENDPOINT} : {}), + }); + } + + /** + * Sends the event to the configured FIFO queue. Delivery failures are logged + * at error level and never propagated: the peg-out status is already stored + * and the daemon must keep processing blocks. + * + * Either outcome is counted, which is what makes a loss visible: a failure + * here means one Atlas event that no retry will ever send. + * + * @param event - The event to publish. + * @param flow - Which peg the event belongs to. + */ + async publish(event: AtlasEvent, flow?: AtlasEventFlow): Promise { + try { + await this.client.send(new SendMessageCommand({ + QueueUrl: this.queueUrl, + MessageBody: JSON.stringify(event), + MessageGroupId: event.swap_id, + MessageDeduplicationId: event.event_id, + })); + this.logger.debug( + {method: 'publish', eventType: event.event_type, swapId: event.swap_id}, + 'Atlas event published', + ); + this.metrics.recordSuccess(event.event_type, flow); + } catch (e) { + this.logger.error( + { + method: 'publish', + err: e, + eventType: event.event_type, + swapId: event.swap_id, + eventId: event.event_id, + }, + 'Could not publish the Atlas event', + ); + this.metrics.recordFailure(event.event_type, flow); + } + } + + /** + * Releases the underlying SQS client sockets. Used by the integration suite. + */ + destroy(): void { + this.client.destroy(); + } +} diff --git a/src/services/pegin-data.processor.ts b/src/services/pegin-data.processor.ts index 53f1fe48..a632ac99 100644 --- a/src/services/pegin-data.processor.ts +++ b/src/services/pegin-data.processor.ts @@ -9,14 +9,20 @@ import {PeginStatusDataService} from './pegin-status-data-services/pegin-status- import {ServicesBindings} from '../dependency-injection-bindings'; import ExtendedBridgeTx from './extended-bridge-tx'; import {ExtendedBridgeEvent} from "../models/types/bridge-transaction-parser"; +import {AtlasEventPublisher} from './atlas/atlas-event-publisher'; +import {PeginAtlasEventBuilder} from './atlas/pegin-atlas-event.builder'; export class PeginDataProcessor implements FilteredBridgeTransactionProcessor { peginStatusStorageService: PeginStatusDataService; + atlasEventPublisher: AtlasEventPublisher; logger: Logger; constructor(@inject(ServicesBindings.PEGIN_STATUS_DATA_SERVICE) - peginStatusStorageService: PeginStatusDataService,) { + peginStatusStorageService: PeginStatusDataService, + @inject(ServicesBindings.ATLAS_EVENT_PUBLISHER) + atlasEventPublisher: AtlasEventPublisher,) { this.logger = getLogger('peginDataProcessor'); this.peginStatusStorageService = peginStatusStorageService; + this.atlasEventPublisher = atlasEventPublisher; } async process(extendedBridgeTx: ExtendedBridgeTx): Promise { @@ -33,6 +39,7 @@ export class PeginDataProcessor implements FilteredBridgeTransactionProcessor { } await this.peginStatusStorageService.set(peginStatus); this.logger.info({method: 'process', txHash: extendedBridgeTx.txHash, btcTxId: peginStatus.btcTxId, status: peginStatus.status}, 'Tx registered'); + await this.publishAtlasEvents(peginStatus, extendedBridgeTx); } catch (e) { this.logger.warn({method: 'process', err: e}, 'There was a problem with the storage'); } @@ -87,7 +94,16 @@ export class PeginDataProcessor implements FilteredBridgeTransactionProcessor { this.logger.debug({method: 'getPeginStatus'}, 'PegIn rejected, unrefundable'); return status; } - this.logger.warn({method: 'getPeginStatus', txHash: extendedBridgeTx.txHash}, 'Call to RegisterBtcTransaction with invalid data'); + // The Bridge rejected the peg-in and emitted no refund branch: neither + // release_requested nor unrefundable_pegin. The funds are not coming + // back, so this is reported as unrefundable rather than dropped, which + // used to leave the user with no status at all. + status.status = RskPeginStatusEnum.REJECTED_NO_REFUND; + this.logger.warn( + {method: 'getPeginStatus', txHash: extendedBridgeTx.txHash}, + 'PegIn rejected and the Bridge emitted no refund branch, recording it as unrefundable', + ); + return status; } } @@ -109,6 +125,38 @@ export class PeginDataProcessor implements FilteredBridgeTransactionProcessor { } } + /** + * Publishes the Atlas SWAP events of a peg-in that has just been written to + * the database. A rejection publishes two events, in the order the builder + * returns them; a status with no equivalent in the v1.0 schema publishes none. + * + * Nothing here is allowed to fail the caller: a peg-in status is never rolled + * back because analytics could not be notified. + * + * @param peginStatus - The status just persisted. + * @param extendedBridgeTx - The Bridge transaction it was parsed from, which + * carries the amount and addresses the persisted status does not keep. + */ + private async publishAtlasEvents( + peginStatus: PeginStatusDataModel, + extendedBridgeTx: ExtendedBridgeTx, + ): Promise { + try { + const context = PeginAtlasEventBuilder.extractContext(extendedBridgeTx); + const events = PeginAtlasEventBuilder.build(peginStatus, context); + // Sequential on purpose: the queue orders by MessageGroupId, so + // swap.created has to be sent before the outcome that follows it. + for (const event of events) { + await this.atlasEventPublisher.publish(event, 'pegin'); + } + } catch (e) { + this.logger.error( + {method: 'publishAtlasEvents', err: e, btcTxId: peginStatus.btcTxId}, + 'Could not build or publish the Atlas events', + ); + } + } + parse(extendedBridgeTx: ExtendedBridgeTx): PeginStatusDataModel | null { // eslint-disable-next-line @typescript-eslint/prefer-optional-chain if (!extendedBridgeTx || !extendedBridgeTx.events || !extendedBridgeTx.events.length) { diff --git a/src/services/pegout-data.processor.ts b/src/services/pegout-data.processor.ts index 802484e1..ec5e705d 100644 --- a/src/services/pegout-data.processor.ts +++ b/src/services/pegout-data.processor.ts @@ -17,20 +17,26 @@ import { PegoutStatusBuilder } from './pegout-status/pegout-status-builder'; import {ExtendedBridgeEvent} from "../models/types/bridge-transaction-parser"; import { sha256 } from '../utils/sha256-utils'; import { FullRskTransaction } from '../models/rsk/full-rsk-transaction.model'; +import { AtlasEventPublisher } from './atlas/atlas-event-publisher'; +import { PegoutAtlasEventBuilder, PegoutAtlasEventContext } from './atlas/pegout-atlas-event.builder'; export class PegoutDataProcessor implements FilteredBridgeTransactionProcessor { private logger: Logger; private pegoutStatusDataService: PegoutStatusDataService; private bridgeService: BridgeService; + private atlasEventPublisher: AtlasEventPublisher; constructor( @inject(ServicesBindings.PEGOUT_STATUS_DATA_SERVICE) pegoutStatusDataService: PegoutStatusDataService, @inject(ServicesBindings.BRIDGE_SERVICE) - bridgeService: BridgeService) { + bridgeService: BridgeService, + @inject(ServicesBindings.ATLAS_EVENT_PUBLISHER) + atlasEventPublisher: AtlasEventPublisher) { this.logger = getLogger('pegoutDataProcessor'); this.pegoutStatusDataService = pegoutStatusDataService; this.bridgeService = bridgeService; + this.atlasEventPublisher = atlasEventPublisher; } getFilters(): BridgeDataFilterModel[] { @@ -136,15 +142,21 @@ export class PegoutDataProcessor implements FilteredBridgeTransactionProcessor { } const batchPegoutCreationTx = releaseBTCEvent.arguments.releaseRskTxHash; - for(let output of parsedBtcTransaction.outs) { - const address = bitcoin.address.fromOutputScript(output.script, btcNetwork); + for(const [outputIndex, output] of parsedBtcTransaction.outs.entries()) { + let address; + try { + address = bitcoin.address.fromOutputScript(output.script, btcNetwork); + } catch (e) { + // Federation change outputs are not addressable pegout recipients. + continue; + } const dbPegout = await this.pegoutStatusDataService.getPegoutByRecipientAndCreationTx(address, batchPegoutCreationTx); - if(!dbPegout || dbPegout.length !== 1 ) { - this.logger.debug({method: 'processSignedStatusByRtx', address, batchPegoutCreationTx}, 'Not found any pegout related to this output'); + const thePegout = this.selectPegoutForOutput(dbPegout, outputIndex); + if(!thePegout) { + this.logger.debug({method: 'processSignedStatusByRtx', address, batchPegoutCreationTx, outputIndex, candidates: dbPegout?.length ?? 0}, 'Not found any pegout related to this output'); continue; } - const [thePegout] = dbPegout; this.logger.debug({method: 'processSignedStatusByRtx', originatingRskTxHash: thePegout.originatingRskTxHash}, 'Found a pegout to be released'); this.logPegoutData(thePegout); @@ -152,7 +164,9 @@ export class PegoutDataProcessor implements FilteredBridgeTransactionProcessor { const newPegoutStatus = PegoutStatusDbDataModel.clonePegoutStatusInstance(thePegout); newPegoutStatus.setRskTxInformation(extendedBridgeTx); newPegoutStatus.btcRawTransaction = rawTx; - newPegoutStatus.btcTxHash = parsedBtcTransaction.getHash().toString('hex'); + // getId() is the canonical big-endian txid; getHash() is the internal + // little-endian hash, which no explorer or Bitcoin node resolves. + newPegoutStatus.btcTxHash = parsedBtcTransaction.getId(); newPegoutStatus.isNewestStatus = true; newPegoutStatus.status = PegoutStatuses.RELEASE_BTC; newPegoutStatus.valueInSatoshisToBeReceived = output.value; @@ -166,12 +180,48 @@ export class PegoutDataProcessor implements FilteredBridgeTransactionProcessor { thePegout.isNewestStatus = false; await this.save(thePegout); await this.save(newPegoutStatus); + await this.publishAtlasEvent(newPegoutStatus, { + receivedCreatedOn: await this.getReceivedCreatedOn(newPegoutStatus.originatingRskTxHash), + }); } catch(e) { this.logger.warn({method: 'processSignedStatusByRtx', err: e}, 'There was a problem with the storage'); } } } + /** + * Picks which peg-out a `release_btc` output belongs to. + * + * A single match is unambiguous. When a batch pays the same Bitcoin address + * more than once — the same user requesting two peg-outs to one address — the + * lookup by recipient returns several rows, and the tie is broken by + * `batchPegoutIndex`, which the Bridge assigns in the same order as the + * outputs of the batch transaction. + * + * Before this disambiguation both rows were skipped, so peg-outs that really + * were paid on Bitcoin never left `WAITING_FOR_SIGNATURE`. + * + * The index is compared numerically on purpose: `PegoutStatusDbDataModel` + * types it as a number, but the Mongo schema stores it as a String, so rows + * read back from the database carry `"0"` rather than `0`. + * + * @param candidates - Rows matching the recipient address and the batch tx. + * @param outputIndex - Index of the output being processed. + * @returns The peg-out that owns this output, or `undefined` when none does. + */ + private selectPegoutForOutput( + candidates: PegoutStatusDbDataModel[] | undefined, + outputIndex: number, + ): PegoutStatusDbDataModel | undefined { + if (!candidates || candidates.length === 0) { + return undefined; + } + if (candidates.length === 1) { + return candidates[0]; + } + return candidates.find(pegout => Number(pegout.batchPegoutIndex) === outputIndex); + } + private async processBatchPegouts(extendedBridgeTx: ExtendedBridgeTx): Promise { this.logger.debug({method: 'processBatchPegouts', txHash: extendedBridgeTx.txHash}, 'Started'); const events: ExtendedBridgeEvent[] = extendedBridgeTx.events as ExtendedBridgeEvent[]; @@ -226,6 +276,7 @@ export class PegoutDataProcessor implements FilteredBridgeTransactionProcessor { const allPegouts = [oldPegoutStatus, newClonedPegoutStatus]; await this.saveMany(allPegouts); this.logger.debug({method: 'processBatchPegouts', count: allPegouts.length}, 'Pegouts were updated'); + await this.publishAtlasEvent(newClonedPegoutStatus); } catch(e) { this.logger.warn({method: 'processBatchPegouts', err: e}, 'There was a problem with the storage'); } @@ -332,6 +383,7 @@ export class PegoutDataProcessor implements FilteredBridgeTransactionProcessor { try { await this.save(oldPegoutStatus); await this.save(newPegoutStatus); + await this.publishAtlasEvent(newPegoutStatus); } catch(e) { this.logger.warn({method: 'processIndividualPegout', err: e}, 'There was a problem with the storage'); } @@ -385,6 +437,7 @@ export class PegoutDataProcessor implements FilteredBridgeTransactionProcessor { try { await this.save(status); this.logger.debug({method: 'processReleaseRequestReceivedStatus', txHash: extendedBridgeTx.txHash}, 'Tx registered'); + await this.publishAtlasEvent(status); } catch(e) { this.logger.warn({method: 'processReleaseRequestReceivedStatus', err: e}, 'There was a problem with the storage'); } @@ -405,6 +458,7 @@ export class PegoutDataProcessor implements FilteredBridgeTransactionProcessor { try { await this.save(status); this.logger.debug({method: 'processReleaseRequestRejectedStatus', txHash: extendedBridgeTx.txHash}, 'Tx registered'); + await this.publishAtlasEvent(status); } catch(e) { this.logger.warn({method: 'processReleaseRequestRejectedStatus', err: e}, 'There was a problem with the storage'); } @@ -431,6 +485,55 @@ export class PegoutDataProcessor implements FilteredBridgeTransactionProcessor { return this.pegoutStatusDataService.set(pegout); } + /** + * Publishes the Atlas SWAP event of a peg-out transition that has already + * been written to the database. Statuses with no equivalent in the v1.0 + * schema (e.g. `WAITING_FOR_SIGNATURE`) publish nothing. + * + * Nothing here is allowed to fail the caller: a peg-out status is never + * rolled back because analytics could not be notified. + * + * @param pegout - The status just persisted. + * @param context - Extra data the builder cannot derive from `pegout` alone. + */ + private async publishAtlasEvent( + pegout: PegoutStatusDbDataModel, + context?: PegoutAtlasEventContext, + ): Promise { + try { + const event = PegoutAtlasEventBuilder.build(pegout, context); + if (!event) { + return; + } + await this.atlasEventPublisher.publish(event, 'pegout'); + } catch (e) { + this.logger.error( + {method: 'publishAtlasEvent', err: e, originatingRskTxHash: pegout.originatingRskTxHash}, + 'Could not build or publish the Atlas event', + ); + } + } + + /** + * Looks up when the peg-out was first received, so `swap.completed` can carry + * the elapsed time of the whole peg-out rather than of its last transition. + * + * @param originatingRskTxHash - The peg-out identifier. + * @returns The `createdOn` of the `RECEIVED` status, or `undefined` when it cannot be found. + */ + private async getReceivedCreatedOn(originatingRskTxHash: string): Promise { + try { + const statuses = await this.pegoutStatusDataService.getManyByOriginatingRskTxHash(originatingRskTxHash) ?? []; + return statuses.find(status => status.status === PegoutStatuses.RECEIVED)?.createdOn; + } catch (e) { + this.logger.warn( + {method: 'getReceivedCreatedOn', err: e, originatingRskTxHash}, + 'Could not read the RECEIVED status to compute the pegout duration', + ); + return undefined; + } + } + private logPegoutData(pegout: PegoutStatusDbDataModel) { try { this.logger.debug({method: 'logPegoutData', status: pegout.status}, 'Pegout data'); diff --git a/src/services/pegout-status-data-services/pegout-status-mongo.service.ts b/src/services/pegout-status-data-services/pegout-status-mongo.service.ts index 8dca9aed..3bb418cd 100644 --- a/src/services/pegout-status-data-services/pegout-status-mongo.service.ts +++ b/src/services/pegout-status-data-services/pegout-status-mongo.service.ts @@ -15,6 +15,7 @@ const PegoutStatusSchema = new mongoose.Schema({ rskTxHash: {type: String, required: true, unique: true}, rskSenderAddress: {type: String, required: true}, btcRecipientAddress: {type: String, required: true}, + btcTxHash: {type: String}, valueRequestedInSatoshis: {type: Number, required: true}, valueInSatoshisToBeReceived: {type: Number, required: true}, feeInSatoshisToBePaid: {type: Number, required: true},