Skip to content

fix(activation-service): migrate to @threefold/tfchain_client - #1105

Merged
sameh-farouk merged 6 commits into
developmentfrom
fix/activation-service-migrate-tfchain-client
Jul 26, 2026
Merged

fix(activation-service): migrate to @threefold/tfchain_client#1105
sameh-farouk merged 6 commits into
developmentfrom
fix/activation-service-migrate-tfchain-client

Conversation

@sameh-farouk

Copy link
Copy Markdown
Member

Unblocks the 2.13.0 release (#1100) without needing the lost npm credentials from #1099.

Why

clients/tfchain-client-js cannot be published — the npm credentials for tfgrid-api-client are lost (sole maintainer threefoldtech, last publish 2023-06-21). activation-service has therefore been pinned to 1.29.1 while the in-repo client moved on 24 commits, and the release was waiting on that publish.

activation-service only makes four client calls, and @threefold/tfchain_client (actively published, 2.9.2 on 2025-11-25) covers all of them. Measured against devnet it is also materially more robust on the two paths that matter here:

  • Startup. bin/www awaits init() before server.listen(). The old client's ApiPromise.create({provider}) never rejects, so with the chain unreachable the service hung before listening, logged nothing and never became ready — confirmed still pending at 20s. connect() now rejects in ~1s and the existing .catch reports it.
  • Transfer outcome. The old transfer resolved on submission, so a failed funding transfer still returned 200. apply() waits for inclusion, detects system.ExtrinsicFailed and rejects.

Balance reads are equivalent between the two clients, so reads are not a reason to migrate; those two are. The #1078/#1101 client fixes are moot here — tfchain_client never injected a static types.json and returns toPrimitive() rather than toJSON() plus a hand-rolled hex2a, so those bug classes cannot arise.

Behaviour changes

  • Invalid substrateAccountID returns 400, not 500. httpError(400) was constructed and never thrown, so execution fell through to keyring.address on an undefined variable. The body for this case is now the standard error object rather than a bare string — the frontend shows a fixed string and never renders the body, so there is no UI impact.
  • Account ids must decode to a 32 byte public key. 0x1234 used to be accepted and funded as the derived address 25NbUg, which nobody holds the key to. Validation is explicit now: the old check was a side effect of keyring.addFromAddress throwing, and tfchain_client.transfer does not validate the address at all.
  • A failed transfer is an error response, not a 200.
  • ACTIVATION_AMOUNT is read again, in whole TFT. History: it was configurable as 1e7 * parseInt(env) (whole TFT) from 2021-10-08; 73560aa then dropped the 1e7 *, which made the deployed value of 1 mean one base unit — below the existential deposit, so every activation failed; 67b41f0 hardcoded 1000000 the same day as damage control. This restores the original semantics. The chart default is set to 0.1, the amount actually funded since 2022, so deploying this changes nothing about what is paid out. Amounts below the chain's existential deposit are refused at startup.
  • A failed startup exits 1. stopGraceful() exited 0 and the process.exit(1) after it was unreachable, so a failed boot reported success. Latent until now, because init() never used to reject.
  • Chain errors map to status by constructor name: ValidationError 400, ConnectionError/TimeoutError 503, otherwise 500.
  • Dockerfile moves to node:22@polkadot/api 15.x declares engines: node >= 18. Required, not cosmetic.

The commented-out validateActivation is removed: its route went away in #1103 and it called client.verify, which does not exist on the new client.

Tests

The service had no tests (npm test was standard alone) and no CI job. Adds unit tests for the funding branches, the amount config, address validation, error mapping and the fail-fast property, plus an opt-in devnet integration test. Integration targets are derived from the funder (//as-ci//1..5) and swept back with transferAll(keepAlive=false) at both ends of the job, so a run that dies mid-way is reclaimed by the next one.

standard is scoped to the server code. It was failing on the committed build/ bundle (170k errors), which meant npm test was already red on development.

Verification

Executed, not inferred:

  • lint + unit: 27/27
  • integration against live devnet with a funded account: 4/4, three consecutive clean runs
  • sweep confirmed reaping the pool account (free 0, nonce 0, removed from storage) and returning funds; ~0.002 TFT of fees per run
  • self-healing confirmed by seeding a pool account to fake a crashed run: reclaimed pool accounts from a previous run: 1, suite still green
  • boot against an unreachable chain: exits 1 in ~1s (hangs indefinitely on development)
  • ACTIVATION_AMOUNT below the existential deposit, and malformed: both refused at startup with a logged error and exit 1
  • docker build + run on node:22: 400 and 500 paths confirmed inside the container
  • SIGTERM: exits 0 with running shutdown actions

Rollout

ACTIVATION_SERVICE_CI_MNEMONIC is set and funded (devnet, ~2450 runs of headroom). Confirm the deployed values file before rolling out: if it sets activation_amount explicitly, 0.1 keeps today's amount and 1 would fund ten times more.

Refs #1099

🤖 Generated with Claude Code

https://claude.ai/code/session_011YKJm3zuWdSepriT9KL9zy

The in-repo `clients/tfchain-client-js` cannot be published: the npm credentials
for `tfgrid-api-client` are lost, so activation-service has been pinned to
1.29.1 (published 2023-06-21) while the client moved on 24 commits. That is what
has been blocking the 2.13.0 release (#1099).

`@threefold/tfchain_client` covers all four calls this service makes and is
actively published (2.9.2, 2025-11-25). Measured against devnet it is also
materially more robust on the two paths that matter here:

- Startup. `bin/www` awaits init() before server.listen(). The old client's
  `ApiPromise.create({provider})` never rejected, so when the chain was
  unreachable the service hung before listening, logged nothing and never became
  ready. connect() now rejects in ~1s and the existing catch reports it.
- Transfer outcome. The old `transfer` resolved on submission, so a failed
  funding transfer still returned 200. `apply()` waits for inclusion, detects
  system.ExtrinsicFailed and rejects, so failures surface as 5xx.

Balance reads are equivalent between the two clients, so reads are not a reason
to migrate; those two are.

Behaviour changes, all intentional:

- An invalid `substrateAccountID` returns 400, not 500. The constructed
  httpError(400) was never thrown, so execution fell through to `keyring.address`
  on an undefined variable. The response body for this case is now the standard
  error object instead of a bare string.
- Account ids must decode to a 32 byte public key. `0x1234` used to be accepted
  and funded as the derived address `25NbUg`, which nobody holds the key to.
  Validation is now explicit: the previous check was a side effect of
  `keyring.addFromAddress` throwing, and tfchain_client's transfer does not
  validate the address at all.
- A failed transfer is now an error response rather than a 200.
- `ACTIVATION_AMOUNT` is finally read, in whole TFT as readme.md and the chart
  have always documented it. It was required by bin/www since the service was
  written but never consumed, and the amount was hardcoded to 1000000 base units
  — 0.1 TFT, so the readme's "currently 1 TFT" overstated it tenfold. Unset it
  still defaults to 0.1 TFT, but the chart's `activation_amount: 1` now funds a
  whole TFT: ten times the previous amount wherever that value is deployed.
  Amounts below the chain's existential deposit are refused at startup.
- A failed startup exits 1. stopGraceful() exited 0 and the process.exit(1) after
  it was unreachable, so a failed boot reported success. Latent until now,
  because init() never used to reject.
- Chain errors map to status codes by constructor name: ValidationError to 400,
  ConnectionError and TimeoutError to 503, anything else 500.
- Dockerfile moves to node:22; @polkadot/api 15.x requires node >= 18.

The commented-out `validateActivation` is removed. Its route went away in #1103
and it referenced `client.verify`, which does not exist on the new client.

Tests: the service had none and no CI job. Adds unit tests for the funding
branches, the amount config, address validation, error mapping and the fail-fast
property, plus an opt-in devnet integration test whose targets are derived from
the funder (//as-ci//1..5) and swept back at both ends of the job so a crashed
run is reclaimed by the next one. `standard` is scoped to the server code; it was
failing on the committed frontend bundle, which made `npm test` red already.

Refs #1099
…g itself

Pushing a branch and then opening a PR from it triggered this workflow twice,
which was visible on #1105. Duplicate lint runs are only noise, but the
integration job is not safe to run concurrently: every run funds and sweeps the
same fixed pool of accounts derived from one shared mnemonic, so two overlapping
runs fight over the same balances — one run's start-of-job sweep empties an
account the other has just funded, and its "pool account should start empty"
assertion fails for a reason that has nothing to do with the code.

Restricts push to the default branch, since pull_request already covers branches,
and adds a single global concurrency group so runs serialise. The group queues
rather than cancelling, so a run already submitting extrinsics is left to reach
its end-of-job sweep instead of being killed mid-flight and stranding funds.
The workflow-level `defaults.run.working-directory` applied to check-funder too,
but that job only reads a secret and never checks the repository out, so its step
ran against a path that does not exist and failed. Because integration is gated on
check-funder's output, the devnet test was skipped rather than run — visible on the
first run of this workflow on #1105.

Moved the setting onto the two jobs that actually run commands in the service
directory.
A workflow-level group with a fixed name meant every branch shared it, so a push
to any other branch touching activation-service cancelled a pending run instead of
queueing behind it — the run for dbf34ab was cancelled outright while older runs
were still going, leaving that commit with no CI result.

Only the integration job shares anything: the fixed pool of accounts derived from
the funder mnemonic. Moved the group there, so lint and unit results land on every
PR while devnet access still serialises. The group name deliberately has no ref in
it, since the pool is shared across branches regardless of which one is testing.
`npm test` never finished on CI: the "Lint and run unit tests" step ran for 39
minutes on a job that takes a second locally. The fail-fast test in
test/startup.test.js was the cause. Its assertion passes — connect() does reject —
but something in the torn-down provider keeps the event loop alive, so the child
process never exits and `node --test` waits for it indefinitely.

It reproduces in node:22 and not on local Node 24, and only when the network is
up: offline the suite passes either way. So the difference between CI and a
developer's machine is what surfaced it.

Adds --test-force-exit, which exits once tests and hooks have finished rather than
waiting on stray handles, and gives the test an explicit timeout so a future
regression here fails the run instead of hanging it — which is precisely the
failure mode this test exists to catch.

Verified in node:22: npm test completes in 1s, 27 pass, 0 fail.
The comment claimed lodash's omit drops the non-enumerable message, so the chain
error would be lost from the response. It does not — omit(httpError(500, 'msg'),
['stack']) returns the message alongside expose: false, which is the leak #1107
fixes. Stated the real reason for logging instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant