From 3f0b4edda14007b6bf539087d882e97650184460 Mon Sep 17 00:00:00 2001 From: Jim Toth Date: Sun, 8 Nov 2026 14:10:19 -0500 Subject: [PATCH] Refuses Set-Delegate and Set-Share messages that change nothing or that staking-rewards would refuse, and limits each per operator per hour. --- apps/hyperbeam-gateway/README.md | 9 ++- apps/hyperbeam-gateway/src/actions.ts | 7 ++ apps/hyperbeam-gateway/src/app.test.ts | 86 ++++++++++++++++++++++- apps/hyperbeam-gateway/src/config.test.ts | 1 + apps/hyperbeam-gateway/src/config.ts | 3 +- apps/hyperbeam-gateway/src/main.ts | 1 + apps/hyperbeam-gateway/src/writes.ts | 35 ++++++++- 7 files changed, 132 insertions(+), 10 deletions(-) diff --git a/apps/hyperbeam-gateway/README.md b/apps/hyperbeam-gateway/README.md index 0eba454..f6a26c9 100644 --- a/apps/hyperbeam-gateway/README.md +++ b/apps/hyperbeam-gateway/README.md @@ -38,7 +38,7 @@ A message is a raw ANS-104 data item sent with `content-type: application/ans104 4. The `action` tag names an action listed below for that process. Every other action answers `403`, including `Add-Scores`, `Complete-Round`, `Claim-Rewards` and `Eval`. 5. The tags are exactly `type=Message`, `data-protocol=ao`, `variant=ao.N.1`, `action`, the action's own tags, and optionally `require-codec=application/json` and `ui-cache-key`. Tag names match in any case and may not repeat. The data has the shape the action takes. 6. A message whose id the gateway has already sent and the node accepted, or is sending now, is answered with that answer and not sent again. See [Message record](#message-record). -7. A signer may send `WRITES_PER_SIGNER_PER_MINUTE` messages a minute. +7. A signer may send `WRITES_PER_SIGNER_PER_MINUTE` messages a minute, and `SETTINGS_CHANGES_PER_SIGNER_PER_HOUR` of each of `Set-Delegate` and `Set-Share` an hour. 8. The signer is allowed to send the action, see below. A signer the gateway does not know as an operator is checked within an allowance of `NEW_SIGNER_CHECKS_PER_MINUTE` checks a minute shared by all such signers; past it, the message answers `503` before the node is asked. Then the gateway posts the item's original bytes to the node with only the `content-type` and `codec-device` headers. @@ -51,11 +51,13 @@ Always available. The gateway reads the signer's entry from operator-registry's | ------- | ------ | ---- | ---- | ----------- | | operator-registry | `Submit-Fingerprint-Certificate` | `fingerprint-certificate` | none | have that fingerprint claimable | | operator-registry | `Renounce-Fingerprint-Certificate` | `fingerprint` | none | have that fingerprint verified | -| relay-rewards | `Set-Delegate` | optional `address`, `share` (0 to 1) | none | be an operator | -| staking-rewards | `Set-Share` | none | `{ "Share": }` | be an operator | +| relay-rewards | `Set-Delegate` | `address` and `share` (0 to 1) together, or neither to reset | none | be an operator, and change its delegate | +| staking-rewards | `Set-Share` | none | `{ "Share": }` | be an operator, and change its share while staking-rewards accepts share changes | An operator is an address with at least one claimable or verified fingerprint. The contract still makes its own checks, for example a blocked address or a missing registration credit. +A settings message that would change nothing answers `409` without reaching the node: a `Set-Delegate` naming the current delegate and share, a reset when there is no delegate, or a `Set-Share` naming the share already set, or already pending when staking-rewards delays share changes. A `Set-Share` while staking-rewards has shares or operator share setting turned off answers `403`. + The gateway knows as operators the addresses in operator-registry's `operators` view, read at start and every `OPERATORS_REFRESH_INTERVAL`, and any signer it has found to be an operator since the last read. Their messages do not count against the new signer allowance, so messages signed with fresh keys cannot crowd out existing operators or load the node beyond that allowance. ### Admin actions @@ -108,6 +110,7 @@ bun run --filter hyperbeam-gateway dev | `NODE_WRITE_TIMEOUT` | `60s` | time allowed for the node to accept a message | | `MAX_BODY_BYTES` | `1048576` | largest message accepted | | `WRITES_PER_SIGNER_PER_MINUTE` | `20` | messages a signer may send per minute | +| `SETTINGS_CHANGES_PER_SIGNER_PER_HOUR` | `6` | `Set-Delegate` and `Set-Share` messages a signer may send per hour, each | | `NEW_SIGNER_CHECKS_PER_MINUTE` | `120` | operator checks a minute, shared by all signers the gateway does not know as operators, in bursts of up to 10 | | `OPERATORS_REFRESH_INTERVAL` | `60s` | how often the operator list is read from operator-registry | diff --git a/apps/hyperbeam-gateway/src/actions.ts b/apps/hyperbeam-gateway/src/actions.ts index 04f591d..946be93 100644 --- a/apps/hyperbeam-gateway/src/actions.ts +++ b/apps/hyperbeam-gateway/src/actions.ts @@ -25,6 +25,8 @@ interface CommonSpec { action: string; tags: Record; data: DataRule; + setting?: boolean; + refine?: (tags: ReadonlyMap) => string | undefined; } export type ActionSpec = (CommonSpec & { audience: 'operator'; check: OperatorCheck }) | (CommonSpec & { audience: 'admin' }); @@ -93,6 +95,8 @@ export const ACTIONS: readonly ActionSpec[] = [ tags: { address: optional(isAddress), share: optional(share) }, data: { kind: 'none' }, check: isOperator, + setting: true, + refine: (tags) => (tags.has('address') !== tags.has('share') ? 'Set-Delegate takes an address and a share together, or neither to reset' : undefined), }, { process: 'staking-rewards', @@ -101,6 +105,7 @@ export const ACTIONS: readonly ActionSpec[] = [ tags: {}, data: { kind: 'json', maxBytes: KIB, valid: (value) => isRecord(value) && hasOnlyKeys(value, ['Share']) && typeof value.Share === 'number' && Number.isFinite(value.Share), description: 'a JSON object { Share: number }' }, check: isOperator, + setting: true, }, admin('operator-registry', 'Admin-Submit-Operator-Certificates', {}, certificates), admin('operator-registry', 'Remove-Fingerprint-Certificate', { fingerprint: required(fingerprint) }), @@ -160,6 +165,8 @@ export function validateMessage(spec: ActionSpec, tags: ReadonlyMap {} }); const operator = testSigner(); const stranger = randomSigner(); +const delegatee = randomSigner(); let node: StubNode; let store: MessageStore; @@ -36,6 +37,7 @@ interface BuildOptions { adminSigners?: string[]; perMinute?: number; newSignersPerMinute?: number; + settingsPerHour?: number; operators?: OperatorDirectory; } @@ -47,7 +49,7 @@ function directory() { return new OperatorDirectory({ node: hyperbeam(), processId: OPERATOR_REGISTRY, refreshMs: 60_000, logger }); } -function gateway({ adminActions = [], adminSigners = [], perMinute = 100, newSignersPerMinute = 100, operators = directory() }: BuildOptions = {}) { +function gateway({ adminActions = [], adminSigners = [], perMinute = 100, newSignersPerMinute = 100, settingsPerHour = 100, operators = directory() }: BuildOptions = {}) { const hb = hyperbeam(); const processes = new ProcessIds({ 'operator-registry': OPERATOR_REGISTRY, 'relay-rewards': RELAY_REWARDS, 'staking-rewards': STAKING_REWARDS }); return createFetchHandler({ @@ -62,6 +64,7 @@ function gateway({ adminActions = [], adminSigners = [], perMinute = 100, newSig limiter: new RateLimiter({ perMinute }), operators, newSigners: new RateLimiter({ perMinute: newSignersPerMinute, burst: newSignersPerMinute }), + settings: new RateLimiter({ perMinute: settingsPerHour / 60, burst: settingsPerHour }), roles: new RolesCache(hb, 60_000), logger, }), @@ -95,6 +98,8 @@ beforeEach(() => { node = new StubNode().start(); store = new MessageStore(':memory:'); node.operators.set(operator.address, { claimable: { [FINGERPRINT]: true }, verified: { [OTHER_FINGERPRINT]: true } }); + node.views.set(`${RELAY_REWARDS}/as/delegate?address=${operator.address}`, { status: 200, body: JSON.stringify({ Address: delegatee.address, Share: 0.5 }) }); + node.views.set(`${STAKING_REWARDS}/as/status`, { status: 200, body: JSON.stringify({ sharesEnabled: true, setSharesEnabled: true }) }); }); afterEach(() => { @@ -146,7 +151,12 @@ describe('operator writes', () => { it('checks the operator against operator-registry for writes to the reward processes', async () => { await push(gateway(), STAKING_REWARDS, message(operator, STAKING_REWARDS, 'Set-Share', [], '{"Share":0.1}')); - expect(node.requests.map((request) => request.path)).toEqual([`/${OPERATOR_REGISTRY}~process@1.0/as/operator?address=${operator.address}`, `/${STAKING_REWARDS}~process@1.0/push`]); + expect(node.requests.map((request) => request.path)).toEqual([ + `/${OPERATOR_REGISTRY}~process@1.0/as/operator?address=${operator.address}`, + `/${STAKING_REWARDS}~process@1.0/as/status`, + `/${STAKING_REWARDS}~process@1.0/as/shares?address=${operator.address}`, + `/${STAKING_REWARDS}~process@1.0/push`, + ]); }); it('refuses a share that is not a number', async () => { @@ -251,7 +261,10 @@ describe('repeated messages', () => { it('limits messages per signer', async () => { const handler = gateway({ perMinute: 1 }); expect((await push(handler, RELAY_REWARDS, message(operator, RELAY_REWARDS, 'Set-Delegate'))).status).toBe(200); - const second = message(operator, RELAY_REWARDS, 'Set-Delegate', [{ name: 'address', value: stranger.address }]); + const second = message(operator, RELAY_REWARDS, 'Set-Delegate', [ + { name: 'address', value: stranger.address }, + { name: 'share', value: '0.25' }, + ]); expect(await refusal(await push(handler, RELAY_REWARDS, second))).toEqual([429, 'too many messages from this signer']); }); @@ -262,6 +275,73 @@ describe('repeated messages', () => { }); }); +describe('settings changes', () => { + const delegate = (address: string, share: string) => [ + { name: 'address', value: address }, + { name: 'share', value: share }, + ]; + const shares = (body: object) => node.views.set(`${STAKING_REWARDS}/as/shares?address=${operator.address}`, { status: 200, body: JSON.stringify(body) }); + const setShare = (share: number) => message(operator, STAKING_REWARDS, 'Set-Share', [], JSON.stringify({ Share: share })); + + it('refuses a delegate change to the current address and share', async () => { + const body = message(operator, RELAY_REWARDS, 'Set-Delegate', delegate(delegatee.address.toLowerCase(), '0.50')); + expect(await refusal(await push(gateway(), RELAY_REWARDS, body))).toEqual([409, 'the delegate is already set to that address and share']); + expect(node.pushes()).toHaveLength(0); + }); + + it('forwards a delegate change of share alone', async () => { + expect((await push(gateway(), RELAY_REWARDS, message(operator, RELAY_REWARDS, 'Set-Delegate', delegate(delegatee.address, '0.4')))).status).toBe(200); + }); + + it('refuses a reset when the signer has no delegate', async () => { + node.views.set(`${RELAY_REWARDS}/as/delegate?address=${operator.address}`, { status: 200, body: JSON.stringify({ Address: '', Share: 0 }) }); + expect(await refusal(await push(gateway(), RELAY_REWARDS, message(operator, RELAY_REWARDS, 'Set-Delegate')))).toEqual([409, 'the signer has no delegate to reset']); + expect(node.pushes()).toHaveLength(0); + }); + + it('refuses an address without a share and a share without an address', async () => { + const reason = 'Set-Delegate takes an address and a share together, or neither to reset'; + const handler = gateway(); + expect(await refusal(await push(handler, RELAY_REWARDS, message(operator, RELAY_REWARDS, 'Set-Delegate', [{ name: 'address', value: stranger.address }])))).toEqual([400, reason]); + expect(await refusal(await push(handler, RELAY_REWARDS, message(operator, RELAY_REWARDS, 'Set-Delegate', [{ name: 'share', value: '0.5' }])))).toEqual([400, reason]); + expect(node.requests).toHaveLength(0); + }); + + it('refuses a share change while staking-rewards does not accept them', async () => { + node.views.set(`${STAKING_REWARDS}/as/status`, { status: 200, body: JSON.stringify({ sharesEnabled: true, setSharesEnabled: false }) }); + expect(await refusal(await push(gateway(), STAKING_REWARDS, setShare(0.1)))).toEqual([403, 'staking-rewards does not accept share changes']); + expect(node.pushes()).toHaveLength(0); + }); + + it('refuses the share already set, or already pending', async () => { + const handler = gateway(); + shares({ address: operator.address, share: 0.2 }); + expect(await refusal(await push(handler, STAKING_REWARDS, setShare(0.2)))).toEqual([409, 'the share is already set to that value']); + shares({ address: operator.address, share: 0.2, pending: { Share: 0.3, RequestedTimestamp: 1 } }); + expect(await refusal(await push(handler, STAKING_REWARDS, setShare(0.3)))).toEqual([409, 'the share is already set to that value']); + expect(node.pushes()).toHaveLength(0); + }); + + it('forwards the current share when another change is pending', async () => { + shares({ address: operator.address, share: 0.2, pending: { Share: 0.3, RequestedTimestamp: 1 } }); + expect((await push(gateway(), STAKING_REWARDS, setShare(0.2))).status).toBe(200); + }); + + it('limits settings changes per signer and action', async () => { + const handler = gateway({ settingsPerHour: 1 }); + expect((await push(handler, RELAY_REWARDS, message(operator, RELAY_REWARDS, 'Set-Delegate', delegate(stranger.address, '0.1')))).status).toBe(200); + const again = message(operator, RELAY_REWARDS, 'Set-Delegate', delegate(stranger.address, '0.2')); + expect(await refusal(await push(handler, RELAY_REWARDS, again))).toEqual([429, 'too many Set-Delegate messages from this signer']); + expect((await push(handler, STAKING_REWARDS, setShare(0.1))).status).toBe(200); + }); + + it('counts settings changes only for operators', async () => { + const handler = gateway({ settingsPerHour: 1 }); + expect((await push(handler, RELAY_REWARDS, message(stranger, RELAY_REWARDS, 'Set-Delegate'))).status).toBe(403); + expect((await push(handler, RELAY_REWARDS, message(stranger, RELAY_REWARDS, 'Set-Delegate', delegate(operator.address, '0.1')))).status).toBe(403); + }); +}); + describe('new signers', () => { const delegate = (address: string) => [ { name: 'address', value: address }, diff --git a/apps/hyperbeam-gateway/src/config.test.ts b/apps/hyperbeam-gateway/src/config.test.ts index 787a129..92083ef 100644 --- a/apps/hyperbeam-gateway/src/config.test.ts +++ b/apps/hyperbeam-gateway/src/config.test.ts @@ -16,6 +16,7 @@ describe('loadConfig', () => { expect(config.VIEW_CACHE_TTL).toBe(30_000); expect(config.NODE_WRITE_TIMEOUT).toBe(60_000); expect(config.NEW_SIGNER_CHECKS_PER_MINUTE).toBe(120); + expect(config.SETTINGS_CHANGES_PER_SIGNER_PER_HOUR).toBe(6); expect(config.OPERATORS_REFRESH_INTERVAL).toBe(60_000); }); diff --git a/apps/hyperbeam-gateway/src/config.ts b/apps/hyperbeam-gateway/src/config.ts index 96458bd..c4875a3 100644 --- a/apps/hyperbeam-gateway/src/config.ts +++ b/apps/hyperbeam-gateway/src/config.ts @@ -19,6 +19,7 @@ export const gatewayEnv = { MAX_BODY_BYTES: env.integer({ default: 1_048_576 }), WRITES_PER_SIGNER_PER_MINUTE: env.integer({ default: 20 }), NEW_SIGNER_CHECKS_PER_MINUTE: env.integer({ default: 120 }), + SETTINGS_CHANGES_PER_SIGNER_PER_HOUR: env.integer({ default: 6 }), OPERATORS_REFRESH_INTERVAL: env.durationMs({ default: 60_000 }), }; @@ -42,7 +43,7 @@ export function loadConfig(source: Environment = process.env) { ...adminActions.map((key) => !ADMIN_ACTION_KEYS.has(key) && `ADMIN_ACTIONS entry ${key} is not a known admin action, expected one of ${[...ADMIN_ACTION_KEYS].join(', ')}`), ...adminSigners.map((address) => !isChecksumAddress(address) && `ADMIN_SIGNERS entry ${address} must be a checksummed address`), adminActions.length > 0 && adminSigners.length === 0 && 'ADMIN_ACTIONS needs at least one ADMIN_SIGNERS entry', - ...(['MAX_BODY_BYTES', 'WRITES_PER_SIGNER_PER_MINUTE', 'NEW_SIGNER_CHECKS_PER_MINUTE', 'OPERATORS_REFRESH_INTERVAL', 'VIEW_CACHE_TTL', 'ROLES_CACHE_TTL', 'NODE_READ_TIMEOUT', 'NODE_WRITE_TIMEOUT'] as const).map( + ...(['MAX_BODY_BYTES', 'WRITES_PER_SIGNER_PER_MINUTE', 'NEW_SIGNER_CHECKS_PER_MINUTE', 'SETTINGS_CHANGES_PER_SIGNER_PER_HOUR', 'OPERATORS_REFRESH_INTERVAL', 'VIEW_CACHE_TTL', 'ROLES_CACHE_TTL', 'NODE_READ_TIMEOUT', 'NODE_WRITE_TIMEOUT'] as const).map( (name) => config[name] <= 0 && `${name} must be greater than 0`, ), ].filter(Boolean); diff --git a/apps/hyperbeam-gateway/src/main.ts b/apps/hyperbeam-gateway/src/main.ts index ca22c53..4eec76e 100644 --- a/apps/hyperbeam-gateway/src/main.ts +++ b/apps/hyperbeam-gateway/src/main.ts @@ -40,6 +40,7 @@ const server = startServer({ limiter: new RateLimiter({ perMinute: config.WRITES_PER_SIGNER_PER_MINUTE }), operators, newSigners: new RateLimiter({ perMinute: config.NEW_SIGNER_CHECKS_PER_MINUTE, burst: 10 }), + settings: new RateLimiter({ perMinute: config.SETTINGS_CHANGES_PER_SIGNER_PER_HOUR / 60, burst: config.SETTINGS_CHANGES_PER_SIGNER_PER_HOUR }), roles: new RolesCache(node, config.ROLES_CACHE_TTL), logger, }), diff --git a/apps/hyperbeam-gateway/src/writes.ts b/apps/hyperbeam-gateway/src/writes.ts index 2bc5cfe..f6dca36 100644 --- a/apps/hyperbeam-gateway/src/writes.ts +++ b/apps/hyperbeam-gateway/src/writes.ts @@ -1,6 +1,6 @@ import type { Logger } from '@anyone-protocol/service-kit'; import { type ActionSpec, actionKey, findAction, InvalidMessage, messageTags, type OperatorEntry, type ProcessName, validateMessage } from './actions'; -import { type DataItem, InvalidDataItem, parseDataItem, verifySigner } from './ans104'; +import { checksumAddress, type DataItem, InvalidDataItem, parseDataItem, verifySigner } from './ans104'; import { type HyperbeamNode, NodeUnavailable, type NodeResponse } from './node'; import type { OperatorDirectory } from './operators'; import type { ProcessIds } from './processes'; @@ -20,6 +20,7 @@ export interface WriteOptions { limiter: RateLimiter; operators: OperatorDirectory; newSigners: RateLimiter; + settings: RateLimiter; roles: RolesCache; logger: Logger; now?: () => number; @@ -138,7 +139,7 @@ export class WriteHandler { throw error; } - private async authorize(spec: ActionSpec, signer: string, tags: ReadonlyMap): Promise { + private async authorize(spec: ActionSpec, signer: string, tags: ReadonlyMap, data: Uint8Array): Promise { if (spec.audience === 'operator') { if (!this.options.operators.has(signer) && !this.options.newSigners.take(NEW_SIGNERS)) throw new Refusal(503, 'too many messages from new signers, retry shortly'); const entry = await this.options.node.view | null>(this.options.processes.id('operator-registry'), 'operator', { address: signer }); @@ -146,6 +147,10 @@ export class WriteHandler { if (operator.claimable.length > 0 || operator.verified.length > 0) this.options.operators.add(signer); const reason = spec.check(operator, tags); if (reason) throw new Refusal(403, reason); + if (spec.setting) { + if (!this.options.settings.take(`${signer}:${spec.action}`)) throw new Refusal(429, `too many ${spec.action} messages from this signer`); + await this.refuseUnchanged(spec, signer, tags, data); + } return; } @@ -155,8 +160,32 @@ export class WriteHandler { if (!holdsRole) throw new Refusal(403, `signer does not hold the admin or ${spec.action} role`); } + private async refuseUnchanged(spec: ActionSpec, signer: string, tags: ReadonlyMap, data: Uint8Array): Promise { + if (spec.action === 'Set-Delegate') { + const current = await this.options.node.view<{ Address?: unknown; Share?: unknown } | null>(this.options.processes.id('relay-rewards'), 'delegate', { address: signer }); + const currentAddress = typeof current?.Address === 'string' ? current.Address : ''; + const address = tags.get('address'); + if (address === undefined) { + if (currentAddress === '') throw new Refusal(409, 'the signer has no delegate to reset'); + } else if (checksumAddress(address) === currentAddress && Number(tags.get('share')) === current?.Share) { + throw new Refusal(409, 'the delegate is already set to that address and share'); + } + return; + } + + if (spec.action === 'Set-Share') { + const staking = this.options.processes.id('staking-rewards'); + const status = await this.options.node.view<{ sharesEnabled?: unknown; setSharesEnabled?: unknown } | null>(staking, 'status'); + if (status?.sharesEnabled !== true || status?.setSharesEnabled !== true) throw new Refusal(403, 'staking-rewards does not accept share changes'); + const current = await this.options.node.view<{ share?: unknown; pending?: { Share?: unknown } } | null>(staking, 'shares', { address: signer }); + const target = current?.pending && typeof current.pending === 'object' ? current.pending.Share : current?.share; + const requested = (JSON.parse(new TextDecoder().decode(data)) as { Share: number }).Share; + if (typeof target === 'number' && target === requested) throw new Refusal(409, 'the share is already set to that value'); + } + } + private async authorizeAndForward(spec: ActionSpec, signer: string, tags: ReadonlyMap, processId: string, item: DataItem, raw: Uint8Array): Promise { - await this.authorize(spec, signer, tags); + await this.authorize(spec, signer, tags, item.data); const response = await this.options.node.push(processId, raw); const slot = responseSlot(response); const accepted: AcceptedMessage = {