fix(activation-service): migrate to @threefold/tfchain_client - #1105
Merged
sameh-farouk merged 6 commits intoJul 26, 2026
Merged
Conversation
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.
This was referenced Jul 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Unblocks the 2.13.0 release (#1100) without needing the lost npm credentials from #1099.
Why
clients/tfchain-client-jscannot be published — the npm credentials fortfgrid-api-clientare lost (sole maintainerthreefoldtech, 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:bin/wwwawaitsinit()beforeserver.listen(). The old client'sApiPromise.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.catchreports it.transferresolved on submission, so a failed funding transfer still returned 200.apply()waits for inclusion, detectssystem.ExtrinsicFailedand 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_clientnever injected a statictypes.jsonand returnstoPrimitive()rather thantoJSON()plus a hand-rolledhex2a, so those bug classes cannot arise.Behaviour changes
substrateAccountIDreturns 400, not 500.httpError(400)was constructed and never thrown, so execution fell through tokeyring.addresson 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.0x1234used to be accepted and funded as the derived address25NbUg, which nobody holds the key to. Validation is explicit now: the old check was a side effect ofkeyring.addFromAddressthrowing, andtfchain_client.transferdoes not validate the address at all.ACTIVATION_AMOUNTis read again, in whole TFT. History: it was configurable as1e7 * parseInt(env)(whole TFT) from 2021-10-08;73560aathen dropped the1e7 *, which made the deployed value of1mean one base unit — below the existential deposit, so every activation failed;67b41f0hardcoded 1000000 the same day as damage control. This restores the original semantics. The chart default is set to0.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.stopGraceful()exited 0 and theprocess.exit(1)after it was unreachable, so a failed boot reported success. Latent until now, becauseinit()never used to reject.ValidationError400,ConnectionError/TimeoutError503, otherwise 500.Dockerfilemoves to node:22 —@polkadot/api15.x declaresengines: node >= 18. Required, not cosmetic.The commented-out
validateActivationis removed: its route went away in #1103 and it calledclient.verify, which does not exist on the new client.Tests
The service had no tests (
npm testwasstandardalone) 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 withtransferAll(keepAlive=false)at both ends of the job, so a run that dies mid-way is reclaimed by the next one.standardis scoped to the server code. It was failing on the committedbuild/bundle (170k errors), which meantnpm testwas already red ondevelopment.Verification
Executed, not inferred:
reclaimed pool accounts from a previous run: 1, suite still greendevelopment)ACTIVATION_AMOUNTbelow the existential deposit, and malformed: both refused at startup with a logged error and exit 1docker build+ run on node:22: 400 and 500 paths confirmed inside the containerrunning shutdown actionsRollout
ACTIVATION_SERVICE_CI_MNEMONICis set and funded (devnet, ~2450 runs of headroom). Confirm the deployed values file before rolling out: if it setsactivation_amountexplicitly,0.1keeps today's amount and1would fund ten times more.Refs #1099
🤖 Generated with Claude Code
https://claude.ai/code/session_011YKJm3zuWdSepriT9KL9zy