diff --git a/README.md b/README.md index de6e4d1..d7e5006 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ console.log(chunk.address.toHex()) | `@ethersphere/core-sdk/mantaray` | `MantarayNode` — the manifest trie used for directory/collection uploads | | `@ethersphere/core-sdk/erasure-coding` | Reed-Solomon parity, redundancy-level tables, and the batching logic used to add parity chunks to a stream | | `@ethersphere/core-sdk/encryption` | Chunk-level stream cipher and XOR helpers | +| `@ethersphere/core-sdk/bps` | BPS-lite live pub/sub (SWIP-74/87): session topics and chunk ids, signed DATA/AUTH/ROSTER/EOS chunks, WebSocket frame codecs, roster payloads, and `BpsPosition` for resuming a subscription | | `@ethersphere/core-sdk/stamper` | Postage stamp signing (`Stamper`, `stamp()`) and effective-capacity math | Every subpath is also re-exported from the package root (`@ethersphere/core-sdk`), so `import { Bytes } from '@ethersphere/core-sdk'` works too. diff --git a/package.json b/package.json index e073535..2bedc16 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,16 @@ "default": "./dist/stamper/index.cjs" } }, + "./bps": { + "import": { + "types": "./dist/bps/index.d.ts", + "default": "./dist/bps/index.js" + }, + "require": { + "types": "./dist/bps/index.d.cts", + "default": "./dist/bps/index.cjs" + } + }, "./package.json": "./package.json" }, "scripts": { diff --git a/src/bps/chunk.ts b/src/bps/chunk.ts new file mode 100644 index 0000000..ab7656a --- /dev/null +++ b/src/bps/chunk.ts @@ -0,0 +1,126 @@ +import { Bytes } from '../bytes/bytes.js' +import { numberToUint64 } from '../bytes/encoding.js' +import { EthAddress } from '../bytes/eth-address.js' +import { Identifier } from '../bytes/identifier.js' +import { PrivateKey } from '../bytes/private-key.js' +import { Span } from '../bytes/span.js' +import { Topic } from '../bytes/topic.js' +import { makeChunkUnchecked, MAX_PAYLOAD_SIZE } from '../chunk/cac.js' +import { makeSingleOwnerChunk, makeSOCAddress, SingleOwnerChunk, unmarshalSingleOwnerChunk } from '../chunk/soc.js' +import { keccak256 } from '../crypto/keccak.js' +import { decodeBpsRoster } from './roster.js' + +export enum BpsKind { + Data = 1, + Auth = 2, + Eos = 3, + Roster = 4, +} + +export const BPS_CHALLENGE_LENGTH = 32 + +const SERVICE_PREFIX = new TextEncoder().encode('bps-service:v1') +const MAX_INDEX = 2n ** 64n - 1n +const SOC_HEADER_LENGTH = Identifier.LENGTH + 65 + Span.LENGTH + +function isEmptyKind(kind: BpsKind): boolean { + return kind === BpsKind.Auth || kind === BpsKind.Eos +} + +function toIndex(index: bigint | number): bigint { + const value = BigInt(index) + + if (value < 0n || value >= MAX_INDEX) { + throw new RangeError(`bps index ${value} out of range`) + } + + return value +} + +function assertEmptyKindAtZero(kind: BpsKind, index: bigint): void { + if (isEmptyKind(kind) && index !== 0n) { + throw new RangeError(`bps ${BpsKind[kind]} chunk must be at index 0, got ${index}`) + } +} + +export function bpsSessionTopic( + kind: BpsKind, + topic: Topic | Uint8Array | string, + challenge: Bytes | Uint8Array | string, +): Topic { + const prefix = kind === BpsKind.Data ? new Uint8Array() : Bytes.concat(SERVICE_PREFIX, new Uint8Array([kind])) + + return new Topic(keccak256(Bytes.concat(prefix, new Topic(topic), new Bytes(challenge, BPS_CHALLENGE_LENGTH)))) +} + +export function bpsIdentifier( + kind: BpsKind, + topic: Topic | Uint8Array | string, + challenge: Bytes | Uint8Array | string, + index: bigint | number, +): Identifier { + const sessionTopic = bpsSessionTopic(kind, topic, challenge) + + return new Identifier(keccak256(Bytes.concat(sessionTopic, numberToUint64(toIndex(index), 'BE')))) +} + +export function makeBpsChunk( + kind: BpsKind, + topic: Topic | Uint8Array | string, + challenge: Bytes | Uint8Array | string, + index: bigint | number, + privateKey: PrivateKey | Uint8Array | string, + payload: Bytes | Uint8Array | string = new Uint8Array(), +): SingleOwnerChunk { + const data = typeof payload === 'string' ? Bytes.fromUtf8(payload) : new Bytes(payload) + const value = toIndex(index) + assertEmptyKindAtZero(kind, value) + + if (isEmptyKind(kind) && data.length > 0) { + throw new RangeError(`bps ${BpsKind[kind]} chunk must have no payload`) + } + + if (data.length > MAX_PAYLOAD_SIZE) { + throw new RangeError(`payload size ${data.length} exceeds maximum ${MAX_PAYLOAD_SIZE}`) + } + + if (kind === BpsKind.Roster) { + decodeBpsRoster(data) + } + + const chunk = makeChunkUnchecked(data, Span.fromBigInt(BigInt(data.length))) + const identifier = bpsIdentifier(kind, topic, challenge, value) + + return makeSingleOwnerChunk(chunk, identifier, new PrivateKey(privateKey).toBigInt()) +} + +export function verifyBpsChunk( + frame: { + kind: BpsKind + owner: EthAddress | Uint8Array | string + challenge: Bytes | Uint8Array | string + index: bigint | number + soc: Bytes | Uint8Array + }, + topic: Topic | Uint8Array | string, +): SingleOwnerChunk { + const data = new Bytes(frame.soc).toUint8Array() + const index = toIndex(frame.index) + assertEmptyKindAtZero(frame.kind, index) + + if (data.length < SOC_HEADER_LENGTH || data.length > SOC_HEADER_LENGTH + MAX_PAYLOAD_SIZE) { + throw new Error(`bps chunk length ${data.length} out of range`) + } + + if (isEmptyKind(frame.kind)) { + const span = Span.fromSlice(data, SOC_HEADER_LENGTH - Span.LENGTH).toBigInt() + + if (data.length !== SOC_HEADER_LENGTH || span !== 0n) { + throw new Error(`bps ${BpsKind[frame.kind]} chunk is not empty`) + } + } + + const identifier = bpsIdentifier(frame.kind, topic, frame.challenge, index) + + return unmarshalSingleOwnerChunk(data, makeSOCAddress(identifier, frame.owner)) +} diff --git a/src/bps/frame.ts b/src/bps/frame.ts new file mode 100644 index 0000000..d501ec8 --- /dev/null +++ b/src/bps/frame.ts @@ -0,0 +1,61 @@ +import { Bytes } from '../bytes/bytes.js' +import { numberToUint64, uint64ToNumber } from '../bytes/encoding.js' +import { EthAddress } from '../bytes/eth-address.js' +import { Identifier } from '../bytes/identifier.js' +import { Span } from '../bytes/span.js' +import type { SingleOwnerChunk } from '../chunk/soc.js' +import { BPS_CHALLENGE_LENGTH, BpsKind } from './chunk.js' + +const INDEX_LENGTH = 8 +const DELIVERY_HEADER_LENGTH = 1 + EthAddress.LENGTH + BPS_CHALLENGE_LENGTH + INDEX_LENGTH +const SOC_PAYLOAD_OFFSET = Identifier.LENGTH + 65 + Span.LENGTH + +export interface BpsDelivery { + kind: BpsKind.Data | BpsKind.Eos | BpsKind.Roster + owner: EthAddress + challenge: Bytes + index: bigint + soc: Uint8Array + payload: Bytes +} + +export function encodeBpsPublishFrame( + kind: BpsKind, + index: bigint | number, + soc: SingleOwnerChunk | Bytes | Uint8Array, +): Uint8Array { + if (!(kind in BpsKind)) { + throw new Error(`unknown bps kind ${kind}`) + } + + const data = 'data' in soc ? soc.data : new Bytes(soc).toUint8Array() + + return Bytes.concat(new Uint8Array([kind]), numberToUint64(BigInt(index), 'BE'), data) +} + +export function decodeBpsDeliveryFrame(frame: Bytes | Uint8Array): BpsDelivery { + const bytes = new Bytes(frame).toUint8Array() + + if (bytes.length < DELIVERY_HEADER_LENGTH + SOC_PAYLOAD_OFFSET) { + throw new Error(`bps delivery frame too short: ${bytes.length} bytes`) + } + + const kind = bytes[0]! + + if (kind !== BpsKind.Data && kind !== BpsKind.Eos && kind !== BpsKind.Roster) { + throw new Error(`unexpected bps delivery kind ${kind}`) + } + + const challengeOffset = 1 + EthAddress.LENGTH + const indexOffset = challengeOffset + BPS_CHALLENGE_LENGTH + const soc = bytes.slice(DELIVERY_HEADER_LENGTH) + + return { + kind, + owner: new EthAddress(bytes.subarray(1, challengeOffset)), + challenge: new Bytes(bytes.subarray(challengeOffset, indexOffset)), + index: uint64ToNumber(bytes.subarray(indexOffset, DELIVERY_HEADER_LENGTH), 'BE'), + soc, + payload: new Bytes(soc.subarray(SOC_PAYLOAD_OFFSET)), + } +} diff --git a/src/bps/index.ts b/src/bps/index.ts new file mode 100644 index 0000000..4b8cb20 --- /dev/null +++ b/src/bps/index.ts @@ -0,0 +1,6 @@ +export { BPS_CHALLENGE_LENGTH, BpsKind, bpsIdentifier, bpsSessionTopic, makeBpsChunk, verifyBpsChunk } from './chunk.js' +export { decodeBpsDeliveryFrame, encodeBpsPublishFrame } from './frame.js' +export type { BpsDelivery } from './frame.js' +export { BPS_MAX_ROSTER_SIZE, decodeBpsRoster, encodeBpsRoster } from './roster.js' +export { BpsPosition } from './position.js' +export type { BpsPositionOptions } from './position.js' diff --git a/src/bps/position.ts b/src/bps/position.ts new file mode 100644 index 0000000..40f56b6 --- /dev/null +++ b/src/bps/position.ts @@ -0,0 +1,103 @@ +import { Bytes } from '../bytes/bytes.js' +import { EthAddress } from '../bytes/eth-address.js' +import { BpsKind } from './chunk.js' +import { decodeBpsRoster } from './roster.js' + +export interface BpsPositionOptions { + cursor?: bigint + roster?: Record + rosterCursor?: bigint + ended?: boolean +} + +export class BpsPosition { + private readonly owner: string + private readonly cursors = new Map() + private publishers: string[] = [] + private nextRosterIndex: bigint + private channelEnded: boolean + + constructor(owner: EthAddress | Uint8Array | string, options: BpsPositionOptions = {}) { + this.owner = new EthAddress(owner).toHex() + this.cursors.set(this.owner, options.cursor ?? 0n) + + for (const [address, cursor] of Object.entries(options.roster ?? {})) { + const publisher = new EthAddress(address).toHex() + this.publishers.push(publisher) + this.cursors.set(publisher, cursor) + } + + this.nextRosterIndex = options.rosterCursor ?? 0n + this.channelEnded = options.ended ?? false + } + + get ended(): boolean { + return this.channelEnded + } + + get roster(): EthAddress[] { + return this.publishers.map(publisher => new EthAddress(publisher)) + } + + get rosterCursor(): bigint { + return this.nextRosterIndex + } + + cursor(publisher: EthAddress | Uint8Array | string = this.owner): bigint { + return this.cursors.get(new EthAddress(publisher).toHex()) ?? 0n + } + + accept(delivery: { + kind: BpsKind + owner: EthAddress | Uint8Array | string + index: bigint + payload: Bytes | Uint8Array + }): boolean { + switch (delivery.kind) { + case BpsKind.Data: { + const publisher = new EthAddress(delivery.owner).toHex() + + if (!this.admits(publisher) || delivery.index < this.cursor(publisher)) { + return false + } + this.cursors.set(publisher, delivery.index + 1n) + + return true + } + case BpsKind.Roster: + if (delivery.index < this.nextRosterIndex) { + return false + } + this.publishers = decodeBpsRoster(delivery.payload).map(publisher => publisher.toHex()) + this.nextRosterIndex = delivery.index + 1n + + return true + case BpsKind.Eos: + if (this.channelEnded) { + return false + } + this.channelEnded = true + + return true + default: + return false + } + } + + toQuery(): Record { + const query: Record = { + cursor: this.cursor().toString(), + rosterCursor: this.nextRosterIndex.toString(), + } + + if (this.publishers.length > 0) { + query.roster = this.publishers.map(publisher => `${publisher}:${this.cursor(publisher)}`).join(',') + } + + return query + } + + private admits(publisher: string): boolean { + return publisher === this.owner || this.publishers.includes(publisher) + } +} diff --git a/src/bps/roster.ts b/src/bps/roster.ts new file mode 100644 index 0000000..dd2c2ed --- /dev/null +++ b/src/bps/roster.ts @@ -0,0 +1,90 @@ +import { Bytes } from '../bytes/bytes.js' +import { EthAddress } from '../bytes/eth-address.js' + +export const BPS_MAX_ROSTER_SIZE = 186 + +const PUBLISHERS_FIELD = 1 +const WIRE_VARINT = 0 +const WIRE_FIXED64 = 1 +const WIRE_LENGTH_DELIMITED = 2 +const WIRE_FIXED32 = 5 +const MAX_VARINT_LENGTH = 10 + +export function encodeBpsRoster(publishers: (EthAddress | Uint8Array | string)[]): Uint8Array { + if (publishers.length > BPS_MAX_ROSTER_SIZE) { + throw new RangeError(`bps roster has ${publishers.length} publishers, maximum is ${BPS_MAX_ROSTER_SIZE}`) + } + + const tag = (PUBLISHERS_FIELD << 3) | WIRE_LENGTH_DELIMITED + + return Bytes.concat( + ...publishers.map(publisher => Bytes.concat(new Uint8Array([tag, EthAddress.LENGTH]), new EthAddress(publisher))), + ) +} + +export function decodeBpsRoster(payload: Bytes | Uint8Array): EthAddress[] { + const bytes = new Bytes(payload).toUint8Array() + const publishers: EthAddress[] = [] + let offset = 0 + + const readVarint = (): number => { + let value = 0 + + for (let i = 0; i < MAX_VARINT_LENGTH && offset < bytes.length; i++) { + const byte = bytes[offset++]! + value += (byte & 0x7f) * 2 ** (7 * i) + + if (byte < 0x80) { + return value + } + } + + throw new Error('bps roster: malformed varint') + } + + const skip = (length: number): void => { + if (offset + length > bytes.length) { + throw new Error('bps roster: truncated field') + } + offset += length + } + + while (offset < bytes.length) { + const tag = readVarint() + const field = Math.floor(tag / 8) + const wireType = tag % 8 + + if (field === 0 || (field === PUBLISHERS_FIELD && wireType !== WIRE_LENGTH_DELIMITED)) { + throw new Error(`bps roster: invalid tag ${tag}`) + } + + switch (wireType) { + case WIRE_VARINT: + readVarint() + break + case WIRE_FIXED64: + skip(8) + break + case WIRE_FIXED32: + skip(4) + break + case WIRE_LENGTH_DELIMITED: { + const length = readVarint() + const start = offset + skip(length) + + if (field === PUBLISHERS_FIELD) { + if (length !== EthAddress.LENGTH) { + throw new Error(`bps roster: address length ${length}`) + } + publishers.push(new EthAddress(bytes.subarray(start, offset))) + } + break + } + default: + throw new Error(`bps roster: unsupported wire type ${wireType}`) + } + } + + return publishers +} diff --git a/src/chunk/cac.ts b/src/chunk/cac.ts index c864253..cb03f1b 100644 --- a/src/chunk/cac.ts +++ b/src/chunk/cac.ts @@ -56,13 +56,18 @@ export function makeContentAddressedChunk(rawPayload: Bytes | Uint8Array | strin ? Span.fromBigInt(span) : span : Span.fromBigInt(BigInt(rawPayload.length)) - const payload = new Bytes(rawPayload) - const data = Bytes.concat(typedSpan.toUint8Array(), payload.toUint8Array()) + + return makeChunkUnchecked(new Bytes(rawPayload), typedSpan) +} + +// Skips the payload size check, e.g. for the empty chunks some protocols sign. +export function makeChunkUnchecked(payload: Bytes, span: Span): Chunk { + const data = Bytes.concat(span.toUint8Array(), payload.toUint8Array()) const address = calculateChunkAddress(data) const chunk: Chunk = { data, - span: typedSpan, + span, payload, address, toSingleOwnerChunk: (identifier, privateKey) => diff --git a/src/index.ts b/src/index.ts index 735672f..74a77a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from './bps/index.js' export * from './bytes/index.js' export * from './chunk/index.js' export * from './crypto/index.js' diff --git a/test/bps.test.ts b/test/bps.test.ts new file mode 100644 index 0000000..fa8f17a --- /dev/null +++ b/test/bps.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest' +import { + BpsKind, + BpsPosition, + bpsIdentifier, + bpsSessionTopic, + decodeBpsDeliveryFrame, + decodeBpsRoster, + encodeBpsPublishFrame, + encodeBpsRoster, + makeBpsChunk, + verifyBpsChunk, +} from '../src/bps/index.js' +import { Bytes } from '../src/bytes/bytes.js' +import { hexToUint8Array, numberToUint64 } from '../src/bytes/encoding.js' + +// Vectors produced by pkg/bps of ethersphere/bee#5626 for these inputs. +const TOPIC = '11'.repeat(32) +const CHALLENGE = '22'.repeat(32) +const PRIVATE_KEY = '4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d' +const OWNER = '90f8bf6a479f320ead074411a4b0e7944ea8c9c1' +const SEAT_A = 'aa'.repeat(20) +const SEAT_B = 'bb'.repeat(20) + +const GO = { + topics: { + [BpsKind.Data]: '3e92e0db88d6afea9edc4eedf62fffa4d92bcdfc310dccbe943747fe8302e871', + [BpsKind.Auth]: 'bc50d4f3a3c9e222fa67eb5d9f6e819c732d4ec2ef0afa4b1019a90e31d50c91', + [BpsKind.Eos]: '2b69db07c90ae59f59e7a98814e9bdda892828e4bd92d9868520f3a2c948ff48', + [BpsKind.Roster]: '721a41a64d068b9191849b9d49ef375b7e50f6a338c19eac7773e8a59b7c87d9', + }, + idsAt7: { + [BpsKind.Data]: 'ae070cf6479865d199201674c717c4bf0135cbbc9baf9ca1ba70b82f12f84d77', + [BpsKind.Auth]: 'd6a4eb8db411721b269362109a352b17d3dec0176a05815c258dccfbb37441b4', + [BpsKind.Eos]: '23721f917432ac955e91aae05b8231a19802680f764582095da2104ab6511100', + [BpsKind.Roster]: '993116f4d852e7bdd43a4958263af3eb25746082e85e6f1d4e405d33c764b9b3', + }, + roster: '0a14aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0a14bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + socData: + 'ae070cf6479865d199201674c717c4bf0135cbbc9baf9ca1ba70b82f12f84d77d8e7a693b04c624efad3b8b1fa27d46431f69f14e162187aae0c783e6fae78a940cecfa3059bb46572cb0232779c040d35a0892f0982dfea7b11f9e3ddf8f8a51b050000000000000068656c6c6f', + socAuth: + '98d2e75f56f7b58ec9d0901a28e6da2c3dfd9501e53199162311e72c699e5f7cde722614162c8fa77821802811be91c0f41e45b9dcfe3d797d97720a3a2400117adc1634a7c06403c47f3e96c24e2996928ec03ae1c9b5307622c4c3f1ac98ab1c0000000000000000', + socRoster: + '7187856fa68c0abb50f4b092a6bd49ce0ce072be2530a09d8f5694913b7b289e34cf682538ded3a77b0139f23a2816838b5d4beeb2140c76659382188ccf5c4045030f3dbad26acb0be3c764202facd2725230b739f0581cb645de458306385d1c2c000000000000000a14aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0a14bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', +} + +const KINDS = [BpsKind.Data, BpsKind.Auth, BpsKind.Eos, BpsKind.Roster] as const + +function deliveryFrame(kind: BpsKind, owner: string, index: bigint, soc: string): Uint8Array { + return Bytes.concat( + new Uint8Array([kind]), + hexToUint8Array(owner), + hexToUint8Array(CHALLENGE), + numberToUint64(index, 'BE'), + hexToUint8Array(soc), + ) +} + +describe('bpsSessionTopic / bpsIdentifier', () => { + it.each(KINDS)('matches bee for kind %i', kind => { + expect(bpsSessionTopic(kind, TOPIC, CHALLENGE).toHex()).toBe(GO.topics[kind]) + expect(bpsIdentifier(kind, TOPIC, CHALLENGE, 7n).toHex()).toBe(GO.idsAt7[kind]) + expect(bpsIdentifier(kind, TOPIC, CHALLENGE, 7).toHex()).toBe(GO.idsAt7[kind]) + }) + + it('rejects a challenge of the wrong length', () => { + expect(() => bpsSessionTopic(BpsKind.Data, TOPIC, '22'.repeat(31))).toThrow() + }) + + it('rejects the last uint64 index and negatives', () => { + expect(() => bpsIdentifier(BpsKind.Data, TOPIC, CHALLENGE, 2n ** 64n - 1n)).toThrow(RangeError) + expect(() => bpsIdentifier(BpsKind.Data, TOPIC, CHALLENGE, -1)).toThrow(RangeError) + }) +}) + +describe('makeBpsChunk', () => { + it('builds the same DATA chunk as bee', () => { + const soc = makeBpsChunk(BpsKind.Data, TOPIC, CHALLENGE, 7n, PRIVATE_KEY, 'hello') + expect(new Bytes(soc.data).toHex()).toBe(GO.socData) + expect(soc.owner.toHex()).toBe(OWNER) + }) + + it('builds the same empty AUTH chunk as bee', () => { + const soc = makeBpsChunk(BpsKind.Auth, TOPIC, CHALLENGE, 0n, PRIVATE_KEY) + expect(new Bytes(soc.data).toHex()).toBe(GO.socAuth) + expect(soc.payload.length).toBe(0) + }) + + it('builds the same ROSTER chunk as bee', () => { + const soc = makeBpsChunk(BpsKind.Roster, TOPIC, CHALLENGE, 1n, PRIVATE_KEY, encodeBpsRoster([SEAT_A, SEAT_B])) + expect(new Bytes(soc.data).toHex()).toBe(GO.socRoster) + }) + + it('allows an empty roster', () => { + const soc = makeBpsChunk(BpsKind.Roster, TOPIC, CHALLENGE, 2n, PRIVATE_KEY, encodeBpsRoster([])) + expect(soc.payload.length).toBe(0) + }) + + it('rejects AUTH and EOS chunks off index 0 or with a payload', () => { + expect(() => makeBpsChunk(BpsKind.Auth, TOPIC, CHALLENGE, 1n, PRIVATE_KEY)).toThrow(RangeError) + expect(() => makeBpsChunk(BpsKind.Eos, TOPIC, CHALLENGE, 0n, PRIVATE_KEY, 'x')).toThrow(RangeError) + }) + + it('rejects a roster payload that does not decode', () => { + expect(() => + makeBpsChunk(BpsKind.Roster, TOPIC, CHALLENGE, 0n, PRIVATE_KEY, new Uint8Array([0x0a, 0x02, 1, 2])), + ).toThrow() + }) + + it('rejects an oversized payload', () => { + expect(() => makeBpsChunk(BpsKind.Data, TOPIC, CHALLENGE, 0n, PRIVATE_KEY, new Uint8Array(4097))).toThrow( + RangeError, + ) + }) +}) + +describe('verifyBpsChunk', () => { + const data = { kind: BpsKind.Data, owner: OWNER, challenge: CHALLENGE, index: 7n, soc: hexToUint8Array(GO.socData) } + + it('accepts chunks signed by bee', () => { + expect(verifyBpsChunk(data, TOPIC).payload.toUtf8()).toBe('hello') + expect( + verifyBpsChunk({ ...data, kind: BpsKind.Auth, index: 0n, soc: hexToUint8Array(GO.socAuth) }, TOPIC).payload + .length, + ).toBe(0) + expect( + verifyBpsChunk( + { ...data, kind: BpsKind.Roster, index: 1n, soc: hexToUint8Array(GO.socRoster) }, + TOPIC, + ).owner.toHex(), + ).toBe(OWNER) + }) + + it('rejects a chunk whose frame fields do not derive its id', () => { + expect(() => verifyBpsChunk({ ...data, index: 8n }, TOPIC)).toThrow() + expect(() => verifyBpsChunk({ ...data, kind: BpsKind.Roster }, TOPIC)).toThrow() + expect(() => verifyBpsChunk({ ...data, challenge: '33'.repeat(32) }, TOPIC)).toThrow() + expect(() => verifyBpsChunk(data, '12'.repeat(32))).toThrow() + }) + + it('rejects a chunk signed by someone else', () => { + expect(() => verifyBpsChunk({ ...data, owner: SEAT_A }, TOPIC)).toThrow() + }) + + it('rejects a non-empty AUTH chunk', () => { + const auth = makeBpsChunk(BpsKind.Auth, TOPIC, CHALLENGE, 0n, PRIVATE_KEY) + const padded = Bytes.concat(auth.data, new Uint8Array([0])) + expect(() => verifyBpsChunk({ ...data, kind: BpsKind.Auth, index: 0n, soc: padded }, TOPIC)).toThrow() + }) + + it('rejects a truncated chunk', () => { + expect(() => verifyBpsChunk({ ...data, soc: new Uint8Array(50) }, TOPIC)).toThrow() + }) +}) + +describe('encodeBpsRoster / decodeBpsRoster', () => { + it('encodes the same payload as bee', () => { + expect(new Bytes(encodeBpsRoster([SEAT_A, SEAT_B])).toHex()).toBe(GO.roster) + }) + + it('round-trips', () => { + expect(decodeBpsRoster(hexToUint8Array(GO.roster)).map(address => address.toHex())).toEqual([SEAT_A, SEAT_B]) + expect(decodeBpsRoster(new Uint8Array())).toEqual([]) + }) + + it('skips unknown fields', () => { + const unknown = new Uint8Array([0x10, 0x96, 0x01, 0x1a, 0x01, 0xff, 0x25, 1, 2, 3, 4, 0x29, 1, 2, 3, 4, 5, 6, 7, 8]) + const decoded = decodeBpsRoster(Bytes.concat(unknown, hexToUint8Array(GO.roster))) + expect(decoded.map(address => address.toHex())).toEqual([SEAT_A, SEAT_B]) + }) + + it('rejects malformed payloads', () => { + expect(() => decodeBpsRoster(new Uint8Array([0x0a, 0x13, ...new Uint8Array(19)]))).toThrow() + expect(() => decodeBpsRoster(new Uint8Array([0x0a, 0x14, 1, 2]))).toThrow() + expect(() => decodeBpsRoster(new Uint8Array([0x08, 0x01]))).toThrow() + expect(() => decodeBpsRoster(new Uint8Array([0x0b]))).toThrow() + expect(() => decodeBpsRoster(new Uint8Array([0x10, 0x80]))).toThrow() + }) + + it('caps the roster at what fits in one chunk', () => { + const full = Array.from({ length: 186 }, () => SEAT_A) + expect(encodeBpsRoster(full).length).toBeLessThanOrEqual(4096) + expect(() => encodeBpsRoster([...full, SEAT_B])).toThrow(RangeError) + }) +}) + +describe('encodeBpsPublishFrame', () => { + it('prefixes the chunk with kind and big-endian index', () => { + const soc = makeBpsChunk(BpsKind.Data, TOPIC, CHALLENGE, 258n, PRIVATE_KEY, 'hi') + const frame = encodeBpsPublishFrame(BpsKind.Data, 258n, soc) + expect(new Bytes(frame.subarray(0, 9)).toHex()).toBe('010000000000000102') + expect(frame.subarray(9)).toEqual(soc.data) + expect(encodeBpsPublishFrame(BpsKind.Data, 258, soc.data)).toEqual(frame) + }) +}) + +describe('decodeBpsDeliveryFrame', () => { + it('decodes the delivery header and chunk', () => { + const delivery = decodeBpsDeliveryFrame(deliveryFrame(BpsKind.Data, OWNER, 7n, GO.socData)) + expect(delivery.kind).toBe(BpsKind.Data) + expect(delivery.owner.toHex()).toBe(OWNER) + expect(delivery.challenge.toHex()).toBe(CHALLENGE) + expect(delivery.index).toBe(7n) + expect(delivery.payload.toUtf8()).toBe('hello') + expect(verifyBpsChunk(delivery, TOPIC).payload.toUtf8()).toBe('hello') + }) + + it('rejects AUTH and unknown kinds', () => { + expect(() => decodeBpsDeliveryFrame(deliveryFrame(BpsKind.Auth, OWNER, 0n, GO.socAuth))).toThrow() + expect(() => decodeBpsDeliveryFrame(deliveryFrame(9 as BpsKind, OWNER, 0n, GO.socAuth))).toThrow() + }) + + it('rejects a short frame', () => { + expect(() => decodeBpsDeliveryFrame(new Uint8Array(100))).toThrow() + }) +}) + +describe('BpsPosition', () => { + const data = (owner: string, index: bigint) => ({ kind: BpsKind.Data, owner, index, payload: new Uint8Array() }) + const roster = (index: bigint, publishers: string[]) => ({ + kind: BpsKind.Roster, + owner: OWNER, + index, + payload: encodeBpsRoster(publishers), + }) + + it('starts at zero and accepts only the owner', () => { + const position = new BpsPosition(OWNER) + expect(position.toQuery()).toEqual({ cursor: '0', rosterCursor: '0' }) + expect(position.accept(data(SEAT_A, 0n))).toBe(false) + expect(position.accept(data(OWNER, 3n))).toBe(true) + expect(position.cursor()).toBe(4n) + }) + + it('drops updates below a publisher cursor', () => { + const position = new BpsPosition(OWNER, { cursor: 10n }) + expect(position.accept(data(OWNER, 9n))).toBe(false) + expect(position.accept(data(OWNER, 10n))).toBe(true) + expect(position.accept(data(OWNER, 10n))).toBe(false) + }) + + it('admits the publishers of the latest roster only', () => { + const position = new BpsPosition(OWNER) + expect(position.accept(roster(0n, [SEAT_A]))).toBe(true) + expect(position.accept(data(SEAT_A, 5n))).toBe(true) + expect(position.accept(roster(1n, [SEAT_B]))).toBe(true) + expect(position.accept(data(SEAT_A, 6n))).toBe(false) + expect(position.accept(data(SEAT_B, 0n))).toBe(true) + expect(position.roster.map(address => address.toHex())).toEqual([SEAT_B]) + }) + + it('ignores a roster below the roster cursor', () => { + const position = new BpsPosition(OWNER) + position.accept(roster(4n, [SEAT_A])) + expect(position.accept(roster(4n, [SEAT_B]))).toBe(false) + expect(position.accept(roster(2n, [SEAT_B]))).toBe(false) + expect(position.rosterCursor).toBe(5n) + expect(position.roster.map(address => address.toHex())).toEqual([SEAT_A]) + }) + + it('marks the channel ended on EOS', () => { + const position = new BpsPosition(OWNER) + const eos = { kind: BpsKind.Eos, owner: OWNER, index: 0n, payload: new Uint8Array() } + expect(position.accept(eos)).toBe(true) + expect(position.ended).toBe(true) + expect(position.accept(eos)).toBe(false) + }) + + it('serializes the query the subscribe endpoint expects and restores from it', () => { + const position = new BpsPosition(OWNER) + position.accept(data(OWNER, 6n)) + position.accept(roster(2n, [SEAT_A, SEAT_B])) + position.accept(data(SEAT_A, 3n)) + expect(position.toQuery()).toEqual({ cursor: '7', rosterCursor: '3', roster: `${SEAT_A}:4,${SEAT_B}:0` }) + + const restored = new BpsPosition(OWNER, { + cursor: 7n, + rosterCursor: 3n, + roster: { [`0x${SEAT_A}`]: 4n, [SEAT_B]: 0n }, + }) + expect(restored.toQuery()).toEqual(position.toQuery()) + expect(restored.accept(data(SEAT_A, 3n))).toBe(false) + expect(restored.accept(data(SEAT_B, 0n))).toBe(true) + }) +}) diff --git a/test/public-api.test.ts b/test/public-api.test.ts index 6bd2448..288f5e9 100644 --- a/test/public-api.test.ts +++ b/test/public-api.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import * as bpsModule from '../src/bps/index.js' import * as bytesModule from '../src/bytes/index.js' import * as chunkModule from '../src/chunk/index.js' import * as cryptoModule from '../src/crypto/index.js' @@ -115,9 +116,31 @@ const STAMPER_EXPORTS = [ const MANTARAY_EXPORTS = ['Fork', 'MantarayNode'] +const BPS_EXPORTS = [ + 'BPS_CHALLENGE_LENGTH', + 'BPS_MAX_ROSTER_SIZE', + 'BpsKind', + 'BpsPosition', + 'bpsIdentifier', + 'bpsSessionTopic', + 'decodeBpsDeliveryFrame', + 'decodeBpsRoster', + 'encodeBpsPublishFrame', + 'encodeBpsRoster', + 'makeBpsChunk', + 'verifyBpsChunk', +] + // Exports that are data, not callables (e.g. a fixed address constant) - // exempted from the "every export is a function" check below. -const NON_FUNCTION_EXPORTS = new Set(['REPLICAS_OWNER', 'MAX_PAYLOAD_SIZE', 'MIN_PAYLOAD_SIZE']) +const NON_FUNCTION_EXPORTS = new Set([ + 'REPLICAS_OWNER', + 'MAX_PAYLOAD_SIZE', + 'MIN_PAYLOAD_SIZE', + 'BPS_CHALLENGE_LENGTH', + 'BPS_MAX_ROSTER_SIZE', + 'BpsKind', +]) function assertExactExports(module: object, expectedNames: string[], label: string) { const actualNames = Object.keys(module).sort() @@ -160,10 +183,15 @@ describe('public API contract', () => { assertExactExports(mantarayModule, MANTARAY_EXPORTS, 'mantaray') }) + it('swarm-core-lib/bps exports exactly the expected functions', () => { + assertExactExports(bpsModule, BPS_EXPORTS, 'bps') + }) + it('swarm-core-lib (root) re-exports the union of every implemented subpath', () => { assertExactExports( rootModule, [ + ...BPS_EXPORTS, ...BYTES_EXPORTS, ...CHUNK_EXPORTS, ...CRYPTO_EXPORTS,