Skip to content

Unsubscribe ends subscriptions on the node (SSFUtils engine, Polkaswap swap screen) - #1320

Closed
takemiyamakoto wants to merge 2 commits into
masterfrom
hotfix/ws-unsubscribe-subscription-id
Closed

takemiyamakoto wants to merge 2 commits into
masterfrom
hotfix/ws-unsubscribe-subscription-id

Conversation

@takemiyamakoto

Copy link
Copy Markdown
Contributor

Summary

WebSocketEngine.unsubsribe(_:) never ended a subscription, and the Polkaswap swap screen did not even call it. Every pair change on the swap screen left one more SORA block subscription running (found while fixing slow quotes in 4.2.1, #1315, which works around it in the screen with a generation guard).

Engine (Native/Sources/SSFUtils)

  • The old processUnsubscription decoded the subscribe request's params as [[Data]]. Storage keys are hex, not base64, so it usually threw DecodingError before sending anything. When it did send, it sent state_unsubscribeStorage with the storage keys, which the node answers with false. Its completion then decoded that boolean as Data, so the local handler was never removed either.
  • Now the handler is removed at once, and the node gets the subscription ID it assigned, through the unsubscribe method that matches the subscribe method (chain_subscribeNewHeads → chain_unsubscribeNewHeads, author_submitAndWatchExtrinsic → author_unwatchExtrinsic, …). A node answers false to a mismatched method and keeps sending.
  • A subscription that has no remote ID yet is unsubscribed as soon as its ID arrives, and is not resent after a reconnect. A subscribe request that was never sent is dropped; a guarded write is never withdrawn, only no longer watched.
  • Watchers registered without a subscribe request (SubstrateTransactionObserver's extrinsic status watchers) are removed locally. Before, their try? unsubscribe threw and they stayed registered for the life of the engine.

App

  • PolkaswapRemoteSubscriptionService.unsubscribe() called cancelForIdentifier, which only withdraws unanswered requests. It now calls the engine's unsubscribe. It also keeps every subscription ID it creates: a second subscribe call used to overwrite the first one's IDs.
  • SSFPolkaswap/PolkaswapPoolSubscriptionService and FailoverChainConnection already call unsubsribe. They are fixed by the engine change with no code change.

Live node check (SORA mainnet, wss://mof2.sora.org, node 4.8.13, 2026-11-07)

Request Node answer Updates afterwards
state_unsubscribeStorage([["<storage key>"]]) false 4 in 15 s
state_unsubscribeStorage(["<subscription id>"]) true none in 20 s
state_unsubscribeStorage(["<newHeads id>"]) false still arriving
chain_unsubscribeNewHeads(["<newHeads id>"]) true none in 13 s

The app's real WebSocketEngine was also run against the same node, built in a throwaway macOS package from the production sources:

  • This branch: the node confirmed the unsubscribe, then sent 0 frames for that subscription in about 35 s.
  • master: unsubsribe threw DecodingError (base64), nothing was sent, and the node kept delivering: 4 updates in 36 s.

Tests

No hosted CI here; all runs are local on this branch's commits.

  • SSFUtilsTests (FearlessNative-Package, iOS 26.5 simulator): 56/56 pass, including 9 new tests. A real engine runs against a node double that answers like the live node. The cases cover:

    • an established subscription ends on the node, and no update follows;
    • unsubscribing before the ID arrives;
    • a queued request is never sent;
    • matching unsubscribe methods;
    • a watcher with no subscribe request;
    • a socket drop before the ID arrives;
    • updates sent before the node handled the unsubscribe.

    On master's engine all 7 behaviour tests fail (33 assertions). The 2 method-mapping tests need the new function, so they were left out of that run.

  • Native/scripts/test-authorized-rpc.py (macOS contract suite): 38/38 pass. It now has 38 cases: one new case for the guarded write, and two existing unsubscribe cases updated. The old "malformed request throws" assertion now expects local removal instead. On master's engine those 3 fail and the other 35 pass.

  • fearlessTests (iOS 26.5, unsigned simulator build): passes on this branch. A real engine talks to a scripted SORA node. The new cases cover:

    • PolkaswapRemoteSubscriptionService.unsubscribe() ends the subscription on the node, and no block follows;
    • five pair changes leave exactly one subscription;
    • subscriptions from an earlier subscribe call are also ended;
    • releasing a SubstrateTransactionObserver removes its watchers.

    The 7 FailoverChainConnectionTests, which drive a real engine, also pass. Full suite: 3,515 passed, 75 skipped, 14 failed:

    • 11 keychain/app-group tests that fail in every unsigned simulator build (AccountImportTests, PasskeyBackupAppAttestRetryTests, RetainedDataRestoreTests, 8× WalletConnectLegacyStorageContinuityTests);
    • 3 Solana send-screen cases in SendDependencyErrorSurfacingTests: one clipboard read returned nil, and two found no confirmation after the tap. They use stubbed services and never reach the WebSocket engine or the Polkaswap service. Run alone on a freshly booted simulator, all 3 pass on this branch and on master. They fail only inside the 50-minute full run at host load ≈450.

    On master's engine and Polkaswap service, all 4 new app tests fail (12 assertions). After five pair changes, every earlier pair still receives each block ([1, 1, 1, 1, 1]). A released observer leaves its 2 watchers in the engine.

  • scripts/deps/verify-native-source.py and test-native-source.py pass. SwiftLint and SwiftFormat report nothing new in the changed files. WebSocketEngine.swift's warnings, including file length, were already on master.

Not in this PR

StorageSubscriptionContainer, WebSocketProviderSource, SpecVersionSubscription, the staking account subscriptions and resolver, PoolStakingAccountSubscription and CrossChainSubmissionTracking also end subscriptions with cancelForIdentifier. Their established subscriptions keep running on the node, and their handlers stay registered in the engine. Moving them to unsubsribe is a follow-up that depends on this fix.

Risk and rollback

The change is limited to unsubsribe and the Polkaswap service. A node that rejects the request costs a logged warning; the handler is still gone. Rollback: revert the two commits.

🤖 Generated with Claude Code

takemiyamakoto and others added 2 commits November 7, 2026 19:21
WebSocketEngine.unsubsribe(_:) never ended a subscription. It decoded the
subscribe request's params as [[Data]] (storage keys are hex, not base64, so
this usually threw before anything was sent) and otherwise sent
state_unsubscribeStorage with the storage keys. A SORA mainnet node
(4.8.13, 2026-11-07) answers that with `false` and keeps notifying; the
completion also decoded the boolean answer as Data, so the local handler
was never removed either.

Now the handler is removed at once and the node gets the subscription ID it
assigned, through the unsubscribe method that matches the subscribe method
(chain_subscribeNewHeads -> chain_unsubscribeNewHeads, and so on; the node
answers `false` to a mismatched method). A subscription still waiting for
its ID is ended when the ID arrives and is not resent after a reconnect; a
subscribe request that was never sent is dropped, while a guarded write is
never withdrawn. Watchers registered without a subscribe request, such as
extrinsic status streams, are removed locally.

Tests: SSFUtilsTests drive a real engine against a node double that answers
like the live node; the AuthorizedRPC contract suite covers the locked send
path and the guarded case (38 cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PolkaswapRemoteSubscriptionService.unsubscribe() called cancelForIdentifier,
which only withdraws requests that have not been answered. Every pair change
on the swap screen therefore left another SORA block subscription running.
It now calls the engine's unsubscribe, and keeps every subscription ID it
creates (a second subscribe call no longer overwrites the first one's IDs).

Tests run a real WebSocketEngine against a scripted SORA node: unsubscribe
ends the subscription on the node, five pair changes leave one subscription,
and releasing a SubstrateTransactionObserver removes its watchers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@takemiyamakoto

Copy link
Copy Markdown
Contributor Author

Folded into #1326, which brings every open iOS PR into master in one change, so one code-owner approval covers it. This PR's branch is kept.

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