From 9b6143feb553e0c96c6a217f505fa6b09077c250 Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Tue, 22 Sep 2026 10:51:27 +0100 Subject: [PATCH 1/7] Drop the Ankr RPC from the shipped provider list Ankr closed its public Polygon endpoint: every call now answers -32000 Unauthorized. With rpcQuorum 1 a share of the node's chain reads land there and fail. Co-authored-by: Claude Opus 5 --- deploy/config/pombo-node.json.example | 3 +-- deploy/install.sh | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/deploy/config/pombo-node.json.example b/deploy/config/pombo-node.json.example index 58f90c10f..0c9a1765e 100644 --- a/deploy/config/pombo-node.json.example +++ b/deploy/config/pombo-node.json.example @@ -7,8 +7,7 @@ "contracts": { "rpcs": [ { "url": "https://polygon.drpc.org" }, - { "url": "https://polygon-bor-rpc.publicnode.com" }, - { "url": "https://rpc.ankr.com/polygon" } + { "url": "https://polygon-bor-rpc.publicnode.com" } ], "rpcQuorum": 1 }, diff --git a/deploy/install.sh b/deploy/install.sh index 93cd89630..96f91ca60 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -15,7 +15,7 @@ set -euo pipefail cd "$(dirname "$0")" -RPCS='[ { "url": "https://polygon.drpc.org" }, { "url": "https://polygon-bor-rpc.publicnode.com" }, { "url": "https://rpc.ankr.com/polygon" } ]' +RPCS='[ { "url": "https://polygon.drpc.org" }, { "url": "https://polygon-bor-rpc.publicnode.com" } ]' RPC0="https://polygon.drpc.org" MIN_WEI="20000000000000000" # 0.02 POL: enough for the assignment stream + registration on Polygon CONFIG_IN_CONTAINER="/home/streamr/.streamr/config/pombo-node.json" From e2e2f1b8de3c7d9ab790ccab91fb6112ba36f5e9 Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Tue, 22 Sep 2026 10:53:40 +0100 Subject: [PATCH 2/7] Tell an unreachable chain apart from an invalid signature The ERC-1271 branch verifies by calling the contract, so any RPC failure became INVALID_SIGNATURE. Callers that drop invalid messages, such as the storage node's ingest guard, were deleting legitimate history whenever a provider was down. Co-authored-by: Claude Opus 5 --- packages/sdk/src/StreamrClientError.ts | 1 + .../sdk/src/contracts/ERC1271ContractFacade.ts | 14 ++++++++++++-- packages/sdk/src/signature/SignatureValidator.ts | 6 ++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/StreamrClientError.ts b/packages/sdk/src/StreamrClientError.ts index daaa0e7ac..21ba327ef 100644 --- a/packages/sdk/src/StreamrClientError.ts +++ b/packages/sdk/src/StreamrClientError.ts @@ -13,6 +13,7 @@ export type StreamrClientErrorCode = 'INVALID_MESSAGE_CONTENT' | 'INVALID_STREAM_METADATA' | 'INVALID_SIGNATURE' | + 'CHAIN_UNAVAILABLE' | 'INVALID_PARTITION' | 'DECRYPT_ERROR' | 'STORAGE_NODE_ERROR' | diff --git a/packages/sdk/src/contracts/ERC1271ContractFacade.ts b/packages/sdk/src/contracts/ERC1271ContractFacade.ts index 8d584b237..6147e80fa 100644 --- a/packages/sdk/src/contracts/ERC1271ContractFacade.ts +++ b/packages/sdk/src/contracts/ERC1271ContractFacade.ts @@ -1,6 +1,7 @@ import { BrandedString, EthereumAddress, EcdsaSecp256k1Evm, MapWithTtl, toUserId, UserID } from '@streamr/utils' import { Lifecycle, scoped } from 'tsyringe' import { RpcProviderSource } from '../RpcProviderSource' +import { StreamrClientError } from '../StreamrClientError' import type { IERC1271 as ERC1271Contract } from '../ethereumArtifacts/IERC1271' import ERC1271ContractArtifact from '../ethereumArtifacts/IERC1271Abi.json' import { createLazyMap, Mapping } from '../utils/Mapping' @@ -47,8 +48,17 @@ export class ERC1271ContractFacade { if (cachedValue !== undefined) { return cachedValue } else { - const contract = await this.contractsByAddress.get(contractAddress) - const result = await contract.isValidSignature(signingUtil.keccakHash(payload), signature) + let result: string + try { + const contract = await this.contractsByAddress.get(contractAddress) + result = await contract.isValidSignature(signingUtil.keccakHash(payload), signature) + } catch (err) { + // Not an answer about the signature: the caller decides what an + // unreachable chain means, and must not read it as a refusal. + const reason = (err instanceof Error) ? err.message : String(err) + throw new StreamrClientError( + `Could not ask ${contractAddress} whether the signature is valid: ${reason}`, 'CHAIN_UNAVAILABLE') + } const isValid = result === SUCCESS_MAGIC_VALUE this.publisherCache.set(cacheKey, isValid) return isValid diff --git a/packages/sdk/src/signature/SignatureValidator.ts b/packages/sdk/src/signature/SignatureValidator.ts index 0c7ba1760..0de40078d 100644 --- a/packages/sdk/src/signature/SignatureValidator.ts +++ b/packages/sdk/src/signature/SignatureValidator.ts @@ -32,6 +32,12 @@ export class SignatureValidator { try { success = await this.validate(streamMessage) } catch (err) { + // A failure to reach the chain is not a verdict on the signature, + // and callers that drop invalid messages must be able to tell the + // two apart. + if (err instanceof StreamrClientError) { + throw err + } // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new StreamrClientError(`An error occurred during address recovery from signature: ${err}`, 'INVALID_SIGNATURE', streamMessage) } From 5d0fe6ed3e8c047a8483e4bac7ef50648fe6811a Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Tue, 22 Sep 2026 10:53:57 +0100 Subject: [PATCH 3/7] Hold an ERC-1271 refusal for seconds, not ten minutes An account that pays its way past a gate publishes right after, and the cached "no" from before the payment silently dropped everything it wrote until the entry expired. Measured at 9m27s of loss on a real channel. Same asymmetry the gate reads already use. Co-authored-by: Claude Opus 5 --- packages/sdk/src/contracts/ERC1271ContractFacade.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/contracts/ERC1271ContractFacade.ts b/packages/sdk/src/contracts/ERC1271ContractFacade.ts index 6147e80fa..b7508e309 100644 --- a/packages/sdk/src/contracts/ERC1271ContractFacade.ts +++ b/packages/sdk/src/contracts/ERC1271ContractFacade.ts @@ -11,7 +11,13 @@ export const SUCCESS_MAGIC_VALUE = '0x1626ba7e' // Magic value for success as de export type CacheKey = BrandedString -const CACHE_TTL = 10 * 60 * 1000 // 10 minutes +const CACHE_TTL = 10 * 60 * 1000 +/** + * A refusal is remembered for seconds. An account publishes the moment it pays + * its way past a gate, and a held "no" drops everything it writes until the + * entry expires. + */ +const DENIAL_TTL = 20 * 1000 // 10 minutes const signingUtil = new EcdsaSecp256k1Evm() @@ -23,7 +29,8 @@ function formCacheKey(contractAddress: EthereumAddress, signerUserId: UserID): C export class ERC1271ContractFacade { private readonly contractsByAddress: Mapping - private readonly publisherCache = new MapWithTtl(() => CACHE_TTL) + private readonly publisherCache = new MapWithTtl( + (isValid) => (isValid ? CACHE_TTL : DENIAL_TTL)) constructor( contractFactory: ContractFactory, From 6a889dc3fe8f664f2a8422b6a85dee53d6aca83f Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Tue, 22 Sep 2026 10:54:22 +0100 Subject: [PATCH 4/7] Name the signer on every ingest rejection In a gated channel the publisher is the gate contract, identical for every member, so a rejection did not say whose message was dropped. Co-authored-by: Claude Opus 5 --- .../node/src/plugins/storage/IngestValidator.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/node/src/plugins/storage/IngestValidator.ts b/packages/node/src/plugins/storage/IngestValidator.ts index 02023ad06..7b5542841 100644 --- a/packages/node/src/plugins/storage/IngestValidator.ts +++ b/packages/node/src/plugins/storage/IngestValidator.ts @@ -44,7 +44,7 @@ export class IngestValidator { await this.client.validateMessage(msg) } catch (err: any) { if (DEFINITIVE_REJECTIONS.has(err?.code)) { - return this.reject(msg, err.code) + return this.reject(msg, err.code, this.signerOf(msg)) } logger.warn('Could not validate message, storing it', { streamId: msg.getStreamId(), @@ -91,6 +91,19 @@ export class IngestValidator { return this.reject(msg, 'READ_ONLY', signer) } + /** + * Who signed, for the log line. In a gated channel the publisher is the + * gate contract, the same for every member, so without this a rejection + * does not say whose message was dropped. + */ + private signerOf(msg: StreamMessage): EthereumAddress | undefined { + try { + return this.client.getMessageSigner(msg) + } catch { + return undefined + } + } + private reject(msg: StreamMessage, reason: string, signer?: EthereumAddress): IngestVerdict { this.metrics.rejectedMessagesPerSecond.record(1) logger.info('Rejected message at ingest', { From 3ce9253d044057a561e301819eb9344ccd5f7ed9 Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Tue, 22 Sep 2026 10:54:34 +0100 Subject: [PATCH 5/7] Let browsers cache the CORS preflight for ten minutes A signed read sends a custom header, so each one costs a preflight plus the read. Measured in the web client: 46% of reads were an OPTIONS 204 followed by the GET. Co-authored-by: Claude Opus 5 --- packages/node/src/httpServer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/node/src/httpServer.ts b/packages/node/src/httpServer.ts index b2d13c697..ef30a8d7a 100644 --- a/packages/node/src/httpServer.ts +++ b/packages/node/src/httpServer.ts @@ -48,7 +48,10 @@ export const startServer = async ( const app = express() app.use(cors({ origin: true, // Access-Control-Allow-Origin: request origin. The default '*' is invalid if credentials included. - credentials: true // Access-Control-Allow-Credentials: true + credentials: true, // Access-Control-Allow-Credentials: true + // Signed reads carry a custom header, so every one of them is preceded + // by a preflight. Without this the browser default is a few seconds. + maxAge: 600 })) endpoints.forEach((endpoint: Endpoint) => { const handlers = [createAuthenticatorMiddleware(endpoint.apiAuthentication)].concat(endpoint.requestHandlers) From 6bb937f501216dd9d3c87522cb06d1ad0304276b Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Tue, 22 Sep 2026 11:10:08 +0100 Subject: [PATCH 6/7] Cover the two new ERC-1271 cache behaviours Co-authored-by: Claude Opus 5 --- .../test/unit/ERC1271ContractFacade.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/sdk/test/unit/ERC1271ContractFacade.test.ts b/packages/sdk/test/unit/ERC1271ContractFacade.test.ts index 5835655ee..452a22ced 100644 --- a/packages/sdk/test/unit/ERC1271ContractFacade.test.ts +++ b/packages/sdk/test/unit/ERC1271ContractFacade.test.ts @@ -75,6 +75,40 @@ describe('ERC1271ContractFacade', () => { expect(contractOne.isValidSignature).toHaveBeenCalledTimes(1) }) + it('isValidSignature: an unreachable chain is not a verdict, and is not cached', async () => { + contractOne.isValidSignature.mockRejectedValue(new Error('RPC is down')) + const err = await contractFacade.isValidSignature(CONTRACT_ADDRESS_ONE, PAYLOAD, signature).catch((e) => e) + expect(err.code).toEqual('CHAIN_UNAVAILABLE') + await contractFacade.isValidSignature(CONTRACT_ADDRESS_ONE, PAYLOAD, signature).catch(() => {}) + expect(contractOne.isValidSignature).toHaveBeenCalledTimes(2) + }) + + it('isValidSignature: an invalid result is re-checked within seconds', async () => { + jest.useFakeTimers() + try { + contractOne.isValidSignature.mockResolvedValue('0xaaaaaaaa') + await contractFacade.isValidSignature(CONTRACT_ADDRESS_ONE, PAYLOAD, signature) + jest.advanceTimersByTime(30 * 1000) + await contractFacade.isValidSignature(CONTRACT_ADDRESS_ONE, PAYLOAD, signature) + expect(contractOne.isValidSignature).toHaveBeenCalledTimes(2) + } finally { + jest.useRealTimers() + } + }) + + it('isValidSignature: a valid result outlives that', async () => { + jest.useFakeTimers() + try { + contractOne.isValidSignature.mockResolvedValue(SUCCESS_MAGIC_VALUE) + await contractFacade.isValidSignature(CONTRACT_ADDRESS_ONE, PAYLOAD, signature) + jest.advanceTimersByTime(30 * 1000) + await contractFacade.isValidSignature(CONTRACT_ADDRESS_ONE, PAYLOAD, signature) + expect(contractOne.isValidSignature).toHaveBeenCalledTimes(1) + } finally { + jest.useRealTimers() + } + }) + it('differentiates between different contracts based on contract address', async () => { contractOne.isValidSignature.mockResolvedValue(SUCCESS_MAGIC_VALUE) contractTwo.isValidSignature.mockResolvedValue('0xaaaaaaaa') From 336a851884445b87aa9b0a27e6beca7060f24d1f Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Tue, 22 Sep 2026 11:46:58 +0100 Subject: [PATCH 7/7] Let the node write in its own home directory On arm64 a dependency creates ~/.local while publishing, and the home was root-owned, so every assignment announcement failed with EACCES. Reproduced with the production image on an Ampere host; amd64 never takes that path. Co-authored-by: Claude Opus 5 --- Dockerfile.node | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile.node b/Dockerfile.node index ea6fa133c..0448c6dcd 100644 --- a/Dockerfile.node +++ b/Dockerfile.node @@ -27,7 +27,8 @@ RUN apt-get update && apt-get --assume-yes --no-install-recommends install \ RUN usermod -d /home/streamr -l streamr node && groupmod -n streamr node # The node writes its GeoIP database under ~/.streamr; when only ~/.streamr/config # is bind-mounted, Docker would create the parent as root and the write fails. -RUN mkdir -p /home/streamr/.streamr && chown streamr:streamr /home/streamr/.streamr +# The home itself must be writable too: on arm64 a dependency creates ~/.local. +RUN mkdir -p /home/streamr/.streamr && chown streamr:streamr /home/streamr /home/streamr/.streamr USER streamr WORKDIR /home/streamr/network COPY --chown=root:root --from=build /usr/src/network/ .