Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/hyperbeam-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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": <number> }` | 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": <number> }` | 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
Expand Down Expand Up @@ -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 |

Expand Down
7 changes: 7 additions & 0 deletions apps/hyperbeam-gateway/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ interface CommonSpec {
action: string;
tags: Record<string, TagRule>;
data: DataRule;
setting?: boolean;
refine?: (tags: ReadonlyMap<string, string>) => string | undefined;
}

export type ActionSpec = (CommonSpec & { audience: 'operator'; check: OperatorCheck }) | (CommonSpec & { audience: 'admin' });
Expand Down Expand Up @@ -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',
Expand All @@ -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) }),
Expand Down Expand Up @@ -160,6 +165,8 @@ export function validateMessage(spec: ActionSpec, tags: ReadonlyMap<string, stri
for (const [name, rule] of Object.entries(rules)) {
if (rule.required && !tags.has(name)) throw new InvalidMessage(`tag ${name} is required for ${spec.action}`);
}
const problem = spec.refine?.(tags);
if (problem) throw new InvalidMessage(problem);
validateData(spec, data);
}

Expand Down
86 changes: 83 additions & 3 deletions apps/hyperbeam-gateway/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const BASE_TAGS: Tag[] = [
const logger = createLogger({ service: 'test', level: 'error', write: () => {} });
const operator = testSigner();
const stranger = randomSigner();
const delegatee = randomSigner();

let node: StubNode;
let store: MessageStore;
Expand All @@ -36,6 +37,7 @@ interface BuildOptions {
adminSigners?: string[];
perMinute?: number;
newSignersPerMinute?: number;
settingsPerHour?: number;
operators?: OperatorDirectory;
}

Expand All @@ -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({
Expand All @@ -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,
}),
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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']);
});

Expand All @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions apps/hyperbeam-gateway/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
3 changes: 2 additions & 1 deletion apps/hyperbeam-gateway/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
};

Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/hyperbeam-gateway/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down
Loading
Loading