feat(Daemon-SQS): publish Atlas SWAP events for native peg-in and peg-out - #481
feat(Daemon-SQS): publish Atlas SWAP events for native peg-in and peg-out#481ronaldsg20 wants to merge 5 commits into
Conversation
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.OpenSSF ScorecardScorecard details
Scanned Files
|
There was a problem hiding this comment.
Pull request overview
Adds an “Atlas SWAP event” publishing pipeline to the daemon so each native peg-out transition emits a schema-validated event to an SQS FIFO queue for analytics (volume, duration, rejection reasons).
Changes:
- Introduces Atlas event schema/models plus a
PegoutAtlasEventBuilderthat maps peg-out statuses toswap.created|pending|completed|rejected. - Adds an
AtlasEventPublisherabstraction with SQS + noop implementations behind anATLAS_EVENTS_ENABLEDkill switch. - Wires publishing into
PegoutDataProcessor, adds unit/integration tests, and provisions LocalStack + CI job for integration testing.
Reviewed changes
Copilot reviewed 27 out of 28 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/services/pegout-data.processor.ts | Publishes an Atlas event after persisting each relevant peg-out transition. |
| src/services/atlas/sqs-atlas-event-publisher.ts | Implements FIFO SQS publishing (MessageGroupId=swap_id, DeduplicationId=event_id). |
| src/services/atlas/pegout-atlas-event.builder.ts | Builds Atlas SWAP events (envelope + data) from persisted peg-out statuses. |
| src/services/atlas/noop-atlas-event-publisher.ts | No-op publisher used when Atlas events are disabled. |
| src/services/atlas/atlas-event-publisher.ts | Defines publisher interface and the ATLAS_EVENTS_ENABLED flag reader. |
| src/models/atlas/atlas-event.model.ts | Defines Atlas event types, envelope, and payload data shapes. |
| src/models/atlas/atlas-chain.ts | Resolves chain IDs from NETWORK and validates configuration. |
| src/dependency-injection-handler.ts | Binds the publisher implementation based on the kill switch. |
| src/dependency-injection-bindings.ts | Adds DI bindings for Atlas events. |
| src/daemon-runner.ts | Validates NETWORK during daemon startup. |
| src/tests/unit/services/pegout-data.processor.unit.ts | Adds coverage ensuring events publish after persistence and don’t break peg-out processing. |
| src/tests/unit/services/daemon.service.unit.ts | Updates daemon tests to construct PegoutDataProcessor with an Atlas publisher. |
| src/tests/unit/services/atlas/sqs-atlas-event-publisher.unit.ts | Unit-tests SQS publishing behavior + DI flag binding. |
| src/tests/unit/services/atlas/pegout-atlas-event.builder.unit.ts | Validates builder output against the JSON schema (AJV). |
| src/tests/unit/models/atlas/atlas-chain.unit.ts | Tests chain-id resolution and NETWORK validation behavior. |
| src/tests/unit/daemon-runner.unit.ts | Tests daemon startup behavior when NETWORK is missing/invalid. |
| src/tests/integration/atlas-pegout-events.integration.ts | Integration test that publishes to LocalStack SQS and validates ordering/dedup. |
| schemas/atlas-swap-event.schema.json | JSON Schema contract for Atlas SWAP events v1.0. |
| README.md | Documents integration test command + LocalStack setup for Atlas events. |
| ENV_VARIABLES.md | Documents new Atlas env vars and event semantics. |
| docker-compose.yml | Adds LocalStack service for local SQS and updates service dependencies. |
| ci/localstack-init/01-create-queue.sh | LocalStack init hook to create the FIFO queue. |
| ci/create-atlas-queue.js | GitHub Actions helper to create the queue for service containers. |
| .github/workflows/build.yml | Adds a dedicated integration-test job with LocalStack. |
| .eslintignore | Ignores ci/ from eslint. |
| .env.test | Adds Atlas env vars for test environments. |
| package.json | Adds AWS SQS client dependency and AJV dev deps; adds integration-test script. |
| package-lock.json | Locks new dependencies. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 46 out of 48 changed files in this pull request and generated 5 comments.
Suppressed comments (1)
src/daemon-runner.ts:12
DaemonRunnernow refuses to start unlessNETWORKis set to mainnet/testnet, even when Atlas event publication is disabled. If the stricter validation is only required to prevent mislabeling Atlas events, consider enforcing it only whenATLAS_EVENTS_ENABLED=trueto avoid breaking existing daemon deployments that relied on the prior defaulting behavior elsewhere in the codebase.
assertNetworkConfigured();
eacc194 to
d494129
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 39 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/tests/integration/atlas-pegout-events.integration.ts:249
- The peg-in integration test passes a context field named
amountInWeis, butPeginAtlasEventBuilderreadsamountInSatoshis. As written, this test will publish aswap.createdwith a zero amount, reducing the value of the integration coverage.
const context = {
amountInWeis: '500000000000000000',
rskRecipient: '0x2D623170Cb518434af6c02602334610f194818c1',
};
schemas/atlas-swap-event.schema.json:5
- The JSON schema description says it is emitted "for native peg-out", but the schema and this PR cover both peg-in and peg-out events. This can mislead consumers/readers of the schema.
"description": "Executable contract for the Atlas SWAP Event Schema v1.0 as emitted by the 2wp-api daemon for native peg-out.",
src/services/pegin-data.processor.ts:14
- The PR title/description focus on native peg-out, but this change introduces Atlas event publication for peg-ins as well (new
PeginAtlasEventBuilderusage and publisher injection). Either update the PR metadata to reflect the broader scope, or drop peg-in publication if it’s out of scope for this change.
src/services/atlas/sqs-atlas-event-publisher.ts:29 - When
ATLAS_EVENTS_ENABLED=truebutATLAS_SQS_QUEUE_URLis missing, the publisher will attempt to send with an empty QueueUrl and log an error for every event. Consider failing fast at construction time to surface misconfiguration early and avoid noisy per-event errors.
d494129 to
3f3ef7a
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 37 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/tests/integration/atlas-pegout-events.integration.ts:249
- In the peg-in integration tests, the context is passed with
amountInWeis, butPeginAtlasEventBuilderexpectsamountInSatoshis. This makesswap.createdpublishinput_amountas zero, reducing the test’s ability to catch regressions and producing an unrealistic event payload.
describe('peg-in', () => {
const context = {
amountInWeis: '500000000000000000',
rskRecipient: '0x2D623170Cb518434af6c02602334610f194818c1',
};
docker-compose.yml:17
apiis configured to wait forlocalstackto become healthy even when Atlas events are disabled (Noop publisher). This makes LocalStack a hard runtime dependency for local docker-compose usage and can prevent the API/daemon from starting if LocalStack is down, undermining the kill switch’s purpose (ship dark with no extra infra).
depends_on:
pp-api-db:
condition: service_healthy
localstack:
condition: service_healthy
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 43 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
docker-compose.yml:17
- The
apiservice now hard-depends onlocalstackbeing healthy. Since LocalStack is only needed for Atlas SQS publishing / integration testing, this makesdocker compose upfail or slow down for developers who just want the API + Mongo stack. Consider making LocalStack optional (or gating it behind a compose profile) and not blocking API startup on it.
depends_on:
pp-api-db:
condition: service_healthy
localstack:
condition: service_healthy
Emits one Atlas SWAP event per native peg-out transition to an SQS FIFO
queue, so peg-out volume, duration and rejection reasons become visible to
the analytics side.
Emits Atlas SWAP events for native peg-in, keyed by btcTxId: LOCKED publishes swap.created, and each rejection publishes swap.created followed by swap.rejected. The daemon observes only Rootstock, so the deposit on Bitcoin is never seen and swap.pending has no trigger; swap.completed is left out for now, which means a successful peg-in stays PENDING on the analytics side. PeginAtlasEventBuilder reads the amount and addresses from the Bridge logs, since PeginStatusDataModel persists neither. Bridge rejection reasons are passed through as PEGIN_REJECTION_<n> / PEGIN_UNREFUNDABLE_<n> rather than translated, so no made-up semantics reach the analytics database. The peg-in logs report satoshis directly, unlike the peg-out logs, whose amount is in weis, so the shared satoshisToDecimalString helper is used with no conversion.
A review of the peg-in emission added in the previous commit found seven problems. All seven are closed here, so the events a peg-in publishes now carry the amount, the reason and the outcome that were missing or wrong. Bridge rejection reasons are translated to the names of the rskj enums instead of travelling as PEGIN_REJECTION_<n> / PEGIN_UNREFUNDABLE_<n>. The two logs carry different enums in the same position — reason 3 means LEGACY_PEGIN_UNDETERMINED_SENDER in rejected_pegin and INVALID_AMOUNT in unrefundable_pegin — so the new atlas-pegin-reasons table branches on the event name first and by number second, which is the confusion the raw numbers invited. The error_code always names the rejected_pegin reason, the root cause present in both branches, and the raw numbers of both logs travel in error_message so a value this build cannot name is still recoverable from the event. INVALID_AMOUNT is reported as validation, not protocol_violation.
4e7c434 to
1206039
Compare
…ents - docker-compose.yml — api no longer depends_on localstack, so compose starts with the feature kill-switched off and LocalStack down - sqs-atlas-event-publisher.ts — assertQueueUrlConfigured() fails the daemon at startup when ATLAS_EVENTS_ENABLED is on without a queue url, instead of failing every send and losing the events with no retry - noop-atlas-event-publisher.ts — publish() no longer rejects if the logger throws, honouring the AtlasEventPublisher contract - atlas-swap-event.schema.json — description covers peg-in and peg-out - ENV_VARIABLES.md — swap_id is originatingRskTxHash for peg-out and btcTxId for peg-in; document the new queue url validation - 6 unit tests for the queue url validation and the noop contract
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces a new cross-cutting event publication pipeline (SQS/DI/CI/schema) plus production-impacting peg-out persistence changes that warrant final human review.
Review details
- Files reviewed: 42/43 changed files
- Comments generated: 4
- Review effort level: Lite
| destination_tx_hash: pegout.btcTxHash, | ||
| output_amount: this.toDecimalAmount(received), | ||
| output_amount_usd: null, | ||
| fee: this.toDecimalAmount(requested - received), | ||
| duration_ms: this.durationMs(pegout, context), |
There was a problem hiding this comment.
Real problem, fixed in dd2bf63. The decimalAmount pattern is ^[0-9]+\.[0-9]{8}$ — no minus sign — so a received above requested produced "-0.00005000" and an event Atlas would reject outright.
Extracted a feeSatoshis() helper that clamps at zero and logs a warn carrying originatingRskTxHash, requested and received. Clamping silently would have hidden the very thing that caused it (bad output-to-pegout matching, or a corrupted status row), so the event stays valid and in the dataset while the warning is what says the numbers behind it cannot be trusted — the same degrade-but-be-noisy approach atlas-pegin-reasons.ts takes for unknown Bridge reasons.
New unit test covers an output paying more than was requested: fee comes out "0.00000000" and the event validates.
| app | ||
| .bind(ConstantsBindings.ATLAS_EVENTS_ENABLED) | ||
| .to(isAtlasEventsEnabled()); | ||
|
|
||
| // The kill switch decides the transport, never the callers: the processors | ||
| // always depend on the AtlasEventPublisher interface. | ||
| const atlasEventPublisher: Constructor<AtlasEventPublisher> = isAtlasEventsEnabled() | ||
| ? SqsAtlasEventPublisher | ||
| : NoopAtlasEventPublisher; |
There was a problem hiding this comment.
Fixed in dd2bf63 — isAtlasEventsEnabled() is now evaluated once into a local const that feeds both the ATLAS_EVENTS_ENABLED binding and the publisher choice, with a comment saying why. Agreed it also reads better as one decision applied twice rather than two independent reads of process.env.
| await events.reduce(async (promise, event) => { | ||
| await promise; | ||
| await this.atlasEventPublisher.publish(event, 'pegin'); | ||
| }, Promise.resolve()); |
There was a problem hiding this comment.
Agreed and changed in dd2bf63 — it is a plain for...of now, with a comment recording why the loop is sequential (the queue orders by MessageGroupId, so swap.created has to be sent before the outcome that follows it).
Worth recording why the reduce was there: for...of trips two airbnb rules in this repo, no-restricted-syntax and no-await-in-loop, so the lint count goes from 204 to 206 warnings (still 0 errors). I deliberately did not silence them with disable directives — the sibling pegout-data.processor.ts writes the same sequential await loop in two places (:145, :477) and simply accepts both warnings, and matching that is more honest than two eslint-disable lines on a three-line loop. Readability was the point of the comment, and disables would have undercut it.
| // 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; |
There was a problem hiding this comment.
Agreed the inconsistency is real, but deferring it to its own PR rather than folding it in here.
The three write paths are:
:169—parsedBtcTransaction.getId(), unprefixed hex (RELEASE_BTC):263/:374— the Bridge log bytes32,0x-prefixed
Two reasons not to normalize it in this PR:
- The target format is a genuine choice, not an obvious one. Bare hex is canonical for a Bitcoin txid and is what explorers resolve — the whole point of the
getId()change. Butswap_idis already deliberately0x-prefixed in both flows per the normalization documented in ENV_VARIABLES.md, so "0x everywhere" is defensible too. Picking one deserves its own discussion. - Byte order is unverified. The
getId()fix was about bitcoinjs-libs little-endiangetHash(). Whether rskj emits the canonical big-endian txid inrelease_requested/batch_pegout_created, or the internal order, I have not confirmed against rskj. If the two differ in byte order then stripping0x` would be purely cosmetic and would leave a real mismatch that now looks consistent — strictly worse than the current state.
Scope is contained in the meantime: btcTxHash is not part of the public API surface (absent from pegout-status.model.ts, the controllers and pegout-status.service.ts), so it reaches only Mongo and the Atlas destination_tx_hash, which the schema constrains to a non-empty string — both formats validate. Nothing is emitting an invalid event today.
There was a problem hiding this comment.
🟡 Changes recommended
The peg-out event builder can emit schema-invalid values (negative fee and negative expected_confirmations) under certain inputs, which should be guarded to keep emitted events contract-compliant.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
src/services/atlas/pegout-atlas-event.builder.ts:136
feeis derived asrequested - receivedwith no guard. IfvalueInSatoshisToBeReceivedis ever greater thanvalueRequestedInSatoshis(bad/misaligned data, parsing issues, etc.), this becomes negative and produces a decimal string that violates the JSON Schema (no negatives), potentially breaking downstream consumers. Clamp the computed fee to a minimum of 0 satoshis before formatting.
src/services/pegin-data.processor.ts:150- Using
Array.reduce(async ...)for sequential publishing is harder to read and easier to get wrong than a simplefor...ofloop. A straightforward loop preserves the current behavior (publish in order, stop on the first thrown error and fall into the surrounding catch) with less cognitive overhead.
- Files reviewed: 42/43 changed files
- Comments generated: 1
- Review effort level: Lite
| private static expectedConfirmations(): number { | ||
| const configured = parseInt(process.env.RSK_PEGOUT_MINIMUM_CONFIRMATIONS ?? '', 10); | ||
| return Number.isFinite(configured) ? configured : 0; | ||
| } |
There was a problem hiding this comment.
Good catch, fixed in dd2bf63. Number.isFinite(-1) is true, so RSK_PEGOUT_MINIMUM_CONFIRMATIONS=-1 went straight through into an expected_confirmations the schema types as {"type": "integer", "minimum": 0}. A negative value is now treated exactly like an unset or unparseable one and falls back to 0:
const configured = parseInt(process.env.RSK_PEGOUT_MINIMUM_CONFIRMATIONS ?? "", 10);
if (!Number.isFinite(configured) || configured < 0) {
return 0;
}New unit test asserts the fallback for -1 and -4000 and validates the resulting event against the JSON Schema.
…e DI wiring - pegout-atlas-event.builder.ts — expected_confirmations rejects negative RSK_PEGOUT_MINIMUM_CONFIRMATIONS the same way it rejects an unset one, and feeSatoshis() clamps a negative fee to zero with a warn: the schema's decimalAmount pattern admits no minus sign, so either would have emitted an event Atlas rejects - pegin-data.processor.ts — sequential publish loop reads as a for...of instead of an async reduce - dependency-injection-handler.ts — evaluate the kill switch once - 2 unit tests covering both guards against the JSON Schema
There was a problem hiding this comment.
🔵 Needs a closer look
Dockerfile runs Node via a shell without exec, which can interfere with signal handling and graceful shutdown in container environments.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
Dockerfile:19
- The container CMD runs Node through
sh -cwithoutexec, so PID 1 is the shell. In orchestrated environments this can prevent SIGTERM (and other signals) from reaching Node reliably, which can break graceful shutdown and increase the risk of abrupt termination during deploys.
- Files reviewed: 42/43 changed files
- Comments generated: 0 new
- Review effort level: Lite
📌 Summary
The daemon now publishes Atlas SWAP Event Schema v1.0 events to an SQS FIFO queue as it processes Bridge transactions, making native peg-in and peg-out volume, duration and rejection reasons visible to the analytics side. The feature ships dark behind the
ATLAS_EVENTS_ENABLEDkill switch.🔍 Description
What was changed
Event model (
src/models/atlas/)atlas-event.model.ts: envelope and the four in-scope payload types (swap.created,swap.pending,swap.completed,swap.rejected).expired,refund_pending,refunded,claim_pendingandclaimedare out of scope for native pegs.atlas-chain.ts: qualified chain ids (rootstock_mainnet/bitcoin_testnet/ …) derived fromNETWORK, withassertNetworkConfigured()deliberately not defaulting to testnet.atlas-identifiers.ts:swap_id/wallet_addressnormalization (0x-prefixed, lowercase) so one transaction cannot reach Atlas under two spellings; Bitcoin addresses are left untouched because base58/bech32 is case sensitive.atlas-amount.ts:big.js-backed satoshi → fixed 8-decimal string formatting, so large values never lose precision throughNumberarithmetic.atlas-pegin-reasons.ts: translation tables for rskj'sRejectedPeginReasonandNonRefundablePeginReason(verified againstrsksmart/rskj@161c3f1), pluserror_categoryclassification.Publishing (
src/services/atlas/)AtlasEventPublisherinterface plus two implementations:SqsAtlasEventPublisher(FIFO queue,MessageGroupId = swap_id,MessageDeduplicationId = event_id) andNoopAtlasEventPublisher, bound while the kill switch is off so no SQS client is ever built.AtlasEventMetrics: per status/flow/event-type counters, one log line per publication carryingmetric: 'atlas_events_published_total'. That field name is the contract with the log aggregator and is pinned by a test.PegoutAtlasEventBuilder: one event per peg-out transition —RECEIVED→swap.created,WAITING_FOR_CONFIRMATION→swap.pending,RELEASE_BTC→swap.completed(withduration_msmeasured from theRECEIVEDstatus),REJECTED→swap.rejected.WAITING_FOR_SIGNATUREpublishes nothing.PeginAtlasEventBuilder: two events per outcome, keyed bybtcTxId—LOCKED→swap.created+swap.completed, each rejection →swap.created+swap.rejected. Amount and addresses are read from the Bridge logs viaextractContext(), becausePeginStatusDataModelpersists neither.Wiring
DependencyInjectionHandler.configureDaemonDependencies(), called only byDaemonRunner: the publisher and both transaction processors are registered there and are simply absent from the API process — a controller cannot inject what was never bound.DaemonRunnercallsassertNetworkConfigured()on start, so a daemon with a missing or bogusNETWORKaborts instead of mislabelling every event.DockerfilegainsENV APP_MODE=ALLandCMD node . --appmode=$APP_MODE, so the orchestrator selects API or DAEMON explicitly rather than relying on an omitted flag.Peg-out processing fixes found while wiring emission
btcTxHashnow usesparsedBtcTransaction.getId()(canonical big-endian txid) instead ofgetHash()(internal little-endian hash), which no explorer or Bitcoin node resolves.btcTxHashwas also missing from the MongoPegoutStatusSchemaand is now declared, so the value is actually persisted.release_btcoutputs whose script is not an addressable recipient (federation change) no longer throw out of the loop; they are skipped.selectPegoutForOutput()breaks the tie bybatchPegoutIndex(compared numerically — the model types it as a number while Mongo stores a String). Previously both candidate rows were skipped and peg-outs that really were paid on Bitcoin never leftWAITING_FOR_SIGNATURE.release_requested, nounrefundable_pegin) is now recorded asREJECTED_NO_REFUNDinstead of being dropped, which used to leave the user with no status.Why it was changed
Atlas is the analytics side of the swap pipeline and had no visibility into native pegs: only Flyover quotes were reachable. Emitting the same schema for powpeg peg-in and peg-out puts both flows in one dataset, which is what makes peg volume, end-to-end duration and rejection-reason breakdowns answerable.
Technical root causes addressed
rejected_peginandunrefundable_pegincarry different rskj enums in the same log position: reason3meansLEGACY_PEGIN_UNDETERMINED_SENDERin the first andINVALID_AMOUNTin the second.atlas-pegin-reasons.tsbranches on the event name first and the number second; unknown values degrade toUNKNOWNwith awarnrather than silently changing meaning.pegin_btc/lock_btc/release_requestedreport satoshis. Running peg-in amounts through the peg-outfromWeiNumberToSatoshiNumberhelper divides by 1e10 and collapses every realistic peg-in to zero, so the peg-in path formats satoshis directly.getHash()returns the internal hash; the value stored inbtcTxHashand shipped asdestination_tx_hashhas to be the canonical txid.Scope of impact
ATLAS_EVENTS_ENABLED=trueenables publication; a typo fails closed.btcTxHash, the new schema field, the federation-change skip and the batch disambiguation apply unconditionally.Supporting artifacts
schemas/atlas-swap-event.schema.json— executable draft-07 contract for the v1.0 envelope and all four payloads, asserted against every message in the integration suite.docker-compose.yml+ci/localstack-init/01-create-queue.sh— LocalStack SQS for local development.ci/create-atlas-queue.js— queue creation for CI, where LocalStack service containers do not runinit/ready.dhooks.integration-testsjob in.github/workflows/build.yml, and a separatenpm run integration-testscript kept out oftest:allso the fast unit cycle stays fast.ENV_VARIABLES.mdandREADME.mddocument every new variable, what each event carries, and the known event-loss window.🧪 How to Test
1. Unit suite (no infrastructure needed)
Expect the full suite green. New coverage: the two builders, the SQS publisher, the metrics counter, the chain/identifier/reason models, the DI handler (that the API does not bind
services.AtlasEventPublisherand the daemon does), and the peg-in / peg-out processors.2. Integration suite against LocalStack
This publishes real messages to a real FIFO queue and asserts, for every message, that it validates against
schemas/atlas-swap-event.schema.json. It covers: peg-outcreated → pending → completedarriving in order, a distinct message group per peg-out,event_iddeduplication inside the FIFO window, a rejected peg-out as a single message, and the four peg-in paths (locked, refundable rejection, unrefundable rejection, no-refund-branch rejection).3. Kill switch (negative path)
ATLAS_EVENTS_ENABLEDunset,false, orTrue/TRUE/1, start the daemon and confirmNoopAtlasEventPublisheris bound: no SQS client is constructed, nothing reaches the queue, and the metrics counters stay at zero.ATLAS_EVENTS_ENABLED=truebutATLAS_SQS_QUEUE_URLpointing nowhere, confirm the daemon keeps processing blocks, logsCould not publish the Atlas eventat error level, and emits astatus: 'failure'metric line.4.
NETWORKvalidation (negative path)Start the daemon with
NETWORKunset, and again withNETWORK=regtest. Both must abort at startup with the explicit message rather than booting and labelling events as testnet.5. Process isolation
Start the API alone (
--appmode=API) and confirm it boots with no Atlas binding and serves/exploreras before. Start the daemon alone (--appmode=DAEMON) and confirm it publishes. Confirm the defaultAPP_MODE=ALLin the image keeps today's behaviour.6. End-to-end on testnet
Point the daemon at a testnet node with the switch on, drain the queue, and verify against the explorer:
swap.createdthenswap.completed, sameswap_id(btcTxId, 0x-prefixed lowercase),fee: "0.00000000",duration_ms: null, and adestination_tx_hashthat resolves to the Rootstock transaction crediting the RBTC.created → pending → completedwithdestination_tx_hashresolving on a Bitcoin explorer (this is thegetId()fix) andinput_amount − output_amountequal to the reportedfee.batchPegoutIndex.7. Metrics
Confirm each publication logs exactly one line with
metric: 'atlas_events_published_total',status,flow,eventTypeand an incrementingtotal, and that the field name is unchanged — an alert on lost events queries it.Verified in this branch:
npm run unit-test→ 387 passing, 0 failing;npm run integration-testagainst LocalStack → 8 passing;npm run eslint→ 0 errors (204 pre-existing warnings). Items 3–7 above are the remaining manual QA.🧠 Known Issues / Limitations
atlasPublishedAtflag on the status. Idempotency bybtcTxIdmeans a re-sync will not retry a peg-in it already recorded.atlas-pegin-reasons.tsmust stay aligned with rskj. A reason added toRejectedPeginReason/NonRefundablePeginReasonupstream lands here asUNKNOWNwith awarn— safe, but only actionable if someone reads the warning.input_amount_usd/output_amount_usd/wallet_type/quote_idalways travelnull. No price oracle or wallet metadata is available in the daemon.swap.pendingis never emitted and peg-induration_msis alwaysnull: the daemon observes Rootstock only and never sees the deposit on Bitcoin. A zero would drag the average swap duration down instead of leaving it unmeasured.input_amount: "0.00000000"— neitherrejected_peginnorunrefundable_pegincarries an amount and there is no other transaction to read it from.WAITING_FOR_SIGNATUREhas no event, so a peg-out sits inswap.pendingfor the whole federation signing window.MessageDeduplicationIdis theevent_id. A queue provisioned with it enabled would drop legitimate transitions.integration-testis intentionally excluded fromtest:alland runs in its own CI job, so a localtest:alldoes not exercise the SQS path.📎 Related task
📸 Screenshots / Logs (if applicable)
Metric line emitted per publication:
{"level":"info","name":"atlasEventMetrics","metric":"atlas_events_published_total", "status":"success","flow":"pegout","eventType":"swap.created","total":1, "message":"Atlas event published"}Integration suite against LocalStack: