From 0ef36254c37f7ee9155fe3915692bf2fbdf226f3 Mon Sep 17 00:00:00 2001 From: Nemanya8 Date: Sun, 18 Oct 2026 20:45:58 +0200 Subject: [PATCH 1/7] feat(individuality): register for the game in one call --- .../packages/individuality/package.json | 18 + .../individuality/src/game-account.ts | 63 + .../packages/individuality/src/host.ts | 383 +++ .../packages/individuality/src/index.ts | 52 + .../individuality/src/register-game.ts | 2064 +++++++++++++++++ .../individuality/src/same-account.ts | 36 + .../packages/individuality/src/seat.ts | 396 ++++ .../packages/individuality/src/signup-lite.ts | 9 +- .../packages/individuality/tsup.config.ts | 2 +- .../sdk/src/individuality/contract.test.ts | 35 + .../individuality-register-for-game.md | 6 + product-sdk/pnpm-lock.yaml | 6 + .../skills/product-sdk-individuality/SKILL.md | 52 +- 13 files changed, 3111 insertions(+), 11 deletions(-) create mode 100644 product-sdk/packages/individuality/src/game-account.ts create mode 100644 product-sdk/packages/individuality/src/host.ts create mode 100644 product-sdk/packages/individuality/src/register-game.ts create mode 100644 product-sdk/packages/individuality/src/same-account.ts create mode 100644 product-sdk/packages/individuality/src/seat.ts create mode 100644 product-sdk/pending-changesets/individuality-register-for-game.md diff --git a/product-sdk/packages/individuality/package.json b/product-sdk/packages/individuality/package.json index 9553cc56..0aff2c4f 100644 --- a/product-sdk/packages/individuality/package.json +++ b/product-sdk/packages/individuality/package.json @@ -10,6 +10,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./host": { + "types": "./dist/host.d.ts", + "import": "./dist/host.js" + }, "./testing": { "types": "./dist/testing.d.ts", "import": "./dist/testing.js" @@ -39,8 +43,22 @@ "@scure/sr25519": "^2.2.0", "polkadot-api": "catalog:" }, + "peerDependencies": { + "@parity/product-sdk-host": "workspace:^", + "@parity/product-sdk-tx": "workspace:^" + }, + "peerDependenciesMeta": { + "@parity/product-sdk-host": { + "optional": true + }, + "@parity/product-sdk-tx": { + "optional": true + } + }, "devDependencies": { "@parity/product-sdk-descriptors": "workspace:*", + "@parity/product-sdk-host": "workspace:*", + "@parity/product-sdk-tx": "workspace:*", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/product-sdk/packages/individuality/src/game-account.ts b/product-sdk/packages/individuality/src/game-account.ts new file mode 100644 index 00000000..86cd5aee --- /dev/null +++ b/product-sdk/packages/individuality/src/game-account.ts @@ -0,0 +1,63 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * The account the weekly game is played on. + * + * Every product that touches a player's game record has to name the same + * product account: the game product, `Game.product_name` on chain (`dim2`), + * under the network's DotNS TLD, at one derivation index. The game product + * signs players up and plays on it. The personhood product registers personhood + * and signs players up as it, under the game product's `context` grant. The + * collectibles product claims the credits the game awards to it. Naming it once + * here keeps all of them on one account. + */ +import { ProductIndividualityError } from "./errors.js"; + +/** `Game.product_name`: the product the game pallet scopes its draws to. */ +export const GAME_PRODUCT_NAME = "dim2"; + +/** + * The derivation index of the game account. + * + * 1, not the host default 0, while the native iOS game still plays its own + * game on index 0 (jollity-next#85): a sign-up on index 0 makes the native app + * start its game over this one. Move it to 0 once the native game is retired, + * in one release for every product. + */ +export const GAME_ACCOUNT_INDEX = 1; + +/** + * The game product's DotNS id on a network, such as `"dim2.paseo"`: the id a + * host derives the game account from, and the product a draw proof's context + * names. Lower case, because the chain hashes the raw string. + * + * @throws ProductIndividualityError when `tld` is empty or has a dot inside. + */ +export function gameProductId(tld: string): string { + const bare = tld.trim().replace(/^\./, "").toLowerCase(); + if (bare.length === 0 || bare.includes(".")) { + throw new ProductIndividualityError(`not a DotNS TLD: "${tld}"`); + } + return `${GAME_PRODUCT_NAME}.${bare}`; +} + +if (import.meta.vitest) { + const { describe, expect, test } = import.meta.vitest; + + describe("gameProductId", () => { + test("joins the game product to the TLD", () => { + expect(gameProductId("paseo")).toBe("dim2.paseo"); + expect(gameProductId("testnet")).toBe("dim2.testnet"); + }); + + test("takes a TLD written with its dot, or in capitals", () => { + expect(gameProductId(".paseo")).toBe("dim2.paseo"); + expect(gameProductId("PASEO")).toBe("dim2.paseo"); + }); + + test("refuses an empty TLD or a full name", () => { + expect(() => gameProductId("")).toThrow(ProductIndividualityError); + expect(() => gameProductId("dim2.paseo")).toThrow(ProductIndividualityError); + }); + }); +} diff --git a/product-sdk/packages/individuality/src/host.ts b/product-sdk/packages/individuality/src/host.ts new file mode 100644 index 00000000..bd0c5010 --- /dev/null +++ b/product-sdk/packages/individuality/src/host.ts @@ -0,0 +1,383 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * `@parity/product-sdk-individuality/host`: the game-registration capabilities, + * built from a Polkadot host. + * + * The main entry stays free of `@parity/product-sdk-host` and + * `@parity/product-sdk-tx` on purpose; this entry is the one place that wires a + * host's accounts and `tx`'s submission into {@link GameRegistrationCapabilities}. + * It declares both packages as optional peer dependencies. + * + * ```ts + * import { fromPapi, registerForGame } from "@parity/product-sdk-individuality"; + * import { hostGameRegistrationCapabilities } from "@parity/product-sdk-individuality/host"; + * + * const caps = await hostGameRegistrationCapabilities({ tld: "paseo" }); + * if (!caps.ok) return; + * const outcome = await registerForGame(fromPapi(client, api), caps.value, { + * tld: "paseo", + * identifierKey, + * }); + * ``` + * + * The same call works from the game product and from any product the game + * product grants `context` in its manifest: the host derives the game account + * for the caller and signs as it only under that grant. The personhood keys are + * the personhood product's; a product other than it needs that product's + * `context` grant to prove with them. + */ +import { + findRingVrfKeyHandle, + formatHostError, + getAccountsProvider, + type AccountsProvider, + type ProductProofContext, + type RingLocation as HostRingLocation, + type RingVrfKeyHandle, +} from "@parity/product-sdk-host"; +import { + submitAndWatch, + TxDispatchError, + TxSigningRejectedError, + TxTimeoutError, + TxValidityError, +} from "@parity/product-sdk-tx"; +import { err, normalizeError, ok, type Result } from "@parity/result"; +import { AccountId } from "polkadot-api"; + +import { ProductIndividualityError } from "./errors.js"; +import { GAME_ACCOUNT_INDEX, gameProductId } from "./game-account.js"; +import { + errorName, + type GameRegistrationCapabilities, + type PersonhoodProver, + type PersonhoodRing, + type SubmitSignUp, +} from "./register-game.js"; +import type { RingLocation } from "./rings.js"; + +/** + * The `Members` pallet's index on the People chain. A host may hold a ring key + * declared with a `PalletInstance` junction in front of the collection id, or + * without one (`rings.ts` builds it without), so a key is looked up under both + * shapes. Carried over from jollity-next, where both shapes were met. + */ +export const MEMBERS_PALLET_INSTANCE = 67; + +/** Options for {@link hostGameRegistrationCapabilities}. */ +export interface HostGameRegistrationOptions { + /** The network's DotNS TLD, such as `"paseo"`. */ + tld: string; + /** Defaults to `getAccountsProvider()`. */ + accounts?: AccountsProvider; + /** Defaults to {@link MEMBERS_PALLET_INSTANCE}. */ + membersPalletInstance?: number; +} + +function hostError(what: string, cause: unknown): ProductIndividualityError { + return new ProductIndividualityError(`${what}: ${formatHostError(cause)}`, { cause }); +} + +/** The shapes a host may hold a personhood ring under. */ +function ringShapes(ring: RingLocation, palletInstance: number): RingLocation[] { + if (ring.junctions.some((junction) => junction.tag === "PalletInstance")) return [ring]; + return [ + ring, + { + chainId: ring.chainId, + junctions: [{ tag: "PalletInstance", value: palletInstance }, ...ring.junctions], + }, + ]; +} + +/** + * The personhood keys the host holds, looked up by owner and ring. A key that + * was found is kept for the life of the prover, so a sign-up's draw proofs ask + * the host to list keys once; a lookup that finds nothing or fails is not kept. + */ +export function hostPersonhoodProver( + accounts: AccountsProvider, + palletInstance: number = MEMBERS_PALLET_INSTANCE, +): PersonhoodProver { + type Found = { handle: RingVrfKeyHandle; location: HostRingLocation } | null; + const found = new Map>(); + + const lookUp = async (ring: PersonhoodRing): Promise => { + const keys = await accounts.listRingVrfKeys(ring.owner).match( + (value) => value, + (cause) => { + throw hostError(`the host could not list the keys of ${ring.owner}`, cause); + }, + ); + for (const shape of ringShapes(ring.ring, palletInstance)) { + const location = shape as HostRingLocation; + const handle = findRingVrfKeyHandle(keys, location); + if (handle) return { handle, location }; + } + return null; + }; + const keyFor = (ring: PersonhoodRing): Promise => { + const cacheKey = `${ring.owner}|${JSON.stringify(ring.ring)}`; + const cached = found.get(cacheKey); + if (cached) return cached; + const lookup = lookUp(ring); + found.set(cacheKey, lookup); + lookup.then( + (key) => { + if (key === null) found.delete(cacheKey); + }, + () => found.delete(cacheKey), + ); + return lookup; + }; + + return { + async holds(ring) { + return (await keyFor(ring)) !== null; + }, + async prove(ring, context, message) { + const key = await keyFor(ring); + if (!key) { + throw new ProductIndividualityError( + `the host holds no ${ring.key} key of ${ring.owner} for this ring`, + ); + } + return accounts + .createRingVRFProof( + key.handle, + context as ProductProofContext, + key.location, + message, + ) + .match( + (proof) => proof, + (cause) => { + throw hostError(`the host refused the ${ring.key} proof`, cause); + }, + ); + }, + }; +} + +/** + * The sign-up submission, through `tx`'s `submitAndWatch`, with its errors read + * the way the flow needs them: a declined signature is `refused`, a timeout may + * still land, and a dispatch or validity error is named by the chain's code. + */ +export const hostSubmitSignUp: SubmitSignUp = async (tx, signer, options) => { + const result = await submitAndWatch(tx, signer, { + waitFor: options.waitFor, + onStatus: (status) => { + if (status === "broadcasting") options.onBroadcast?.(); + }, + }); + if (result.ok) { + return result.value.ok + ? { ok: true } + : { ok: false, code: errorName(result.value.dispatchError) }; + } + const error = result.error; + if (error instanceof TxSigningRejectedError) { + return { ok: false, code: "SigningRejected", refused: true }; + } + if (error instanceof TxTimeoutError) + return { ok: false, code: "Timeout", landingUnknown: true }; + if (error instanceof TxDispatchError) + return { ok: false, code: errorName(error.dispatchError) }; + if (error instanceof TxValidityError) return { ok: false, code: errorName(error.reason) }; + return { + ok: false, + code: errorName(error), + ...(/tracking stopped/i.test(error.message) ? { landingUnknown: true } : {}), + }; +}; + +/** + * The capabilities a sign-up needs, from a host: the game account + * (`dim2.` at `GAME_ACCOUNT_INDEX`) and its signer, `tx`'s submission, the + * draw tickets, and the personhood keys. + */ +export async function hostGameRegistrationCapabilities( + options: HostGameRegistrationOptions, +): Promise> { + try { + const accounts = options.accounts ?? (await getAccountsProvider()); + if (!accounts) { + throw new ProductIndividualityError("no host accounts: not inside a Polkadot host"); + } + const product = gameProductId(options.tld); + const account = await accounts.getProductAccount(product, GAME_ACCOUNT_INDEX).match( + (value) => value, + (cause) => { + throw hostError(`the host would not name ${product}/${GAME_ACCOUNT_INDEX}`, cause); + }, + ); + return ok({ + account: { address: AccountId().dec(account.publicKey), publicKey: account.publicKey }, + signer: accounts.getProductAccountSigner(account), + submit: hostSubmitSignUp, + signVrf: (transcriptLabel, items) => + accounts.signVrf(account, transcriptLabel, items).match( + (signature) => ({ preOutput: signature.preOutput, proof: signature.proof }), + (cause) => { + throw hostError("the host refused the draw ticket", cause); + }, + ), + personhood: hostPersonhoodProver(accounts, options.membersPalletInstance), + }); + } catch (cause) { + return err(normalizeError(cause, ProductIndividualityError)); + } +} + +if (import.meta.vitest) { + const { describe, expect, test, vi } = import.meta.vitest; + const { unwrapErr, unwrapOk } = await import("@parity/result"); + + /** The one method of a host's `ResultAsync` this entry calls. */ + const okAsync = (value: T) => ({ + match: async (onOk: (v: T) => R) => onOk(value), + }); + const errAsync = (cause: unknown) => ({ + match: async (_onOk: unknown, onErr: (e: unknown) => R) => onErr(cause), + }); + + const PUBLIC_KEY = new Uint8Array(32).fill(7); + const RING: RingLocation = { + chainId: "0x01", + junctions: [{ tag: "CollectionId", value: `0x${"20".repeat(32)}` }], + }; + const LITE: PersonhoodRing = { key: "lite", owner: "peopl.paseo", ring: RING }; + + const provider = (keys: unknown[] = []) => + ({ + getProductAccount: vi.fn(() => okAsync({ publicKey: PUBLIC_KEY })), + getProductAccountSigner: vi.fn(() => ({ publicKey: PUBLIC_KEY })), + signVrf: vi.fn(() => + okAsync({ preOutput: new Uint8Array(32), proof: new Uint8Array(64) }), + ), + listRingVrfKeys: vi.fn(() => okAsync(keys)), + createRingVRFProof: vi.fn(() => okAsync({ proof: new Uint8Array(1) })), + }) as unknown as AccountsProvider; + + describe("hostGameRegistrationCapabilities", () => { + test("asks the host for the game account at the game index, and submits through tx", async () => { + const accounts = provider(); + const caps = unwrapOk( + await hostGameRegistrationCapabilities({ tld: "paseo", accounts }), + ); + expect(accounts.getProductAccount).toHaveBeenCalledWith( + "dim2.paseo", + GAME_ACCOUNT_INDEX, + ); + expect(caps.account.publicKey).toBe(PUBLIC_KEY); + expect(caps.account.address).toBe(AccountId().dec(PUBLIC_KEY)); + expect(caps.submit).toBe(hostSubmitSignUp); + }); + + test("reports a host that will not name the account on the err channel", async () => { + const accounts = { + ...provider(), + getProductAccount: () => errAsync(new Error("denied")), + } as unknown as AccountsProvider; + const result = await hostGameRegistrationCapabilities({ tld: "paseo", accounts }); + expect(unwrapErr(result).message).toContain("dim2.paseo/1"); + }); + }); + + describe("hostPersonhoodProver", () => { + test("finds a key held under the ring as declared", async () => { + const keys = [{ handle: "h1", rings: [RING] }]; + expect(await hostPersonhoodProver(provider(keys)).holds(LITE)).toBe(true); + }); + + test("finds a key held under the ring with the pallet in front", async () => { + const withPallet = { + chainId: RING.chainId, + junctions: [{ tag: "PalletInstance", value: 67 }, ...RING.junctions], + }; + const keys = [{ handle: "h1", rings: [withPallet] }]; + expect(await hostPersonhoodProver(provider(keys)).holds(LITE)).toBe(true); + }); + + test("lists the keys once for many proofs", async () => { + const accounts = provider([{ handle: "h1", rings: [RING] }]); + const prover = hostPersonhoodProver(accounts); + const context = { + productId: "peopl.paseo", + suffix: { tag: "Index" as const, value: 0 }, + }; + await prover.prove(LITE, context, new Uint8Array()); + await prover.prove(LITE, context, new Uint8Array()); + expect(accounts.listRingVrfKeys).toHaveBeenCalledOnce(); + expect(accounts.createRingVRFProof).toHaveBeenCalledTimes(2); + }); + + test("says so when the host holds no key, and asks again next time", async () => { + const accounts = provider([]); + const prover = hostPersonhoodProver(accounts); + expect(await prover.holds(LITE)).toBe(false); + await expect( + prover.prove( + LITE, + { productId: "peopl.paseo", suffix: { tag: "Index", value: 0 } }, + new Uint8Array(), + ), + ).rejects.toThrow(ProductIndividualityError); + expect(accounts.listRingVrfKeys).toHaveBeenCalledTimes(2); + }); + }); + + describe("hostSubmitSignUp", () => { + const signer = {} as Parameters[1]; + /** A transaction whose watch replays `events`, the way PAPI emits them. */ + const txOf = (events: Record[]) => + ({ + getEstimatedFees: async () => 0n, + createSubmitAndWatch: () => ({ + subscribe(handlers: { next: (event: unknown) => void }) { + for (const event of events) handlers.next(event); + return { unsubscribe() {} }; + }, + }), + }) as unknown as Parameters[0]; + const BLOCK = { hash: "0x01", number: 1, index: 0 }; + + test("reports the broadcast and succeeds at finality", async () => { + const onBroadcast = vi.fn(); + const result = await hostSubmitSignUp( + txOf([ + { type: "broadcasted", txHash: "0xaa" }, + { type: "inBestBlock", ok: true, block: BLOCK, events: [] }, + { type: "finalized", ok: true, block: BLOCK, events: [] }, + ]), + signer, + { waitFor: "finalized", onBroadcast }, + ); + expect(result).toEqual({ ok: true }); + expect(onBroadcast).toHaveBeenCalledOnce(); + }); + + test("names a refused dispatch by the pallet's error", async () => { + const result = await hostSubmitSignUp( + txOf([ + { type: "broadcasted", txHash: "0xaa" }, + { + type: "inBestBlock", + ok: false, + block: BLOCK, + events: [], + dispatchError: { + type: "Module", + value: { type: "Game", value: { type: "AlreadyRegistered" } }, + }, + }, + ]), + signer, + { waitFor: "finalized" }, + ); + expect(result).toEqual({ ok: false, code: "AlreadyRegistered" }); + }); + }); +} diff --git a/product-sdk/packages/individuality/src/index.ts b/product-sdk/packages/individuality/src/index.ts index db0b056e..68576c8b 100644 --- a/product-sdk/packages/individuality/src/index.ts +++ b/product-sdk/packages/individuality/src/index.ts @@ -472,6 +472,58 @@ export type { // and the participant arm of Game.report, from a 0-balance account. export { withScoreParticipant } from "./as-score-participant-signer.js"; +// Registering for the game in one call, from any product: the game account every +// product names, the seat (which route, and whether it can go now, by the chain's +// clock), the lite bind, and the sign-up with its draw entries. Every failure is a +// `GameRegistrationError` naming its step and whether a retry can help, and every +// outcome carries a report for telemetry. The host side comes in as capabilities; +// `@parity/product-sdk-individuality/host` builds them from a host and `tx`. +export { GAME_ACCOUNT_INDEX, GAME_PRODUCT_NAME, gameProductId } from "./game-account.js"; +export { DEFAULT_FEE_HEADROOM, deriveSeat, readGameSeat, seatRequired } from "./seat.js"; +export type { + GameSeat, + GameSeatChain, + ReadGameSeatOptions, + Seat, + SeatFacts, + SeatPath, + SeatRoute, +} from "./seat.js"; +export { + bindGameSeat, + GAME_REGISTRATION_RETRY_MS, + GameRegistrationError, + GameSeatBindError, + holdsLiteKey, + registerForGame, + registrationRetryDelay, +} from "./register-game.js"; +export type { + BindGameSeatOptions, + BindGameSeatOutcome, + DrawOutcome, + DrawSkipKind, + DrawWaitOptions, + GameAccount, + GameRegistrationCapabilities, + GameRegistrationChain, + GameRegistrationReport, + GameRegistrationStage, + GameRegistrationStep, + GameSeatBindChain, + GameSeatBindRefusal, + PersonhoodKey, + PersonhoodProofContext, + PersonhoodProver, + PersonhoodRing, + RegisterForGameOptions, + RegisterForGameOutcome, + SignUpSubmitOptions, + SignUpSubmitResult, + SignUpTransaction, + SubmitSignUp, +} from "./register-game.js"; + // The metadata-driven pieces underneath stay internal on purpose, even though // `withLiteAlias` proves they generalise: widening a surface later never breaks // anyone, narrowing one after it ships does. Export them when something outside diff --git a/product-sdk/packages/individuality/src/register-game.ts b/product-sdk/packages/individuality/src/register-game.ts new file mode 100644 index 00000000..69504a2b --- /dev/null +++ b/product-sdk/packages/individuality/src/register-game.ts @@ -0,0 +1,2064 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Sign a player up for the weekly game in one call, from any product: the game's + * own app and chat worker, or the personhood product. + * + * {@link registerForGame} runs the steps in order: + * + * 1. **Seat.** Read which route the player takes ({@link readGameSeat}), judged + * by the chain's clock. Already registered is success; a closed registration + * or an account that cannot pay ends here with nothing signed. + * 2. **Bind.** On the free route with no binding yet, bind the person's lite + * alias to the game account ({@link bindGameSeat}). A bind the chain will + * refuse for good closes the lite route, and the seat is read again without it. + * 3. **Draws.** Decide the prize-draw entries from the sign-up requirement at the + * best block, waiting a block at a time for a draw that is not open yet. + * Entries can only ride the sign-up, so a sign-up never waits on them for + * good: it goes without them, and says why. + * 4. **Sign.** Build the call for the route, wrap the signer for the origin the + * route needs, and hand both to the `submit` capability. + * + * Every step reads the chain before it acts, so running the call again resumes + * where the last one stopped: a bind that landed is not sent again, and a + * sign-up that landed comes back as `already-registered`. Every outcome and + * every {@link GameRegistrationError} carries a {@link GameRegistrationReport} + * for the product's telemetry. + * + * Everything a host does comes in through {@link GameRegistrationCapabilities}, + * so this module imports neither `@parity/product-sdk-host` nor + * `@parity/product-sdk-tx`. The `@parity/product-sdk-individuality/host` entry + * builds the capabilities from a host, submitting with `tx`'s `submitAndWatch`. + * + * Ported from jollity-next `packages/game-chain/src/engine.ts` (`bind`, + * `register`, `decideDraws`), where the game's app and chat worker shared it. + */ +import type { CommonSignerTxCreator } from "@polkadot-api/signers-common"; +import { bytesToHex } from "@parity/product-sdk-utils"; +import { err, normalizeError, ok, type Result } from "@parity/result"; + +import { airdropPhase, statusTag } from "./airdrop-decode.js"; +import type { AirdropChain } from "./airdrop-read.js"; +import { withLiteAlias } from "./as-lite-alias-signer.js"; +import { withScoreParticipant } from "./as-score-participant-signer.js"; +import { buildLiteAliasBindTx, type LiteAliasBindChain } from "./bind-lite-alias.js"; +import { ProductIndividualityError } from "./errors.js"; +import { gameProductId } from "./game-account.js"; +import type { GameChain } from "./game-read.js"; +import type { CreateRingVRFProof, RingVRFProof } from "./origin-extension.js"; +import { + litePeopleRing, + peopleRing, + readScoreContext, + type RingLocation, + type ScoreContextChain, +} from "./rings.js"; +import { sameAccount } from "./same-account.js"; +import { + chainNow, + readGameSeat, + type GameSeat, + type SeatPath, + type SeatRoute, + type TimestampChain, +} from "./seat.js"; +import { + mintAccountAirdropVrfs, + mintAliasAirdropVrfs, + readGameSignUpRequirement, + signUpWithAccountTx, + type AirdropVrfSigner, + type AliasAirdropVrfs, + type AliasDrawProofContext, + type FeeEstimable, + type SignUpChain, + type SignUpFundsChain, +} from "./signup.js"; +import { + readLiteSignUpRequirement, + signUpWithLiteInviteTx, + type LiteSignUpChain, +} from "./signup-lite.js"; +import type { + AccountVrfSignature, + LiteSignUpBlocker, + LiteSignUpRequirement, +} from "./signup-types.js"; +import { airdropVrfTranscript, type VrfTranscript } from "./signup-vrf.js"; + +// ── what the module needs from the chain ───────────────────────────────────── + +/** One event of a transaction watch, in the fields this module reads. */ +export interface TxWatchEvent { + type: string; + ok?: boolean; + dispatchError?: unknown; +} + +/** + * A transaction watch: PAPI's `Observable` of transaction events satisfies it, + * and so does `tx`'s `SubmittableTransaction` watch. The observer's members are + * methods so their parameters compare bivariantly, as `tx`'s own type relies on. + */ +export interface TxWatch { + subscribe(observer: { + next(event: TxWatchEvent): void; + error(error: unknown): void; + complete?(): void; + }): { unsubscribe(): void }; +} + +/** + * A built sign-up call. PAPI 3 transactions have both methods; the fee read + * uses the first and the `submit` capability the second. + */ +export interface SignUpTransaction extends FeeEstimable { + createSubmitAndWatch(signer: CommonSignerTxCreator): TxWatch; +} + +/** + * Where the unsigned bind goes out: PAPI's `PolkadotClient.submitAndWatch`. The + * bind is finished bytes with nothing to sign, which `tx`'s `submitAndWatch` + * does not take, so it is submitted here. + */ +export interface RawSubmitChain { + raw: { + individuality: { + submitAndWatch(transaction: Uint8Array): TxWatch; + }; + }; +} + +/** The parts both chains share, over the transaction type the sign-up calls return. */ +type SeatBindParts = GameChain & + SignUpChain & + ScoreContextChain & + LiteSignUpChain & + LiteAliasBindChain & + RawSubmitChain; + +/** What {@link bindGameSeat} reads and submits through. */ +export type GameSeatBindChain = SeatBindParts; + +/** + * What {@link registerForGame} reads and submits through. A PAPI client and the + * typed API for the individuality chain, joined by `fromPapi`, satisfy it. + */ +export type GameRegistrationChain = SeatBindParts & + SignUpFundsChain & + AirdropChain & + TimestampChain; + +// ── what the module needs from a host ──────────────────────────────────────── + +/** The game account: `dim2.` at `GAME_ACCOUNT_INDEX`. */ +export interface GameAccount { + /** SS58. */ + address: string; + /** The 32-byte sr25519 public key. */ + publicKey: Uint8Array; +} + +/** Which personhood key a proof is made with. */ +export type PersonhoodKey = "lite" | "full"; + +/** A personhood key, the ring it is a member of, and the product that holds it. */ +export interface PersonhoodRing { + key: PersonhoodKey; + /** The personhood product, `peopl.`: the owner a host lists the key under. */ + owner: string; + ring: RingLocation; +} + +/** A ring-VRF proof context, as a host's `createRingVRFProof` takes it. */ +export type PersonhoodProofContext = + | { productId: string; suffix: { tag: "Index"; value: number } } + | AliasDrawProofContext; + +/** + * The personhood product's keys, as a host exposes them. The personhood product + * uses its own keys; any other product needs its `context` grant. + */ +export interface PersonhoodProver { + /** Whether the host holds this person's key in that ring. */ + holds(ring: PersonhoodRing): Promise; + /** A proof over `message` in `context` with that key. Rejects when the host refuses. */ + prove( + ring: PersonhoodRing, + context: PersonhoodProofContext, + message: Uint8Array, + ): Promise; +} + +/** How a sign-up submission ended, as the `submit` capability reports it. */ +export type SignUpSubmitResult = + | { ok: true } + | { + ok: false; + /** The chain's error name, or what stopped it before the chain. */ + code: string; + /** The player declined to sign. Never retried. */ + refused?: boolean; + /** The watch lost track of it: it may still land. Read the seat before trying again. */ + landingUnknown?: boolean; + }; + +/** Options the flow hands the `submit` capability. */ +export interface SignUpSubmitOptions { + waitFor: "best-block" | "finalized"; + /** Called once the transaction is on the wire, for an optimistic UI. */ + onBroadcast?: () => void; +} + +/** Signs and submits the built sign-up. The host adapter wraps `tx`'s `submitAndWatch`. */ +export type SubmitSignUp = ( + tx: SignUpTransaction, + signer: CommonSignerTxCreator, + options: SignUpSubmitOptions, +) => Promise; + +/** + * Everything a host does for a sign-up. Build it from a host with + * `hostGameRegistrationCapabilities` from `@parity/product-sdk-individuality/host`. + */ +export interface GameRegistrationCapabilities { + account: GameAccount; + /** The game account's signer: `getProductAccountSigner`. */ + signer: CommonSignerTxCreator; + /** Submits the built sign-up. */ + submit: SubmitSignUp; + /** An sr25519 VRF by the game account, for account-arm draw tickets. Without it, no tickets. */ + signVrf?: AirdropVrfSigner["signVrf"]; + /** + * Checks a ticket before it rides the sign-up. One bad ticket fails the whole + * sign-up, so a product that can verify should; this package has no + * schnorrkel verifier of its own. + */ + verifyVrf?: ( + publicKey: Uint8Array, + transcript: VrfTranscript, + signature: AccountVrfSignature, + ) => boolean; + /** The personhood keys. Without it, no free route and no member draw entries. */ + personhood?: PersonhoodProver; +} + +// ── errors, the report, retries ────────────────────────────────────────────── + +/** A step of {@link registerForGame}. */ +export type GameRegistrationStep = "seat" | "bind" | "draws" | "sign"; + +/** Where {@link registerForGame} is, for a product's progress UI: a step, or the broadcast. */ +export type GameRegistrationStage = GameRegistrationStep | "broadcast"; + +/** Why a sign-up went out without its draw entries. */ +export type DrawSkipKind = + /** A draw was not taking entries yet, and the wait ran out. */ + | "not-open" + /** The game has left registration, or a draw has closed. */ + | "closed" + /** Registration is about to close; the sign-up goes now. */ + | "closing" + /** A read failed, and the wait ran out. */ + | "read-failed" + /** No VRF signer, or the tickets were not signed or did not verify. Not retried. */ + | "signature" + /** A recognized person, and the member key could not prove the draws. Not retried. */ + | "verified-person" + /** The chain would refuse the entry for something about the player. */ + | "refused"; + +/** What a sign-up carried for the game's prize draws. */ +export interface DrawOutcome { + /** How many draws the sign-up enters. */ + entered: number; + /** `Game.airdrops_scheduled`. */ + scheduled: number; + /** Why none, when `entered` is 0 and draws were scheduled. */ + skipped?: string; + kind?: DrawSkipKind; + /** How long the sign-up waited for a draw to open. */ + waitedMs: number; + retries: number; +} + +/** Why a bind did not put the person's binding on the game account. */ +export type GameSeatBindRefusal = + /** + * The person's free seat is pinned to another account (`Game.LiteInvites`). + * Binding this one would strand that seat, so nothing is submitted. + */ + | "pinned-elsewhere" + /** + * A bind cannot succeed here: the account is itself a lite person's + * (`AccountIsALitePerson`), or carries a binding in another context + * (`AliasBoundElsewhere`, which only an unbind in that context clears). + * Refused before any proof is asked for. + */ + | "dead-end" + /** The chain's score context is not one a host can mint the proof in. */ + | "no-proof-context" + /** No personhood prover, or the host holds no lite key for this person. */ + | "no-lite-key" + /** The chain refused the submission. `code` is the chain's own error name. */ + | "rejected"; + +/** What {@link bindGameSeat} did. */ +export type BindGameSeatOutcome = "bound" | "already-bound"; + +/** + * What one {@link registerForGame} call did, for the product's telemetry: when + * each step ran and how it ended. Carried by every outcome and every + * {@link GameRegistrationError}, so a failure reports how far it got. + */ +export interface GameRegistrationReport { + /** When each step started and ended, in wall-clock milliseconds. */ + steps: Partial>; + /** When the sign-up went on the wire. */ + broadcastAt?: number; + seat?: { path: SeatPath; ready: boolean; registered: boolean; open: boolean }; + bind?: + | { outcome: BindGameSeatOutcome } + | { outcome: "refused"; refusal: GameSeatBindRefusal; code: string; permanent: boolean }; + route?: SeatRoute; + draws?: DrawOutcome; + signUp?: { + outcome: "registered" | "already-registered" | "failed"; + code?: string; + landingUnknown?: boolean; + }; +} + +/** + * A step of the registration that failed. `stage` says which, `code` what the + * chain or the host answered, and `retryable` whether running the call again can + * change the answer. Running it again is always safe: every step re-reads the + * chain first. + */ +export class GameRegistrationError extends ProductIndividualityError { + readonly stage: GameRegistrationStep; + readonly code: string; + /** False when trying again cannot help: a refusal, a closed registration, a bad call. */ + readonly retryable: boolean; + /** The sign-up's watch lost track: it may still land. */ + readonly landingUnknown: boolean; + /** What the call did up to the failure. Set by {@link registerForGame}. */ + report?: GameRegistrationReport; + + constructor( + stage: GameRegistrationStep, + code: string, + options: { + retryable: boolean; + landingUnknown?: boolean; + message?: string; + cause?: unknown; + }, + ) { + super( + options.message ?? `game registration failed at ${stage}: ${code}`, + options.cause === undefined ? undefined : { cause: options.cause }, + ); + this.name = "GameRegistrationError"; + this.stage = stage; + this.code = code; + this.retryable = options.retryable; + this.landingUnknown = options.landingUnknown === true; + } +} + +/** + * The chain's refusals of `PeopleLite.set_alias_account` that trying again will + * not change: the account already carries another person's binding, the proof's + * context is not one the chain binds in, or the account is itself a lite person. + * `CallBlockOutOfRange` and the pool's validity codes can pass next time, so + * they are not here; `AliasAccountAlreadySet` means the binding is there. + */ +const PERMANENT_BIND_CODES: ReadonlySet = new Set([ + "AccountInUse", + "InvalidAliasContext", + "AlreadyRegistered", +]); + +function bindIsPermanent(refusal: GameSeatBindRefusal, code: string): boolean { + return refusal !== "rejected" || PERMANENT_BIND_CODES.has(code); +} + +/** A bind that did not land. `permanent` closes the lite route for this account. */ +export class GameSeatBindError extends GameRegistrationError { + /** True when trying again cannot change the answer. The opposite of `retryable`. */ + readonly permanent: boolean; + + constructor( + readonly refusal: GameSeatBindRefusal, + code: string, + /** For `"pinned-elsewhere"`, the account the seat is pinned to. */ + readonly account?: string, + ) { + const permanent = bindIsPermanent(refusal, code); + super("bind", code, { + retryable: !permanent, + message: + refusal === "pinned-elsewhere" + ? `lite bind refused: the person's free seat is on ${account ?? "another account"}` + : `lite bind failed: ${code}`, + }); + this.name = "GameSeatBindError"; + this.permanent = permanent; + } +} + +/** + * The sign-up refusals that trying again will not change: no game or a closed + * registration, a draw entry the chain will never take, a seat pinned to another + * account, an account that cannot pay, a declined signature, or a call that + * could not be built. + */ +const NOT_RETRYABLE_SIGN_UP: ReadonlySet = new Set([ + "NoGame", + "NoRegistration", + "InvalidAirdropVrfCount", + "InvalidAirdropVrfVariant", + "InvalidAirdropVrfVariantForAccount", + "InvalidAirdropVrfVariantForRecognition", + "AnotherAccountInvited", + "StatementAccountAlreadyInUse", + "Payment", + "InsufficientBalance", + "SigningRejected", + "InvalidSignUp", +]); + +/** + * How long to wait before running a failed registration again, after the + * `failures`-th failure in a row (1-based): 5 s, 15 s, 45 s, 2 min, then 5 min + * between tries. Ported from jollity-next's bind ladder. When to retry at all + * is the product's call; check `retryable` first. + */ +export const GAME_REGISTRATION_RETRY_MS: readonly number[] = [ + 5_000, 15_000, 45_000, 120_000, 300_000, +]; + +export function registrationRetryDelay(failures: number): number { + const index = + Math.min(Math.max(Math.floor(failures), 1), GAME_REGISTRATION_RETRY_MS.length) - 1; + return GAME_REGISTRATION_RETRY_MS[index]!; +} + +/** + * The name of a chain error, wherever PAPI or `tx` nests it: the innermost + * `type` of a dispatch or validity error, else the error's message. + * + * Internal: shared with the host adapter. + */ +export function errorName(error: unknown): string { + let node: unknown = error; + let name: string | undefined; + while (node !== null && typeof node === "object") { + const { type, value } = node as { type?: unknown; value?: unknown }; + if (typeof type === "string") name = type; + node = value; + } + if (name) return name; + if (error instanceof Error && error.message) return error.message; + return typeof error === "string" && error ? error : "DispatchError"; +} + +function asRegistrationError(step: GameRegistrationStep, error: unknown): GameRegistrationError { + if (error instanceof GameRegistrationError) return error; + const aborted = error instanceof Error && error.name === "AbortError"; + return new GameRegistrationError(step, aborted ? "Aborted" : errorName(error), { + retryable: true, + cause: error, + }); +} + +// ── personhood facts ───────────────────────────────────────────────────────── + +/** The personhood rings on this chain, and whether a host can prove in them. */ +interface PersonhoodFacts { + /** `Score.score_context` is the product derivation a host mints in. */ + derived: boolean; + /** `peopl.`. */ + owner: string; + lite: PersonhoodRing; + full: PersonhoodRing; +} + +async function readPersonhoodFacts( + chain: ScoreContextChain & Pick, + tld: string, + signal: AbortSignal | undefined, +): Promise { + const [score, spec] = await Promise.all([ + readScoreContext(chain, { tld, signal }), + chain.raw.individuality.getChainSpecData(), + ]); + if (!score.ok) throw score.error; + const genesis = spec.genesisHash as `0x${string}`; + const owner = score.value.productId; + return { + derived: score.value.tag === "ProductDerived", + owner, + lite: { key: "lite", owner, ring: litePeopleRing(genesis) }, + full: { key: "full", owner, ring: peopleRing(genesis) }, + }; +} + +/** The lite proof the bind and a stale binding's re-point ride: the lite key, in the score context. */ +function liteProof(prover: PersonhoodProver, facts: PersonhoodFacts): CreateRingVRFProof { + const context = { productId: facts.owner, suffix: { tag: "Index" as const, value: 0 } }; + return (message) => prover.prove(facts.lite, context, message); +} + +/** + * Whether this person can take the free route: the chain's score context is one + * a host mints in, and the host holds their lite key. + */ +export async function holdsLiteKey( + chain: ScoreContextChain & Pick, + capabilities: Pick, + options: { tld: string; signal?: AbortSignal }, +): Promise> { + try { + const prover = capabilities.personhood; + if (!prover) return ok(false); + const facts = await readPersonhoodFacts(chain, options.tld, options.signal); + return ok(facts.derived && (await prover.holds(facts.lite))); + } catch (cause) { + return err(normalizeError(cause, ProductIndividualityError)); + } +} + +// ── submission of the raw bind ─────────────────────────────────────────────── + +/** Where a raw submission settled. */ +type Settled = { ok: true } | { ok: false; error: string; landingUnknown?: boolean }; + +/** PAPI's words when the block subscription under a watch ended before the transaction's fate was known. */ +function isTrackingStopped(error: unknown): boolean { + return error instanceof Error && /tracking stopped/i.test(error.message); +} + +/** + * Drive the raw bind's watch to the best block that holds it. An invalid + * transaction arrives on the error channel and settles as a failure, never a + * throw; a watch that completes with no block holding it settles as a failure + * too. An abort before `broadcasted` rejects with the signal's reason; after it + * the transaction may land, so the abort is ignored. + */ +function settleRaw(watch: TxWatch, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + let settled = false; + let broadcast = false; + // biome-ignore lint/style/useConst: PAPI can emit during `subscribe`, and `finish` reads `sub` then; a `const` would still be in its temporal dead zone. + let sub: { unsubscribe(): void } | undefined; + const finish = (done: () => void) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + sub?.unsubscribe(); + done(); + }; + const onAbort = () => { + if (!broadcast) finish(() => reject(signal?.reason)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + sub = watch.subscribe({ + next: (event) => { + if (event.type === "broadcasted") { + broadcast = true; + return; + } + if (event.type === "inBestBlock" || event.type === "finalized") { + finish(() => + resolve( + event.ok + ? { ok: true } + : { ok: false, error: errorName(event.dispatchError) }, + ), + ); + } + }, + error: (error) => + finish(() => + resolve({ + ok: false, + error: errorName(error), + ...(isTrackingStopped(error) ? { landingUnknown: true } : {}), + }), + ), + complete: () => + finish(() => + resolve({ + ok: false, + error: "the watch ended before any block held the transaction", + }), + ), + }); + if (settled) sub.unsubscribe(); + }); +} + +// ── step 2: bind ───────────────────────────────────────────────────────────── + +/** Options for {@link bindGameSeat}. */ +export interface BindGameSeatOptions { + tld: string; + signal?: AbortSignal; +} + +/** The reads and the submission the bind makes, swappable so its decisions can be tested alone. */ +interface BindSteps { + requirement( + chain: GameSeatBindChain, + account: string, + tld: string, + signal?: AbortSignal, + ): Promise<{ blockers: readonly LiteSignUpBlocker[] }>; + facts(chain: GameSeatBindChain, tld: string, signal?: AbortSignal): Promise; + build( + chain: GameSeatBindChain, + account: string, + createProof: CreateRingVRFProof, + signal?: AbortSignal, + ): Promise<{ transaction: Uint8Array }>; + pinnedTo( + chain: GameSeatBindChain, + alias: Uint8Array, + signal?: AbortSignal, + ): Promise; + submitRaw( + chain: GameSeatBindChain, + transaction: Uint8Array, + signal?: AbortSignal, + ): Promise; +} + +const BIND_STEPS: BindSteps = { + async requirement(chain, account, tld, signal) { + const read = await readLiteSignUpRequirement(chain, { account, tld, at: "best", signal }); + if (!read.ok) throw read.error; + return read.value; + }, + facts: readPersonhoodFacts, + build: (chain, account, createProof, signal) => + buildLiteAliasBindTx(chain, { account, createProof, signal }), + pinnedTo: (chain, alias, signal) => + chain.individuality.query.Game.LiteInvites.getValue(`0x${bytesToHex(alias)}`, { + at: "best", + signal, + }), + submitRaw: (chain, transaction, signal) => + settleRaw(chain.raw.individuality.submitAndWatch(transaction), signal), +}; + +/** + * Bind the person's lite alias to the game account: `PeopleLite.set_alias_account`, + * unsigned, carrying a proof from the personhood product's lite key in + * `Score.score_context`. Resolves once a best block holds it. + * + * Reads before it builds, because the binding is the only replay protection and + * a retry after a timeout may follow a bind that landed: + * + * - **Already bound** in the score context, current or stale, resolves + * `already-bound` with nothing asked for. A stale binding is re-pointed by the + * sign-up's revised leg. + * - **A bind that cannot succeed** (the account is a lite person's own, it is + * bound in another context, the seat is pinned elsewhere, or no host can mint + * the proof) is refused before any proof is requested. + * - **Otherwise** it proves, checks the forever pin (`Game.LiteInvites[alias]`) + * on the alias the proof names, and submits. The requirement read reports + * that pin only once this account holds the binding, which is too late, so + * the check is here. + * + * A chain answer of `AliasAccountAlreadySet` (another bind landed first) is + * `already-bound`. Fails with {@link GameSeatBindError}; check `permanent`. + */ +export async function bindGameSeat( + chain: GameSeatBindChain, + capabilities: Pick, + options: BindGameSeatOptions, +): Promise> { + try { + return ok(await runBind(chain, capabilities, options)); + } catch (cause) { + return err(asRegistrationError("bind", cause)); + } +} + +async function runBind( + chain: GameSeatBindChain, + capabilities: Pick, + options: BindGameSeatOptions, + known?: PersonhoodFacts, + steps: BindSteps = BIND_STEPS, +): Promise { + const { signal, tld } = options; + const account = capabilities.account.address; + const { blockers } = await steps.requirement(chain, account, tld, signal); + const has = (tag: LiteSignUpBlocker["tag"]) => blockers.some((blocker) => blocker.tag === tag); + + for (const tag of ["AccountIsALitePerson", "AliasBoundElsewhere"] as const) { + if (has(tag)) throw new GameSeatBindError("dead-end", tag); + } + const invited = blockers.find((blocker) => blocker.tag === "AnotherAccountInvited"); + if (invited && "invited" in invited) { + throw new GameSeatBindError("pinned-elsewhere", "AnotherAccountInvited", invited.invited); + } + if (has("ContextNotProductDerived")) { + throw new GameSeatBindError("no-proof-context", "ContextNotProductDerived"); + } + if (!has("AliasNotBound") && !has("StaleAlias")) return "already-bound"; + + const facts = known ?? (await steps.facts(chain, tld, signal)); + if (!facts.derived) throw new GameSeatBindError("no-proof-context", "ContextNotProductDerived"); + const prover = capabilities.personhood; + if (!prover) throw new GameSeatBindError("no-lite-key", "NoPersonhoodProver"); + + let alias: Uint8Array | undefined; + const prove = liteProof(prover, facts); + const built = await steps.build( + chain, + account, + async (message) => { + const proof = await prove(message); + alias = proof.contextualAlias.alias; + return proof; + }, + signal, + ); + if (alias) { + const pinned = await steps.pinnedTo(chain, alias, signal); + if (pinned !== undefined && !sameAccount(pinned, account)) { + throw new GameSeatBindError("pinned-elsewhere", "AnotherAccountInvited", pinned); + } + } + signal?.throwIfAborted(); + const result = await steps.submitRaw(chain, built.transaction, signal); + if (result.ok) return "bound"; + if (result.error === "AliasAccountAlreadySet") return "already-bound"; + throw new GameSeatBindError("rejected", result.error); +} + +// ── step 3: the draw entries ───────────────────────────────────────────────── + +type DrawEntries = + | { entries: AccountVrfSignature[] | AliasAirdropVrfs; scheduled: number } + | { + entries?: undefined; + skipped: string; + scheduled: number; + /** The reason is of the moment: wait a block and ask again. */ + wait?: boolean; + kind?: DrawSkipKind; + }; + +/** What the draw decision reads from a sign-up requirement, of either route. */ +type DrawRequirement = Pick< + LiteSignUpRequirement, + "variant" | "phase" | "registrationEnds" | "airdropsScheduled" | "eventIds" +> & { + blockers: readonly { tag: string }[]; +}; + +/** + * The requirement's blockers that describe the game's moment rather than the + * player. The draw decision reads the phase and every draw's status at the best + * block itself, so only blockers about the player skip the draws. + */ +const MOMENT_BLOCKERS: ReadonlySet = new Set([ + "NoGameRunning", + "NotInRegistration", + "RegistrationEnded", + "NoDrawsScheduled", +]); + +/** A stale binding is re-pointed inside the free sign-up, so it does not cost the draws. */ +const FIXED_BY_THE_SIGN_UP: ReadonlySet = new Set(["StaleAlias"]); + +const entryCount = (entries: AccountVrfSignature[] | AliasAirdropVrfs) => + Array.isArray(entries) ? entries.length : entries.proofs.length; + +/** + * The draw entries a sign-up carries, decided from the requirement read at the + * best block, the one the sign-up is validated against. There is no way to + * enter a draw after signing up, and a count that disagrees with + * `airdrops_scheduled` fails the whole sign-up, so every gate is checked here: + * draws are scheduled, the game is in registration, nothing about the player + * bars the entry, every draw is `Registering`, and the tickets (or a recognized + * person's proofs) are minted. A draw that is not open yet says `wait`; a + * signature or proof that is refused does not, so a player who declined is not + * asked again. + */ +async function decideDraws( + chain: AirdropChain, + capabilities: GameRegistrationCapabilities, + requirement: DrawRequirement, + tld: string, + facts: () => Promise, + signal: AbortSignal | undefined, +): Promise { + const scheduled = requirement.airdropsScheduled; + if (requirement.phase === null) return { skipped: "no game at the best block", scheduled: 0 }; + if (scheduled === 0) return { skipped: "the game has no prize draw", scheduled }; + if (requirement.phase !== "Registration") { + return { skipped: `the game is in ${requirement.phase}`, scheduled, kind: "closed" }; + } + if (requirement.variant === null || requirement.eventIds.length !== scheduled) { + return { + skipped: "the requirement does not describe the game's draws", + scheduled, + wait: true, + kind: "read-failed", + }; + } + const recognized = requirement.variant === "Alias"; + if (recognized && !capabilities.personhood) { + return { + skipped: "a recognized person, and nothing here can prove the draw", + scheduled, + kind: "verified-person", + }; + } + if (!recognized && !capabilities.signVrf) { + return { skipped: "no VRF signer", scheduled, kind: "signature" }; + } + const blockers = requirement.blockers.filter((blocker) => !MOMENT_BLOCKERS.has(blocker.tag)); + if (blockers.length > 0) { + return { + skipped: `the chain would refuse the entry: ${blockers.map((b) => b.tag).join(", ")}`, + scheduled, + kind: "refused", + }; + } + + const { eventIds } = requirement; + try { + const events = await Promise.all( + eventIds.map((id) => + chain.individuality.query.Airdrop.Events.getValue(id, { at: "best", signal }), + ), + ); + for (const [index, event] of events.entries()) { + // The offchain worker opens each draw a block after the game appears. + const phase = + event === undefined ? "Upcoming" : airdropPhase(statusTag(event.status.type)); + if (phase === "Upcoming") { + return { + skipped: `draw #${index} is not taking entries yet`, + scheduled, + wait: true, + kind: "not-open", + }; + } + if (phase !== "Registering") { + return { + skipped: `draw #${index} is ${phase}, not Registering`, + scheduled, + kind: "closed", + }; + } + } + } catch (error) { + return { + skipped: `the draws could not be read: ${errorName(error)}`, + scheduled, + wait: true, + kind: "read-failed", + }; + } + + const registrant = { tag: "Account" as const, accountAddress: capabilities.account.address }; + if (recognized) { + let personhood: PersonhoodFacts; + try { + personhood = await facts(); + } catch (error) { + return { + skipped: `no People key to prove the draws with: ${errorName(error)}`, + scheduled, + kind: "verified-person", + }; + } + const prover = capabilities.personhood!; + const proved = await mintAliasAirdropVrfs( + (context, message) => prover.prove(personhood.full, context, message), + { eventIds, registrant, productId: gameProductId(tld), signal }, + ); + if (!proved.ok) { + return { + skipped: `the draws were not proved: ${errorName(proved.error)}`, + scheduled, + kind: "verified-person", + }; + } + return { entries: proved.value, scheduled }; + } + + const { publicKey } = capabilities.account; + const minted = await mintAccountAirdropVrfs( + { signVrf: capabilities.signVrf! }, + { eventIds, publicKey, signal }, + ); + if (!minted.ok) { + return { + skipped: `the tickets were not signed: ${errorName(minted.error)}`, + scheduled, + kind: "signature", + }; + } + if (capabilities.verifyVrf) { + for (const [index, entry] of minted.value.entries()) { + const transcript = airdropVrfTranscript({ eventId: eventIds[index]!, publicKey }); + if (!capabilities.verifyVrf(publicKey, transcript, entry)) { + return { + skipped: `the ticket for draw #${index} does not verify against the game account`, + scheduled, + kind: "signature", + }; + } + } + } + return { entries: minted.value, scheduled }; +} + +/** How long a sign-up waits for draws that are not open yet. */ +export interface DrawWaitOptions { + /** The longest wait. Default 20 000 ms. */ + maxMs?: number; + /** How often to ask again. Default 2 000 ms, about a block. */ + retryMs?: number; + /** + * Never wait into the last this-many seconds of registration, by the chain's + * clock: a player who misses the game has lost more than one who misses a + * ticket. Default 15. + */ + closingSeconds?: number; +} + +const DEFAULT_DRAW_WAIT_MS = 20_000; +const DEFAULT_DRAW_RETRY_MS = 2_000; +const DEFAULT_DRAW_CLOSING_S = 15; + +function sleep(ms: number, signal: AbortSignal | undefined): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +// ── the flow ───────────────────────────────────────────────────────────────── + +/** Options for {@link registerForGame}. */ +export interface RegisterForGameOptions { + /** The network's DotNS TLD, such as `"paseo"`. */ + tld: string; + /** + * `CommunicationIdentifier`, exactly 65 bytes: the P-256 key co-players reach + * this player with. Rewritten on every sign-up, so pass a key whose private + * half the game product can use at game time. + */ + identifierKey: Uint8Array; + /** A seat read moments ago, to skip reading it again. Never pass a cached one on a retry. */ + seat?: GameSeat; + /** + * When the sign-up counts as done. Default `"best-block"`, as `tx`'s own: a + * refusal arrives at the best block either way, and a sign-up a reorg drops is + * caught by the next call, which reads the seat first. Pass `"finalized"` when + * the answer must not be undone, at the cost of waiting for finality. + */ + waitFor?: "best-block" | "finalized"; + drawWait?: DrawWaitOptions; + /** Passed to {@link readGameSeat}. */ + feeHeadroom?: number; + /** Stops the flow. Ignored once the sign-up is broadcast: it may still land. */ + signal?: AbortSignal; + onStage?(stage: GameRegistrationStage): void; + onSeat?(seat: GameSeat): void; + onDraws?(draws: DrawOutcome): void; +} + +/** How {@link registerForGame} ended, when it did not fail. Every variant carries its report. */ +export type RegisterForGameOutcome = + | { kind: "registered"; route: SeatRoute; draws: DrawOutcome; report: GameRegistrationReport } + /** Signed up already, by this product or another. */ + | { kind: "already-registered"; report: GameRegistrationReport } + /** Registration is not open by the chain's clock: no game, or past its registration. */ + | { kind: "not-open"; seat: GameSeat; report: GameRegistrationReport } + /** The deposit route, and the account cannot pay. Nothing was signed. */ + | { kind: "unfunded"; seat: GameSeat; missing: bigint; report: GameRegistrationReport }; + +/** + * Sign the player up for the running game: seat, bind, draws, sign. See the + * module doc. + * + * The ok channel carries every answer that is not a failure, including + * `already-registered`, which is also what a race with another product ends in. + * The err channel carries a {@link GameRegistrationError} (a + * {@link GameSeatBindError} for the bind) with the failing step, the code, + * whether a retry can help, and the report. + */ +export async function registerForGame( + chain: GameRegistrationChain, + capabilities: GameRegistrationCapabilities, + options: RegisterForGameOptions, +): Promise> { + try { + return ok(await runRegistration(chain, capabilities, options, CHAIN_STEPS)); + } catch (cause) { + return err(normalizeError(cause, ProductIndividualityError)); + } +} + +/** The reads and the bind the flow makes, swappable so the flow's own decisions can be tested alone. */ +interface RegistrationSteps { + seat( + chain: GameRegistrationChain, + options: Parameters[1], + ): Promise; + bind( + chain: GameRegistrationChain, + capabilities: GameRegistrationCapabilities, + options: BindGameSeatOptions, + facts: PersonhoodFacts, + ): Promise; + requirement( + chain: GameRegistrationChain, + route: SeatRoute, + options: { account: string; tld: string; now: number; signal?: AbortSignal }, + ): Promise; + facts( + chain: GameRegistrationChain, + tld: string, + signal?: AbortSignal, + ): Promise; + now(chain: GameRegistrationChain, signal?: AbortSignal): Promise; + draws( + chain: GameRegistrationChain, + capabilities: GameRegistrationCapabilities, + requirement: DrawRequirement, + tld: string, + facts: () => Promise, + signal: AbortSignal | undefined, + ): Promise; +} + +const CHAIN_STEPS: RegistrationSteps = { + async seat(chain, options) { + const seat = await readGameSeat(chain, options); + if (!seat.ok) throw seat.error; + return seat.value; + }, + bind: (chain, capabilities, options, facts) => runBind(chain, capabilities, options, facts), + async requirement(chain, route, { account, tld, now, signal }) { + // Both at the best block, judged by the chain's clock: a bind that just + // landed is not final yet, and a phone's clock can be minutes off. + const read = + route === "free" + ? await readLiteSignUpRequirement(chain, { account, tld, at: "best", now, signal }) + : await readGameSignUpRequirement(chain, { + registrant: { tag: "Account", accountAddress: account }, + keyType: "sr25519", + at: "best", + now, + signal, + }); + if (!read.ok) throw read.error; + return read.value; + }, + facts: readPersonhoodFacts, + now: chainNow, + draws: decideDraws, +}; + +async function runRegistration( + chain: GameRegistrationChain, + capabilities: GameRegistrationCapabilities, + options: RegisterForGameOptions, + steps: RegistrationSteps, +): Promise { + const { tld, signal } = options; + const account = capabilities.account.address; + const report: GameRegistrationReport = { steps: {} }; + let current: GameRegistrationStep = "seat"; + const begin = (step: GameRegistrationStep) => { + current = step; + report.steps[step] = { startedAt: Date.now() }; + options.onStage?.(step); + }; + const end = (step: GameRegistrationStep) => { + const entry = report.steps[step]; + if (entry) entry.endedAt = Date.now(); + }; + + try { + return await flow(); + } catch (error) { + const failure = asRegistrationError(current, error); + failure.report = report; + throw failure; + } + + async function flow(): Promise { + let factsRead: Promise | undefined; + const facts = () => { + factsRead ??= steps.facts(chain, tld, signal); + factsRead.catch(() => { + factsRead = undefined; + }); + return factsRead; + }; + const recordSeat = (seat: GameSeat) => { + report.seat = { + path: seat.path, + ready: seat.ready, + registered: seat.registered, + open: seat.open, + }; + options.onSeat?.(seat); + }; + const readSeat = (litePerson: boolean) => + steps.seat(chain, { + account, + litePerson, + tld, + feeHeadroom: options.feeHeadroom, + signal, + }); + + // ── 1. seat + begin("seat"); + let seat = options.seat; + if (!seat) { + let litePerson = false; + if (capabilities.personhood) { + const personhood = await facts(); + litePerson = + personhood.derived && (await capabilities.personhood.holds(personhood.lite)); + } + seat = await readSeat(litePerson); + } + recordSeat(seat); + end("seat"); + if (seat.registered) return { kind: "already-registered", report }; + if (!seat.open) return { kind: "not-open", seat, report }; + + // ── 2. bind + if (seat.path === "free" && !seat.ready) { + begin("bind"); + try { + const outcome = await steps.bind( + chain, + capabilities, + { tld, signal }, + await facts(), + ); + report.bind = { outcome }; + seat = { ...seat, ready: true }; + } catch (error) { + if (!(error instanceof GameSeatBindError) || !error.permanent) throw error; + report.bind = { + outcome: "refused", + refusal: error.refusal, + code: error.code, + permanent: true, + }; + // The lite route is closed for this account: seat the player like anyone else. + seat = await readSeat(false); + recordSeat(seat); + } + end("bind"); + } + if (seat.path === "unfunded") { + return { kind: "unfunded", seat, missing: seat.required - seat.funds.free, report }; + } + const route = seat.path; + report.route = route; + + // ── 3. draws + begin("draws"); + const wait = options.drawWait ?? {}; + const maxMs = wait.maxMs ?? DEFAULT_DRAW_WAIT_MS; + const retryMs = wait.retryMs ?? DEFAULT_DRAW_RETRY_MS; + const closingSeconds = wait.closingSeconds ?? DEFAULT_DRAW_CLOSING_S; + const startedAt = Date.now(); + let retries = 0; + let signer = capabilities.signer; + let registrationEnds: number | null = null; + + const decide = async (): Promise => { + const now = await steps.now(chain, signal); + const requirement = await steps.requirement(chain, route, { + account, + tld, + now, + signal, + }); + registrationEnds = requirement.registrationEnds; + if (route === "free") { + const stale = requirement.blockers.some((blocker) => blocker.tag === "StaleAlias"); + if (stale && !capabilities.personhood) { + throw new GameRegistrationError("draws", "StaleAliasNeedsLiteKey", { + retryable: false, + message: + "the binding is stale, and nothing here can make the proof that re-points it", + }); + } + signer = withLiteAlias( + capabilities.signer, + stale + ? { + tag: "AliasWithAccountRevised", + createProof: liteProof(capabilities.personhood!, await facts()), + } + : { tag: "AliasWithAccount" }, + ); + const blockers = requirement.blockers.filter( + (blocker) => !FIXED_BY_THE_SIGN_UP.has(blocker.tag), + ); + return steps.draws( + chain, + capabilities, + { ...requirement, blockers }, + tld, + facts, + signal, + ); + } + signer = + route === "returning" + ? withScoreParticipant(capabilities.signer) + : capabilities.signer; + return steps.draws(chain, capabilities, requirement, tld, facts, signal); + }; + + const closing = async () => { + if (registrationEnds === null) return false; + try { + return (await steps.now(chain, signal)) + closingSeconds >= registrationEnds; + } catch { + return false; + } + }; + + let draws = await decide(); + while (draws.entries === undefined && draws.wait) { + const isClosing = await closing(); + if (Date.now() - startedAt >= maxMs || isClosing) { + draws = { + ...draws, + skipped: `${draws.skipped} (gave up waiting${isClosing ? ": registration is closing" : ""})`, + wait: false, + ...(isClosing ? { kind: "closing" as const } : {}), + }; + break; + } + await sleep(retryMs, signal); + retries += 1; + draws = await decide(); + } + const outcome: DrawOutcome = { + entered: draws.entries ? entryCount(draws.entries) : 0, + scheduled: draws.scheduled, + ...(draws.entries === undefined ? { skipped: draws.skipped, kind: draws.kind } : {}), + waitedMs: Date.now() - startedAt, + retries, + }; + report.draws = outcome; + options.onDraws?.(outcome); + end("draws"); + + // ── 4. sign + begin("sign"); + signal?.throwIfAborted(); + let tx: SignUpTransaction; + try { + tx = + route === "free" + ? signUpWithLiteInviteTx(chain, { + account, + identifierKey: options.identifierKey, + airdrops: draws.entries, + airdropsScheduled: draws.scheduled, + }) + : signUpWithAccountTx(chain, { + identifierKey: options.identifierKey, + airdrops: draws.entries, + airdropsScheduled: draws.scheduled, + }); + } catch (error) { + throw new GameRegistrationError("sign", "InvalidSignUp", { + retryable: false, + cause: error, + }); + } + const result = await capabilities.submit(tx, signer, { + waitFor: options.waitFor ?? "best-block", + onBroadcast: () => { + report.broadcastAt = Date.now(); + options.onStage?.("broadcast"); + }, + }); + end("sign"); + if (!result.ok) { + // Another product may have signed the same account up a moment earlier. + if (result.code === "AlreadyRegistered") { + report.signUp = { outcome: "already-registered" }; + return { kind: "already-registered", report }; + } + report.signUp = { + outcome: "failed", + code: result.code, + ...(result.landingUnknown ? { landingUnknown: true } : {}), + }; + throw new GameRegistrationError("sign", result.code, { + retryable: result.refused !== true && !NOT_RETRYABLE_SIGN_UP.has(result.code), + landingUnknown: result.landingUnknown, + }); + } + report.signUp = { outcome: "registered" }; + return { kind: "registered", route, draws: outcome, report }; + } +} + +if (import.meta.vitest) { + const { describe, expect, test, vi } = import.meta.vitest; + const { unwrapErr } = await import("@parity/result"); + const { AccountId } = await import("polkadot-api"); + + const ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; + const OTHER = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"; + const IDENTIFIER = new Uint8Array(65).fill(0x22); + const PUBLIC_KEY = AccountId().enc(ADDRESS); + const RING = { chainId: "0x01" as const, junctions: [] }; + const FACTS: PersonhoodFacts = { + derived: true, + owner: "peopl.paseo", + lite: { key: "lite", owner: "peopl.paseo", ring: RING }, + full: { key: "full", owner: "peopl.paseo", ring: RING }, + }; + const EVENT_IDS = [`0x${"11".repeat(32)}`, `0x${"12".repeat(32)}`]; + const TICKET = { preOutput: new Uint8Array(32).fill(3), proof: new Uint8Array(64).fill(4) }; + + const baseSigner = { publicKey: PUBLIC_KEY } as unknown as CommonSignerTxCreator; + const tagged = (...tags: string[]) => tags.map((tag) => ({ tag }) as LiteSignUpBlocker); + + const drawRequirement = (overrides: Partial = {}): DrawRequirement => ({ + variant: "Account", + phase: "Registration", + registrationEnds: 2_000, + airdropsScheduled: 2, + eventIds: EVENT_IDS, + blockers: [], + ...overrides, + }); + + const seatOf = (overrides: Partial = {}): GameSeat => + ({ + path: "deposit", + ready: true, + registered: false, + open: true, + required: 10n, + requirement: { blockers: [] }, + funds: { free: 100n, deposit: 10n, estimatedFee: 0n }, + ...overrides, + }) as GameSeat; + + /** A chain whose sign-up calls are named, so a test can tell which was built. */ + const fakeChain = () => { + const tx = (call: string) => ({ call }) as unknown as SignUpTransaction; + return { + individuality: { + tx: { + Game: { + sign_up_with_account: () => tx("sign_up_with_account"), + sign_up_with_account_lite_invite: () => + tx("sign_up_with_account_lite_invite"), + }, + }, + }, + } as unknown as GameRegistrationChain; + }; + + /** A `submit` capability that records what it was handed and answers `result`. */ + const fakeSubmit = (result: SignUpSubmitResult = { ok: true }) => { + const calls: { + call: string; + signer: CommonSignerTxCreator; + options: SignUpSubmitOptions; + }[] = []; + const submit: SubmitSignUp = async (tx, signer, options) => { + calls.push({ call: (tx as unknown as { call: string }).call, signer, options }); + options.onBroadcast?.(); + return result; + }; + return { submit, calls }; + }; + + const capabilities = ( + extra: Partial = {}, + ): GameRegistrationCapabilities => ({ + account: { address: ADDRESS, publicKey: PUBLIC_KEY }, + signer: baseSigner, + submit: fakeSubmit().submit, + ...extra, + }); + + const steps = (overrides: Partial = {}): RegistrationSteps => ({ + seat: async () => seatOf(), + bind: async () => "bound", + requirement: async () => drawRequirement(), + facts: async () => FACTS, + now: async () => 1_000, + draws: async () => ({ skipped: "the game has no prize draw", scheduled: 0 }), + ...overrides, + }); + + const run = ( + caps: GameRegistrationCapabilities, + stepOverrides: Partial = {}, + options: Partial = {}, + ) => + runRegistration( + fakeChain(), + caps, + { tld: "paseo", identifierKey: IDENTIFIER, ...options }, + steps(stepOverrides), + ); + + const failure = async (promise: Promise) => { + try { + await promise; + } catch (error) { + return error as GameRegistrationError & Partial; + } + throw new Error("expected the flow to fail"); + }; + + describe("registerForGame: the seat decides", () => { + test("a registered account is success, nothing is signed, and the report says why", async () => { + const { submit, calls } = fakeSubmit(); + const outcome = await run(capabilities({ submit }), { + seat: async () => seatOf({ registered: true }), + }); + expect(outcome.kind).toBe("already-registered"); + expect(outcome.report.seat).toMatchObject({ registered: true }); + expect(calls).toEqual([]); + }); + + test("a closed registration ends without signing", async () => { + const { submit, calls } = fakeSubmit(); + const outcome = await run(capabilities({ submit }), { + seat: async () => seatOf({ open: false }), + }); + expect(outcome.kind).toBe("not-open"); + expect(calls).toEqual([]); + }); + + test("an account that cannot pay says how much is missing", async () => { + const { submit, calls } = fakeSubmit(); + const outcome = await run(capabilities({ submit }), { + seat: async () => + seatOf({ + path: "unfunded", + required: 30n, + funds: { free: 12n } as GameSeat["funds"], + }), + }); + expect(outcome).toMatchObject({ kind: "unfunded", missing: 18n }); + expect(calls).toEqual([]); + }); + + test("a seat read that fails is a retryable failure of the seat step, with the report", async () => { + const error = await failure( + run(capabilities(), { + seat: async () => { + throw new Error("socket closed"); + }, + }), + ); + expect(error).toBeInstanceOf(GameRegistrationError); + expect(error).toMatchObject({ stage: "seat", retryable: true }); + expect(error.report?.steps.seat?.startedAt).toBeTypeOf("number"); + }); + }); + + describe("registerForGame: the route picks the call and the signer", () => { + test("deposit submits the account call with the plain signer, at the best block by default", async () => { + const { submit, calls } = fakeSubmit(); + const outcome = await run(capabilities({ submit })); + expect(outcome).toMatchObject({ kind: "registered", route: "deposit" }); + expect(calls).toHaveLength(1); + expect(calls[0]!.call).toBe("sign_up_with_account"); + expect(calls[0]!.signer).toBe(baseSigner); + expect(calls[0]!.options.waitFor).toBe("best-block"); + }); + + test("returning wraps the signer for the fee-free participant origin", async () => { + const { submit, calls } = fakeSubmit(); + await run(capabilities({ submit }), { + seat: async () => seatOf({ path: "returning" }), + }); + expect(calls[0]!.call).toBe("sign_up_with_account"); + expect(calls[0]!.signer).not.toBe(baseSigner); + }); + + test("free submits the lite invite call under the lite alias", async () => { + const { submit, calls } = fakeSubmit(); + const personhood = { holds: async () => true, prove: vi.fn() }; + await run(capabilities({ submit, personhood }), { + seat: async () => seatOf({ path: "free" }), + }); + expect(calls[0]!.call).toBe("sign_up_with_account_lite_invite"); + expect(calls[0]!.signer).not.toBe(baseSigner); + }); + + test("a stale binding with no lite key fails the draws step, not retryable", async () => { + const error = await failure( + run(capabilities(), { + seat: async () => seatOf({ path: "free" }), + requirement: async () => drawRequirement({ blockers: tagged("StaleAlias") }), + }), + ); + expect(error).toMatchObject({ + stage: "draws", + code: "StaleAliasNeedsLiteKey", + retryable: false, + }); + }); + + test("reads the requirement by the chain's clock", async () => { + const requirement = vi.fn(async () => drawRequirement()); + await run(capabilities(), { now: async () => 4_242, requirement }); + expect(requirement).toHaveBeenCalledWith( + expect.anything(), + "deposit", + expect.objectContaining({ now: 4_242 }), + ); + }); + }); + + describe("registerForGame: the bind", () => { + const personhood = { holds: async () => true, prove: vi.fn() }; + + test("binds an unbound free seat before signing up, and reports each stage", async () => { + const { submit, calls } = fakeSubmit(); + const bind = vi.fn(async () => "bound" as const); + const stages: string[] = []; + const outcome = await run( + capabilities({ submit, personhood }), + { seat: async () => seatOf({ path: "free", ready: false }), bind }, + { onStage: (stage) => stages.push(stage) }, + ); + expect(bind).toHaveBeenCalledOnce(); + expect(calls[0]!.call).toBe("sign_up_with_account_lite_invite"); + expect(stages).toEqual(["seat", "bind", "draws", "sign", "broadcast"]); + expect(outcome.report.bind).toEqual({ outcome: "bound" }); + expect(outcome.report.broadcastAt).toBeTypeOf("number"); + }); + + test("a bind refused for good seats the player on the account route", async () => { + const { submit, calls } = fakeSubmit(); + const seats = [seatOf({ path: "free", ready: false }), seatOf({ path: "deposit" })]; + const seat = vi.fn(async () => seats.shift()!); + const outcome = await run(capabilities({ submit, personhood }), { + seat, + bind: async () => { + throw new GameSeatBindError("pinned-elsewhere", "AnotherAccountInvited", OTHER); + }, + }); + expect(seat).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ litePerson: false }), + ); + expect(outcome).toMatchObject({ kind: "registered", route: "deposit" }); + expect(outcome.report.bind).toMatchObject({ + outcome: "refused", + refusal: "pinned-elsewhere", + }); + expect(calls[0]!.call).toBe("sign_up_with_account"); + }); + + test("a bind that may pass next time fails the bind step, retryable", async () => { + const { submit, calls } = fakeSubmit(); + const error = await failure( + run(capabilities({ submit, personhood }), { + seat: async () => seatOf({ path: "free", ready: false }), + bind: async () => { + throw new GameSeatBindError("rejected", "CallBlockOutOfRange"); + }, + }), + ); + expect(error).toMatchObject({ stage: "bind", retryable: true, permanent: false }); + expect(error.report?.steps.bind).toBeDefined(); + expect(calls).toEqual([]); + }); + }); + + describe("registerForGame: the sign-up's result", () => { + test("losing a race to another product is success", async () => { + const { submit } = fakeSubmit({ ok: false, code: "AlreadyRegistered" }); + const outcome = await run(capabilities({ submit })); + expect(outcome.kind).toBe("already-registered"); + expect(outcome.report.signUp).toEqual({ outcome: "already-registered" }); + }); + + test("a closed registration fails the sign step, not retryable", async () => { + const { submit } = fakeSubmit({ ok: false, code: "NoRegistration" }); + const error = await failure(run(capabilities({ submit }))); + expect(error).toMatchObject({ + stage: "sign", + code: "NoRegistration", + retryable: false, + }); + expect(error.report?.signUp).toMatchObject({ + outcome: "failed", + code: "NoRegistration", + }); + }); + + test("a declined signature is never retryable", async () => { + const { submit } = fakeSubmit({ ok: false, code: "SigningRejected", refused: true }); + const error = await failure(run(capabilities({ submit }))); + expect(error).toMatchObject({ stage: "sign", retryable: false }); + }); + + test("a lost watch is retryable and says the landing is unknown", async () => { + const { submit } = fakeSubmit({ ok: false, code: "Timeout", landingUnknown: true }); + const error = await failure(run(capabilities({ submit }))); + expect(error).toMatchObject({ stage: "sign", retryable: true, landingUnknown: true }); + }); + + test("passes waitFor through when asked", async () => { + const { submit, calls } = fakeSubmit(); + await run(capabilities({ submit }), {}, { waitFor: "finalized" }); + expect(calls[0]!.options.waitFor).toBe("finalized"); + }); + }); + + describe("registerForGame: the draw wait", () => { + test("waits a block for a draw that is not open, then carries the tickets", async () => { + const { submit } = fakeSubmit(); + const answers: DrawEntries[] = [ + { + skipped: "draw #0 is not taking entries yet", + scheduled: 1, + wait: true, + kind: "not-open", + }, + { entries: [TICKET], scheduled: 1 }, + ]; + const draws = vi.fn(async () => answers.shift()!); + const outcome = await run( + capabilities({ submit }), + { draws }, + { drawWait: { retryMs: 1 } }, + ); + expect(draws).toHaveBeenCalledTimes(2); + expect(outcome).toMatchObject({ + kind: "registered", + draws: { entered: 1, retries: 1 }, + }); + }); + + test("goes without tickets once the wait runs out, and still signs up", async () => { + const { submit, calls } = fakeSubmit(); + const outcome = await run( + capabilities({ submit }), + { + draws: async () => ({ + skipped: "draw #0 is not taking entries yet", + scheduled: 1, + wait: true, + kind: "not-open", + }), + }, + { drawWait: { maxMs: 0, retryMs: 1 } }, + ); + expect(outcome).toMatchObject({ draws: { entered: 0, kind: "not-open" } }); + expect(outcome.kind === "registered" && outcome.draws.skipped).toContain( + "gave up waiting", + ); + expect(calls).toHaveLength(1); + }); + + test("never waits into the last seconds of registration, by the chain's clock", async () => { + const { submit } = fakeSubmit(); + const draws = vi.fn( + async (): Promise => ({ + skipped: "draw #0 is not taking entries yet", + scheduled: 1, + wait: true, + kind: "not-open", + }), + ); + const outcome = await run( + capabilities({ submit }), + { + draws, + now: async () => 1_990, + requirement: async () => drawRequirement({ registrationEnds: 2_000 }), + }, + { drawWait: { retryMs: 1 } }, + ); + expect(draws).toHaveBeenCalledOnce(); + expect(outcome).toMatchObject({ draws: { kind: "closing" } }); + }); + }); + + describe("decideDraws", () => { + const eventsChain = (statuses: (string | undefined)[]) => + ({ + individuality: { + query: { + Airdrop: { + Events: { + getValue: async (id: string) => { + const status = statuses[EVENT_IDS.indexOf(id)]; + return status === undefined + ? undefined + : { status: { type: status } }; + }, + }, + }, + }, + }, + }) as unknown as AirdropChain; + const decide = ( + chain: AirdropChain, + caps: GameRegistrationCapabilities, + requirement: DrawRequirement = drawRequirement(), + ) => decideDraws(chain, caps, requirement, "paseo", async () => FACTS, undefined); + const signVrf = () => vi.fn(async () => TICKET); + const open = () => eventsChain(["Registering", "Registering"]); + + test("enters every scheduled draw, one ticket per draw, in airdrop-index order", async () => { + const sign = signVrf(); + const draws = await decide(open(), capabilities({ signVrf: sign })); + expect(draws.entries).toHaveLength(2); + expect(sign).toHaveBeenCalledTimes(2); + }); + + test("skips without a VRF signer, signing nothing", async () => { + const draws = await decide(open(), capabilities()); + expect(draws).toMatchObject({ kind: "signature" }); + expect(draws.entries).toBeUndefined(); + }); + + test("skips a game with no draw scheduled, signing nothing", async () => { + const sign = signVrf(); + const draws = await decide( + eventsChain([]), + capabilities({ signVrf: sign }), + drawRequirement({ airdropsScheduled: 0, eventIds: [] }), + ); + expect(draws).toMatchObject({ skipped: "the game has no prize draw" }); + expect(sign).not.toHaveBeenCalled(); + }); + + test("waits for a draw that is not on chain yet, or still scheduled", async () => { + const caps = capabilities({ signVrf: signVrf() }); + expect(await decide(eventsChain([undefined, "Registering"]), caps)).toMatchObject({ + wait: true, + kind: "not-open", + }); + expect(await decide(eventsChain(["Scheduled", "Registering"]), caps)).toMatchObject({ + wait: true, + kind: "not-open", + }); + }); + + test("skips a draw that has closed, without waiting", async () => { + const draws = await decide( + eventsChain(["Registering", "Claiming"]), + capabilities({ signVrf: signVrf() }), + ); + expect(draws).toMatchObject({ kind: "closed" }); + expect("wait" in draws && draws.wait).toBeFalsy(); + }); + + test("enters even when the requirement says registration ended: the phase and the draws decide", async () => { + const draws = await decide( + open(), + capabilities({ signVrf: signVrf() }), + drawRequirement({ blockers: tagged("RegistrationEnded") }), + ); + expect(draws.entries).toHaveLength(2); + }); + + test("skips on a blocker about the player", async () => { + const draws = await decide( + open(), + capabilities({ signVrf: signVrf() }), + drawRequirement({ blockers: tagged("NotSr25519") }), + ); + expect(draws).toMatchObject({ kind: "refused" }); + }); + + test("a refused ticket skips the draws without waiting, so nobody is asked again", async () => { + const sign = vi.fn(async () => { + throw new Error("user refused"); + }); + const draws = await decide(open(), capabilities({ signVrf: sign })); + expect(draws).toMatchObject({ kind: "signature" }); + expect("wait" in draws && draws.wait).toBeFalsy(); + expect(sign).toHaveBeenCalledOnce(); + }); + + test("a ticket that does not verify enters nothing", async () => { + const draws = await decide( + open(), + capabilities({ signVrf: signVrf(), verifyVrf: () => false }), + ); + expect(draws).toMatchObject({ kind: "signature" }); + }); + + test("a recognized person with nothing to prove the draws skips them", async () => { + const draws = await decide( + open(), + capabilities({ signVrf: signVrf() }), + drawRequirement({ variant: "Alias" }), + ); + expect(draws).toMatchObject({ kind: "verified-person" }); + }); + + test("a refused member proof skips the draws without waiting", async () => { + const prove = vi.fn(async () => { + throw new Error("no grant for the People key"); + }); + const draws = await decide( + open(), + capabilities({ personhood: { holds: async () => true, prove } }), + drawRequirement({ variant: "Alias" }), + ); + expect(draws).toMatchObject({ kind: "verified-person" }); + expect("wait" in draws && draws.wait).toBeFalsy(); + }); + }); + + describe("bind", () => { + const BUILT = { transaction: new Uint8Array([1, 2, 3]) }; + const ALIAS = new Uint8Array(32).fill(9); + const proofOf = async () => + ({ + proof: new Uint8Array(1), + contextualAlias: { context: new Uint8Array(32), alias: ALIAS }, + ringIndex: 0, + ringRevision: 0, + }) as RingVRFProof; + + const bindSteps = (overrides: Partial = {}) => { + const submitRaw = vi.fn(async () => ({ ok: true })); + const steps: BindSteps = { + requirement: async () => ({ blockers: tagged("AliasNotBound") }), + facts: async () => FACTS, + build: async (_chain, _account, createProof) => { + await createProof(new Uint8Array(32)); + return BUILT; + }, + pinnedTo: async () => undefined, + submitRaw, + ...overrides, + }; + return { steps, submitRaw }; + }; + const prover = () => ({ holds: async () => true, prove: vi.fn(proofOf) }); + const bind = (personhood: PersonhoodProver | undefined, steps: BindSteps) => + runBind( + {} as GameSeatBindChain, + { account: { address: ADDRESS, publicKey: PUBLIC_KEY }, personhood }, + { tld: "paseo" }, + undefined, + steps, + ); + + test("sends the bytes raw and resolves once a block holds the bind", async () => { + const { steps, submitRaw } = bindSteps(); + expect(await bind(prover(), steps)).toBe("bound"); + expect(submitRaw).toHaveBeenCalledWith(expect.anything(), BUILT.transaction, undefined); + }); + + test("resolves at once, asking for no proof, when the account is bound already", async () => { + const personhood = prover(); + const { steps, submitRaw } = bindSteps({ requirement: async () => ({ blockers: [] }) }); + expect(await bind(personhood, steps)).toBe("already-bound"); + expect(personhood.prove).not.toHaveBeenCalled(); + expect(submitRaw).not.toHaveBeenCalled(); + }); + + test("re-points a binding the ring moved past", async () => { + const { steps, submitRaw } = bindSteps({ + requirement: async () => ({ blockers: tagged("StaleAlias") }), + }); + expect(await bind(prover(), steps)).toBe("bound"); + expect(submitRaw).toHaveBeenCalledOnce(); + }); + + test("refuses a bind that cannot succeed before asking for any proof", async () => { + for (const tag of ["AccountIsALitePerson", "AliasBoundElsewhere"]) { + const personhood = prover(); + const { steps, submitRaw } = bindSteps({ + requirement: async () => ({ blockers: tagged(tag) }), + }); + await expect(bind(personhood, steps)).rejects.toMatchObject({ + refusal: "dead-end", + code: tag, + permanent: true, + }); + expect(personhood.prove).not.toHaveBeenCalled(); + expect(submitRaw).not.toHaveBeenCalled(); + } + }); + + test("refuses before submitting when the person's free seat is pinned to another account", async () => { + const { steps, submitRaw } = bindSteps({ pinnedTo: async () => OTHER }); + await expect(bind(prover(), steps)).rejects.toMatchObject({ + refusal: "pinned-elsewhere", + account: OTHER, + permanent: true, + }); + expect(submitRaw).not.toHaveBeenCalled(); + }); + + test("submits when the pin names this account, whatever prefix the chain spells it in", async () => { + const polkadot = AccountId(0).dec(PUBLIC_KEY); + const { steps, submitRaw } = bindSteps({ pinnedTo: async () => polkadot }); + expect(await bind(prover(), steps)).toBe("bound"); + expect(submitRaw).toHaveBeenCalledOnce(); + }); + + test("a bind that lost the race to another one counts as bound", async () => { + const { steps } = bindSteps({ + submitRaw: vi.fn(async () => ({ + ok: false as const, + error: "AliasAccountAlreadySet", + })), + }); + expect(await bind(prover(), steps)).toBe("already-bound"); + }); + + test("rejects with the chain's code, permanent only for the lasting refusals", async () => { + const lasting = bindSteps({ + submitRaw: vi.fn(async () => ({ ok: false as const, error: "AccountInUse" })), + }); + await expect(bind(prover(), lasting.steps)).rejects.toMatchObject({ + code: "AccountInUse", + permanent: true, + }); + const passing = bindSteps({ + submitRaw: vi.fn(async () => ({ ok: false as const, error: "BadProof" })), + }); + await expect(bind(prover(), passing.steps)).rejects.toMatchObject({ + code: "BadProof", + permanent: false, + retryable: true, + }); + }); + + test("without a lite key it fails for good, submitting nothing", async () => { + const { steps, submitRaw } = bindSteps(); + await expect(bind(undefined, steps)).rejects.toMatchObject({ + refusal: "no-lite-key", + permanent: true, + }); + expect(submitRaw).not.toHaveBeenCalled(); + }); + }); + + describe("bindGameSeat", () => { + test("reports a failed read on the err channel as a bind failure, and submits nothing", async () => { + const submitAndWatch = vi.fn(); + const chain = { + raw: { individuality: { submitAndWatch } }, + } as unknown as GameSeatBindChain; + const result = await bindGameSeat(chain, capabilities(), { tld: "paseo" }); + expect(unwrapErr(result)).toMatchObject({ + name: "GameRegistrationError", + stage: "bind", + }); + expect(submitAndWatch).not.toHaveBeenCalled(); + }); + }); + + describe("settleRaw", () => { + const watch = (emit: (observer: Parameters[0]) => void): TxWatch => ({ + subscribe(observer) { + emit(observer); + return { unsubscribe() {} }; + }, + }); + + test("resolves at the best block that holds the bind", async () => { + const result = await settleRaw( + watch((observer) => { + observer.next({ type: "broadcasted" }); + observer.next({ type: "inBestBlock", ok: true }); + }), + ); + expect(result).toEqual({ ok: true }); + }); + + test("a watch that ends with no block is a failure, not a hang", async () => { + const result = await settleRaw( + watch((observer) => { + observer.next({ type: "broadcasted" }); + observer.complete?.(); + }), + ); + expect(result.ok).toBe(false); + }); + + test("a lost watch says the landing is unknown", async () => { + const result = await settleRaw( + watch((observer) => observer.error(new Error("tracking stopped"))), + ); + expect(result).toMatchObject({ ok: false, landingUnknown: true }); + }); + }); + + describe("registrationRetryDelay", () => { + test("climbs the ladder and stays on its last rung", () => { + expect([1, 2, 3, 4, 5, 9].map(registrationRetryDelay)).toEqual([ + 5_000, 15_000, 45_000, 120_000, 300_000, 300_000, + ]); + expect(registrationRetryDelay(0)).toBe(5_000); + }); + }); + + describe("errorName", () => { + test("finds the innermost name of a nested dispatch error", () => { + expect( + errorName({ + type: "Module", + value: { type: "Game", value: { type: "NoRegistration" } }, + }), + ).toBe("NoRegistration"); + expect(errorName(new Error("socket closed"))).toBe("socket closed"); + }); + }); + + test("registerForGame reports a failed seat read on the err channel, with its step", async () => { + const result = await registerForGame({} as GameRegistrationChain, capabilities(), { + tld: "paseo", + identifierKey: IDENTIFIER, + }); + expect(unwrapErr(result)).toMatchObject({ name: "GameRegistrationError", stage: "seat" }); + }); +} diff --git a/product-sdk/packages/individuality/src/same-account.ts b/product-sdk/packages/individuality/src/same-account.ts new file mode 100644 index 00000000..e10df227 --- /dev/null +++ b/product-sdk/packages/individuality/src/same-account.ts @@ -0,0 +1,36 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Whether two SS58 strings name the same account. The chain's SS58 prefix need + * not be the caller's, so the strings are compared as the account ids they + * decode to. Throws on a string that is not an address, as the codec does. + * + * Internal: shared by the lite sign-up read and the registration flow, not part + * of the package surface. + */ +import { AccountId } from "polkadot-api"; + +export function sameAccount(left: string, right: string): boolean { + const codec = AccountId(); + const a = codec.enc(left); + const b = codec.enc(right); + return a.length === b.length && a.every((byte, index) => byte === b[index]); +} + +if (import.meta.vitest) { + const { expect, test } = import.meta.vitest; + + test("matches one account written with two SS58 prefixes", () => { + const id = new Uint8Array(32).fill(7); + const generic = AccountId(42).dec(id); + const polkadot = AccountId(0).dec(id); + expect(generic).not.toBe(polkadot); + expect(sameAccount(generic, polkadot)).toBe(true); + }); + + test("tells two accounts apart", () => { + const a = AccountId().dec(new Uint8Array(32).fill(1)); + const b = AccountId().dec(new Uint8Array(32).fill(2)); + expect(sameAccount(a, b)).toBe(false); + }); +} diff --git a/product-sdk/packages/individuality/src/seat.ts b/product-sdk/packages/individuality/src/seat.ts new file mode 100644 index 00000000..4d848249 --- /dev/null +++ b/product-sdk/packages/individuality/src/seat.ts @@ -0,0 +1,396 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Which sign-up route a player takes for the weekly game, and whether it can be + * taken now: the one answer every product that shows or starts a sign-up has to + * agree on. + * + * The chain offers several routes, and a player is shown one, chosen from their + * situation: + * + * - `"returning"`: a player record already exists (`AlreadyPlaying`). The + * account call is free for them, with no deposit, and `withScoreParticipant` + * waives the fee. + * - `"free"`: a lite person takes `sign_up_with_account_lite_invite`, `Pays::No`, + * valid on a zero balance. The seat is ready once the lite alias is bound to + * the account. A binding the ring has moved past (`StaleAlias`) is still + * ready, because the sign-up re-points it. + * - `"deposit"`: nobody the chain knows, with enough funds. The account call + * holds the play deposit until the player leaves. + * - `"unfunded"`: nobody the chain knows, who cannot pay the deposit and the fee. + * Nothing can be signed. + * + * The full person's route, `sign_up_with_alias`, cannot be assembled yet, so it + * is never chosen. Game-phase blockers (`NoGameRunning`, `NotInRegistration`, + * `AlreadyRegistered`, …) do not change the route, only whether a sign-up can + * go out now; {@link GameSeat} carries those separately. + * + * Ported from jollity-next `packages/protocol/src/seat.ts`, which the game's app + * and chat worker used before this. + */ +import { err, normalizeError, ok, type Result } from "@parity/result"; + +import { ProductIndividualityError } from "./errors.js"; +import type { GameChain } from "./game-read.js"; +import type { ReadAt } from "./pinned.js"; +import type { ScoreContextChain } from "./rings.js"; +import { + readSignUpFunds, + signUpWithAccountTx, + type FeeEstimable, + type SignUpChain, + type SignUpFunds, + type SignUpFundsChain, +} from "./signup.js"; +import { readLiteSignUpRequirement, type LiteSignUpChain } from "./signup-lite.js"; +import type { LiteSignUpBlocker, LiteSignUpRequirement } from "./signup-types.js"; +import type { BlockAt } from "./types.js"; + +/** The ways a sign-up can actually be submitted. */ +export type SeatRoute = "returning" | "free" | "deposit"; + +/** What a product shows: a route, or why none is open to this account. */ +export type SeatPath = SeatRoute | "unfunded"; + +/** What the chain and the host say about the player. */ +export interface SeatFacts { + /** The blockers of {@link readLiteSignUpRequirement} for the game account. */ + blockers: readonly LiteSignUpBlocker[]; + /** The account's free balance. */ + free: bigint; + /** What the deposit route needs up front. See {@link seatRequired}. */ + required: bigint; + /** + * The host holds a lite-personhood key for this person, and nothing has + * closed the lite route for the session (a bind refused for good does). + */ + litePerson: boolean; + /** + * This product's own bind of the account sits in a best block. A read at + * the finalized block trails it by a few blocks and still says + * `AliasNotBound` about a seat that is bound. + */ + bound?: boolean; +} + +/** A route, and whether it can be pressed now. */ +export interface Seat { + path: SeatPath; + /** False only while the free route's bind has not landed. */ + ready: boolean; +} + +/** An active player record exists: the returning player's account route. */ +const RETURNING = "AlreadyPlaying"; +/** The lite gate the bind clears. */ +const UNBOUND = "AliasNotBound"; +/** The lite gates that close the lite route for this account for good. */ +const LITE_CLOSED: ReadonlySet = new Set([ + "AliasBoundElsewhere", + "AnotherAccountInvited", + "AccountIsALitePerson", + "AccountIsAStatementAccount", + "ContextNotProductDerived", +]); +/** The game-phase blockers: no sign-up can go out until they clear. */ +const NOT_OPEN: ReadonlySet = new Set([ + "NoGameRunning", + "NotInRegistration", + "RegistrationEnded", +]); + +/** The fee headroom the deposit route asks for, as a multiple of the estimate. */ +export const DEFAULT_FEE_HEADROOM = 2; + +/** + * What the deposit route needs up front: the deposit, plus the fee times + * `feeHeadroom`. An estimate the runtime could not make counts as no fee. + */ +export function seatRequired( + funds: Pick, + feeHeadroom: number = DEFAULT_FEE_HEADROOM, +): bigint { + return funds.deposit + (funds.estimatedFee ?? 0n) * BigInt(feeHeadroom); +} + +/** + * The seat, from the facts alone. Pure, so every product decides the route the + * same way, and a renamed blocker is a compile error in one place. + */ +export function deriveSeat(facts: SeatFacts): Seat { + const tags = new Set(facts.blockers.map((blocker) => blocker.tag)); + let path: SeatPath; + if (tags.has(RETURNING)) { + path = "returning"; + } else if (facts.litePerson && ![...tags].some((tag) => LITE_CLOSED.has(tag))) { + path = "free"; + } else { + path = facts.free >= facts.required ? "deposit" : "unfunded"; + } + const ready = path !== "free" || facts.bound === true || !tags.has(UNBOUND); + return { path, ready }; +} + +/** {@link Seat}, with what decided it and whether a sign-up can go out now. */ +export interface GameSeat extends Seat { + /** `AlreadyRegistered`: this account is signed up for the running game. */ + registered: boolean; + /** + * Registration is open: none of `NoGameRunning`, `NotInRegistration`, + * `RegistrationEnded`, judged by the chain's clock at the best block, never + * the device's. + */ + open: boolean; + /** What the deposit route needs up front; see {@link seatRequired}. */ + required: bigint; + requirement: LiteSignUpRequirement; + funds: SignUpFunds; +} + +/** `Timestamp.Now`: the chain's clock, which registration closes by. */ +export interface TimestampChain { + individuality: { + query: { + Timestamp: { + /** Milliseconds. */ + Now: { getValue(options: ReadAt): Promise }; + }; + }; + }; +} + +/** + * The chain's clock at the best block, in Unix seconds: what the pallet judges + * registration by. A phone's clock can be off by minutes, and a requirement read + * judged by it reports `RegistrationEnded` for a game still taking sign-ups. + * + * Internal: shared with the registration flow. + */ +export async function chainNow(chain: TimestampChain, signal?: AbortSignal): Promise { + const ms = await chain.individuality.query.Timestamp.Now.getValue({ at: "best", signal }); + return Number(ms / 1000n); +} + +/** What {@link readGameSeat} reads. */ +export type GameSeatChain = GameChain & + SignUpChain & + ScoreContextChain & + LiteSignUpChain & + SignUpFundsChain & + TimestampChain; + +/** Options for {@link readGameSeat}. */ +export interface ReadGameSeatOptions { + /** SS58. The game account, `dim2.` at `GAME_ACCOUNT_INDEX`. */ + account: string; + /** See {@link SeatFacts.litePerson}. */ + litePerson: boolean; + /** See {@link SeatFacts.bound}. */ + bound?: boolean; + /** The network's DotNS TLD, such as `"paseo"`. */ + tld: string; + /** Defaults to `"best"`, the block a sign-up is validated against. */ + at?: BlockAt; + /** Defaults to {@link DEFAULT_FEE_HEADROOM}. */ + feeHeadroom?: number; + signal?: AbortSignal; +} + +/** Any 65 bytes estimate the same fee as the real communication identifier. */ +const PLACEHOLDER_IDENTIFIER = new Uint8Array(65); + +/** + * The player's seat for the running game: the lite sign-up requirement and the + * sign-up funds, read at `at`, folded by {@link deriveSeat}. Registration is + * judged by the chain's clock (see {@link chainNow}). + */ +export async function readGameSeat( + chain: GameSeatChain, + options: ReadGameSeatOptions, +): Promise> { + try { + const at = options.at ?? "best"; + const { account, signal } = options; + const now = await chainNow(chain, signal); + const [requirement, funds] = await Promise.all([ + readLiteSignUpRequirement(chain, { account, tld: options.tld, at, now, signal }), + readFundsWithFee(chain, account, at, signal), + ]); + if (!requirement.ok) return err(requirement.error); + const required = seatRequired(funds, options.feeHeadroom); + const seat = deriveSeat({ + blockers: requirement.value.blockers, + free: funds.free, + required, + litePerson: options.litePerson, + bound: options.bound, + }); + const tags = requirement.value.blockers.map((blocker) => blocker.tag); + return ok({ + ...seat, + registered: tags.includes("AlreadyRegistered"), + open: !tags.some((tag) => NOT_OPEN.has(tag)), + required, + requirement: requirement.value, + funds, + }); + } catch (cause) { + return err(normalizeError(cause, ProductIndividualityError)); + } +} + +/** The funds with the sign-up's fee, or without it when the runtime cannot estimate one. */ +async function readFundsWithFee( + chain: GameSeatChain, + account: string, + at: BlockAt, + signal: AbortSignal | undefined, +): Promise { + const tx = signUpWithAccountTx(chain, { identifierKey: PLACEHOLDER_IDENTIFIER }); + const withFee = await readSignUpFunds(chain, { account, tx, at, signal }); + if (withFee.ok) return withFee.value; + const plain = await readSignUpFunds(chain, { account, at, signal }); + if (!plain.ok) throw plain.error; + return plain.value; +} + +if (import.meta.vitest) { + const { describe, expect, test } = import.meta.vitest; + + const REQUIRED = 20_000_000_000n; + const tagged = (tags: string[]) => tags.map((tag) => ({ tag }) as LiteSignUpBlocker); + const facts = ( + tags: string[], + free: bigint, + extra: Partial> = {}, + ): SeatFacts => ({ + blockers: tagged(tags), + free, + required: REQUIRED, + litePerson: false, + ...extra, + }); + const person = (tags: string[], extra: Partial> = {}) => + facts(tags, 0n, { litePerson: true, ...extra }); + + describe("deriveSeat: nobody the chain knows takes the account route", () => { + test("is the deposit route when the account can pay, whatever the phase", () => { + expect(deriveSeat(facts([], 30_000_000_000n)).path).toBe("deposit"); + expect(deriveSeat(facts(["NoGameRunning"], REQUIRED)).path).toBe("deposit"); + expect( + deriveSeat(facts(["NotInRegistration", "AlreadyRegistered"], REQUIRED)).path, + ).toBe("deposit"); + }); + + test("is unfunded one planck short", () => { + expect(deriveSeat(facts([], REQUIRED - 1n)).path).toBe("unfunded"); + expect(deriveSeat(facts([], 0n)).path).toBe("unfunded"); + }); + + test("ignores the lite gates of someone who is not a lite person", () => { + expect(deriveSeat(facts(["AliasNotBound"], 30_000_000_000n)).path).toBe("deposit"); + expect(deriveSeat(facts(["StaleAlias"], 0n)).path).toBe("unfunded"); + }); + }); + + describe("deriveSeat: the returning player", () => { + test("is the returning route, however empty the account", () => { + expect(deriveSeat(facts(["AlreadyPlaying"], 0n)).path).toBe("returning"); + expect( + deriveSeat(facts(["NoGameRunning", "AlreadyPlaying", "StaleAlias"], 0n)).path, + ).toBe("returning"); + }); + + test("wins over the lite route", () => { + expect(deriveSeat(person(["AlreadyPlaying", "AliasNotBound"])).path).toBe("returning"); + }); + }); + + describe("deriveSeat: a lite person takes the free route", () => { + test("on a zero balance, bound or not, stale or not", () => { + expect(deriveSeat(person([])).path).toBe("free"); + expect(deriveSeat(person(["NoGameRunning"])).path).toBe("free"); + expect(deriveSeat(person(["AliasNotBound"])).path).toBe("free"); + expect(deriveSeat(person(["StaleAlias"])).path).toBe("free"); + }); + + test("with funds in the account too: a person never pays a deposit", () => { + expect(deriveSeat(facts([], 30_000_000_000n, { litePerson: true })).path).toBe("free"); + }); + + test("falls back to the account route once the lite route is closed", () => { + expect( + deriveSeat( + facts(["AliasNotBound", "AnotherAccountInvited"], 30_000_000_000n, { + litePerson: true, + }), + ).path, + ).toBe("deposit"); + expect(deriveSeat(person(["ContextNotProductDerived"])).path).toBe("unfunded"); + expect(deriveSeat(person(["AliasBoundElsewhere"])).path).toBe("unfunded"); + }); + }); + + describe("deriveSeat: ready", () => { + test("is ready on every route but a free seat with no binding", () => { + expect(deriveSeat(facts([], REQUIRED))).toEqual({ path: "deposit", ready: true }); + expect(deriveSeat(facts([], 0n))).toEqual({ path: "unfunded", ready: true }); + expect(deriveSeat(facts(["AlreadyPlaying"], 0n))).toEqual({ + path: "returning", + ready: true, + }); + expect(deriveSeat(person([]))).toEqual({ path: "free", ready: true }); + }); + + test("is not ready while the account has no binding", () => { + expect(deriveSeat(person(["AliasNotBound"]))).toEqual({ path: "free", ready: false }); + }); + + test("is ready once this product's own bind is in a best block", () => { + expect(deriveSeat(person(["AliasNotBound"], { bound: true }))).toEqual({ + path: "free", + ready: true, + }); + }); + + test("is ready on a stale binding: the sign-up re-points it", () => { + expect(deriveSeat(person(["StaleAlias"]))).toEqual({ path: "free", ready: true }); + }); + }); + + describe("chainNow", () => { + test("reads the best block's timestamp and returns whole seconds", async () => { + const seen: string[] = []; + const chain = { + individuality: { + query: { + Timestamp: { + Now: { + getValue: async (options: ReadAt) => { + seen.push(options.at); + return 1_792_159_444_713n; + }, + }, + }, + }, + }, + }; + expect(await chainNow(chain)).toBe(1_792_159_444); + expect(seen).toEqual(["best"]); + }); + }); + + describe("seatRequired", () => { + test("is the deposit plus twice the fee by default", () => { + expect(seatRequired({ deposit: 100n, estimatedFee: 7n })).toBe(114n); + }); + + test("counts a fee the runtime could not estimate as none", () => { + expect(seatRequired({ deposit: 100n, estimatedFee: null })).toBe(100n); + }); + + test("takes another headroom", () => { + expect(seatRequired({ deposit: 100n, estimatedFee: 7n }, 3)).toBe(121n); + }); + }); +} diff --git a/product-sdk/packages/individuality/src/signup-lite.ts b/product-sdk/packages/individuality/src/signup-lite.ts index 09e32b1e..d1c218ef 100644 --- a/product-sdk/packages/individuality/src/signup-lite.ts +++ b/product-sdk/packages/individuality/src/signup-lite.ts @@ -54,6 +54,7 @@ import { bytesToHex } from "@parity/product-sdk-utils"; import { ProductIndividualityError } from "./errors.js"; import type { GameChain } from "./game-read.js"; import { readAt, readPinned, type ReadAt } from "./pinned.js"; +import { sameAccount } from "./same-account.js"; import type { BlockAt, FinalizedSnapshot } from "./types.js"; import { ringCollectionId, @@ -211,14 +212,6 @@ function normalizedHex(value: string): string { return (value.startsWith("0x") ? value.slice(2) : value).toLowerCase(); } -/** The chain's SS58 prefix need not be the caller's. */ -function sameAccount(left: string, right: string): boolean { - const codec = AccountId(); - const a = codec.enc(left); - const b = codec.enc(right); - return a.length === b.length && a.every((byte, index) => byte === b[index]); -} - /** * What this account may do about the free lite sign-up, at one pinned block: * the account sign-up read plus the lite gates, with the blockers merged. diff --git a/product-sdk/packages/individuality/tsup.config.ts b/product-sdk/packages/individuality/tsup.config.ts index d1c62145..3aa4f3d8 100644 --- a/product-sdk/packages/individuality/tsup.config.ts +++ b/product-sdk/packages/individuality/tsup.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/testing.ts"], + entry: ["src/index.ts", "src/testing.ts", "src/host.ts"], format: ["esm"], dts: true, sourcemap: true, diff --git a/product-sdk/packages/sdk/src/individuality/contract.test.ts b/product-sdk/packages/sdk/src/individuality/contract.test.ts index d3032f88..e985d43a 100644 --- a/product-sdk/packages/sdk/src/individuality/contract.test.ts +++ b/product-sdk/packages/sdk/src/individuality/contract.test.ts @@ -46,6 +46,9 @@ import type { FeeEstimable, GameChain, GamePlayersChain, + GameRegistrationChain, + GameSeatBindChain, + GameSeatChain, IndividualityChain, LegacySuffixChain, LiteAliasBindChain, @@ -542,6 +545,38 @@ type RejectsBogusBindClient = Assert< ClientWithoutIndividuality extends LiteAliasBindChain ? false : true >; +// Registering for the game in one call composes every read and builder above +// with the two submission methods PAPI hands back (`createSubmitAndWatch` on a +// transaction, `submitAndWatch` on the client) and `Timestamp.Now`. These are +// the contracts the game's app, its chat worker and the personhood product pass +// a `fromPapi` chain to, so a descriptor change that breaks any of them fails here. +type PaseoSatisfiesGameSeatContract = Assert; +type PreviewnetSatisfiesGameSeatContract = Assert< + PreviewnetClient extends GameSeatChain ? true : false +>; +type PaseoSatisfiesSeatBindContract = Assert; +type PreviewnetSatisfiesSeatBindContract = Assert< + PreviewnetClient extends GameSeatBindChain ? true : false +>; +type PaseoSatisfiesRegistrationContract = Assert< + PaseoClient extends GameRegistrationChain ? true : false +>; +type PreviewnetSatisfiesRegistrationContract = Assert< + PreviewnetClient extends GameRegistrationChain ? true : false +>; +type FromPapiSatisfiesRegistrationContract = Assert< + PapiIndividualityChain< + PaseoClient["individuality"], + PaseoClient["raw"]["individuality"] + > extends GameRegistrationChain + ? true + : false +>; +type DevnetPredatesRegistration = Assert; +type RejectsBogusRegistrationClient = Assert< + ClientWithoutIndividuality extends GameRegistrationChain ? false : true +>; + // Pinned by name for the same reason `sign_up_with_account`'s are: PAPI encodes // the object it is handed, so a renamed field silently encodes as `undefined` — // and `account` here is a call argument, not implied by the signer. diff --git a/product-sdk/pending-changesets/individuality-register-for-game.md b/product-sdk/pending-changesets/individuality-register-for-game.md new file mode 100644 index 00000000..b16b1a8d --- /dev/null +++ b/product-sdk/pending-changesets/individuality-register-for-game.md @@ -0,0 +1,6 @@ +--- +"@parity/product-sdk-individuality": minor +"@parity/product-sdk": minor +--- + +Add game registration in one call, for every product that signs players up: `registerForGame` reads the seat, binds the lite alias when the free route needs it, decides the prize-draw entries from the sign-up requirement at the best block, then hands the call, signed under the origin its route needs, to a `submit` capability. Registration is judged by the chain's clock, the sign-up resolves at the best block unless `waitFor: "finalized"` is asked for, a refused signature is never asked for again, and every outcome and `GameRegistrationError` carries a report and says which step failed and whether a retry can help (`registrationRetryDelay` gives the backoff). `readGameSeat` and the pure `deriveSeat` give the route (`returning`, `free`, `deposit` or `unfunded`) so every product shows the same answer, and `bindGameSeat` runs the bind leg alone: already-bound and doomed binds ask for no proof, and the `Game.LiteInvites` pin is checked before it goes out. `GAME_PRODUCT_NAME`, `GAME_ACCOUNT_INDEX` and `gameProductId` name the game account in one place. The new `@parity/product-sdk-individuality/host` entry builds the capabilities with `hostGameRegistrationCapabilities`, submitting through `@parity/product-sdk-tx`; it takes `@parity/product-sdk-host` and `@parity/product-sdk-tx` as optional peer dependencies so the main entry stays free of both. diff --git a/product-sdk/pnpm-lock.yaml b/product-sdk/pnpm-lock.yaml index 9820e7e8..65906959 100644 --- a/product-sdk/pnpm-lock.yaml +++ b/product-sdk/pnpm-lock.yaml @@ -687,6 +687,12 @@ importers: '@parity/product-sdk-descriptors': specifier: workspace:* version: link:../descriptors + '@parity/product-sdk-host': + specifier: workspace:* + version: link:../host + '@parity/product-sdk-tx': + specifier: workspace:* + version: link:../tx tsup: specifier: 'catalog:' version: 8.5.1(postcss@8.5.15)(typescript@5.9.3) diff --git a/product-sdk/skills/product-sdk-individuality/SKILL.md b/product-sdk/skills/product-sdk-individuality/SKILL.md index 69b0255b..3d6d3e5e 100644 --- a/product-sdk/skills/product-sdk-individuality/SKILL.md +++ b/product-sdk/skills/product-sdk-individuality/SKILL.md @@ -11,8 +11,10 @@ description: > productContext, personhoodContext, peopleRing, litePeopleRing and readScoreContext for the ring-VRF proof contexts; withAsPerson, withLiteAlias and the RestrictOrigins requirement that otherwise fails every call; the two-transaction lite personhood bind then sign-up - flow with buildLiteAliasBindTx, readLiteSignUpRequirement and signUpWithLiteInviteTx; and full-personhood registration with registerMessage, Score.register and - withScoreParticipant. + flow with buildLiteAliasBindTx, readLiteSignUpRequirement and signUpWithLiteInviteTx; game + registration in one call with registerForGame, readGameSeat, deriveSeat, bindGameSeat and the + /host entry's hostGameRegistrationCapabilities; and full-personhood registration with + registerMessage, Score.register and withScoreParticipant. --- # Product SDK Individuality @@ -428,6 +430,52 @@ The first four stop the extrinsic; the last three stop only the draw entry. `can - **A rejected sign-up costs a fee.** `Pays::No` applies on success only, and the airdrop registration rides inside the same extrinsic, so one bad VRF entry loses the game sign-up too. Read the blockers before submitting. - **No local VRF verification exists.** One bad entry fails everything, but schnorrkel verification is not in this workspace. What it would catch is the wrong key, which the transcript already binds. +### Registering in One Call + +Every product that signs a player up takes the same steps: read the seat, bind the lite alias when the free route needs it, decide the draw entries, sign up. `registerForGame` runs them in that order, so the game's app, its chat worker and the personhood product all sign players up the same way. Reach for the pieces above only when you need a step on its own. + +```ts +import { fromPapi, readGameSeat, registerForGame, registrationRetryDelay } from "@parity/product-sdk-individuality"; +import { hostGameRegistrationCapabilities } from "@parity/product-sdk-individuality/host"; + +const caps = await hostGameRegistrationCapabilities({ tld: "paseo" }); +if (!caps.ok) return; + +const outcome = await registerForGame(fromPapi(client, api), caps.value, { + tld: "paseo", + identifierKey, // 65-byte P-256 key the game uses at game time + onStage: (stage) => show(stage), // "seat" | "bind" | "draws" | "sign" | "broadcast" +}); +if (!outcome.ok) { + const e = outcome.error; // GameRegistrationError (GameSeatBindError for the bind) + send(e.report); // how far it got, for telemetry + if (e.retryable) retryIn(registrationRetryDelay(failures)); // the call resumes where it stopped + return; +} +send(outcome.value.report); +switch (outcome.value.kind) { + case "registered": // route + what the draws carried + case "already-registered": // also what a race with another product ends in + case "not-open": // no game, or past registration, by the chain's clock + case "unfunded": // deposit route, `missing` short +} +``` + +| Step | What runs | Notes | +|---|---|---| +| Seat | `readGameSeat`: `readLiteSignUpRequirement` + `readSignUpFunds` at best, judged by `Timestamp.Now`, folded by `deriveSeat` | Route `returning`, `free`, `deposit` or `unfunded`. Show it with `readGameSeat` alone | +| Bind | `bindGameSeat`: already bound (current or stale) resolves at once; a lite person's own account, a binding in another context, a pinned seat or an underivable context are refused before any proof; otherwise `buildLiteAliasBindTx`, the `Game.LiteInvites` pin check, a raw submit | `AliasAccountAlreadySet` counts as bound. A permanent refusal seats the player on the account route instead | +| Draws | The requirement's `eventIds` and `airdropsScheduled`, each draw's status decoded with `statusTag`/`airdropPhase` at best; waits a block at a time for a draw not open yet, never into the last 15 s of registration | A refused ticket or proof is not asked for again: the sign-up goes without it, and `DrawOutcome.kind` says why | +| Sign | `signUpWithLiteInviteTx` under `withLiteAlias` (`AliasWithAccountRevised` on a stale binding), or `signUpWithAccountTx` under `withScoreParticipant` / plain, handed to `submit` | Resolves at the best block by default, as `tx` does; pass `waitFor: "finalized"` when the answer must not be undone. `AlreadyRegistered` from the chain is success | + +> **RETRY BY CALLING AGAIN.** Every step reads the chain before it acts, so a second call resumes: a bind that landed is not sent again and a sign-up that landed is `already-registered`. Check `retryable` first (a declined signature, a closed registration or a pinned seat never are), wait `registrationRetryDelay(failures)` (5 s, 15 s, 45 s, 2 min, 5 min), and never pass a cached `seat` on a retry. + +> **ONE ACCOUNT FOR EVERY PRODUCT.** The game account is `gameProductId(tld)` at `GAME_ACCOUNT_INDEX` (1 while the native iOS game holds 0). The host adapter asks for exactly that. A product other than the game product signs as it only under the game product's `context` grant, and proves with the personhood keys only under the personhood product's. + +> **THE IDENTIFIER IS WHOEVER REGISTERS.** `identifierKey` is rewritten on every sign-up, and no call changes it afterwards. A product that registers for the game must pass a key whose private half the game product can use at game time. + +> **CAPABILITIES, NOT A HOST.** The main entry imports neither `@parity/product-sdk-host` nor `@parity/product-sdk-tx`. `GameRegistrationCapabilities` carries the account, its signer, `submit`, `signVrf`, an optional `verifyVrf`, and the personhood prover. `hostGameRegistrationCapabilities` builds them with `tx`'s `submitAndWatch` as `submit`; a worker that cannot load `tx` passes its own. + ## Proof Contexts and Ring Locations Before a product mints any ring-VRF proof it needs two things the chain will not hand it: the **32-byte context** the proof must be minted in, and the **ring** the proof comes from. Both are pure derivations — no client, no host, no round trip — so a product can predict them offline and compare against what the host actually returned. From 89d4663f9a25c60c2afafb9b4f1f5d2436dd795c Mon Sep 17 00:00:00 2001 From: Nemanya8 Date: Mon, 19 Oct 2026 11:42:22 +0200 Subject: [PATCH 2/7] fix(individuality): harden game registration after review --- .../packages/individuality/package.json | 16 +- .../packages/individuality/src/host.ts | 3 +- .../packages/individuality/src/index.ts | 4 + .../individuality/src/register-game.ts | 401 ++++++++++++++++-- .../individuality-register-for-game.md | 9 +- product-sdk/pnpm-lock.yaml | 12 +- .../skills/product-sdk-individuality/SKILL.md | 2 +- 7 files changed, 397 insertions(+), 50 deletions(-) diff --git a/product-sdk/packages/individuality/package.json b/product-sdk/packages/individuality/package.json index 0aff2c4f..586a2a78 100644 --- a/product-sdk/packages/individuality/package.json +++ b/product-sdk/packages/individuality/package.json @@ -33,6 +33,8 @@ "dependencies": { "@noble/curves": "^2.2.0", "@parity/product-sdk-errors": "workspace:*", + "@parity/product-sdk-host": "workspace:*", + "@parity/product-sdk-tx": "workspace:*", "@parity/product-sdk-utils": "workspace:*", "@parity/result": "workspace:*", "@polkadot-api/metadata-builders": "catalog:", @@ -43,22 +45,8 @@ "@scure/sr25519": "^2.2.0", "polkadot-api": "catalog:" }, - "peerDependencies": { - "@parity/product-sdk-host": "workspace:^", - "@parity/product-sdk-tx": "workspace:^" - }, - "peerDependenciesMeta": { - "@parity/product-sdk-host": { - "optional": true - }, - "@parity/product-sdk-tx": { - "optional": true - } - }, "devDependencies": { "@parity/product-sdk-descriptors": "workspace:*", - "@parity/product-sdk-host": "workspace:*", - "@parity/product-sdk-tx": "workspace:*", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/product-sdk/packages/individuality/src/host.ts b/product-sdk/packages/individuality/src/host.ts index bd0c5010..82c3406b 100644 --- a/product-sdk/packages/individuality/src/host.ts +++ b/product-sdk/packages/individuality/src/host.ts @@ -7,7 +7,8 @@ * The main entry stays free of `@parity/product-sdk-host` and * `@parity/product-sdk-tx` on purpose; this entry is the one place that wires a * host's accounts and `tx`'s submission into {@link GameRegistrationCapabilities}. - * It declares both packages as optional peer dependencies. + * Both are dependencies of the package, so a product that never imports this + * entry bundles neither. * * ```ts * import { fromPapi, registerForGame } from "@parity/product-sdk-individuality"; diff --git a/product-sdk/packages/individuality/src/index.ts b/product-sdk/packages/individuality/src/index.ts index 68576c8b..c7c60a26 100644 --- a/product-sdk/packages/individuality/src/index.ts +++ b/product-sdk/packages/individuality/src/index.ts @@ -488,6 +488,7 @@ export type { SeatFacts, SeatPath, SeatRoute, + TimestampChain, } from "./seat.js"; export { bindGameSeat, @@ -516,12 +517,15 @@ export type { PersonhoodProofContext, PersonhoodProver, PersonhoodRing, + RawSubmitChain, RegisterForGameOptions, RegisterForGameOutcome, SignUpSubmitOptions, SignUpSubmitResult, SignUpTransaction, SubmitSignUp, + TxWatch, + TxWatchEvent, } from "./register-game.js"; // The metadata-driven pieces underneath stay internal on purpose, even though diff --git a/product-sdk/packages/individuality/src/register-game.ts b/product-sdk/packages/individuality/src/register-game.ts index 69504a2b..aff9fa08 100644 --- a/product-sdk/packages/individuality/src/register-game.ts +++ b/product-sdk/packages/individuality/src/register-game.ts @@ -208,6 +208,13 @@ export interface SignUpSubmitOptions { waitFor: "best-block" | "finalized"; /** Called once the transaction is on the wire, for an optimistic UI. */ onBroadcast?: () => void; + /** + * The caller's abort. The flow already drops a signature that is still being + * made when it aborts, so nothing reaches the wire; a `submit` that can stop + * earlier should. Once the transaction is broadcast it may land, so the answer + * still comes. + */ + signal?: AbortSignal; } /** Signs and submits the built sign-up. The host adapter wraps `tx`'s `submitAndWatch`. */ @@ -317,6 +324,11 @@ export interface GameRegistrationReport { /** When the sign-up went on the wire. */ broadcastAt?: number; seat?: { path: SeatPath; ready: boolean; registered: boolean; open: boolean }; + /** + * Why the host's lite key could not be looked up, when it could not: the + * player was seated without the free route. + */ + liteKeyLookupFailed?: string; bind?: | { outcome: BindGameSeatOutcome } | { outcome: "refused"; refusal: GameSeatBindRefusal; code: string; permanent: boolean }; @@ -394,10 +406,12 @@ export class GameSeatBindError extends GameRegistrationError { code: string, /** For `"pinned-elsewhere"`, the account the seat is pinned to. */ readonly account?: string, + options: { landingUnknown?: boolean } = {}, ) { const permanent = bindIsPermanent(refusal, code); super("bind", code, { retryable: !permanent, + landingUnknown: options.landingUnknown, message: refusal === "pinned-elsewhere" ? `lite bind refused: the person's free seat is on ${account ?? "another account"}` @@ -447,17 +461,23 @@ export function registrationRetryDelay(failures: number): number { /** * The name of a chain error, wherever PAPI or `tx` nests it: the innermost - * `type` of a dispatch or validity error, else the error's message. + * `type` of a dispatch or validity error, else the error's message. PAPI's + * `InvalidTxError` keeps the validity error under `error`, and its message is + * that error as JSON, so it is read through. * * Internal: shared with the host adapter. */ export function errorName(error: unknown): string { let node: unknown = error; let name: string | undefined; - while (node !== null && typeof node === "object") { - const { type, value } = node as { type?: unknown; value?: unknown }; + for (let depth = 0; depth < 8 && node !== null && typeof node === "object"; depth += 1) { + const { + type, + value, + error: inner, + } = node as { type?: unknown; value?: unknown; error?: unknown }; if (typeof type === "string") name = type; - node = value; + node = value !== undefined ? value : inner; } if (name) return name; if (error instanceof Error && error.message) return error.message; @@ -540,14 +560,24 @@ function isTrackingStopped(error: unknown): boolean { return error instanceof Error && /tracking stopped/i.test(error.message); } +/** How long the bind waits for a best block to hold it, from submission. */ +const DEFAULT_BIND_TIMEOUT_MS = 120_000; + /** * Drive the raw bind's watch to the best block that holds it. An invalid * transaction arrives on the error channel and settles as a failure, never a * throw; a watch that completes with no block holding it settles as a failure * too. An abort before `broadcasted` rejects with the signal's reason; after it - * the transaction may land, so the abort is ignored. + * the transaction may land, so the abort is ignored. A watch that has not + * settled after `timeoutMs` settles as a `Timeout` whose landing is unknown: a + * transaction the pool dropped would otherwise hold the call until its + * mortality ran out. */ -function settleRaw(watch: TxWatch, signal?: AbortSignal): Promise { +function settleRaw( + watch: TxWatch, + signal?: AbortSignal, + timeoutMs: number = DEFAULT_BIND_TIMEOUT_MS, +): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(signal.reason); @@ -555,11 +585,16 @@ function settleRaw(watch: TxWatch, signal?: AbortSignal): Promise { } let settled = false; let broadcast = false; + const timer = setTimeout( + () => finish(() => resolve({ ok: false, error: "Timeout", landingUnknown: true })), + timeoutMs, + ); // biome-ignore lint/style/useConst: PAPI can emit during `subscribe`, and `finish` reads `sub` then; a `const` would still be in its temporal dead zone. let sub: { unsubscribe(): void } | undefined; const finish = (done: () => void) => { if (settled) return; settled = true; + clearTimeout(timer); signal?.removeEventListener("abort", onAbort); sub?.unsubscribe(); done(); @@ -610,6 +645,12 @@ function settleRaw(watch: TxWatch, signal?: AbortSignal): Promise { export interface BindGameSeatOptions { tld: string; signal?: AbortSignal; + /** + * How long to wait for a best block to hold the bind, from submission. + * Default 120 000 ms; past it the bind fails with `Timeout` and + * `landingUnknown`, and running it again reads the binding first. + */ + timeoutMs?: number; } /** The reads and the submission the bind makes, swappable so its decisions can be tested alone. */ @@ -636,6 +677,7 @@ interface BindSteps { chain: GameSeatBindChain, transaction: Uint8Array, signal?: AbortSignal, + timeoutMs?: number, ): Promise; } @@ -653,8 +695,8 @@ const BIND_STEPS: BindSteps = { at: "best", signal, }), - submitRaw: (chain, transaction, signal) => - settleRaw(chain.raw.individuality.submitAndWatch(transaction), signal), + submitRaw: (chain, transaction, signal, timeoutMs) => + settleRaw(chain.raw.individuality.submitAndWatch(transaction), signal, timeoutMs), }; /** @@ -665,9 +707,11 @@ const BIND_STEPS: BindSteps = { * Reads before it builds, because the binding is the only replay protection and * a retry after a timeout may follow a bind that landed: * - * - **Already bound** in the score context, current or stale, resolves - * `already-bound` with nothing asked for. A stale binding is re-pointed by the - * sign-up's revised leg. + * - **Already bound** in the score context, and current, resolves + * `already-bound` with nothing asked for. A **stale** binding (the lite ring + * has moved past it) is bound again with a fresh proof. {@link registerForGame} + * never asks for that: its seat counts a stale binding as ready, and the + * sign-up's revised leg re-points it in the same transaction. * - **A bind that cannot succeed** (the account is a lite person's own, it is * bound in another context, the seat is pinned elsewhere, or no host can mint * the proof) is refused before any proof is requested. @@ -739,10 +783,12 @@ async function runBind( } } signal?.throwIfAborted(); - const result = await steps.submitRaw(chain, built.transaction, signal); + const result = await steps.submitRaw(chain, built.transaction, signal, options.timeoutMs); if (result.ok) return "bound"; if (result.error === "AliasAccountAlreadySet") return "already-bound"; - throw new GameSeatBindError("rejected", result.error); + throw new GameSeatBindError("rejected", result.error, undefined, { + landingUnknown: result.landingUnknown, + }); } // ── step 3: the draw entries ───────────────────────────────────────────────── @@ -963,6 +1009,34 @@ function sleep(ms: number, signal: AbortSignal | undefined): Promise { }); } +/** + * The signer, made to drop a signature still being made when `signal` aborts, + * and to refuse one asked for after it. The transaction goes on the wire only + * once the signer hands back the signed bytes, so an abort while the host's + * signing prompt is open sends nothing. A build with a mocked signature (a fee + * estimate) is passed through. + * + * Ported from jollity-next `withAbort` (#577). + */ +function abortable(signer: CommonSignerTxCreator, signal: AbortSignal): CommonSignerTxCreator { + const create = (...args: Parameters): Promise => { + const [, , , mockedSignature] = args; + if (mockedSignature) return signer(...args); + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + signer(...args) + .then(resolve, reject) + .finally(() => signal.removeEventListener("abort", onAbort)); + }); + }; + return Object.assign(create, { + publicKey: signer.publicKey, + signBytes: (data: Uint8Array) => signer.signBytes(data), + }) as CommonSignerTxCreator; +} + // ── the flow ───────────────────────────────────────────────────────────────── /** Options for {@link registerForGame}. */ @@ -987,7 +1061,11 @@ export interface RegisterForGameOptions { drawWait?: DrawWaitOptions; /** Passed to {@link readGameSeat}. */ feeHeadroom?: number; - /** Stops the flow. Ignored once the sign-up is broadcast: it may still land. */ + /** + * Stops the flow, up to the broadcast: a signature still being made is + * dropped, so nothing goes out. Ignored once the sign-up is broadcast: it may + * still land. + */ signal?: AbortSignal; onStage?(stage: GameRegistrationStage): void; onSeat?(seat: GameSeat): void; @@ -1148,9 +1226,17 @@ async function runRegistration( if (!seat) { let litePerson = false; if (capabilities.personhood) { - const personhood = await facts(); - litePerson = - personhood.derived && (await capabilities.personhood.holds(personhood.lite)); + try { + const personhood = await facts(); + litePerson = + personhood.derived && + (await capabilities.personhood.holds(personhood.lite)); + } catch (error) { + signal?.throwIfAborted(); + // A host that cannot list the keys (no grant, not connected) + // closes the free route, never the others. + report.liteKeyLookupFailed = errorName(error); + } } seat = await readSeat(litePerson); } @@ -1184,6 +1270,10 @@ async function runRegistration( recordSeat(seat); } end("bind"); + // The bind can take a while (the proof prompt): the game may have closed, + // or another product signed the player up, in the meantime. + if (seat.registered) return { kind: "already-registered", report }; + if (!seat.open) return { kind: "not-open", seat, report }; } if (seat.path === "unfunded") { return { kind: "unfunded", seat, missing: seat.required - seat.funds.free, report }; @@ -1199,18 +1289,39 @@ async function runRegistration( const closingSeconds = wait.closingSeconds ?? DEFAULT_DRAW_CLOSING_S; const startedAt = Date.now(); let retries = 0; - let signer = capabilities.signer; + // The origin the route signs under; a read below may revise it (a stale binding). + let signer = + route === "free" + ? withLiteAlias(capabilities.signer, { tag: "AliasWithAccount" }) + : route === "returning" + ? withScoreParticipant(capabilities.signer) + : capabilities.signer; let registrationEnds: number | null = null; + let scheduled = 0; const decide = async (): Promise => { - const now = await steps.now(chain, signal); - const requirement = await steps.requirement(chain, route, { - account, - tld, - now, - signal, - }); + let requirement: DrawRequirement; + try { + const now = await steps.now(chain, signal); + requirement = await steps.requirement(chain, route, { + account, + tld, + now, + signal, + }); + } catch (error) { + signal?.throwIfAborted(); + // The draws never block the sign-up: wait a block and ask again, + // and if the read does not come back, sign up without tickets. + return { + skipped: `the sign-up requirement could not be read: ${errorName(error)}`, + scheduled, + wait: true, + kind: "read-failed", + }; + } registrationEnds = requirement.registrationEnds; + scheduled = requirement.airdropsScheduled; if (route === "free") { const stale = requirement.blockers.some((blocker) => blocker.tag === "StaleAlias"); if (stale && !capabilities.personhood) { @@ -1273,6 +1384,8 @@ async function runRegistration( retries += 1; draws = await decide(); } + // An abort during the minting comes back as a skip; it is an abort. + signal?.throwIfAborted(); const outcome: DrawOutcome = { entered: draws.entries ? entryCount(draws.entries) : 0, scheduled: draws.scheduled, @@ -1308,14 +1421,18 @@ async function runRegistration( cause: error, }); } - const result = await capabilities.submit(tx, signer, { + const result = await capabilities.submit(tx, signal ? abortable(signer, signal) : signer, { waitFor: options.waitFor ?? "best-block", onBroadcast: () => { report.broadcastAt = Date.now(); options.onStage?.("broadcast"); }, + ...(signal ? { signal } : {}), }); end("sign"); + // A signature dropped by the abort reads as a refusal to the submitter; it is + // an abort, and nothing went out. + if (!result.ok && report.broadcastAt === undefined) signal?.throwIfAborted(); if (!result.ok) { // Another product may have signed the same account up a moment earlier. if (result.code === "AlreadyRegistered") { @@ -1486,6 +1603,25 @@ if (import.meta.vitest) { expect(calls).toEqual([]); }); + test("a host that cannot list the lite keys closes only the free route", async () => { + const { submit, calls } = fakeSubmit(); + const seat = vi.fn(async () => seatOf()); + const personhood = { + holds: async () => { + throw new Error("Rejected"); + }, + prove: vi.fn(), + }; + const outcome = await run(capabilities({ submit, personhood }), { seat }); + expect(seat).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ litePerson: false }), + ); + expect(outcome).toMatchObject({ kind: "registered", route: "deposit" }); + expect(outcome.report.liteKeyLookupFailed).toBe("Rejected"); + expect(calls).toHaveLength(1); + }); + test("a seat read that fails is a retryable failure of the seat step, with the report", async () => { const error = await failure( run(capabilities(), { @@ -1596,6 +1732,24 @@ if (import.meta.vitest) { expect(calls[0]!.call).toBe("sign_up_with_account"); }); + test("after a refused bind, a seat that is now registered or closed signs nothing", async () => { + for (const [after, kind] of [ + [seatOf({ registered: true }), "already-registered"], + [seatOf({ open: false }), "not-open"], + ] as const) { + const { submit, calls } = fakeSubmit(); + const seats = [seatOf({ path: "free", ready: false }), after]; + const outcome = await run(capabilities({ submit, personhood }), { + seat: async () => seats.shift()!, + bind: async () => { + throw new GameSeatBindError("dead-end", "AliasBoundElsewhere"); + }, + }); + expect(outcome.kind).toBe(kind); + expect(calls).toEqual([]); + } + }); + test("a bind that may pass next time fails the bind step, retryable", async () => { const { submit, calls } = fakeSubmit(); const error = await failure( @@ -1646,6 +1800,77 @@ if (import.meta.vitest) { expect(error).toMatchObject({ stage: "sign", retryable: true, landingUnknown: true }); }); + /** A signer that signs only once `release` is called. */ + const slowSigner = () => { + let release!: () => void; + const signed = new Promise((resolve) => { + release = resolve; + }); + const signer = Object.assign( + async () => { + await signed; + return "0x51"; + }, + { publicKey: PUBLIC_KEY, signBytes: async () => new Uint8Array(64) }, + ) as unknown as CommonSignerTxCreator; + return { signer, release }; + }; + /** A `submit` that broadcasts only what its signer hands back, as `tx` does. */ + const signingSubmit = (onSigning: () => void): SubmitSignUp => { + return async (_tx, signer, options) => { + const signing = signer({} as never, {} as never, {} as never, false); + onSigning(); + try { + await signing; + } catch { + return { ok: false, code: "SigningRejected", refused: true }; + } + options.onBroadcast?.(); + return { ok: true }; + }; + }; + + test("an abort while the signature is being made sends nothing, and is an abort", async () => { + const abort = new AbortController(); + const { signer, release } = slowSigner(); + const stages: string[] = []; + const submit = signingSubmit(() => { + abort.abort(); + release(); + }); + const error = await failure( + run( + capabilities({ signer, submit }), + {}, + { + signal: abort.signal, + onStage: (stage) => stages.push(stage), + }, + ), + ); + expect(error).toMatchObject({ stage: "sign", code: "Aborted", retryable: true }); + expect(stages).not.toContain("broadcast"); + expect(error.report?.broadcastAt).toBeUndefined(); + }); + + test("an abort after the broadcast is ignored: the sign-up may land", async () => { + const abort = new AbortController(); + const submit: SubmitSignUp = async (_tx, _signer, options) => { + options.onBroadcast?.(); + abort.abort(); + return { ok: true }; + }; + const outcome = await run(capabilities({ submit }), {}, { signal: abort.signal }); + expect(outcome.kind).toBe("registered"); + }); + + test("hands the submitter the caller's signal", async () => { + const { submit, calls } = fakeSubmit(); + const abort = new AbortController(); + await run(capabilities({ submit }), {}, { signal: abort.signal }); + expect(calls[0]!.options.signal).toBe(abort.signal); + }); + test("passes waitFor through when asked", async () => { const { submit, calls } = fakeSubmit(); await run(capabilities({ submit }), {}, { waitFor: "finalized" }); @@ -1699,6 +1924,76 @@ if (import.meta.vitest) { expect(calls).toHaveLength(1); }); + test("a requirement read that fails waits a block and asks again", async () => { + const { submit } = fakeSubmit(); + const reads = [ + async (): Promise => { + throw new Error("socket closed"); + }, + async () => drawRequirement(), + ]; + const draws = vi.fn( + async (): Promise => ({ entries: [TICKET], scheduled: 1 }), + ); + const outcome = await run( + capabilities({ submit }), + { requirement: () => reads.shift()!(), draws }, + { drawWait: { retryMs: 1 } }, + ); + expect(draws).toHaveBeenCalledOnce(); + expect(outcome).toMatchObject({ + kind: "registered", + draws: { entered: 1, retries: 1 }, + }); + }); + + test("a requirement that cannot be read costs the tickets, never the sign-up", async () => { + const { submit, calls } = fakeSubmit(); + const personhood = { holds: async () => true, prove: vi.fn() }; + const outcome = await run( + capabilities({ submit, personhood }), + { + seat: async () => seatOf({ path: "free" }), + requirement: async () => { + throw new Error("socket closed"); + }, + }, + { drawWait: { maxMs: 0, retryMs: 1 } }, + ); + expect(outcome).toMatchObject({ + kind: "registered", + draws: { entered: 0, kind: "read-failed" }, + }); + // the free route still signs under the lite alias + expect(calls[0]!.call).toBe("sign_up_with_account_lite_invite"); + expect(calls[0]!.signer).not.toBe(baseSigner); + }); + + test("an abort while the tickets are minted fails the draws step, with no draw outcome", async () => { + const abort = new AbortController(); + const onDraws = vi.fn(); + const { submit, calls } = fakeSubmit(); + const error = await failure( + run( + capabilities({ submit }), + { + draws: async () => { + abort.abort(); + return { + skipped: "the tickets were not signed", + scheduled: 1, + kind: "signature", + }; + }, + }, + { signal: abort.signal, onDraws }, + ), + ); + expect(error).toMatchObject({ stage: "draws", code: "Aborted" }); + expect(onDraws).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + }); + test("never waits into the last seconds of registration, by the chain's clock", async () => { const { submit } = fakeSubmit(); const draws = vi.fn( @@ -1892,7 +2187,12 @@ if (import.meta.vitest) { test("sends the bytes raw and resolves once a block holds the bind", async () => { const { steps, submitRaw } = bindSteps(); expect(await bind(prover(), steps)).toBe("bound"); - expect(submitRaw).toHaveBeenCalledWith(expect.anything(), BUILT.transaction, undefined); + expect(submitRaw).toHaveBeenCalledWith( + expect.anything(), + BUILT.transaction, + undefined, + undefined, + ); }); test("resolves at once, asking for no proof, when the account is bound already", async () => { @@ -1972,6 +2272,21 @@ if (import.meta.vitest) { }); }); + test("a bind no block held in time is retryable, and may still land", async () => { + const { steps } = bindSteps({ + submitRaw: vi.fn(async () => ({ + ok: false as const, + error: "Timeout", + landingUnknown: true, + })), + }); + await expect(bind(prover(), steps)).rejects.toMatchObject({ + code: "Timeout", + retryable: true, + landingUnknown: true, + }); + }); + test("without a lite key it fails for good, submitting nothing", async () => { const { steps, submitRaw } = bindSteps(); await expect(bind(undefined, steps)).rejects.toMatchObject({ @@ -2031,6 +2346,29 @@ if (import.meta.vitest) { ); expect(result).toMatchObject({ ok: false, landingUnknown: true }); }); + + test("a watch no block answers gives up at the deadline, and lets go of it", async () => { + vi.useFakeTimers(); + try { + const unsubscribe = vi.fn(); + const silent: TxWatch = { + subscribe(observer) { + observer.next({ type: "broadcasted" }); + return { unsubscribe }; + }, + }; + const settling = settleRaw(silent, undefined, 1_000); + await vi.advanceTimersByTimeAsync(1_000); + expect(await settling).toEqual({ + ok: false, + error: "Timeout", + landingUnknown: true, + }); + expect(unsubscribe).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); }); describe("registrationRetryDelay", () => { @@ -2052,6 +2390,15 @@ if (import.meta.vitest) { ).toBe("NoRegistration"); expect(errorName(new Error("socket closed"))).toBe("socket closed"); }); + + test("reads PAPI's InvalidTxError through its error, not its JSON message", () => { + const validity = { type: "Invalid", value: { type: "BadProof" } }; + const invalid = Object.assign(new Error(JSON.stringify(validity, null, 2)), { + name: "InvalidTxError", + error: validity, + }); + expect(errorName(invalid)).toBe("BadProof"); + }); }); test("registerForGame reports a failed seat read on the err channel, with its step", async () => { diff --git a/product-sdk/pending-changesets/individuality-register-for-game.md b/product-sdk/pending-changesets/individuality-register-for-game.md index b16b1a8d..4cfebfec 100644 --- a/product-sdk/pending-changesets/individuality-register-for-game.md +++ b/product-sdk/pending-changesets/individuality-register-for-game.md @@ -3,4 +3,11 @@ "@parity/product-sdk": minor --- -Add game registration in one call, for every product that signs players up: `registerForGame` reads the seat, binds the lite alias when the free route needs it, decides the prize-draw entries from the sign-up requirement at the best block, then hands the call, signed under the origin its route needs, to a `submit` capability. Registration is judged by the chain's clock, the sign-up resolves at the best block unless `waitFor: "finalized"` is asked for, a refused signature is never asked for again, and every outcome and `GameRegistrationError` carries a report and says which step failed and whether a retry can help (`registrationRetryDelay` gives the backoff). `readGameSeat` and the pure `deriveSeat` give the route (`returning`, `free`, `deposit` or `unfunded`) so every product shows the same answer, and `bindGameSeat` runs the bind leg alone: already-bound and doomed binds ask for no proof, and the `Game.LiteInvites` pin is checked before it goes out. `GAME_PRODUCT_NAME`, `GAME_ACCOUNT_INDEX` and `gameProductId` name the game account in one place. The new `@parity/product-sdk-individuality/host` entry builds the capabilities with `hostGameRegistrationCapabilities`, submitting through `@parity/product-sdk-tx`; it takes `@parity/product-sdk-host` and `@parity/product-sdk-tx` as optional peer dependencies so the main entry stays free of both. +Add game registration in one call, so every product that signs players up for the weekly game (the game's app and chat, the personhood product) runs the same steps and gives the same answers. + +- `registerForGame(chain, capabilities, options)` reads the seat, binds the lite alias when the free route needs it, decides the prize-draw entries at the best block, then hands the call, signed under the origin its route needs, to a `submit` capability. Registration is judged by the chain's clock, and the sign-up resolves at the best block unless `waitFor: "finalized"` is asked for. The draws never block the sign-up: a draw that is not open yet, or a read that fails, costs the tickets, and the outcome says why. An abort before the broadcast sends nothing. +- Every outcome, and every `GameRegistrationError`, carries a report and names the step that failed and whether a retry can help; `registrationRetryDelay` gives the backoff. A declined signature is never asked for again. +- `readGameSeat` and the pure `deriveSeat` give the route (`returning`, `free`, `deposit` or `unfunded`), so every product shows the same answer. +- `bindGameSeat` runs the bind on its own: a current binding and a bind that cannot succeed ask for no proof, the `Game.LiteInvites` pin is checked before it goes out, and a bind no block holds within `timeoutMs` fails with `landingUnknown`. +- `GAME_PRODUCT_NAME`, `GAME_ACCOUNT_INDEX` and `gameProductId` name the game account in one place. +- The new `@parity/product-sdk-individuality/host` entry builds the capabilities from a host with `hostGameRegistrationCapabilities`, submitting through `@parity/product-sdk-tx`. The main entry imports neither. diff --git a/product-sdk/pnpm-lock.yaml b/product-sdk/pnpm-lock.yaml index 65906959..47b14290 100644 --- a/product-sdk/pnpm-lock.yaml +++ b/product-sdk/pnpm-lock.yaml @@ -656,6 +656,12 @@ importers: '@parity/product-sdk-errors': specifier: workspace:* version: link:../errors + '@parity/product-sdk-host': + specifier: workspace:* + version: link:../host + '@parity/product-sdk-tx': + specifier: workspace:* + version: link:../tx '@parity/product-sdk-utils': specifier: workspace:* version: link:../utils @@ -687,12 +693,6 @@ importers: '@parity/product-sdk-descriptors': specifier: workspace:* version: link:../descriptors - '@parity/product-sdk-host': - specifier: workspace:* - version: link:../host - '@parity/product-sdk-tx': - specifier: workspace:* - version: link:../tx tsup: specifier: 'catalog:' version: 8.5.1(postcss@8.5.15)(typescript@5.9.3) diff --git a/product-sdk/skills/product-sdk-individuality/SKILL.md b/product-sdk/skills/product-sdk-individuality/SKILL.md index 3d6d3e5e..090696dd 100644 --- a/product-sdk/skills/product-sdk-individuality/SKILL.md +++ b/product-sdk/skills/product-sdk-individuality/SKILL.md @@ -464,7 +464,7 @@ switch (outcome.value.kind) { | Step | What runs | Notes | |---|---|---| | Seat | `readGameSeat`: `readLiteSignUpRequirement` + `readSignUpFunds` at best, judged by `Timestamp.Now`, folded by `deriveSeat` | Route `returning`, `free`, `deposit` or `unfunded`. Show it with `readGameSeat` alone | -| Bind | `bindGameSeat`: already bound (current or stale) resolves at once; a lite person's own account, a binding in another context, a pinned seat or an underivable context are refused before any proof; otherwise `buildLiteAliasBindTx`, the `Game.LiteInvites` pin check, a raw submit | `AliasAccountAlreadySet` counts as bound. A permanent refusal seats the player on the account route instead | +| Bind | `bindGameSeat`: a current binding resolves at once, and a stale one is bound again with a fresh proof (`registerForGame` never binds a stale one: the sign-up's revised leg re-points it); a lite person's own account, a binding in another context, a pinned seat or an underivable context are refused before any proof; otherwise `buildLiteAliasBindTx`, the `Game.LiteInvites` pin check, a raw submit | `AliasAccountAlreadySet` counts as bound. A bind no block holds after `timeoutMs` (120 s) fails with `Timeout` and `landingUnknown`. A permanent refusal seats the player on the account route instead | | Draws | The requirement's `eventIds` and `airdropsScheduled`, each draw's status decoded with `statusTag`/`airdropPhase` at best; waits a block at a time for a draw not open yet, never into the last 15 s of registration | A refused ticket or proof is not asked for again: the sign-up goes without it, and `DrawOutcome.kind` says why | | Sign | `signUpWithLiteInviteTx` under `withLiteAlias` (`AliasWithAccountRevised` on a stale binding), or `signUpWithAccountTx` under `withScoreParticipant` / plain, handed to `submit` | Resolves at the best block by default, as `tx` does; pass `waitFor: "finalized"` when the answer must not be undone. `AlreadyRegistered` from the chain is success | From 3f4794e6d6e8685a6d25f86cc7b8b9524b7b7af2 Mon Sep 17 00:00:00 2001 From: Nemanya8 Date: Mon, 19 Oct 2026 12:01:48 +0200 Subject: [PATCH 3/7] fix(individuality): play on the default product account, index 0 --- .../packages/individuality/src/game-account.ts | 18 +++++++++++------- product-sdk/packages/individuality/src/host.ts | 2 +- .../skills/product-sdk-individuality/SKILL.md | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/product-sdk/packages/individuality/src/game-account.ts b/product-sdk/packages/individuality/src/game-account.ts index 86cd5aee..ec533f35 100644 --- a/product-sdk/packages/individuality/src/game-account.ts +++ b/product-sdk/packages/individuality/src/game-account.ts @@ -17,14 +17,12 @@ import { ProductIndividualityError } from "./errors.js"; export const GAME_PRODUCT_NAME = "dim2"; /** - * The derivation index of the game account. - * - * 1, not the host default 0, while the native iOS game still plays its own - * game on index 0 (jollity-next#85): a sign-up on index 0 makes the native app - * start its game over this one. Move it to 0 once the native game is retired, - * in one release for every product. + * The derivation index of the game account: 0, the host's default product + * account (RFC-0022 `//product//dim2/0`). Every product must use the same one, + * because a lite person's free seat is pinned to a single account for good + * (`Game.LiteInvites`). */ -export const GAME_ACCOUNT_INDEX = 1; +export const GAME_ACCOUNT_INDEX = 0; /** * The game product's DotNS id on a network, such as `"dim2.paseo"`: the id a @@ -44,6 +42,12 @@ export function gameProductId(tld: string): string { if (import.meta.vitest) { const { describe, expect, test } = import.meta.vitest; + describe("GAME_ACCOUNT_INDEX", () => { + test("is the host's default product account", () => { + expect(GAME_ACCOUNT_INDEX).toBe(0); + }); + }); + describe("gameProductId", () => { test("joins the game product to the TLD", () => { expect(gameProductId("paseo")).toBe("dim2.paseo"); diff --git a/product-sdk/packages/individuality/src/host.ts b/product-sdk/packages/individuality/src/host.ts index 82c3406b..b833a9f7 100644 --- a/product-sdk/packages/individuality/src/host.ts +++ b/product-sdk/packages/individuality/src/host.ts @@ -283,7 +283,7 @@ if (import.meta.vitest) { getProductAccount: () => errAsync(new Error("denied")), } as unknown as AccountsProvider; const result = await hostGameRegistrationCapabilities({ tld: "paseo", accounts }); - expect(unwrapErr(result).message).toContain("dim2.paseo/1"); + expect(unwrapErr(result).message).toContain(`dim2.paseo/${GAME_ACCOUNT_INDEX}`); }); }); diff --git a/product-sdk/skills/product-sdk-individuality/SKILL.md b/product-sdk/skills/product-sdk-individuality/SKILL.md index 090696dd..076efc55 100644 --- a/product-sdk/skills/product-sdk-individuality/SKILL.md +++ b/product-sdk/skills/product-sdk-individuality/SKILL.md @@ -470,7 +470,7 @@ switch (outcome.value.kind) { > **RETRY BY CALLING AGAIN.** Every step reads the chain before it acts, so a second call resumes: a bind that landed is not sent again and a sign-up that landed is `already-registered`. Check `retryable` first (a declined signature, a closed registration or a pinned seat never are), wait `registrationRetryDelay(failures)` (5 s, 15 s, 45 s, 2 min, 5 min), and never pass a cached `seat` on a retry. -> **ONE ACCOUNT FOR EVERY PRODUCT.** The game account is `gameProductId(tld)` at `GAME_ACCOUNT_INDEX` (1 while the native iOS game holds 0). The host adapter asks for exactly that. A product other than the game product signs as it only under the game product's `context` grant, and proves with the personhood keys only under the personhood product's. +> **ONE ACCOUNT FOR EVERY PRODUCT.** The game account is `gameProductId(tld)` at `GAME_ACCOUNT_INDEX` (0, the host's default product account). The host adapter asks for exactly that. A product other than the game product signs as it only under the game product's `context` grant, and proves with the personhood keys only under the personhood product's. > **THE IDENTIFIER IS WHOEVER REGISTERS.** `identifierKey` is rewritten on every sign-up, and no call changes it afterwards. A product that registers for the game must pass a key whose private half the game product can use at game time. From 65274a90f064d00053f95d7897e8941769bc0ce6 Mon Sep 17 00:00:00 2001 From: Nemanya8 Date: Mon, 19 Oct 2026 12:11:41 +0200 Subject: [PATCH 4/7] refactor(individuality): split game registration into modules and trim its docs --- .../individuality/src/bind-game-seat.ts | 536 ++++++ .../individuality/src/draw-entries.ts | 322 ++++ .../packages/individuality/src/host.ts | 14 +- .../packages/individuality/src/index.ts | 17 +- .../individuality/src/register-game-errors.ts | 182 ++ .../src/register-game-harness.ts | 71 + .../individuality/src/register-game-types.ts | 238 +++ .../individuality/src/register-game.ts | 1496 +---------------- .../packages/individuality/src/seat.ts | 34 +- 9 files changed, 1443 insertions(+), 1467 deletions(-) create mode 100644 product-sdk/packages/individuality/src/bind-game-seat.ts create mode 100644 product-sdk/packages/individuality/src/draw-entries.ts create mode 100644 product-sdk/packages/individuality/src/register-game-errors.ts create mode 100644 product-sdk/packages/individuality/src/register-game-harness.ts create mode 100644 product-sdk/packages/individuality/src/register-game-types.ts diff --git a/product-sdk/packages/individuality/src/bind-game-seat.ts b/product-sdk/packages/individuality/src/bind-game-seat.ts new file mode 100644 index 00000000..f5c0934b --- /dev/null +++ b/product-sdk/packages/individuality/src/bind-game-seat.ts @@ -0,0 +1,536 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Step 2 of game registration: bind the person's lite alias to the game + * account, and the personhood facts the bind and the draws read. + */ +import { bytesToHex } from "@parity/product-sdk-utils"; +import { err, normalizeError, ok, type Result } from "@parity/result"; + +import { buildLiteAliasBindTx, type LiteAliasBindChain } from "./bind-lite-alias.js"; +import { ProductIndividualityError } from "./errors.js"; +import type { CreateRingVRFProof, RingVRFProof } from "./origin-extension.js"; +import { asRegistrationError, errorName, GameSeatBindError } from "./register-game-errors.js"; +import type { + BindGameSeatOutcome, + GameRegistrationCapabilities, + GameSeatBindChain, + PersonhoodProver, + PersonhoodRing, + TxWatch, +} from "./register-game-types.js"; +import { litePeopleRing, peopleRing, readScoreContext, type ScoreContextChain } from "./rings.js"; +import { sameAccount } from "./same-account.js"; +import { readLiteSignUpRequirement } from "./signup-lite.js"; +import type { LiteSignUpBlocker } from "./signup-types.js"; + +/** The personhood rings on this chain, and whether a host can prove in them. */ +export interface PersonhoodFacts { + /** `Score.score_context` is the product derivation a host mints in. */ + derived: boolean; + /** `peopl.`. */ + owner: string; + lite: PersonhoodRing; + full: PersonhoodRing; +} + +export async function readPersonhoodFacts( + chain: ScoreContextChain & Pick, + tld: string, + signal: AbortSignal | undefined, +): Promise { + const [score, spec] = await Promise.all([ + readScoreContext(chain, { tld, signal }), + chain.raw.individuality.getChainSpecData(), + ]); + if (!score.ok) throw score.error; + const genesis = spec.genesisHash as `0x${string}`; + const owner = score.value.productId; + return { + derived: score.value.tag === "ProductDerived", + owner, + lite: { key: "lite", owner, ring: litePeopleRing(genesis) }, + full: { key: "full", owner, ring: peopleRing(genesis) }, + }; +} + +/** The lite proof the bind and a stale binding's re-point ride: the lite key, in the score context. */ +export function liteProof(prover: PersonhoodProver, facts: PersonhoodFacts): CreateRingVRFProof { + const context = { productId: facts.owner, suffix: { tag: "Index" as const, value: 0 } }; + return (message) => prover.prove(facts.lite, context, message); +} + +/** + * Whether this person can take the free route: the chain's score context is one + * a host mints in, and the host holds their lite key. + */ +export async function holdsLiteKey( + chain: ScoreContextChain & Pick, + capabilities: Pick, + options: { tld: string; signal?: AbortSignal }, +): Promise> { + try { + const prover = capabilities.personhood; + if (!prover) return ok(false); + const facts = await readPersonhoodFacts(chain, options.tld, options.signal); + return ok(facts.derived && (await prover.holds(facts.lite))); + } catch (cause) { + return err(normalizeError(cause, ProductIndividualityError)); + } +} + +// ── submission of the raw bind ─────────────────────────────────────────────── + +/** Where a raw submission settled. */ +type Settled = { ok: true } | { ok: false; error: string; landingUnknown?: boolean }; + +/** PAPI's words when a watch lost its blocks before the transaction's fate was known. */ +function isTrackingStopped(error: unknown): boolean { + return error instanceof Error && /tracking stopped/i.test(error.message); +} + +/** How long the bind waits for a best block to hold it, from submission. */ +const DEFAULT_BIND_TIMEOUT_MS = 120_000; + +/** + * Drive the bind's watch to the best block that holds it. Every ending settles, + * never throws: a refusal, a watch that ends empty, or `Timeout` (landing + * unknown) after `timeoutMs`. An abort counts only before the broadcast. + */ +function settleRaw( + watch: TxWatch, + signal?: AbortSignal, + timeoutMs: number = DEFAULT_BIND_TIMEOUT_MS, +): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + let settled = false; + let broadcast = false; + const timer = setTimeout( + () => finish(() => resolve({ ok: false, error: "Timeout", landingUnknown: true })), + timeoutMs, + ); + // biome-ignore lint/style/useConst: PAPI can emit during `subscribe`, and `finish` reads `sub` then; a `const` would still be in its temporal dead zone. + let sub: { unsubscribe(): void } | undefined; + const finish = (done: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + sub?.unsubscribe(); + done(); + }; + const onAbort = () => { + if (!broadcast) finish(() => reject(signal?.reason)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + sub = watch.subscribe({ + next: (event) => { + if (event.type === "broadcasted") { + broadcast = true; + return; + } + if (event.type === "inBestBlock" || event.type === "finalized") { + finish(() => + resolve( + event.ok + ? { ok: true } + : { ok: false, error: errorName(event.dispatchError) }, + ), + ); + } + }, + error: (error) => + finish(() => + resolve({ + ok: false, + error: errorName(error), + ...(isTrackingStopped(error) ? { landingUnknown: true } : {}), + }), + ), + complete: () => + finish(() => + resolve({ + ok: false, + error: "the watch ended before any block held the transaction", + }), + ), + }); + if (settled) sub.unsubscribe(); + }); +} + +// ── step 2: bind ───────────────────────────────────────────────────────────── + +/** Options for {@link bindGameSeat}. */ +export interface BindGameSeatOptions { + tld: string; + signal?: AbortSignal; + /** How long to wait for a block to hold the bind. Default 120 000 ms, then `Timeout`. */ + timeoutMs?: number; +} + +/** The reads and the submission the bind makes, swappable so its decisions can be tested alone. */ +interface BindSteps { + requirement( + chain: GameSeatBindChain, + account: string, + tld: string, + signal?: AbortSignal, + ): Promise<{ blockers: readonly LiteSignUpBlocker[] }>; + facts(chain: GameSeatBindChain, tld: string, signal?: AbortSignal): Promise; + build( + chain: GameSeatBindChain, + account: string, + createProof: CreateRingVRFProof, + signal?: AbortSignal, + ): Promise<{ transaction: Uint8Array }>; + pinnedTo( + chain: GameSeatBindChain, + alias: Uint8Array, + signal?: AbortSignal, + ): Promise; + submitRaw( + chain: GameSeatBindChain, + transaction: Uint8Array, + signal?: AbortSignal, + timeoutMs?: number, + ): Promise; +} + +const BIND_STEPS: BindSteps = { + async requirement(chain, account, tld, signal) { + const read = await readLiteSignUpRequirement(chain, { account, tld, at: "best", signal }); + if (!read.ok) throw read.error; + return read.value; + }, + facts: readPersonhoodFacts, + build: (chain, account, createProof, signal) => + buildLiteAliasBindTx(chain, { account, createProof, signal }), + pinnedTo: (chain, alias, signal) => + chain.individuality.query.Game.LiteInvites.getValue(`0x${bytesToHex(alias)}`, { + at: "best", + signal, + }), + submitRaw: (chain, transaction, signal, timeoutMs) => + settleRaw(chain.raw.individuality.submitAndWatch(transaction), signal, timeoutMs), +}; + +/** + * Bind the person's lite alias to the game account: `PeopleLite.set_alias_account`, + * unsigned, carrying a proof from the personhood product's lite key in + * `Score.score_context`. Resolves once a best block holds it. + * + * Reads before it builds, so a retry never binds twice: + * + * - A current binding resolves `already-bound`, asking for nothing. A stale one + * is bound again ({@link registerForGame} leaves that to the sign-up). + * - A bind that cannot succeed is refused before any proof is asked for. + * - Otherwise it proves, checks the `Game.LiteInvites` pin on the alias, and + * submits. + * + * A chain answer of `AliasAccountAlreadySet` (another bind landed first) is + * `already-bound`. Fails with {@link GameSeatBindError}; check `permanent`. + */ +export async function bindGameSeat( + chain: GameSeatBindChain, + capabilities: Pick, + options: BindGameSeatOptions, +): Promise> { + try { + return ok(await runBind(chain, capabilities, options)); + } catch (cause) { + return err(asRegistrationError("bind", cause)); + } +} + +export async function runBind( + chain: GameSeatBindChain, + capabilities: Pick, + options: BindGameSeatOptions, + known?: PersonhoodFacts, + steps: BindSteps = BIND_STEPS, +): Promise { + const { signal, tld } = options; + const account = capabilities.account.address; + const { blockers } = await steps.requirement(chain, account, tld, signal); + const has = (tag: LiteSignUpBlocker["tag"]) => blockers.some((blocker) => blocker.tag === tag); + + for (const tag of ["AccountIsALitePerson", "AliasBoundElsewhere"] as const) { + if (has(tag)) throw new GameSeatBindError("dead-end", tag); + } + const invited = blockers.find((blocker) => blocker.tag === "AnotherAccountInvited"); + if (invited && "invited" in invited) { + throw new GameSeatBindError("pinned-elsewhere", "AnotherAccountInvited", invited.invited); + } + if (has("ContextNotProductDerived")) { + throw new GameSeatBindError("no-proof-context", "ContextNotProductDerived"); + } + if (!has("AliasNotBound") && !has("StaleAlias")) return "already-bound"; + + const facts = known ?? (await steps.facts(chain, tld, signal)); + if (!facts.derived) throw new GameSeatBindError("no-proof-context", "ContextNotProductDerived"); + const prover = capabilities.personhood; + if (!prover) throw new GameSeatBindError("no-lite-key", "NoPersonhoodProver"); + + let alias: Uint8Array | undefined; + const prove = liteProof(prover, facts); + const built = await steps.build( + chain, + account, + async (message) => { + const proof = await prove(message); + alias = proof.contextualAlias.alias; + return proof; + }, + signal, + ); + if (alias) { + const pinned = await steps.pinnedTo(chain, alias, signal); + if (pinned !== undefined && !sameAccount(pinned, account)) { + throw new GameSeatBindError("pinned-elsewhere", "AnotherAccountInvited", pinned); + } + } + signal?.throwIfAborted(); + const result = await steps.submitRaw(chain, built.transaction, signal, options.timeoutMs); + if (result.ok) return "bound"; + if (result.error === "AliasAccountAlreadySet") return "already-bound"; + throw new GameSeatBindError("rejected", result.error, undefined, { + landingUnknown: result.landingUnknown, + }); +} + +if (import.meta.vitest) { + const { describe, expect, test, vi } = import.meta.vitest; + const { unwrapErr } = await import("@parity/result"); + const { AccountId } = await import("polkadot-api"); + const { ADDRESS, FACTS, OTHER, PUBLIC_KEY, capabilities, tagged } = await import( + "./register-game-harness.js" + ); + + describe("bind", () => { + const BUILT = { transaction: new Uint8Array([1, 2, 3]) }; + const ALIAS = new Uint8Array(32).fill(9); + const proofOf = async () => + ({ + proof: new Uint8Array(1), + contextualAlias: { context: new Uint8Array(32), alias: ALIAS }, + ringIndex: 0, + ringRevision: 0, + }) as RingVRFProof; + + const bindSteps = (overrides: Partial = {}) => { + const submitRaw = vi.fn(async () => ({ ok: true })); + const steps: BindSteps = { + requirement: async () => ({ blockers: tagged("AliasNotBound") }), + facts: async () => FACTS, + build: async (_chain, _account, createProof) => { + await createProof(new Uint8Array(32)); + return BUILT; + }, + pinnedTo: async () => undefined, + submitRaw, + ...overrides, + }; + return { steps, submitRaw }; + }; + const prover = () => ({ holds: async () => true, prove: vi.fn(proofOf) }); + const bind = (personhood: PersonhoodProver | undefined, steps: BindSteps) => + runBind( + {} as GameSeatBindChain, + { account: { address: ADDRESS, publicKey: PUBLIC_KEY }, personhood }, + { tld: "paseo" }, + undefined, + steps, + ); + + test("sends the bytes raw and resolves once a block holds the bind", async () => { + const { steps, submitRaw } = bindSteps(); + expect(await bind(prover(), steps)).toBe("bound"); + expect(submitRaw).toHaveBeenCalledWith( + expect.anything(), + BUILT.transaction, + undefined, + undefined, + ); + }); + + test("resolves at once, asking for no proof, when the account is bound already", async () => { + const personhood = prover(); + const { steps, submitRaw } = bindSteps({ requirement: async () => ({ blockers: [] }) }); + expect(await bind(personhood, steps)).toBe("already-bound"); + expect(personhood.prove).not.toHaveBeenCalled(); + expect(submitRaw).not.toHaveBeenCalled(); + }); + + test("re-points a binding the ring moved past", async () => { + const { steps, submitRaw } = bindSteps({ + requirement: async () => ({ blockers: tagged("StaleAlias") }), + }); + expect(await bind(prover(), steps)).toBe("bound"); + expect(submitRaw).toHaveBeenCalledOnce(); + }); + + test("refuses a bind that cannot succeed before asking for any proof", async () => { + for (const tag of ["AccountIsALitePerson", "AliasBoundElsewhere"]) { + const personhood = prover(); + const { steps, submitRaw } = bindSteps({ + requirement: async () => ({ blockers: tagged(tag) }), + }); + await expect(bind(personhood, steps)).rejects.toMatchObject({ + refusal: "dead-end", + code: tag, + permanent: true, + }); + expect(personhood.prove).not.toHaveBeenCalled(); + expect(submitRaw).not.toHaveBeenCalled(); + } + }); + + test("refuses before submitting when the person's free seat is pinned to another account", async () => { + const { steps, submitRaw } = bindSteps({ pinnedTo: async () => OTHER }); + await expect(bind(prover(), steps)).rejects.toMatchObject({ + refusal: "pinned-elsewhere", + account: OTHER, + permanent: true, + }); + expect(submitRaw).not.toHaveBeenCalled(); + }); + + test("submits when the pin names this account, whatever prefix the chain spells it in", async () => { + const polkadot = AccountId(0).dec(PUBLIC_KEY); + const { steps, submitRaw } = bindSteps({ pinnedTo: async () => polkadot }); + expect(await bind(prover(), steps)).toBe("bound"); + expect(submitRaw).toHaveBeenCalledOnce(); + }); + + test("a bind that lost the race to another one counts as bound", async () => { + const { steps } = bindSteps({ + submitRaw: vi.fn(async () => ({ + ok: false as const, + error: "AliasAccountAlreadySet", + })), + }); + expect(await bind(prover(), steps)).toBe("already-bound"); + }); + + test("rejects with the chain's code, permanent only for the lasting refusals", async () => { + const lasting = bindSteps({ + submitRaw: vi.fn(async () => ({ ok: false as const, error: "AccountInUse" })), + }); + await expect(bind(prover(), lasting.steps)).rejects.toMatchObject({ + code: "AccountInUse", + permanent: true, + }); + const passing = bindSteps({ + submitRaw: vi.fn(async () => ({ ok: false as const, error: "BadProof" })), + }); + await expect(bind(prover(), passing.steps)).rejects.toMatchObject({ + code: "BadProof", + permanent: false, + retryable: true, + }); + }); + + test("a bind no block held in time is retryable, and may still land", async () => { + const { steps } = bindSteps({ + submitRaw: vi.fn(async () => ({ + ok: false as const, + error: "Timeout", + landingUnknown: true, + })), + }); + await expect(bind(prover(), steps)).rejects.toMatchObject({ + code: "Timeout", + retryable: true, + landingUnknown: true, + }); + }); + + test("without a lite key it fails for good, submitting nothing", async () => { + const { steps, submitRaw } = bindSteps(); + await expect(bind(undefined, steps)).rejects.toMatchObject({ + refusal: "no-lite-key", + permanent: true, + }); + expect(submitRaw).not.toHaveBeenCalled(); + }); + }); + + describe("bindGameSeat", () => { + test("reports a failed read on the err channel as a bind failure, and submits nothing", async () => { + const submitAndWatch = vi.fn(); + const chain = { + raw: { individuality: { submitAndWatch } }, + } as unknown as GameSeatBindChain; + const result = await bindGameSeat(chain, capabilities(), { tld: "paseo" }); + expect(unwrapErr(result)).toMatchObject({ + name: "GameRegistrationError", + stage: "bind", + }); + expect(submitAndWatch).not.toHaveBeenCalled(); + }); + }); + + describe("settleRaw", () => { + const watch = (emit: (observer: Parameters[0]) => void): TxWatch => ({ + subscribe(observer) { + emit(observer); + return { unsubscribe() {} }; + }, + }); + + test("resolves at the best block that holds the bind", async () => { + const result = await settleRaw( + watch((observer) => { + observer.next({ type: "broadcasted" }); + observer.next({ type: "inBestBlock", ok: true }); + }), + ); + expect(result).toEqual({ ok: true }); + }); + + test("a watch that ends with no block is a failure, not a hang", async () => { + const result = await settleRaw( + watch((observer) => { + observer.next({ type: "broadcasted" }); + observer.complete?.(); + }), + ); + expect(result.ok).toBe(false); + }); + + test("a lost watch says the landing is unknown", async () => { + const result = await settleRaw( + watch((observer) => observer.error(new Error("tracking stopped"))), + ); + expect(result).toMatchObject({ ok: false, landingUnknown: true }); + }); + + test("a watch no block answers gives up at the deadline, and lets go of it", async () => { + vi.useFakeTimers(); + try { + const unsubscribe = vi.fn(); + const silent: TxWatch = { + subscribe(observer) { + observer.next({ type: "broadcasted" }); + return { unsubscribe }; + }, + }; + const settling = settleRaw(silent, undefined, 1_000); + await vi.advanceTimersByTimeAsync(1_000); + expect(await settling).toEqual({ + ok: false, + error: "Timeout", + landingUnknown: true, + }); + expect(unsubscribe).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + }); +} diff --git a/product-sdk/packages/individuality/src/draw-entries.ts b/product-sdk/packages/individuality/src/draw-entries.ts new file mode 100644 index 00000000..cf0864f2 --- /dev/null +++ b/product-sdk/packages/individuality/src/draw-entries.ts @@ -0,0 +1,322 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Step 3 of game registration: the prize-draw entries a sign-up carries. + */ +import { airdropPhase, statusTag } from "./airdrop-decode.js"; +import type { AirdropChain } from "./airdrop-read.js"; +import type { PersonhoodFacts } from "./bind-game-seat.js"; +import { gameProductId } from "./game-account.js"; +import { errorName } from "./register-game-errors.js"; +import type { DrawSkipKind, GameRegistrationCapabilities } from "./register-game-types.js"; +import { mintAccountAirdropVrfs, mintAliasAirdropVrfs, type AliasAirdropVrfs } from "./signup.js"; +import type { AccountVrfSignature, LiteSignUpRequirement } from "./signup-types.js"; +import { airdropVrfTranscript } from "./signup-vrf.js"; + +export type DrawEntries = + | { entries: AccountVrfSignature[] | AliasAirdropVrfs; scheduled: number } + | { + entries?: undefined; + skipped: string; + scheduled: number; + /** The reason is of the moment: wait a block and ask again. */ + wait?: boolean; + kind?: DrawSkipKind; + }; + +/** What the draw decision reads from a sign-up requirement, of either route. */ +export type DrawRequirement = Pick< + LiteSignUpRequirement, + "variant" | "phase" | "registrationEnds" | "airdropsScheduled" | "eventIds" +> & { + blockers: readonly { tag: string }[]; +}; + +/** Blockers about the game's moment, not the player: the decision reads those itself. */ +const MOMENT_BLOCKERS: ReadonlySet = new Set([ + "NoGameRunning", + "NotInRegistration", + "RegistrationEnded", + "NoDrawsScheduled", +]); + +/** A stale binding is re-pointed inside the free sign-up, so it does not cost the draws. */ +export const FIXED_BY_THE_SIGN_UP: ReadonlySet = new Set(["StaleAlias"]); + +export const entryCount = (entries: AccountVrfSignature[] | AliasAirdropVrfs) => + Array.isArray(entries) ? entries.length : entries.proofs.length; + +/** + * The draw entries a sign-up carries, from the requirement at the best block. + * Entries ride only the sign-up, and a wrong count fails it, so every gate is + * checked here. A draw not open yet says `wait`; a refused signature does not, + * so a player who declined is not asked again. + */ +export async function decideDraws( + chain: AirdropChain, + capabilities: GameRegistrationCapabilities, + requirement: DrawRequirement, + tld: string, + facts: () => Promise, + signal: AbortSignal | undefined, +): Promise { + const scheduled = requirement.airdropsScheduled; + if (requirement.phase === null) return { skipped: "no game at the best block", scheduled: 0 }; + if (scheduled === 0) return { skipped: "the game has no prize draw", scheduled }; + if (requirement.phase !== "Registration") { + return { skipped: `the game is in ${requirement.phase}`, scheduled, kind: "closed" }; + } + if (requirement.variant === null || requirement.eventIds.length !== scheduled) { + return { + skipped: "the requirement does not describe the game's draws", + scheduled, + wait: true, + kind: "read-failed", + }; + } + const recognized = requirement.variant === "Alias"; + if (recognized && !capabilities.personhood) { + return { + skipped: "a recognized person, and nothing here can prove the draw", + scheduled, + kind: "verified-person", + }; + } + if (!recognized && !capabilities.signVrf) { + return { skipped: "no VRF signer", scheduled, kind: "signature" }; + } + const blockers = requirement.blockers.filter((blocker) => !MOMENT_BLOCKERS.has(blocker.tag)); + if (blockers.length > 0) { + return { + skipped: `the chain would refuse the entry: ${blockers.map((b) => b.tag).join(", ")}`, + scheduled, + kind: "refused", + }; + } + + const { eventIds } = requirement; + try { + const events = await Promise.all( + eventIds.map((id) => + chain.individuality.query.Airdrop.Events.getValue(id, { at: "best", signal }), + ), + ); + for (const [index, event] of events.entries()) { + // The offchain worker opens each draw a block after the game appears. + const phase = + event === undefined ? "Upcoming" : airdropPhase(statusTag(event.status.type)); + if (phase === "Upcoming") { + return { + skipped: `draw #${index} is not taking entries yet`, + scheduled, + wait: true, + kind: "not-open", + }; + } + if (phase !== "Registering") { + return { + skipped: `draw #${index} is ${phase}, not Registering`, + scheduled, + kind: "closed", + }; + } + } + } catch (error) { + return { + skipped: `the draws could not be read: ${errorName(error)}`, + scheduled, + wait: true, + kind: "read-failed", + }; + } + + const registrant = { tag: "Account" as const, accountAddress: capabilities.account.address }; + if (recognized) { + let personhood: PersonhoodFacts; + try { + personhood = await facts(); + } catch (error) { + return { + skipped: `no People key to prove the draws with: ${errorName(error)}`, + scheduled, + kind: "verified-person", + }; + } + const prover = capabilities.personhood!; + const proved = await mintAliasAirdropVrfs( + (context, message) => prover.prove(personhood.full, context, message), + { eventIds, registrant, productId: gameProductId(tld), signal }, + ); + if (!proved.ok) { + return { + skipped: `the draws were not proved: ${errorName(proved.error)}`, + scheduled, + kind: "verified-person", + }; + } + return { entries: proved.value, scheduled }; + } + + const { publicKey } = capabilities.account; + const minted = await mintAccountAirdropVrfs( + { signVrf: capabilities.signVrf! }, + { eventIds, publicKey, signal }, + ); + if (!minted.ok) { + return { + skipped: `the tickets were not signed: ${errorName(minted.error)}`, + scheduled, + kind: "signature", + }; + } + if (capabilities.verifyVrf) { + for (const [index, entry] of minted.value.entries()) { + const transcript = airdropVrfTranscript({ eventId: eventIds[index]!, publicKey }); + if (!capabilities.verifyVrf(publicKey, transcript, entry)) { + return { + skipped: `the ticket for draw #${index} does not verify against the game account`, + scheduled, + kind: "signature", + }; + } + } + } + return { entries: minted.value, scheduled }; +} + +if (import.meta.vitest) { + const { describe, expect, test, vi } = import.meta.vitest; + const { EVENT_IDS, FACTS, TICKET, capabilities, drawRequirement, tagged } = await import( + "./register-game-harness.js" + ); + + describe("decideDraws", () => { + const eventsChain = (statuses: (string | undefined)[]) => + ({ + individuality: { + query: { + Airdrop: { + Events: { + getValue: async (id: string) => { + const status = statuses[EVENT_IDS.indexOf(id)]; + return status === undefined + ? undefined + : { status: { type: status } }; + }, + }, + }, + }, + }, + }) as unknown as AirdropChain; + const decide = ( + chain: AirdropChain, + caps: GameRegistrationCapabilities, + requirement: DrawRequirement = drawRequirement(), + ) => decideDraws(chain, caps, requirement, "paseo", async () => FACTS, undefined); + const signVrf = () => vi.fn(async () => TICKET); + const open = () => eventsChain(["Registering", "Registering"]); + + test("enters every scheduled draw, one ticket per draw, in airdrop-index order", async () => { + const sign = signVrf(); + const draws = await decide(open(), capabilities({ signVrf: sign })); + expect(draws.entries).toHaveLength(2); + expect(sign).toHaveBeenCalledTimes(2); + }); + + test("skips without a VRF signer, signing nothing", async () => { + const draws = await decide(open(), capabilities()); + expect(draws).toMatchObject({ kind: "signature" }); + expect(draws.entries).toBeUndefined(); + }); + + test("skips a game with no draw scheduled, signing nothing", async () => { + const sign = signVrf(); + const draws = await decide( + eventsChain([]), + capabilities({ signVrf: sign }), + drawRequirement({ airdropsScheduled: 0, eventIds: [] }), + ); + expect(draws).toMatchObject({ skipped: "the game has no prize draw" }); + expect(sign).not.toHaveBeenCalled(); + }); + + test("waits for a draw that is not on chain yet, or still scheduled", async () => { + const caps = capabilities({ signVrf: signVrf() }); + expect(await decide(eventsChain([undefined, "Registering"]), caps)).toMatchObject({ + wait: true, + kind: "not-open", + }); + expect(await decide(eventsChain(["Scheduled", "Registering"]), caps)).toMatchObject({ + wait: true, + kind: "not-open", + }); + }); + + test("skips a draw that has closed, without waiting", async () => { + const draws = await decide( + eventsChain(["Registering", "Claiming"]), + capabilities({ signVrf: signVrf() }), + ); + expect(draws).toMatchObject({ kind: "closed" }); + expect("wait" in draws && draws.wait).toBeFalsy(); + }); + + test("enters even when the requirement says registration ended: the phase and the draws decide", async () => { + const draws = await decide( + open(), + capabilities({ signVrf: signVrf() }), + drawRequirement({ blockers: tagged("RegistrationEnded") }), + ); + expect(draws.entries).toHaveLength(2); + }); + + test("skips on a blocker about the player", async () => { + const draws = await decide( + open(), + capabilities({ signVrf: signVrf() }), + drawRequirement({ blockers: tagged("NotSr25519") }), + ); + expect(draws).toMatchObject({ kind: "refused" }); + }); + + test("a refused ticket skips the draws without waiting, so nobody is asked again", async () => { + const sign = vi.fn(async () => { + throw new Error("user refused"); + }); + const draws = await decide(open(), capabilities({ signVrf: sign })); + expect(draws).toMatchObject({ kind: "signature" }); + expect("wait" in draws && draws.wait).toBeFalsy(); + expect(sign).toHaveBeenCalledOnce(); + }); + + test("a ticket that does not verify enters nothing", async () => { + const draws = await decide( + open(), + capabilities({ signVrf: signVrf(), verifyVrf: () => false }), + ); + expect(draws).toMatchObject({ kind: "signature" }); + }); + + test("a recognized person with nothing to prove the draws skips them", async () => { + const draws = await decide( + open(), + capabilities({ signVrf: signVrf() }), + drawRequirement({ variant: "Alias" }), + ); + expect(draws).toMatchObject({ kind: "verified-person" }); + }); + + test("a refused member proof skips the draws without waiting", async () => { + const prove = vi.fn(async () => { + throw new Error("no grant for the People key"); + }); + const draws = await decide( + open(), + capabilities({ personhood: { holds: async () => true, prove } }), + drawRequirement({ variant: "Alias" }), + ); + expect(draws).toMatchObject({ kind: "verified-person" }); + expect("wait" in draws && draws.wait).toBeFalsy(); + }); + }); +} diff --git a/product-sdk/packages/individuality/src/host.ts b/product-sdk/packages/individuality/src/host.ts index b833a9f7..c35ee327 100644 --- a/product-sdk/packages/individuality/src/host.ts +++ b/product-sdk/packages/individuality/src/host.ts @@ -49,13 +49,13 @@ import { AccountId } from "polkadot-api"; import { ProductIndividualityError } from "./errors.js"; import { GAME_ACCOUNT_INDEX, gameProductId } from "./game-account.js"; -import { - errorName, - type GameRegistrationCapabilities, - type PersonhoodProver, - type PersonhoodRing, - type SubmitSignUp, -} from "./register-game.js"; +import { errorName } from "./register-game-errors.js"; +import type { + GameRegistrationCapabilities, + PersonhoodProver, + PersonhoodRing, + SubmitSignUp, +} from "./register-game-types.js"; import type { RingLocation } from "./rings.js"; /** diff --git a/product-sdk/packages/individuality/src/index.ts b/product-sdk/packages/individuality/src/index.ts index c7c60a26..5dd11dc3 100644 --- a/product-sdk/packages/individuality/src/index.ts +++ b/product-sdk/packages/individuality/src/index.ts @@ -490,21 +490,24 @@ export type { SeatRoute, TimestampChain, } from "./seat.js"; +export { bindGameSeat, holdsLiteKey } from "./bind-game-seat.js"; +export type { BindGameSeatOptions } from "./bind-game-seat.js"; export { - bindGameSeat, GAME_REGISTRATION_RETRY_MS, GameRegistrationError, GameSeatBindError, - holdsLiteKey, - registerForGame, registrationRetryDelay, +} from "./register-game-errors.js"; +export { registerForGame } from "./register-game.js"; +export type { + DrawWaitOptions, + RegisterForGameOptions, + RegisterForGameOutcome, } from "./register-game.js"; export type { - BindGameSeatOptions, BindGameSeatOutcome, DrawOutcome, DrawSkipKind, - DrawWaitOptions, GameAccount, GameRegistrationCapabilities, GameRegistrationChain, @@ -518,15 +521,13 @@ export type { PersonhoodProver, PersonhoodRing, RawSubmitChain, - RegisterForGameOptions, - RegisterForGameOutcome, SignUpSubmitOptions, SignUpSubmitResult, SignUpTransaction, SubmitSignUp, TxWatch, TxWatchEvent, -} from "./register-game.js"; +} from "./register-game-types.js"; // The metadata-driven pieces underneath stay internal on purpose, even though // `withLiteAlias` proves they generalise: widening a surface later never breaks diff --git a/product-sdk/packages/individuality/src/register-game-errors.ts b/product-sdk/packages/individuality/src/register-game-errors.ts new file mode 100644 index 00000000..6a4f18bc --- /dev/null +++ b/product-sdk/packages/individuality/src/register-game-errors.ts @@ -0,0 +1,182 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * How game registration fails: the error classes, which chain answers a retry + * can change, the retry ladder, and the name of a chain error. + */ +import { ProductIndividualityError } from "./errors.js"; +import type { + GameRegistrationReport, + GameRegistrationStep, + GameSeatBindRefusal, +} from "./register-game-types.js"; + +/** + * A step of the registration that failed: `stage` says which, `code` what the + * chain or host answered, `retryable` whether running it again can help. + */ +export class GameRegistrationError extends ProductIndividualityError { + readonly stage: GameRegistrationStep; + readonly code: string; + /** False when trying again cannot help: a refusal, a closed registration, a bad call. */ + readonly retryable: boolean; + /** The sign-up's watch lost track: it may still land. */ + readonly landingUnknown: boolean; + /** What the call did up to the failure. Set by {@link registerForGame}. */ + report?: GameRegistrationReport; + + constructor( + stage: GameRegistrationStep, + code: string, + options: { + retryable: boolean; + landingUnknown?: boolean; + message?: string; + cause?: unknown; + }, + ) { + super( + options.message ?? `game registration failed at ${stage}: ${code}`, + options.cause === undefined ? undefined : { cause: options.cause }, + ); + this.name = "GameRegistrationError"; + this.stage = stage; + this.code = code; + this.retryable = options.retryable; + this.landingUnknown = options.landingUnknown === true; + } +} + +/** The chain's refusals of `PeopleLite.set_alias_account` that a retry will not change. */ +const PERMANENT_BIND_CODES: ReadonlySet = new Set([ + "AccountInUse", + "InvalidAliasContext", + "AlreadyRegistered", +]); + +function bindIsPermanent(refusal: GameSeatBindRefusal, code: string): boolean { + return refusal !== "rejected" || PERMANENT_BIND_CODES.has(code); +} + +/** A bind that did not land. `permanent` closes the lite route for this account. */ +export class GameSeatBindError extends GameRegistrationError { + /** True when trying again cannot change the answer. The opposite of `retryable`. */ + readonly permanent: boolean; + + constructor( + readonly refusal: GameSeatBindRefusal, + code: string, + /** For `"pinned-elsewhere"`, the account the seat is pinned to. */ + readonly account?: string, + options: { landingUnknown?: boolean } = {}, + ) { + const permanent = bindIsPermanent(refusal, code); + super("bind", code, { + retryable: !permanent, + landingUnknown: options.landingUnknown, + message: + refusal === "pinned-elsewhere" + ? `lite bind refused: the person's free seat is on ${account ?? "another account"}` + : `lite bind failed: ${code}`, + }); + this.name = "GameSeatBindError"; + this.permanent = permanent; + } +} + +/** The sign-up refusals that a retry will not change. */ +export const NOT_RETRYABLE_SIGN_UP: ReadonlySet = new Set([ + "NoGame", + "NoRegistration", + "InvalidAirdropVrfCount", + "InvalidAirdropVrfVariant", + "InvalidAirdropVrfVariantForAccount", + "InvalidAirdropVrfVariantForRecognition", + "AnotherAccountInvited", + "StatementAccountAlreadyInUse", + "Payment", + "InsufficientBalance", + "SigningRejected", + "InvalidSignUp", +]); + +/** + * How long to wait before the next try, after `failures` failures in a row: + * 5 s, 15 s, 45 s, 2 min, then 5 min. Check `retryable` first. + */ +export const GAME_REGISTRATION_RETRY_MS: readonly number[] = [ + 5_000, 15_000, 45_000, 120_000, 300_000, +]; + +export function registrationRetryDelay(failures: number): number { + const index = + Math.min(Math.max(Math.floor(failures), 1), GAME_REGISTRATION_RETRY_MS.length) - 1; + return GAME_REGISTRATION_RETRY_MS[index]!; +} + +/** + * The name of a chain error: the innermost `type` of a dispatch or validity + * error (read through PAPI's `InvalidTxError.error`), else the message. + */ +export function errorName(error: unknown): string { + let node: unknown = error; + let name: string | undefined; + for (let depth = 0; depth < 8 && node !== null && typeof node === "object"; depth += 1) { + const { + type, + value, + error: inner, + } = node as { type?: unknown; value?: unknown; error?: unknown }; + if (typeof type === "string") name = type; + node = value !== undefined ? value : inner; + } + if (name) return name; + if (error instanceof Error && error.message) return error.message; + return typeof error === "string" && error ? error : "DispatchError"; +} + +export function asRegistrationError( + step: GameRegistrationStep, + error: unknown, +): GameRegistrationError { + if (error instanceof GameRegistrationError) return error; + const aborted = error instanceof Error && error.name === "AbortError"; + return new GameRegistrationError(step, aborted ? "Aborted" : errorName(error), { + retryable: true, + cause: error, + }); +} + +if (import.meta.vitest) { + const { describe, expect, test } = import.meta.vitest; + + describe("registrationRetryDelay", () => { + test("climbs the ladder and stays on its last rung", () => { + expect([1, 2, 3, 4, 5, 9].map(registrationRetryDelay)).toEqual([ + 5_000, 15_000, 45_000, 120_000, 300_000, 300_000, + ]); + expect(registrationRetryDelay(0)).toBe(5_000); + }); + }); + + describe("errorName", () => { + test("finds the innermost name of a nested dispatch error", () => { + expect( + errorName({ + type: "Module", + value: { type: "Game", value: { type: "NoRegistration" } }, + }), + ).toBe("NoRegistration"); + expect(errorName(new Error("socket closed"))).toBe("socket closed"); + }); + + test("reads PAPI's InvalidTxError through its error, not its JSON message", () => { + const validity = { type: "Invalid", value: { type: "BadProof" } }; + const invalid = Object.assign(new Error(JSON.stringify(validity, null, 2)), { + name: "InvalidTxError", + error: validity, + }); + expect(errorName(invalid)).toBe("BadProof"); + }); + }); +} diff --git a/product-sdk/packages/individuality/src/register-game-harness.ts b/product-sdk/packages/individuality/src/register-game-harness.ts new file mode 100644 index 00000000..79007e6b --- /dev/null +++ b/product-sdk/packages/individuality/src/register-game-harness.ts @@ -0,0 +1,71 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Fixtures the game-registration tests share: an account, the personhood facts, + * draw requirements, and capabilities with a recording `submit`. + * + * Imported only from in-source test blocks, so no published entry contains it. + */ +import type { CommonSignerTxCreator } from "@polkadot-api/signers-common"; +import { AccountId } from "polkadot-api"; + +import type { PersonhoodFacts } from "./bind-game-seat.js"; +import type { DrawRequirement } from "./draw-entries.js"; +import type { + GameRegistrationCapabilities, + SignUpSubmitOptions, + SignUpSubmitResult, + SubmitSignUp, +} from "./register-game-types.js"; +import type { LiteSignUpBlocker } from "./signup-types.js"; + +export const ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; +export const OTHER = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"; +export const IDENTIFIER = new Uint8Array(65).fill(0x22); +export const PUBLIC_KEY = AccountId().enc(ADDRESS); +const RING = { chainId: "0x01" as const, junctions: [] }; +export const FACTS: PersonhoodFacts = { + derived: true, + owner: "peopl.paseo", + lite: { key: "lite", owner: "peopl.paseo", ring: RING }, + full: { key: "full", owner: "peopl.paseo", ring: RING }, +}; +export const EVENT_IDS = [`0x${"11".repeat(32)}`, `0x${"12".repeat(32)}`]; +export const TICKET = { preOutput: new Uint8Array(32).fill(3), proof: new Uint8Array(64).fill(4) }; + +export const baseSigner = { publicKey: PUBLIC_KEY } as unknown as CommonSignerTxCreator; +export const tagged = (...tags: string[]) => tags.map((tag) => ({ tag }) as LiteSignUpBlocker); + +export const drawRequirement = (overrides: Partial = {}): DrawRequirement => ({ + variant: "Account", + phase: "Registration", + registrationEnds: 2_000, + airdropsScheduled: 2, + eventIds: EVENT_IDS, + blockers: [], + ...overrides, +}); + +/** A `submit` capability that records what it was handed and answers `result`. */ +export const fakeSubmit = (result: SignUpSubmitResult = { ok: true }) => { + const calls: { + call: string; + signer: CommonSignerTxCreator; + options: SignUpSubmitOptions; + }[] = []; + const submit: SubmitSignUp = async (tx, signer, options) => { + calls.push({ call: (tx as unknown as { call: string }).call, signer, options }); + options.onBroadcast?.(); + return result; + }; + return { submit, calls }; +}; + +export const capabilities = ( + extra: Partial = {}, +): GameRegistrationCapabilities => ({ + account: { address: ADDRESS, publicKey: PUBLIC_KEY }, + signer: baseSigner, + submit: fakeSubmit().submit, + ...extra, +}); diff --git a/product-sdk/packages/individuality/src/register-game-types.ts b/product-sdk/packages/individuality/src/register-game-types.ts new file mode 100644 index 00000000..baa0ccc7 --- /dev/null +++ b/product-sdk/packages/individuality/src/register-game-types.ts @@ -0,0 +1,238 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * The types game registration shares across its modules: what it needs from + * the chain and from a host, and what it reports. + */ +import type { CommonSignerTxCreator } from "@polkadot-api/signers-common"; + +import type { AirdropChain } from "./airdrop-read.js"; +import type { LiteAliasBindChain } from "./bind-lite-alias.js"; +import type { GameChain } from "./game-read.js"; +import type { RingVRFProof } from "./origin-extension.js"; +import type { RingLocation, ScoreContextChain } from "./rings.js"; +import type { SeatPath, SeatRoute, TimestampChain } from "./seat.js"; +import type { + AirdropVrfSigner, + AliasDrawProofContext, + FeeEstimable, + SignUpChain, + SignUpFundsChain, +} from "./signup.js"; +import type { LiteSignUpChain } from "./signup-lite.js"; +import type { AccountVrfSignature } from "./signup-types.js"; +import type { VrfTranscript } from "./signup-vrf.js"; + +// ── what the module needs from the chain ───────────────────────────────────── + +/** One event of a transaction watch, in the fields this module reads. */ +export interface TxWatchEvent { + type: string; + ok?: boolean; + dispatchError?: unknown; +} + +/** + * A transaction watch, as PAPI and `tx` both give one. The observer's members are + * methods so they compare bivariantly, as `tx`'s type needs. + */ +export interface TxWatch { + subscribe(observer: { + next(event: TxWatchEvent): void; + error(error: unknown): void; + complete?(): void; + }): { unsubscribe(): void }; +} + +/** A built sign-up call, as PAPI 3 returns it: fee-estimable and submittable. */ +export interface SignUpTransaction extends FeeEstimable { + createSubmitAndWatch(signer: CommonSignerTxCreator): TxWatch; +} + +/** Where the bind's finished, unsigned bytes go out: PAPI's `submitAndWatch`. */ +export interface RawSubmitChain { + raw: { + individuality: { + submitAndWatch(transaction: Uint8Array): TxWatch; + }; + }; +} + +/** The parts both chains share, over the transaction type the sign-up calls return. */ +type SeatBindParts = GameChain & + SignUpChain & + ScoreContextChain & + LiteSignUpChain & + LiteAliasBindChain & + RawSubmitChain; + +/** What {@link bindGameSeat} reads and submits through. */ +export type GameSeatBindChain = SeatBindParts; + +/** What {@link registerForGame} reads and submits through; `fromPapi` gives one. */ +export type GameRegistrationChain = SeatBindParts & + SignUpFundsChain & + AirdropChain & + TimestampChain; + +// ── what the module needs from a host ──────────────────────────────────────── + +/** The game account: `dim2.` at `GAME_ACCOUNT_INDEX`. */ +export interface GameAccount { + /** SS58. */ + address: string; + /** The 32-byte sr25519 public key. */ + publicKey: Uint8Array; +} + +/** Which personhood key a proof is made with. */ +export type PersonhoodKey = "lite" | "full"; + +/** A personhood key, the ring it is a member of, and the product that holds it. */ +export interface PersonhoodRing { + key: PersonhoodKey; + /** The personhood product, `peopl.`: the owner a host lists the key under. */ + owner: string; + ring: RingLocation; +} + +/** A ring-VRF proof context, as a host's `createRingVRFProof` takes it. */ +export type PersonhoodProofContext = + | { productId: string; suffix: { tag: "Index"; value: number } } + | AliasDrawProofContext; + +/** The personhood product's keys, as a host exposes them; other products need its `context` grant. */ +export interface PersonhoodProver { + /** Whether the host holds this person's key in that ring. */ + holds(ring: PersonhoodRing): Promise; + /** A proof over `message` in `context` with that key. Rejects when the host refuses. */ + prove( + ring: PersonhoodRing, + context: PersonhoodProofContext, + message: Uint8Array, + ): Promise; +} + +/** How a sign-up submission ended, as the `submit` capability reports it. */ +export type SignUpSubmitResult = + | { ok: true } + | { + ok: false; + /** The chain's error name, or what stopped it before the chain. */ + code: string; + /** The player declined to sign. Never retried. */ + refused?: boolean; + /** The watch lost track of it: it may still land. Read the seat before trying again. */ + landingUnknown?: boolean; + }; + +/** Options the flow hands the `submit` capability. */ +export interface SignUpSubmitOptions { + waitFor: "best-block" | "finalized"; + /** Called once the transaction is on the wire, for an optimistic UI. */ + onBroadcast?: () => void; + /** The caller's abort. The flow already drops a late signature; a `submit` that can stop sooner should. */ + signal?: AbortSignal; +} + +/** Signs and submits the built sign-up. The host adapter wraps `tx`'s `submitAndWatch`. */ +export type SubmitSignUp = ( + tx: SignUpTransaction, + signer: CommonSignerTxCreator, + options: SignUpSubmitOptions, +) => Promise; + +/** Everything a host does for a sign-up; `hostGameRegistrationCapabilities` builds it. */ +export interface GameRegistrationCapabilities { + account: GameAccount; + /** The game account's signer: `getProductAccountSigner`. */ + signer: CommonSignerTxCreator; + /** Submits the built sign-up. */ + submit: SubmitSignUp; + /** An sr25519 VRF by the game account, for account-arm draw tickets. Without it, no tickets. */ + signVrf?: AirdropVrfSigner["signVrf"]; + /** Checks a ticket before it rides the sign-up, where one bad ticket fails it all. */ + verifyVrf?: ( + publicKey: Uint8Array, + transcript: VrfTranscript, + signature: AccountVrfSignature, + ) => boolean; + /** The personhood keys. Without it, no free route and no member draw entries. */ + personhood?: PersonhoodProver; +} + +// ── steps, draws and the report ────────────────────────────────────────────── + +/** A step of {@link registerForGame}. */ +export type GameRegistrationStep = "seat" | "bind" | "draws" | "sign"; + +/** Where {@link registerForGame} is, for a product's progress UI: a step, or the broadcast. */ +export type GameRegistrationStage = GameRegistrationStep | "broadcast"; + +/** Why a sign-up went out without its draw entries. */ +export type DrawSkipKind = + /** A draw was not taking entries yet, and the wait ran out. */ + | "not-open" + /** The game has left registration, or a draw has closed. */ + | "closed" + /** Registration is about to close; the sign-up goes now. */ + | "closing" + /** A read failed, and the wait ran out. */ + | "read-failed" + /** No VRF signer, or the tickets were not signed or did not verify. Not retried. */ + | "signature" + /** A recognized person, and the member key could not prove the draws. Not retried. */ + | "verified-person" + /** The chain would refuse the entry for something about the player. */ + | "refused"; + +/** What a sign-up carried for the game's prize draws. */ +export interface DrawOutcome { + /** How many draws the sign-up enters. */ + entered: number; + /** `Game.airdrops_scheduled`. */ + scheduled: number; + /** Why none, when `entered` is 0 and draws were scheduled. */ + skipped?: string; + kind?: DrawSkipKind; + /** How long the sign-up waited for a draw to open. */ + waitedMs: number; + retries: number; +} + +/** Why a bind did not put the person's binding on the game account. */ +export type GameSeatBindRefusal = + /** The person's free seat is pinned to another account (`Game.LiteInvites`). */ + | "pinned-elsewhere" + /** The bind cannot succeed: `AccountIsALitePerson` or `AliasBoundElsewhere`. */ + | "dead-end" + /** The chain's score context is not one a host can mint the proof in. */ + | "no-proof-context" + /** No personhood prover, or the host holds no lite key for this person. */ + | "no-lite-key" + /** The chain refused the submission. `code` is the chain's own error name. */ + | "rejected"; + +/** What {@link bindGameSeat} did. */ +export type BindGameSeatOutcome = "bound" | "already-bound"; + +/** What one {@link registerForGame} call did, for telemetry; failures carry it too. */ +export interface GameRegistrationReport { + /** When each step started and ended, in wall-clock milliseconds. */ + steps: Partial>; + /** When the sign-up went on the wire. */ + broadcastAt?: number; + seat?: { path: SeatPath; ready: boolean; registered: boolean; open: boolean }; + /** Why the host's lite key could not be looked up; the free route was closed. */ + liteKeyLookupFailed?: string; + bind?: + | { outcome: BindGameSeatOutcome } + | { outcome: "refused"; refusal: GameSeatBindRefusal; code: string; permanent: boolean }; + route?: SeatRoute; + draws?: DrawOutcome; + signUp?: { + outcome: "registered" | "already-registered" | "failed"; + code?: string; + landingUnknown?: boolean; + }; +} diff --git a/product-sdk/packages/individuality/src/register-game.ts b/product-sdk/packages/individuality/src/register-game.ts index aff9fa08..4c79a495 100644 --- a/product-sdk/packages/individuality/src/register-game.ts +++ b/product-sdk/packages/individuality/src/register-game.ts @@ -1,977 +1,63 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: Apache-2.0 /** - * Sign a player up for the weekly game in one call, from any product: the game's - * own app and chat worker, or the personhood product. + * Sign a player up for the weekly game in one call, from any product. + * {@link registerForGame} runs four steps: * - * {@link registerForGame} runs the steps in order: + * 1. **Seat**: which route the player takes ({@link readGameSeat}). Already + * registered is success; a closed game or an unfunded account signs nothing. + * 2. **Bind**: on the free route with no binding yet, {@link bindGameSeat}. + * 3. **Draws**: the prize-draw entries. They never block the sign-up: without + * them it goes anyway, and says why. + * 4. **Sign**: the call for the route, under the origin it needs, handed to the + * `submit` capability. * - * 1. **Seat.** Read which route the player takes ({@link readGameSeat}), judged - * by the chain's clock. Already registered is success; a closed registration - * or an account that cannot pay ends here with nothing signed. - * 2. **Bind.** On the free route with no binding yet, bind the person's lite - * alias to the game account ({@link bindGameSeat}). A bind the chain will - * refuse for good closes the lite route, and the seat is read again without it. - * 3. **Draws.** Decide the prize-draw entries from the sign-up requirement at the - * best block, waiting a block at a time for a draw that is not open yet. - * Entries can only ride the sign-up, so a sign-up never waits on them for - * good: it goes without them, and says why. - * 4. **Sign.** Build the call for the route, wrap the signer for the origin the - * route needs, and hand both to the `submit` capability. + * Every step reads the chain first, so running the call again resumes where it + * stopped. The host comes in as {@link GameRegistrationCapabilities}; the + * `/host` entry builds them. * - * Every step reads the chain before it acts, so running the call again resumes - * where the last one stopped: a bind that landed is not sent again, and a - * sign-up that landed comes back as `already-registered`. Every outcome and - * every {@link GameRegistrationError} carries a {@link GameRegistrationReport} - * for the product's telemetry. - * - * Everything a host does comes in through {@link GameRegistrationCapabilities}, - * so this module imports neither `@parity/product-sdk-host` nor - * `@parity/product-sdk-tx`. The `@parity/product-sdk-individuality/host` entry - * builds the capabilities from a host, submitting with `tx`'s `submitAndWatch`. - * - * Ported from jollity-next `packages/game-chain/src/engine.ts` (`bind`, - * `register`, `decideDraws`), where the game's app and chat worker shared it. + * Ported from jollity-next `packages/game-chain/src/engine.ts`. */ import type { CommonSignerTxCreator } from "@polkadot-api/signers-common"; -import { bytesToHex } from "@parity/product-sdk-utils"; import { err, normalizeError, ok, type Result } from "@parity/result"; -import { airdropPhase, statusTag } from "./airdrop-decode.js"; -import type { AirdropChain } from "./airdrop-read.js"; import { withLiteAlias } from "./as-lite-alias-signer.js"; import { withScoreParticipant } from "./as-score-participant-signer.js"; -import { buildLiteAliasBindTx, type LiteAliasBindChain } from "./bind-lite-alias.js"; -import { ProductIndividualityError } from "./errors.js"; -import { gameProductId } from "./game-account.js"; -import type { GameChain } from "./game-read.js"; -import type { CreateRingVRFProof, RingVRFProof } from "./origin-extension.js"; import { - litePeopleRing, - peopleRing, - readScoreContext, - type RingLocation, - type ScoreContextChain, -} from "./rings.js"; -import { sameAccount } from "./same-account.js"; + liteProof, + readPersonhoodFacts, + runBind, + type BindGameSeatOptions, + type PersonhoodFacts, +} from "./bind-game-seat.js"; import { - chainNow, - readGameSeat, - type GameSeat, - type SeatPath, - type SeatRoute, - type TimestampChain, -} from "./seat.js"; -import { - mintAccountAirdropVrfs, - mintAliasAirdropVrfs, - readGameSignUpRequirement, - signUpWithAccountTx, - type AirdropVrfSigner, - type AliasAirdropVrfs, - type AliasDrawProofContext, - type FeeEstimable, - type SignUpChain, - type SignUpFundsChain, -} from "./signup.js"; + decideDraws, + entryCount, + FIXED_BY_THE_SIGN_UP, + type DrawEntries, + type DrawRequirement, +} from "./draw-entries.js"; +import { ProductIndividualityError } from "./errors.js"; import { - readLiteSignUpRequirement, - signUpWithLiteInviteTx, - type LiteSignUpChain, -} from "./signup-lite.js"; + asRegistrationError, + errorName, + GameRegistrationError, + GameSeatBindError, + NOT_RETRYABLE_SIGN_UP, +} from "./register-game-errors.js"; import type { - AccountVrfSignature, - LiteSignUpBlocker, - LiteSignUpRequirement, -} from "./signup-types.js"; -import { airdropVrfTranscript, type VrfTranscript } from "./signup-vrf.js"; - -// ── what the module needs from the chain ───────────────────────────────────── - -/** One event of a transaction watch, in the fields this module reads. */ -export interface TxWatchEvent { - type: string; - ok?: boolean; - dispatchError?: unknown; -} - -/** - * A transaction watch: PAPI's `Observable` of transaction events satisfies it, - * and so does `tx`'s `SubmittableTransaction` watch. The observer's members are - * methods so their parameters compare bivariantly, as `tx`'s own type relies on. - */ -export interface TxWatch { - subscribe(observer: { - next(event: TxWatchEvent): void; - error(error: unknown): void; - complete?(): void; - }): { unsubscribe(): void }; -} - -/** - * A built sign-up call. PAPI 3 transactions have both methods; the fee read - * uses the first and the `submit` capability the second. - */ -export interface SignUpTransaction extends FeeEstimable { - createSubmitAndWatch(signer: CommonSignerTxCreator): TxWatch; -} - -/** - * Where the unsigned bind goes out: PAPI's `PolkadotClient.submitAndWatch`. The - * bind is finished bytes with nothing to sign, which `tx`'s `submitAndWatch` - * does not take, so it is submitted here. - */ -export interface RawSubmitChain { - raw: { - individuality: { - submitAndWatch(transaction: Uint8Array): TxWatch; - }; - }; -} - -/** The parts both chains share, over the transaction type the sign-up calls return. */ -type SeatBindParts = GameChain & - SignUpChain & - ScoreContextChain & - LiteSignUpChain & - LiteAliasBindChain & - RawSubmitChain; - -/** What {@link bindGameSeat} reads and submits through. */ -export type GameSeatBindChain = SeatBindParts; - -/** - * What {@link registerForGame} reads and submits through. A PAPI client and the - * typed API for the individuality chain, joined by `fromPapi`, satisfy it. - */ -export type GameRegistrationChain = SeatBindParts & - SignUpFundsChain & - AirdropChain & - TimestampChain; - -// ── what the module needs from a host ──────────────────────────────────────── - -/** The game account: `dim2.` at `GAME_ACCOUNT_INDEX`. */ -export interface GameAccount { - /** SS58. */ - address: string; - /** The 32-byte sr25519 public key. */ - publicKey: Uint8Array; -} - -/** Which personhood key a proof is made with. */ -export type PersonhoodKey = "lite" | "full"; - -/** A personhood key, the ring it is a member of, and the product that holds it. */ -export interface PersonhoodRing { - key: PersonhoodKey; - /** The personhood product, `peopl.`: the owner a host lists the key under. */ - owner: string; - ring: RingLocation; -} - -/** A ring-VRF proof context, as a host's `createRingVRFProof` takes it. */ -export type PersonhoodProofContext = - | { productId: string; suffix: { tag: "Index"; value: number } } - | AliasDrawProofContext; - -/** - * The personhood product's keys, as a host exposes them. The personhood product - * uses its own keys; any other product needs its `context` grant. - */ -export interface PersonhoodProver { - /** Whether the host holds this person's key in that ring. */ - holds(ring: PersonhoodRing): Promise; - /** A proof over `message` in `context` with that key. Rejects when the host refuses. */ - prove( - ring: PersonhoodRing, - context: PersonhoodProofContext, - message: Uint8Array, - ): Promise; -} - -/** How a sign-up submission ended, as the `submit` capability reports it. */ -export type SignUpSubmitResult = - | { ok: true } - | { - ok: false; - /** The chain's error name, or what stopped it before the chain. */ - code: string; - /** The player declined to sign. Never retried. */ - refused?: boolean; - /** The watch lost track of it: it may still land. Read the seat before trying again. */ - landingUnknown?: boolean; - }; - -/** Options the flow hands the `submit` capability. */ -export interface SignUpSubmitOptions { - waitFor: "best-block" | "finalized"; - /** Called once the transaction is on the wire, for an optimistic UI. */ - onBroadcast?: () => void; - /** - * The caller's abort. The flow already drops a signature that is still being - * made when it aborts, so nothing reaches the wire; a `submit` that can stop - * earlier should. Once the transaction is broadcast it may land, so the answer - * still comes. - */ - signal?: AbortSignal; -} - -/** Signs and submits the built sign-up. The host adapter wraps `tx`'s `submitAndWatch`. */ -export type SubmitSignUp = ( - tx: SignUpTransaction, - signer: CommonSignerTxCreator, - options: SignUpSubmitOptions, -) => Promise; - -/** - * Everything a host does for a sign-up. Build it from a host with - * `hostGameRegistrationCapabilities` from `@parity/product-sdk-individuality/host`. - */ -export interface GameRegistrationCapabilities { - account: GameAccount; - /** The game account's signer: `getProductAccountSigner`. */ - signer: CommonSignerTxCreator; - /** Submits the built sign-up. */ - submit: SubmitSignUp; - /** An sr25519 VRF by the game account, for account-arm draw tickets. Without it, no tickets. */ - signVrf?: AirdropVrfSigner["signVrf"]; - /** - * Checks a ticket before it rides the sign-up. One bad ticket fails the whole - * sign-up, so a product that can verify should; this package has no - * schnorrkel verifier of its own. - */ - verifyVrf?: ( - publicKey: Uint8Array, - transcript: VrfTranscript, - signature: AccountVrfSignature, - ) => boolean; - /** The personhood keys. Without it, no free route and no member draw entries. */ - personhood?: PersonhoodProver; -} - -// ── errors, the report, retries ────────────────────────────────────────────── - -/** A step of {@link registerForGame}. */ -export type GameRegistrationStep = "seat" | "bind" | "draws" | "sign"; - -/** Where {@link registerForGame} is, for a product's progress UI: a step, or the broadcast. */ -export type GameRegistrationStage = GameRegistrationStep | "broadcast"; - -/** Why a sign-up went out without its draw entries. */ -export type DrawSkipKind = - /** A draw was not taking entries yet, and the wait ran out. */ - | "not-open" - /** The game has left registration, or a draw has closed. */ - | "closed" - /** Registration is about to close; the sign-up goes now. */ - | "closing" - /** A read failed, and the wait ran out. */ - | "read-failed" - /** No VRF signer, or the tickets were not signed or did not verify. Not retried. */ - | "signature" - /** A recognized person, and the member key could not prove the draws. Not retried. */ - | "verified-person" - /** The chain would refuse the entry for something about the player. */ - | "refused"; - -/** What a sign-up carried for the game's prize draws. */ -export interface DrawOutcome { - /** How many draws the sign-up enters. */ - entered: number; - /** `Game.airdrops_scheduled`. */ - scheduled: number; - /** Why none, when `entered` is 0 and draws were scheduled. */ - skipped?: string; - kind?: DrawSkipKind; - /** How long the sign-up waited for a draw to open. */ - waitedMs: number; - retries: number; -} - -/** Why a bind did not put the person's binding on the game account. */ -export type GameSeatBindRefusal = - /** - * The person's free seat is pinned to another account (`Game.LiteInvites`). - * Binding this one would strand that seat, so nothing is submitted. - */ - | "pinned-elsewhere" - /** - * A bind cannot succeed here: the account is itself a lite person's - * (`AccountIsALitePerson`), or carries a binding in another context - * (`AliasBoundElsewhere`, which only an unbind in that context clears). - * Refused before any proof is asked for. - */ - | "dead-end" - /** The chain's score context is not one a host can mint the proof in. */ - | "no-proof-context" - /** No personhood prover, or the host holds no lite key for this person. */ - | "no-lite-key" - /** The chain refused the submission. `code` is the chain's own error name. */ - | "rejected"; - -/** What {@link bindGameSeat} did. */ -export type BindGameSeatOutcome = "bound" | "already-bound"; - -/** - * What one {@link registerForGame} call did, for the product's telemetry: when - * each step ran and how it ended. Carried by every outcome and every - * {@link GameRegistrationError}, so a failure reports how far it got. - */ -export interface GameRegistrationReport { - /** When each step started and ended, in wall-clock milliseconds. */ - steps: Partial>; - /** When the sign-up went on the wire. */ - broadcastAt?: number; - seat?: { path: SeatPath; ready: boolean; registered: boolean; open: boolean }; - /** - * Why the host's lite key could not be looked up, when it could not: the - * player was seated without the free route. - */ - liteKeyLookupFailed?: string; - bind?: - | { outcome: BindGameSeatOutcome } - | { outcome: "refused"; refusal: GameSeatBindRefusal; code: string; permanent: boolean }; - route?: SeatRoute; - draws?: DrawOutcome; - signUp?: { - outcome: "registered" | "already-registered" | "failed"; - code?: string; - landingUnknown?: boolean; - }; -} - -/** - * A step of the registration that failed. `stage` says which, `code` what the - * chain or the host answered, and `retryable` whether running the call again can - * change the answer. Running it again is always safe: every step re-reads the - * chain first. - */ -export class GameRegistrationError extends ProductIndividualityError { - readonly stage: GameRegistrationStep; - readonly code: string; - /** False when trying again cannot help: a refusal, a closed registration, a bad call. */ - readonly retryable: boolean; - /** The sign-up's watch lost track: it may still land. */ - readonly landingUnknown: boolean; - /** What the call did up to the failure. Set by {@link registerForGame}. */ - report?: GameRegistrationReport; - - constructor( - stage: GameRegistrationStep, - code: string, - options: { - retryable: boolean; - landingUnknown?: boolean; - message?: string; - cause?: unknown; - }, - ) { - super( - options.message ?? `game registration failed at ${stage}: ${code}`, - options.cause === undefined ? undefined : { cause: options.cause }, - ); - this.name = "GameRegistrationError"; - this.stage = stage; - this.code = code; - this.retryable = options.retryable; - this.landingUnknown = options.landingUnknown === true; - } -} - -/** - * The chain's refusals of `PeopleLite.set_alias_account` that trying again will - * not change: the account already carries another person's binding, the proof's - * context is not one the chain binds in, or the account is itself a lite person. - * `CallBlockOutOfRange` and the pool's validity codes can pass next time, so - * they are not here; `AliasAccountAlreadySet` means the binding is there. - */ -const PERMANENT_BIND_CODES: ReadonlySet = new Set([ - "AccountInUse", - "InvalidAliasContext", - "AlreadyRegistered", -]); - -function bindIsPermanent(refusal: GameSeatBindRefusal, code: string): boolean { - return refusal !== "rejected" || PERMANENT_BIND_CODES.has(code); -} - -/** A bind that did not land. `permanent` closes the lite route for this account. */ -export class GameSeatBindError extends GameRegistrationError { - /** True when trying again cannot change the answer. The opposite of `retryable`. */ - readonly permanent: boolean; - - constructor( - readonly refusal: GameSeatBindRefusal, - code: string, - /** For `"pinned-elsewhere"`, the account the seat is pinned to. */ - readonly account?: string, - options: { landingUnknown?: boolean } = {}, - ) { - const permanent = bindIsPermanent(refusal, code); - super("bind", code, { - retryable: !permanent, - landingUnknown: options.landingUnknown, - message: - refusal === "pinned-elsewhere" - ? `lite bind refused: the person's free seat is on ${account ?? "another account"}` - : `lite bind failed: ${code}`, - }); - this.name = "GameSeatBindError"; - this.permanent = permanent; - } -} - -/** - * The sign-up refusals that trying again will not change: no game or a closed - * registration, a draw entry the chain will never take, a seat pinned to another - * account, an account that cannot pay, a declined signature, or a call that - * could not be built. - */ -const NOT_RETRYABLE_SIGN_UP: ReadonlySet = new Set([ - "NoGame", - "NoRegistration", - "InvalidAirdropVrfCount", - "InvalidAirdropVrfVariant", - "InvalidAirdropVrfVariantForAccount", - "InvalidAirdropVrfVariantForRecognition", - "AnotherAccountInvited", - "StatementAccountAlreadyInUse", - "Payment", - "InsufficientBalance", - "SigningRejected", - "InvalidSignUp", -]); - -/** - * How long to wait before running a failed registration again, after the - * `failures`-th failure in a row (1-based): 5 s, 15 s, 45 s, 2 min, then 5 min - * between tries. Ported from jollity-next's bind ladder. When to retry at all - * is the product's call; check `retryable` first. - */ -export const GAME_REGISTRATION_RETRY_MS: readonly number[] = [ - 5_000, 15_000, 45_000, 120_000, 300_000, -]; - -export function registrationRetryDelay(failures: number): number { - const index = - Math.min(Math.max(Math.floor(failures), 1), GAME_REGISTRATION_RETRY_MS.length) - 1; - return GAME_REGISTRATION_RETRY_MS[index]!; -} - -/** - * The name of a chain error, wherever PAPI or `tx` nests it: the innermost - * `type` of a dispatch or validity error, else the error's message. PAPI's - * `InvalidTxError` keeps the validity error under `error`, and its message is - * that error as JSON, so it is read through. - * - * Internal: shared with the host adapter. - */ -export function errorName(error: unknown): string { - let node: unknown = error; - let name: string | undefined; - for (let depth = 0; depth < 8 && node !== null && typeof node === "object"; depth += 1) { - const { - type, - value, - error: inner, - } = node as { type?: unknown; value?: unknown; error?: unknown }; - if (typeof type === "string") name = type; - node = value !== undefined ? value : inner; - } - if (name) return name; - if (error instanceof Error && error.message) return error.message; - return typeof error === "string" && error ? error : "DispatchError"; -} - -function asRegistrationError(step: GameRegistrationStep, error: unknown): GameRegistrationError { - if (error instanceof GameRegistrationError) return error; - const aborted = error instanceof Error && error.name === "AbortError"; - return new GameRegistrationError(step, aborted ? "Aborted" : errorName(error), { - retryable: true, - cause: error, - }); -} - -// ── personhood facts ───────────────────────────────────────────────────────── - -/** The personhood rings on this chain, and whether a host can prove in them. */ -interface PersonhoodFacts { - /** `Score.score_context` is the product derivation a host mints in. */ - derived: boolean; - /** `peopl.`. */ - owner: string; - lite: PersonhoodRing; - full: PersonhoodRing; -} - -async function readPersonhoodFacts( - chain: ScoreContextChain & Pick, - tld: string, - signal: AbortSignal | undefined, -): Promise { - const [score, spec] = await Promise.all([ - readScoreContext(chain, { tld, signal }), - chain.raw.individuality.getChainSpecData(), - ]); - if (!score.ok) throw score.error; - const genesis = spec.genesisHash as `0x${string}`; - const owner = score.value.productId; - return { - derived: score.value.tag === "ProductDerived", - owner, - lite: { key: "lite", owner, ring: litePeopleRing(genesis) }, - full: { key: "full", owner, ring: peopleRing(genesis) }, - }; -} - -/** The lite proof the bind and a stale binding's re-point ride: the lite key, in the score context. */ -function liteProof(prover: PersonhoodProver, facts: PersonhoodFacts): CreateRingVRFProof { - const context = { productId: facts.owner, suffix: { tag: "Index" as const, value: 0 } }; - return (message) => prover.prove(facts.lite, context, message); -} - -/** - * Whether this person can take the free route: the chain's score context is one - * a host mints in, and the host holds their lite key. - */ -export async function holdsLiteKey( - chain: ScoreContextChain & Pick, - capabilities: Pick, - options: { tld: string; signal?: AbortSignal }, -): Promise> { - try { - const prover = capabilities.personhood; - if (!prover) return ok(false); - const facts = await readPersonhoodFacts(chain, options.tld, options.signal); - return ok(facts.derived && (await prover.holds(facts.lite))); - } catch (cause) { - return err(normalizeError(cause, ProductIndividualityError)); - } -} - -// ── submission of the raw bind ─────────────────────────────────────────────── - -/** Where a raw submission settled. */ -type Settled = { ok: true } | { ok: false; error: string; landingUnknown?: boolean }; - -/** PAPI's words when the block subscription under a watch ended before the transaction's fate was known. */ -function isTrackingStopped(error: unknown): boolean { - return error instanceof Error && /tracking stopped/i.test(error.message); -} - -/** How long the bind waits for a best block to hold it, from submission. */ -const DEFAULT_BIND_TIMEOUT_MS = 120_000; - -/** - * Drive the raw bind's watch to the best block that holds it. An invalid - * transaction arrives on the error channel and settles as a failure, never a - * throw; a watch that completes with no block holding it settles as a failure - * too. An abort before `broadcasted` rejects with the signal's reason; after it - * the transaction may land, so the abort is ignored. A watch that has not - * settled after `timeoutMs` settles as a `Timeout` whose landing is unknown: a - * transaction the pool dropped would otherwise hold the call until its - * mortality ran out. - */ -function settleRaw( - watch: TxWatch, - signal?: AbortSignal, - timeoutMs: number = DEFAULT_BIND_TIMEOUT_MS, -): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason); - return; - } - let settled = false; - let broadcast = false; - const timer = setTimeout( - () => finish(() => resolve({ ok: false, error: "Timeout", landingUnknown: true })), - timeoutMs, - ); - // biome-ignore lint/style/useConst: PAPI can emit during `subscribe`, and `finish` reads `sub` then; a `const` would still be in its temporal dead zone. - let sub: { unsubscribe(): void } | undefined; - const finish = (done: () => void) => { - if (settled) return; - settled = true; - clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - sub?.unsubscribe(); - done(); - }; - const onAbort = () => { - if (!broadcast) finish(() => reject(signal?.reason)); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - sub = watch.subscribe({ - next: (event) => { - if (event.type === "broadcasted") { - broadcast = true; - return; - } - if (event.type === "inBestBlock" || event.type === "finalized") { - finish(() => - resolve( - event.ok - ? { ok: true } - : { ok: false, error: errorName(event.dispatchError) }, - ), - ); - } - }, - error: (error) => - finish(() => - resolve({ - ok: false, - error: errorName(error), - ...(isTrackingStopped(error) ? { landingUnknown: true } : {}), - }), - ), - complete: () => - finish(() => - resolve({ - ok: false, - error: "the watch ended before any block held the transaction", - }), - ), - }); - if (settled) sub.unsubscribe(); - }); -} - -// ── step 2: bind ───────────────────────────────────────────────────────────── - -/** Options for {@link bindGameSeat}. */ -export interface BindGameSeatOptions { - tld: string; - signal?: AbortSignal; - /** - * How long to wait for a best block to hold the bind, from submission. - * Default 120 000 ms; past it the bind fails with `Timeout` and - * `landingUnknown`, and running it again reads the binding first. - */ - timeoutMs?: number; -} - -/** The reads and the submission the bind makes, swappable so its decisions can be tested alone. */ -interface BindSteps { - requirement( - chain: GameSeatBindChain, - account: string, - tld: string, - signal?: AbortSignal, - ): Promise<{ blockers: readonly LiteSignUpBlocker[] }>; - facts(chain: GameSeatBindChain, tld: string, signal?: AbortSignal): Promise; - build( - chain: GameSeatBindChain, - account: string, - createProof: CreateRingVRFProof, - signal?: AbortSignal, - ): Promise<{ transaction: Uint8Array }>; - pinnedTo( - chain: GameSeatBindChain, - alias: Uint8Array, - signal?: AbortSignal, - ): Promise; - submitRaw( - chain: GameSeatBindChain, - transaction: Uint8Array, - signal?: AbortSignal, - timeoutMs?: number, - ): Promise; -} - -const BIND_STEPS: BindSteps = { - async requirement(chain, account, tld, signal) { - const read = await readLiteSignUpRequirement(chain, { account, tld, at: "best", signal }); - if (!read.ok) throw read.error; - return read.value; - }, - facts: readPersonhoodFacts, - build: (chain, account, createProof, signal) => - buildLiteAliasBindTx(chain, { account, createProof, signal }), - pinnedTo: (chain, alias, signal) => - chain.individuality.query.Game.LiteInvites.getValue(`0x${bytesToHex(alias)}`, { - at: "best", - signal, - }), - submitRaw: (chain, transaction, signal, timeoutMs) => - settleRaw(chain.raw.individuality.submitAndWatch(transaction), signal, timeoutMs), -}; - -/** - * Bind the person's lite alias to the game account: `PeopleLite.set_alias_account`, - * unsigned, carrying a proof from the personhood product's lite key in - * `Score.score_context`. Resolves once a best block holds it. - * - * Reads before it builds, because the binding is the only replay protection and - * a retry after a timeout may follow a bind that landed: - * - * - **Already bound** in the score context, and current, resolves - * `already-bound` with nothing asked for. A **stale** binding (the lite ring - * has moved past it) is bound again with a fresh proof. {@link registerForGame} - * never asks for that: its seat counts a stale binding as ready, and the - * sign-up's revised leg re-points it in the same transaction. - * - **A bind that cannot succeed** (the account is a lite person's own, it is - * bound in another context, the seat is pinned elsewhere, or no host can mint - * the proof) is refused before any proof is requested. - * - **Otherwise** it proves, checks the forever pin (`Game.LiteInvites[alias]`) - * on the alias the proof names, and submits. The requirement read reports - * that pin only once this account holds the binding, which is too late, so - * the check is here. - * - * A chain answer of `AliasAccountAlreadySet` (another bind landed first) is - * `already-bound`. Fails with {@link GameSeatBindError}; check `permanent`. - */ -export async function bindGameSeat( - chain: GameSeatBindChain, - capabilities: Pick, - options: BindGameSeatOptions, -): Promise> { - try { - return ok(await runBind(chain, capabilities, options)); - } catch (cause) { - return err(asRegistrationError("bind", cause)); - } -} - -async function runBind( - chain: GameSeatBindChain, - capabilities: Pick, - options: BindGameSeatOptions, - known?: PersonhoodFacts, - steps: BindSteps = BIND_STEPS, -): Promise { - const { signal, tld } = options; - const account = capabilities.account.address; - const { blockers } = await steps.requirement(chain, account, tld, signal); - const has = (tag: LiteSignUpBlocker["tag"]) => blockers.some((blocker) => blocker.tag === tag); - - for (const tag of ["AccountIsALitePerson", "AliasBoundElsewhere"] as const) { - if (has(tag)) throw new GameSeatBindError("dead-end", tag); - } - const invited = blockers.find((blocker) => blocker.tag === "AnotherAccountInvited"); - if (invited && "invited" in invited) { - throw new GameSeatBindError("pinned-elsewhere", "AnotherAccountInvited", invited.invited); - } - if (has("ContextNotProductDerived")) { - throw new GameSeatBindError("no-proof-context", "ContextNotProductDerived"); - } - if (!has("AliasNotBound") && !has("StaleAlias")) return "already-bound"; - - const facts = known ?? (await steps.facts(chain, tld, signal)); - if (!facts.derived) throw new GameSeatBindError("no-proof-context", "ContextNotProductDerived"); - const prover = capabilities.personhood; - if (!prover) throw new GameSeatBindError("no-lite-key", "NoPersonhoodProver"); - - let alias: Uint8Array | undefined; - const prove = liteProof(prover, facts); - const built = await steps.build( - chain, - account, - async (message) => { - const proof = await prove(message); - alias = proof.contextualAlias.alias; - return proof; - }, - signal, - ); - if (alias) { - const pinned = await steps.pinnedTo(chain, alias, signal); - if (pinned !== undefined && !sameAccount(pinned, account)) { - throw new GameSeatBindError("pinned-elsewhere", "AnotherAccountInvited", pinned); - } - } - signal?.throwIfAborted(); - const result = await steps.submitRaw(chain, built.transaction, signal, options.timeoutMs); - if (result.ok) return "bound"; - if (result.error === "AliasAccountAlreadySet") return "already-bound"; - throw new GameSeatBindError("rejected", result.error, undefined, { - landingUnknown: result.landingUnknown, - }); -} - -// ── step 3: the draw entries ───────────────────────────────────────────────── - -type DrawEntries = - | { entries: AccountVrfSignature[] | AliasAirdropVrfs; scheduled: number } - | { - entries?: undefined; - skipped: string; - scheduled: number; - /** The reason is of the moment: wait a block and ask again. */ - wait?: boolean; - kind?: DrawSkipKind; - }; - -/** What the draw decision reads from a sign-up requirement, of either route. */ -type DrawRequirement = Pick< - LiteSignUpRequirement, - "variant" | "phase" | "registrationEnds" | "airdropsScheduled" | "eventIds" -> & { - blockers: readonly { tag: string }[]; -}; - -/** - * The requirement's blockers that describe the game's moment rather than the - * player. The draw decision reads the phase and every draw's status at the best - * block itself, so only blockers about the player skip the draws. - */ -const MOMENT_BLOCKERS: ReadonlySet = new Set([ - "NoGameRunning", - "NotInRegistration", - "RegistrationEnded", - "NoDrawsScheduled", -]); - -/** A stale binding is re-pointed inside the free sign-up, so it does not cost the draws. */ -const FIXED_BY_THE_SIGN_UP: ReadonlySet = new Set(["StaleAlias"]); - -const entryCount = (entries: AccountVrfSignature[] | AliasAirdropVrfs) => - Array.isArray(entries) ? entries.length : entries.proofs.length; - -/** - * The draw entries a sign-up carries, decided from the requirement read at the - * best block, the one the sign-up is validated against. There is no way to - * enter a draw after signing up, and a count that disagrees with - * `airdrops_scheduled` fails the whole sign-up, so every gate is checked here: - * draws are scheduled, the game is in registration, nothing about the player - * bars the entry, every draw is `Registering`, and the tickets (or a recognized - * person's proofs) are minted. A draw that is not open yet says `wait`; a - * signature or proof that is refused does not, so a player who declined is not - * asked again. - */ -async function decideDraws( - chain: AirdropChain, - capabilities: GameRegistrationCapabilities, - requirement: DrawRequirement, - tld: string, - facts: () => Promise, - signal: AbortSignal | undefined, -): Promise { - const scheduled = requirement.airdropsScheduled; - if (requirement.phase === null) return { skipped: "no game at the best block", scheduled: 0 }; - if (scheduled === 0) return { skipped: "the game has no prize draw", scheduled }; - if (requirement.phase !== "Registration") { - return { skipped: `the game is in ${requirement.phase}`, scheduled, kind: "closed" }; - } - if (requirement.variant === null || requirement.eventIds.length !== scheduled) { - return { - skipped: "the requirement does not describe the game's draws", - scheduled, - wait: true, - kind: "read-failed", - }; - } - const recognized = requirement.variant === "Alias"; - if (recognized && !capabilities.personhood) { - return { - skipped: "a recognized person, and nothing here can prove the draw", - scheduled, - kind: "verified-person", - }; - } - if (!recognized && !capabilities.signVrf) { - return { skipped: "no VRF signer", scheduled, kind: "signature" }; - } - const blockers = requirement.blockers.filter((blocker) => !MOMENT_BLOCKERS.has(blocker.tag)); - if (blockers.length > 0) { - return { - skipped: `the chain would refuse the entry: ${blockers.map((b) => b.tag).join(", ")}`, - scheduled, - kind: "refused", - }; - } - - const { eventIds } = requirement; - try { - const events = await Promise.all( - eventIds.map((id) => - chain.individuality.query.Airdrop.Events.getValue(id, { at: "best", signal }), - ), - ); - for (const [index, event] of events.entries()) { - // The offchain worker opens each draw a block after the game appears. - const phase = - event === undefined ? "Upcoming" : airdropPhase(statusTag(event.status.type)); - if (phase === "Upcoming") { - return { - skipped: `draw #${index} is not taking entries yet`, - scheduled, - wait: true, - kind: "not-open", - }; - } - if (phase !== "Registering") { - return { - skipped: `draw #${index} is ${phase}, not Registering`, - scheduled, - kind: "closed", - }; - } - } - } catch (error) { - return { - skipped: `the draws could not be read: ${errorName(error)}`, - scheduled, - wait: true, - kind: "read-failed", - }; - } - - const registrant = { tag: "Account" as const, accountAddress: capabilities.account.address }; - if (recognized) { - let personhood: PersonhoodFacts; - try { - personhood = await facts(); - } catch (error) { - return { - skipped: `no People key to prove the draws with: ${errorName(error)}`, - scheduled, - kind: "verified-person", - }; - } - const prover = capabilities.personhood!; - const proved = await mintAliasAirdropVrfs( - (context, message) => prover.prove(personhood.full, context, message), - { eventIds, registrant, productId: gameProductId(tld), signal }, - ); - if (!proved.ok) { - return { - skipped: `the draws were not proved: ${errorName(proved.error)}`, - scheduled, - kind: "verified-person", - }; - } - return { entries: proved.value, scheduled }; - } - - const { publicKey } = capabilities.account; - const minted = await mintAccountAirdropVrfs( - { signVrf: capabilities.signVrf! }, - { eventIds, publicKey, signal }, - ); - if (!minted.ok) { - return { - skipped: `the tickets were not signed: ${errorName(minted.error)}`, - scheduled, - kind: "signature", - }; - } - if (capabilities.verifyVrf) { - for (const [index, entry] of minted.value.entries()) { - const transcript = airdropVrfTranscript({ eventId: eventIds[index]!, publicKey }); - if (!capabilities.verifyVrf(publicKey, transcript, entry)) { - return { - skipped: `the ticket for draw #${index} does not verify against the game account`, - scheduled, - kind: "signature", - }; - } - } - } - return { entries: minted.value, scheduled }; -} + BindGameSeatOutcome, + DrawOutcome, + GameRegistrationCapabilities, + GameRegistrationChain, + GameRegistrationReport, + GameRegistrationStage, + GameRegistrationStep, + SignUpTransaction, +} from "./register-game-types.js"; +import { chainNow, readGameSeat, type GameSeat, type SeatRoute } from "./seat.js"; +import { readGameSignUpRequirement, signUpWithAccountTx } from "./signup.js"; +import { readLiteSignUpRequirement, signUpWithLiteInviteTx } from "./signup-lite.js"; /** How long a sign-up waits for draws that are not open yet. */ export interface DrawWaitOptions { @@ -979,11 +65,7 @@ export interface DrawWaitOptions { maxMs?: number; /** How often to ask again. Default 2 000 ms, about a block. */ retryMs?: number; - /** - * Never wait into the last this-many seconds of registration, by the chain's - * clock: a player who misses the game has lost more than one who misses a - * ticket. Default 15. - */ + /** Stop waiting this many seconds before registration closes. Default 15. */ closingSeconds?: number; } @@ -1010,13 +92,9 @@ function sleep(ms: number, signal: AbortSignal | undefined): Promise { } /** - * The signer, made to drop a signature still being made when `signal` aborts, - * and to refuse one asked for after it. The transaction goes on the wire only - * once the signer hands back the signed bytes, so an abort while the host's - * signing prompt is open sends nothing. A build with a mocked signature (a fee - * estimate) is passed through. - * - * Ported from jollity-next `withAbort` (#577). + * The signer, made to drop a signature that arrives after `signal` aborts. The + * transaction goes out only with the signed bytes, so an abort while the signing + * prompt is open sends nothing. Fee estimates (mocked signatures) pass through. */ function abortable(signer: CommonSignerTxCreator, signal: AbortSignal): CommonSignerTxCreator { const create = (...args: Parameters): Promise => { @@ -1043,29 +121,16 @@ function abortable(signer: CommonSignerTxCreator, signal: AbortSignal): CommonSi export interface RegisterForGameOptions { /** The network's DotNS TLD, such as `"paseo"`. */ tld: string; - /** - * `CommunicationIdentifier`, exactly 65 bytes: the P-256 key co-players reach - * this player with. Rewritten on every sign-up, so pass a key whose private - * half the game product can use at game time. - */ + /** `CommunicationIdentifier`: the 65-byte P-256 key co-players reach this player with. */ identifierKey: Uint8Array; /** A seat read moments ago, to skip reading it again. Never pass a cached one on a retry. */ seat?: GameSeat; - /** - * When the sign-up counts as done. Default `"best-block"`, as `tx`'s own: a - * refusal arrives at the best block either way, and a sign-up a reorg drops is - * caught by the next call, which reads the seat first. Pass `"finalized"` when - * the answer must not be undone, at the cost of waiting for finality. - */ + /** When the sign-up counts as done. Default `"best-block"`; `"finalized"` waits for finality. */ waitFor?: "best-block" | "finalized"; drawWait?: DrawWaitOptions; /** Passed to {@link readGameSeat}. */ feeHeadroom?: number; - /** - * Stops the flow, up to the broadcast: a signature still being made is - * dropped, so nothing goes out. Ignored once the sign-up is broadcast: it may - * still land. - */ + /** Stops the flow up to the broadcast; after it, the sign-up may still land. */ signal?: AbortSignal; onStage?(stage: GameRegistrationStage): void; onSeat?(seat: GameSeat): void; @@ -1083,14 +148,9 @@ export type RegisterForGameOutcome = | { kind: "unfunded"; seat: GameSeat; missing: bigint; report: GameRegistrationReport }; /** - * Sign the player up for the running game: seat, bind, draws, sign. See the - * module doc. - * - * The ok channel carries every answer that is not a failure, including - * `already-registered`, which is also what a race with another product ends in. - * The err channel carries a {@link GameRegistrationError} (a - * {@link GameSeatBindError} for the bind) with the failing step, the code, - * whether a retry can help, and the report. + * Sign the player up for the running game: seat, bind, draws, sign. Fails with a + * {@link GameRegistrationError} naming the step, the code and whether a retry + * can help; losing a race to another product is `already-registered`. */ export async function registerForGame( chain: GameRegistrationChain, @@ -1457,34 +517,19 @@ async function runRegistration( if (import.meta.vitest) { const { describe, expect, test, vi } = import.meta.vitest; const { unwrapErr } = await import("@parity/result"); - const { AccountId } = await import("polkadot-api"); - - const ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; - const OTHER = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"; - const IDENTIFIER = new Uint8Array(65).fill(0x22); - const PUBLIC_KEY = AccountId().enc(ADDRESS); - const RING = { chainId: "0x01" as const, junctions: [] }; - const FACTS: PersonhoodFacts = { - derived: true, - owner: "peopl.paseo", - lite: { key: "lite", owner: "peopl.paseo", ring: RING }, - full: { key: "full", owner: "peopl.paseo", ring: RING }, - }; - const EVENT_IDS = [`0x${"11".repeat(32)}`, `0x${"12".repeat(32)}`]; - const TICKET = { preOutput: new Uint8Array(32).fill(3), proof: new Uint8Array(64).fill(4) }; - - const baseSigner = { publicKey: PUBLIC_KEY } as unknown as CommonSignerTxCreator; - const tagged = (...tags: string[]) => tags.map((tag) => ({ tag }) as LiteSignUpBlocker); - - const drawRequirement = (overrides: Partial = {}): DrawRequirement => ({ - variant: "Account", - phase: "Registration", - registrationEnds: 2_000, - airdropsScheduled: 2, - eventIds: EVENT_IDS, - blockers: [], - ...overrides, - }); + const { + FACTS, + IDENTIFIER, + OTHER, + PUBLIC_KEY, + TICKET, + baseSigner, + capabilities, + drawRequirement, + fakeSubmit, + tagged, + } = await import("./register-game-harness.js"); + type SubmitSignUp = import("./register-game-types.js").SubmitSignUp; const seatOf = (overrides: Partial = {}): GameSeat => ({ @@ -1514,30 +559,6 @@ if (import.meta.vitest) { } as unknown as GameRegistrationChain; }; - /** A `submit` capability that records what it was handed and answers `result`. */ - const fakeSubmit = (result: SignUpSubmitResult = { ok: true }) => { - const calls: { - call: string; - signer: CommonSignerTxCreator; - options: SignUpSubmitOptions; - }[] = []; - const submit: SubmitSignUp = async (tx, signer, options) => { - calls.push({ call: (tx as unknown as { call: string }).call, signer, options }); - options.onBroadcast?.(); - return result; - }; - return { submit, calls }; - }; - - const capabilities = ( - extra: Partial = {}, - ): GameRegistrationCapabilities => ({ - account: { address: ADDRESS, publicKey: PUBLIC_KEY }, - signer: baseSigner, - submit: fakeSubmit().submit, - ...extra, - }); - const steps = (overrides: Partial = {}): RegistrationSteps => ({ seat: async () => seatOf(), bind: async () => "bound", @@ -2018,389 +1039,6 @@ if (import.meta.vitest) { }); }); - describe("decideDraws", () => { - const eventsChain = (statuses: (string | undefined)[]) => - ({ - individuality: { - query: { - Airdrop: { - Events: { - getValue: async (id: string) => { - const status = statuses[EVENT_IDS.indexOf(id)]; - return status === undefined - ? undefined - : { status: { type: status } }; - }, - }, - }, - }, - }, - }) as unknown as AirdropChain; - const decide = ( - chain: AirdropChain, - caps: GameRegistrationCapabilities, - requirement: DrawRequirement = drawRequirement(), - ) => decideDraws(chain, caps, requirement, "paseo", async () => FACTS, undefined); - const signVrf = () => vi.fn(async () => TICKET); - const open = () => eventsChain(["Registering", "Registering"]); - - test("enters every scheduled draw, one ticket per draw, in airdrop-index order", async () => { - const sign = signVrf(); - const draws = await decide(open(), capabilities({ signVrf: sign })); - expect(draws.entries).toHaveLength(2); - expect(sign).toHaveBeenCalledTimes(2); - }); - - test("skips without a VRF signer, signing nothing", async () => { - const draws = await decide(open(), capabilities()); - expect(draws).toMatchObject({ kind: "signature" }); - expect(draws.entries).toBeUndefined(); - }); - - test("skips a game with no draw scheduled, signing nothing", async () => { - const sign = signVrf(); - const draws = await decide( - eventsChain([]), - capabilities({ signVrf: sign }), - drawRequirement({ airdropsScheduled: 0, eventIds: [] }), - ); - expect(draws).toMatchObject({ skipped: "the game has no prize draw" }); - expect(sign).not.toHaveBeenCalled(); - }); - - test("waits for a draw that is not on chain yet, or still scheduled", async () => { - const caps = capabilities({ signVrf: signVrf() }); - expect(await decide(eventsChain([undefined, "Registering"]), caps)).toMatchObject({ - wait: true, - kind: "not-open", - }); - expect(await decide(eventsChain(["Scheduled", "Registering"]), caps)).toMatchObject({ - wait: true, - kind: "not-open", - }); - }); - - test("skips a draw that has closed, without waiting", async () => { - const draws = await decide( - eventsChain(["Registering", "Claiming"]), - capabilities({ signVrf: signVrf() }), - ); - expect(draws).toMatchObject({ kind: "closed" }); - expect("wait" in draws && draws.wait).toBeFalsy(); - }); - - test("enters even when the requirement says registration ended: the phase and the draws decide", async () => { - const draws = await decide( - open(), - capabilities({ signVrf: signVrf() }), - drawRequirement({ blockers: tagged("RegistrationEnded") }), - ); - expect(draws.entries).toHaveLength(2); - }); - - test("skips on a blocker about the player", async () => { - const draws = await decide( - open(), - capabilities({ signVrf: signVrf() }), - drawRequirement({ blockers: tagged("NotSr25519") }), - ); - expect(draws).toMatchObject({ kind: "refused" }); - }); - - test("a refused ticket skips the draws without waiting, so nobody is asked again", async () => { - const sign = vi.fn(async () => { - throw new Error("user refused"); - }); - const draws = await decide(open(), capabilities({ signVrf: sign })); - expect(draws).toMatchObject({ kind: "signature" }); - expect("wait" in draws && draws.wait).toBeFalsy(); - expect(sign).toHaveBeenCalledOnce(); - }); - - test("a ticket that does not verify enters nothing", async () => { - const draws = await decide( - open(), - capabilities({ signVrf: signVrf(), verifyVrf: () => false }), - ); - expect(draws).toMatchObject({ kind: "signature" }); - }); - - test("a recognized person with nothing to prove the draws skips them", async () => { - const draws = await decide( - open(), - capabilities({ signVrf: signVrf() }), - drawRequirement({ variant: "Alias" }), - ); - expect(draws).toMatchObject({ kind: "verified-person" }); - }); - - test("a refused member proof skips the draws without waiting", async () => { - const prove = vi.fn(async () => { - throw new Error("no grant for the People key"); - }); - const draws = await decide( - open(), - capabilities({ personhood: { holds: async () => true, prove } }), - drawRequirement({ variant: "Alias" }), - ); - expect(draws).toMatchObject({ kind: "verified-person" }); - expect("wait" in draws && draws.wait).toBeFalsy(); - }); - }); - - describe("bind", () => { - const BUILT = { transaction: new Uint8Array([1, 2, 3]) }; - const ALIAS = new Uint8Array(32).fill(9); - const proofOf = async () => - ({ - proof: new Uint8Array(1), - contextualAlias: { context: new Uint8Array(32), alias: ALIAS }, - ringIndex: 0, - ringRevision: 0, - }) as RingVRFProof; - - const bindSteps = (overrides: Partial = {}) => { - const submitRaw = vi.fn(async () => ({ ok: true })); - const steps: BindSteps = { - requirement: async () => ({ blockers: tagged("AliasNotBound") }), - facts: async () => FACTS, - build: async (_chain, _account, createProof) => { - await createProof(new Uint8Array(32)); - return BUILT; - }, - pinnedTo: async () => undefined, - submitRaw, - ...overrides, - }; - return { steps, submitRaw }; - }; - const prover = () => ({ holds: async () => true, prove: vi.fn(proofOf) }); - const bind = (personhood: PersonhoodProver | undefined, steps: BindSteps) => - runBind( - {} as GameSeatBindChain, - { account: { address: ADDRESS, publicKey: PUBLIC_KEY }, personhood }, - { tld: "paseo" }, - undefined, - steps, - ); - - test("sends the bytes raw and resolves once a block holds the bind", async () => { - const { steps, submitRaw } = bindSteps(); - expect(await bind(prover(), steps)).toBe("bound"); - expect(submitRaw).toHaveBeenCalledWith( - expect.anything(), - BUILT.transaction, - undefined, - undefined, - ); - }); - - test("resolves at once, asking for no proof, when the account is bound already", async () => { - const personhood = prover(); - const { steps, submitRaw } = bindSteps({ requirement: async () => ({ blockers: [] }) }); - expect(await bind(personhood, steps)).toBe("already-bound"); - expect(personhood.prove).not.toHaveBeenCalled(); - expect(submitRaw).not.toHaveBeenCalled(); - }); - - test("re-points a binding the ring moved past", async () => { - const { steps, submitRaw } = bindSteps({ - requirement: async () => ({ blockers: tagged("StaleAlias") }), - }); - expect(await bind(prover(), steps)).toBe("bound"); - expect(submitRaw).toHaveBeenCalledOnce(); - }); - - test("refuses a bind that cannot succeed before asking for any proof", async () => { - for (const tag of ["AccountIsALitePerson", "AliasBoundElsewhere"]) { - const personhood = prover(); - const { steps, submitRaw } = bindSteps({ - requirement: async () => ({ blockers: tagged(tag) }), - }); - await expect(bind(personhood, steps)).rejects.toMatchObject({ - refusal: "dead-end", - code: tag, - permanent: true, - }); - expect(personhood.prove).not.toHaveBeenCalled(); - expect(submitRaw).not.toHaveBeenCalled(); - } - }); - - test("refuses before submitting when the person's free seat is pinned to another account", async () => { - const { steps, submitRaw } = bindSteps({ pinnedTo: async () => OTHER }); - await expect(bind(prover(), steps)).rejects.toMatchObject({ - refusal: "pinned-elsewhere", - account: OTHER, - permanent: true, - }); - expect(submitRaw).not.toHaveBeenCalled(); - }); - - test("submits when the pin names this account, whatever prefix the chain spells it in", async () => { - const polkadot = AccountId(0).dec(PUBLIC_KEY); - const { steps, submitRaw } = bindSteps({ pinnedTo: async () => polkadot }); - expect(await bind(prover(), steps)).toBe("bound"); - expect(submitRaw).toHaveBeenCalledOnce(); - }); - - test("a bind that lost the race to another one counts as bound", async () => { - const { steps } = bindSteps({ - submitRaw: vi.fn(async () => ({ - ok: false as const, - error: "AliasAccountAlreadySet", - })), - }); - expect(await bind(prover(), steps)).toBe("already-bound"); - }); - - test("rejects with the chain's code, permanent only for the lasting refusals", async () => { - const lasting = bindSteps({ - submitRaw: vi.fn(async () => ({ ok: false as const, error: "AccountInUse" })), - }); - await expect(bind(prover(), lasting.steps)).rejects.toMatchObject({ - code: "AccountInUse", - permanent: true, - }); - const passing = bindSteps({ - submitRaw: vi.fn(async () => ({ ok: false as const, error: "BadProof" })), - }); - await expect(bind(prover(), passing.steps)).rejects.toMatchObject({ - code: "BadProof", - permanent: false, - retryable: true, - }); - }); - - test("a bind no block held in time is retryable, and may still land", async () => { - const { steps } = bindSteps({ - submitRaw: vi.fn(async () => ({ - ok: false as const, - error: "Timeout", - landingUnknown: true, - })), - }); - await expect(bind(prover(), steps)).rejects.toMatchObject({ - code: "Timeout", - retryable: true, - landingUnknown: true, - }); - }); - - test("without a lite key it fails for good, submitting nothing", async () => { - const { steps, submitRaw } = bindSteps(); - await expect(bind(undefined, steps)).rejects.toMatchObject({ - refusal: "no-lite-key", - permanent: true, - }); - expect(submitRaw).not.toHaveBeenCalled(); - }); - }); - - describe("bindGameSeat", () => { - test("reports a failed read on the err channel as a bind failure, and submits nothing", async () => { - const submitAndWatch = vi.fn(); - const chain = { - raw: { individuality: { submitAndWatch } }, - } as unknown as GameSeatBindChain; - const result = await bindGameSeat(chain, capabilities(), { tld: "paseo" }); - expect(unwrapErr(result)).toMatchObject({ - name: "GameRegistrationError", - stage: "bind", - }); - expect(submitAndWatch).not.toHaveBeenCalled(); - }); - }); - - describe("settleRaw", () => { - const watch = (emit: (observer: Parameters[0]) => void): TxWatch => ({ - subscribe(observer) { - emit(observer); - return { unsubscribe() {} }; - }, - }); - - test("resolves at the best block that holds the bind", async () => { - const result = await settleRaw( - watch((observer) => { - observer.next({ type: "broadcasted" }); - observer.next({ type: "inBestBlock", ok: true }); - }), - ); - expect(result).toEqual({ ok: true }); - }); - - test("a watch that ends with no block is a failure, not a hang", async () => { - const result = await settleRaw( - watch((observer) => { - observer.next({ type: "broadcasted" }); - observer.complete?.(); - }), - ); - expect(result.ok).toBe(false); - }); - - test("a lost watch says the landing is unknown", async () => { - const result = await settleRaw( - watch((observer) => observer.error(new Error("tracking stopped"))), - ); - expect(result).toMatchObject({ ok: false, landingUnknown: true }); - }); - - test("a watch no block answers gives up at the deadline, and lets go of it", async () => { - vi.useFakeTimers(); - try { - const unsubscribe = vi.fn(); - const silent: TxWatch = { - subscribe(observer) { - observer.next({ type: "broadcasted" }); - return { unsubscribe }; - }, - }; - const settling = settleRaw(silent, undefined, 1_000); - await vi.advanceTimersByTimeAsync(1_000); - expect(await settling).toEqual({ - ok: false, - error: "Timeout", - landingUnknown: true, - }); - expect(unsubscribe).toHaveBeenCalledOnce(); - } finally { - vi.useRealTimers(); - } - }); - }); - - describe("registrationRetryDelay", () => { - test("climbs the ladder and stays on its last rung", () => { - expect([1, 2, 3, 4, 5, 9].map(registrationRetryDelay)).toEqual([ - 5_000, 15_000, 45_000, 120_000, 300_000, 300_000, - ]); - expect(registrationRetryDelay(0)).toBe(5_000); - }); - }); - - describe("errorName", () => { - test("finds the innermost name of a nested dispatch error", () => { - expect( - errorName({ - type: "Module", - value: { type: "Game", value: { type: "NoRegistration" } }, - }), - ).toBe("NoRegistration"); - expect(errorName(new Error("socket closed"))).toBe("socket closed"); - }); - - test("reads PAPI's InvalidTxError through its error, not its JSON message", () => { - const validity = { type: "Invalid", value: { type: "BadProof" } }; - const invalid = Object.assign(new Error(JSON.stringify(validity, null, 2)), { - name: "InvalidTxError", - error: validity, - }); - expect(errorName(invalid)).toBe("BadProof"); - }); - }); - test("registerForGame reports a failed seat read on the err channel, with its step", async () => { const result = await registerForGame({} as GameRegistrationChain, capabilities(), { tld: "paseo", diff --git a/product-sdk/packages/individuality/src/seat.ts b/product-sdk/packages/individuality/src/seat.ts index 4d848249..fca3d9dc 100644 --- a/product-sdk/packages/individuality/src/seat.ts +++ b/product-sdk/packages/individuality/src/seat.ts @@ -1,32 +1,20 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: Apache-2.0 /** - * Which sign-up route a player takes for the weekly game, and whether it can be - * taken now: the one answer every product that shows or starts a sign-up has to - * agree on. + * Which sign-up route a player takes for the weekly game, so every product shows + * the same answer: * - * The chain offers several routes, and a player is shown one, chosen from their - * situation: + * - `"returning"`: a player record exists (`AlreadyPlaying`); no deposit, and + * `withScoreParticipant` waives the fee. + * - `"free"`: a lite person, on `sign_up_with_account_lite_invite` with a zero + * balance. Ready once the lite alias is bound (a stale binding counts). + * - `"deposit"`: anyone else who can pay the deposit and the fee. + * - `"unfunded"`: anyone else who cannot. Nothing can be signed. * - * - `"returning"`: a player record already exists (`AlreadyPlaying`). The - * account call is free for them, with no deposit, and `withScoreParticipant` - * waives the fee. - * - `"free"`: a lite person takes `sign_up_with_account_lite_invite`, `Pays::No`, - * valid on a zero balance. The seat is ready once the lite alias is bound to - * the account. A binding the ring has moved past (`StaleAlias`) is still - * ready, because the sign-up re-points it. - * - `"deposit"`: nobody the chain knows, with enough funds. The account call - * holds the play deposit until the player leaves. - * - `"unfunded"`: nobody the chain knows, who cannot pay the deposit and the fee. - * Nothing can be signed. + * The full person's `sign_up_with_alias` is never chosen yet. Game-phase + * blockers do not change the route; {@link GameSeat} carries them separately. * - * The full person's route, `sign_up_with_alias`, cannot be assembled yet, so it - * is never chosen. Game-phase blockers (`NoGameRunning`, `NotInRegistration`, - * `AlreadyRegistered`, …) do not change the route, only whether a sign-up can - * go out now; {@link GameSeat} carries those separately. - * - * Ported from jollity-next `packages/protocol/src/seat.ts`, which the game's app - * and chat worker used before this. + * Ported from jollity-next `packages/protocol/src/seat.ts`. */ import { err, normalizeError, ok, type Result } from "@parity/result"; From a2bd8b7e6524dc966efdbc34091f9d66b94b035d Mon Sep 17 00:00:00 2001 From: Nemanya8 Date: Mon, 19 Oct 2026 16:43:44 +0200 Subject: [PATCH 5/7] feat(sdk): reach individuality/host through the umbrella --- product-sdk/packages/sdk/package.json | 4 ++++ .../packages/sdk/src/individuality/host.test.ts | 13 +++++++++++++ product-sdk/packages/sdk/src/individuality/host.ts | 9 +++++++++ product-sdk/packages/sdk/tsup.config.ts | 1 + 4 files changed, 27 insertions(+) create mode 100644 product-sdk/packages/sdk/src/individuality/host.test.ts create mode 100644 product-sdk/packages/sdk/src/individuality/host.ts diff --git a/product-sdk/packages/sdk/package.json b/product-sdk/packages/sdk/package.json index 25654aa6..53081347 100644 --- a/product-sdk/packages/sdk/package.json +++ b/product-sdk/packages/sdk/package.json @@ -48,6 +48,10 @@ "import": "./dist/individuality/index.js", "types": "./dist/individuality/index.d.ts" }, + "./individuality/host": { + "import": "./dist/individuality/host.js", + "types": "./dist/individuality/host.d.ts" + }, "./nfts": { "import": "./dist/nfts/index.js", "types": "./dist/nfts/index.d.ts" diff --git a/product-sdk/packages/sdk/src/individuality/host.test.ts b/product-sdk/packages/sdk/src/individuality/host.test.ts new file mode 100644 index 00000000..a6897c80 --- /dev/null +++ b/product-sdk/packages/sdk/src/individuality/host.test.ts @@ -0,0 +1,13 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; + +import * as umbrella from "./host.js"; + +describe("@parity/product-sdk/individuality/host", () => { + it("reaches the game-registration capabilities without a second dependency", () => { + expect(typeof umbrella.hostGameRegistrationCapabilities).toBe("function"); + expect(typeof umbrella.hostPersonhoodProver).toBe("function"); + expect(typeof umbrella.hostSubmitSignUp).toBe("function"); + }); +}); diff --git a/product-sdk/packages/sdk/src/individuality/host.ts b/product-sdk/packages/sdk/src/individuality/host.ts new file mode 100644 index 00000000..bc64c264 --- /dev/null +++ b/product-sdk/packages/sdk/src/individuality/host.ts @@ -0,0 +1,9 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * @parity/product-sdk/individuality/host + * + * Re-exports from @parity/product-sdk-individuality/host: the game-registration + * capabilities built from a Polkadot host. + */ +export * from "@parity/product-sdk-individuality/host"; diff --git a/product-sdk/packages/sdk/tsup.config.ts b/product-sdk/packages/sdk/tsup.config.ts index b92c767e..cd4f12ef 100644 --- a/product-sdk/packages/sdk/tsup.config.ts +++ b/product-sdk/packages/sdk/tsup.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ "host/index": "src/host/index.ts", "identity/index": "src/identity/index.ts", "individuality/index": "src/individuality/index.ts", + "individuality/host": "src/individuality/host.ts", "nfts/index": "src/nfts/index.ts", "react/index": "src/react/index.ts", "local-storage/index": "src/local-storage/index.ts", From b79b4b571255a8596e9d4b4bec1f39009d4944e6 Mon Sep 17 00:00:00 2001 From: Nemanya8 Date: Mon, 19 Oct 2026 16:43:48 +0200 Subject: [PATCH 6/7] fix(individuality): address review on game registration --- .../individuality/src/bind-game-seat.ts | 47 +++- .../packages/individuality/src/host.ts | 216 +++++++++++++----- .../packages/individuality/src/index.ts | 1 + .../individuality/src/register-game-errors.ts | 129 +++++++++-- .../individuality/src/register-game-types.ts | 20 +- .../individuality/src/register-game.ts | 60 ++++- .../individuality-register-for-game.md | 4 +- .../skills/product-sdk-individuality/SKILL.md | 8 +- 8 files changed, 401 insertions(+), 84 deletions(-) diff --git a/product-sdk/packages/individuality/src/bind-game-seat.ts b/product-sdk/packages/individuality/src/bind-game-seat.ts index f5c0934b..10a91687 100644 --- a/product-sdk/packages/individuality/src/bind-game-seat.ts +++ b/product-sdk/packages/individuality/src/bind-game-seat.ts @@ -10,7 +10,13 @@ import { err, normalizeError, ok, type Result } from "@parity/result"; import { buildLiteAliasBindTx, type LiteAliasBindChain } from "./bind-lite-alias.js"; import { ProductIndividualityError } from "./errors.js"; import type { CreateRingVRFProof, RingVRFProof } from "./origin-extension.js"; -import { asRegistrationError, errorName, GameSeatBindError } from "./register-game-errors.js"; +import { + asRegistrationError, + errorDetail, + errorName, + GameSeatBindError, + type GameRegistrationCode, +} from "./register-game-errors.js"; import type { BindGameSeatOutcome, GameRegistrationCapabilities, @@ -81,8 +87,10 @@ export async function holdsLiteKey( // ── submission of the raw bind ─────────────────────────────────────────────── -/** Where a raw submission settled. */ -type Settled = { ok: true } | { ok: false; error: string; landingUnknown?: boolean }; +/** Where a raw submission settled: a code, and what the error said beside it. */ +type Settled = + | { ok: true } + | { ok: false; error: GameRegistrationCode; message?: string; landingUnknown?: boolean }; /** PAPI's words when a watch lost its blocks before the transaction's fate was known. */ function isTrackingStopped(error: unknown): boolean { @@ -148,6 +156,7 @@ function settleRaw( resolve({ ok: false, error: errorName(error), + message: errorDetail(error), ...(isTrackingStopped(error) ? { landingUnknown: true } : {}), }), ), @@ -155,7 +164,8 @@ function settleRaw( finish(() => resolve({ ok: false, - error: "the watch ended before any block held the transaction", + error: "Dropped", + message: "the watch ended before any block held the transaction", }), ), }); @@ -300,6 +310,7 @@ export async function runBind( if (result.error === "AliasAccountAlreadySet") return "already-bound"; throw new GameSeatBindError("rejected", result.error, undefined, { landingUnknown: result.landingUnknown, + detail: result.message, }); } @@ -435,6 +446,19 @@ if (import.meta.vitest) { }); }); + test("a refused bind keeps the code and puts what the chain said in the message", async () => { + const { steps } = bindSteps({ + submitRaw: vi.fn(async () => ({ + ok: false as const, + error: "Network", + message: "WebSocket closed", + })), + }); + const error = await bind(prover(), steps).catch((e: GameSeatBindError) => e); + expect(error).toMatchObject({ code: "Network", retryable: true }); + expect((error as Error).message).toContain("WebSocket closed"); + }); + test("a bind no block held in time is retryable, and may still land", async () => { const { steps } = bindSteps({ submitRaw: vi.fn(async () => ({ @@ -493,14 +517,25 @@ if (import.meta.vitest) { expect(result).toEqual({ ok: true }); }); - test("a watch that ends with no block is a failure, not a hang", async () => { + test("a watch that ends with no block is a failure, not a hang, coded Dropped", async () => { const result = await settleRaw( watch((observer) => { observer.next({ type: "broadcasted" }); observer.complete?.(); }), ); - expect(result.ok).toBe(false); + expect(result).toMatchObject({ ok: false, error: "Dropped" }); + }); + + test("a watch that fails keeps a code, and what the error said beside it", async () => { + const result = await settleRaw( + watch((observer) => observer.error(new Error("WebSocket closed at 0x12…"))), + ); + expect(result).toEqual({ + ok: false, + error: "Network", + message: "WebSocket closed at 0x12…", + }); }); test("a lost watch says the landing is unknown", async () => { diff --git a/product-sdk/packages/individuality/src/host.ts b/product-sdk/packages/individuality/src/host.ts index c35ee327..d61c4fef 100644 --- a/product-sdk/packages/individuality/src/host.ts +++ b/product-sdk/packages/individuality/src/host.ts @@ -34,6 +34,7 @@ import { getAccountsProvider, type AccountsProvider, type ProductProofContext, + type RegisteredRingVrfKey, type RingLocation as HostRingLocation, type RingVrfKeyHandle, } from "@parity/product-sdk-host"; @@ -58,50 +59,70 @@ import type { } from "./register-game-types.js"; import type { RingLocation } from "./rings.js"; -/** - * The `Members` pallet's index on the People chain. A host may hold a ring key - * declared with a `PalletInstance` junction in front of the collection id, or - * without one (`rings.ts` builds it without), so a key is looked up under both - * shapes. Carried over from jollity-next, where both shapes were met. - */ -export const MEMBERS_PALLET_INSTANCE = 67; - /** Options for {@link hostGameRegistrationCapabilities}. */ export interface HostGameRegistrationOptions { /** The network's DotNS TLD, such as `"paseo"`. */ tld: string; /** Defaults to `getAccountsProvider()`. */ accounts?: AccountsProvider; - /** Defaults to {@link MEMBERS_PALLET_INSTANCE}. */ - membersPalletInstance?: number; + /** + * The game account's derivation index. Defaults to {@link GAME_ACCOUNT_INDEX} + * (0, the host's default product account); every product that signs the + * same player up must pass the same one. + */ + accountIndex?: number; } function hostError(what: string, cause: unknown): ProductIndividualityError { return new ProductIndividualityError(`${what}: ${formatHostError(cause)}`, { cause }); } -/** The shapes a host may hold a personhood ring under. */ -function ringShapes(ring: RingLocation, palletInstance: number): RingLocation[] { - if (ring.junctions.some((junction) => junction.tag === "PalletInstance")) return [ring]; - return [ - ring, - { - chainId: ring.chainId, - junctions: [{ tag: "PalletInstance", value: palletInstance }, ...ring.junctions], - }, - ]; +type FoundKey = { handle: RingVrfKeyHandle; location: HostRingLocation }; + +/** A ring's chain and collection id, the part every host spells the same way. */ +function ringId(ring: HostRingLocation): string | undefined { + const collection = ring.junctions.find((junction) => junction.tag === "CollectionId"); + return collection && `${ring.chainId}|${collection.value}`.toLowerCase(); +} + +function palletOf(ring: HostRingLocation): number | undefined { + const pallet = ring.junctions.find((junction) => junction.tag === "PalletInstance"); + return pallet?.tag === "PalletInstance" ? pallet.value : undefined; +} + +/** + * The key to prove with for `ring`, and the location to prove under, which is + * always the ring as the key declares it. A key declared exactly as asked wins. + * Otherwise a key declared on the same chain and collection id matches whatever + * `PalletInstance` is in front of it: one host spells the `Members` ring with + * the pallet's index (read from its runtime), another without, and the index + * moves when the runtime does. Keys that name two different pallets are + * ambiguous, and none is picked. + */ +function findPersonhoodKey(keys: RegisteredRingVrfKey[], ring: RingLocation): FoundKey | null { + const asked = ring as HostRingLocation; + const exact = findRingVrfKeyHandle(keys, asked); + if (exact) return { handle: exact, location: asked }; + const id = ringId(asked); + if (!id) return null; + const matches = keys.flatMap((key) => + key.rings + .filter((declared) => ringId(declared) === id) + .map((location) => ({ handle: key.handle, location })), + ); + const pallets = new Set(matches.map((match) => palletOf(match.location))); + pallets.delete(undefined); + return pallets.size > 1 ? null : (matches[0] ?? null); } /** - * The personhood keys the host holds, looked up by owner and ring. A key that - * was found is kept for the life of the prover, so a sign-up's draw proofs ask - * the host to list keys once; a lookup that finds nothing or fails is not kept. + * The personhood keys the host holds, looked up by owner and ring, with no + * pallet index of its own. A key that was found is kept for the life of the + * prover, so a sign-up's draw proofs ask the host to list keys once; a lookup + * that finds nothing or fails is not kept. */ -export function hostPersonhoodProver( - accounts: AccountsProvider, - palletInstance: number = MEMBERS_PALLET_INSTANCE, -): PersonhoodProver { - type Found = { handle: RingVrfKeyHandle; location: HostRingLocation } | null; +export function hostPersonhoodProver(accounts: AccountsProvider): PersonhoodProver { + type Found = FoundKey | null; const found = new Map>(); const lookUp = async (ring: PersonhoodRing): Promise => { @@ -111,12 +132,7 @@ export function hostPersonhoodProver( throw hostError(`the host could not list the keys of ${ring.owner}`, cause); }, ); - for (const shape of ringShapes(ring.ring, palletInstance)) { - const location = shape as HostRingLocation; - const handle = findRingVrfKeyHandle(keys, location); - if (handle) return { handle, location }; - } - return null; + return findPersonhoodKey(keys, ring.ring); }; const keyFor = (ring: PersonhoodRing): Promise => { const cacheKey = `${ring.owner}|${JSON.stringify(ring.ring)}`; @@ -179,25 +195,29 @@ export const hostSubmitSignUp: SubmitSignUp = async (tx, signer, options) => { : { ok: false, code: errorName(result.value.dispatchError) }; } const error = result.error; + // the code is a name; what the error said goes beside it + const said = { message: error.message, cause: error }; if (error instanceof TxSigningRejectedError) { - return { ok: false, code: "SigningRejected", refused: true }; + return { ok: false, code: "SigningRejected", refused: true, ...said }; } if (error instanceof TxTimeoutError) - return { ok: false, code: "Timeout", landingUnknown: true }; + return { ok: false, code: "Timeout", landingUnknown: true, ...said }; if (error instanceof TxDispatchError) - return { ok: false, code: errorName(error.dispatchError) }; - if (error instanceof TxValidityError) return { ok: false, code: errorName(error.reason) }; + return { ok: false, code: errorName(error.dispatchError), ...said }; + if (error instanceof TxValidityError) + return { ok: false, code: errorName(error.reason), ...said }; return { ok: false, code: errorName(error), + ...said, ...(/tracking stopped/i.test(error.message) ? { landingUnknown: true } : {}), }; }; /** * The capabilities a sign-up needs, from a host: the game account - * (`dim2.` at `GAME_ACCOUNT_INDEX`) and its signer, `tx`'s submission, the - * draw tickets, and the personhood keys. + * (`dim2.` at `accountIndex`, by default `GAME_ACCOUNT_INDEX`) and its + * signer, `tx`'s submission, the draw tickets, and the personhood keys. */ export async function hostGameRegistrationCapabilities( options: HostGameRegistrationOptions, @@ -208,10 +228,11 @@ export async function hostGameRegistrationCapabilities( throw new ProductIndividualityError("no host accounts: not inside a Polkadot host"); } const product = gameProductId(options.tld); - const account = await accounts.getProductAccount(product, GAME_ACCOUNT_INDEX).match( + const index = options.accountIndex ?? GAME_ACCOUNT_INDEX; + const account = await accounts.getProductAccount(product, index).match( (value) => value, (cause) => { - throw hostError(`the host would not name ${product}/${GAME_ACCOUNT_INDEX}`, cause); + throw hostError(`the host would not name ${product}/${index}`, cause); }, ); return ok({ @@ -225,7 +246,7 @@ export async function hostGameRegistrationCapabilities( throw hostError("the host refused the draw ticket", cause); }, ), - personhood: hostPersonhoodProver(accounts, options.membersPalletInstance), + personhood: hostPersonhoodProver(accounts), }); } catch (cause) { return err(normalizeError(cause, ProductIndividualityError)); @@ -277,6 +298,14 @@ if (import.meta.vitest) { expect(caps.submit).toBe(hostSubmitSignUp); }); + test("asks for another index when the product passes one", async () => { + const accounts = provider(); + unwrapOk( + await hostGameRegistrationCapabilities({ tld: "paseo", accounts, accountIndex: 3 }), + ); + expect(accounts.getProductAccount).toHaveBeenCalledWith("dim2.paseo", 3); + }); + test("reports a host that will not name the account on the err channel", async () => { const accounts = { ...provider(), @@ -293,13 +322,49 @@ if (import.meta.vitest) { expect(await hostPersonhoodProver(provider(keys)).holds(LITE)).toBe(true); }); - test("finds a key held under the ring with the pallet in front", async () => { - const withPallet = { - chainId: RING.chainId, - junctions: [{ tag: "PalletInstance", value: 67 }, ...RING.junctions], - }; - const keys = [{ handle: "h1", rings: [withPallet] }]; - expect(await hostPersonhoodProver(provider(keys)).holds(LITE)).toBe(true); + /** The ring as a host spells it with a pallet index in front. */ + const withPallet = (index: number, chainId: string = RING.chainId) => ({ + chainId, + junctions: [{ tag: "PalletInstance", value: index }, ...RING.junctions], + }); + const CONTEXT = { productId: "peopl.paseo", suffix: { tag: "Index" as const, value: 0 } }; + + test("finds a key held with any pallet in front, and proves under that ring", async () => { + const accounts = provider([{ handle: "h1", rings: [withPallet(68)] }]); + await hostPersonhoodProver(accounts).prove(LITE, CONTEXT, new Uint8Array()); + expect(accounts.createRingVRFProof).toHaveBeenCalledWith( + "h1", + CONTEXT, + withPallet(68), + new Uint8Array(), + ); + }); + + test("prefers the key declared exactly as asked", async () => { + const accounts = provider([ + { handle: "h1", rings: [withPallet(67)] }, + { handle: "h2", rings: [RING] }, + ]); + await hostPersonhoodProver(accounts).prove(LITE, CONTEXT, new Uint8Array()); + expect(accounts.createRingVRFProof).toHaveBeenCalledWith( + "h2", + CONTEXT, + RING, + new Uint8Array(), + ); + }); + + test("picks none when the keys name two different pallets", async () => { + const keys = [ + { handle: "h1", rings: [withPallet(67)] }, + { handle: "h2", rings: [withPallet(68)] }, + ]; + expect(await hostPersonhoodProver(provider(keys)).holds(LITE)).toBe(false); + }); + + test("never matches the same collection on another chain", async () => { + const keys = [{ handle: "h1", rings: [withPallet(67, "0x02")] }]; + expect(await hostPersonhoodProver(provider(keys)).holds(LITE)).toBe(false); }); test("lists the keys once for many proofs", async () => { @@ -360,6 +425,55 @@ if (import.meta.vitest) { expect(onBroadcast).toHaveBeenCalledOnce(); }); + /** A transaction whose watch fails with `error` before any block. */ + const txFailing = (error: unknown) => + ({ + getEstimatedFees: async () => 0n, + createSubmitAndWatch: () => ({ + subscribe(handlers: { error: (e: unknown) => void }) { + handlers.error(error); + return { unsubscribe() {} }; + }, + }), + }) as unknown as Parameters[0]; + + test("a declined signature is refused, never retried", async () => { + const result = await hostSubmitSignUp( + txFailing(new Error("User rejected the request")), + signer, + { waitFor: "best-block" }, + ); + expect(result).toMatchObject({ ok: false, code: "SigningRejected", refused: true }); + }); + + test("a validity error is named by its reason", async () => { + const validity = { type: "Invalid", value: { type: "BadProof" } }; + const invalid = Object.assign(new Error(JSON.stringify(validity)), { + name: "InvalidTxError", + error: validity, + }); + const result = await hostSubmitSignUp(txFailing(invalid), signer, { + waitFor: "best-block", + }); + expect(result).toMatchObject({ ok: false, code: "BadProof" }); + }); + + test("a lost watch may still land, coded Network with what it said beside it", async () => { + const result = await hostSubmitSignUp( + txFailing(new Error("Tracking stopped")), + signer, + { + waitFor: "best-block", + }, + ); + expect(result).toMatchObject({ + ok: false, + code: "Network", + landingUnknown: true, + message: "Tracking stopped", + }); + }); + test("names a refused dispatch by the pallet's error", async () => { const result = await hostSubmitSignUp( txOf([ @@ -378,7 +492,7 @@ if (import.meta.vitest) { signer, { waitFor: "finalized" }, ); - expect(result).toEqual({ ok: false, code: "AlreadyRegistered" }); + expect(result).toMatchObject({ ok: false, code: "AlreadyRegistered" }); }); }); } diff --git a/product-sdk/packages/individuality/src/index.ts b/product-sdk/packages/individuality/src/index.ts index 5dd11dc3..f2e73374 100644 --- a/product-sdk/packages/individuality/src/index.ts +++ b/product-sdk/packages/individuality/src/index.ts @@ -498,6 +498,7 @@ export { GameSeatBindError, registrationRetryDelay, } from "./register-game-errors.js"; +export type { GameRegistrationCode, GameRegistrationOwnCode } from "./register-game-errors.js"; export { registerForGame } from "./register-game.js"; export type { DrawWaitOptions, diff --git a/product-sdk/packages/individuality/src/register-game-errors.ts b/product-sdk/packages/individuality/src/register-game-errors.ts index 6a4f18bc..aa46dd2d 100644 --- a/product-sdk/packages/individuality/src/register-game-errors.ts +++ b/product-sdk/packages/individuality/src/register-game-errors.ts @@ -11,13 +11,46 @@ import type { GameSeatBindRefusal, } from "./register-game-types.js"; +/** + * The codes this package sets itself, beside the chain's own error names. + * + * - `Timeout`: no answer in time, from the watch or the host. + * - `Aborted`: the caller's signal. + * - `SigningRejected`: the player declined to sign. + * - `HostRefused`: the host turned a call down (a grant, a proof, a key). + * - `Network`: the connection or the block subscription dropped. + * - `Dropped`: the transaction left the pool without a block holding it. + * - `InvalidSignUp`: the sign-up call could not be built. + * - `StaleAliasNeedsLiteKey`, `NoPersonhoodProver`: nothing here can make a proof the step needs. + * - `Unknown`: anything else; the error's `message` and `cause` say what. + */ +export type GameRegistrationOwnCode = + | "Timeout" + | "Aborted" + | "SigningRejected" + | "HostRefused" + | "Network" + | "Dropped" + | "InvalidSignUp" + | "StaleAliasNeedsLiteKey" + | "NoPersonhoodProver" + | "Unknown"; + +/** + * A failure's code: one of {@link GameRegistrationOwnCode}, or the chain's own + * error name (`NoRegistration`, `AccountInUse`, `Custom(172)`, a blocker such as + * `AliasBoundElsewhere`). Never free text, so it can be grouped and compared; + * what the error said rides along as the error's `message` and `cause`. + */ +export type GameRegistrationCode = GameRegistrationOwnCode | (string & {}); + /** * A step of the registration that failed: `stage` says which, `code` what the * chain or host answered, `retryable` whether running it again can help. */ export class GameRegistrationError extends ProductIndividualityError { readonly stage: GameRegistrationStep; - readonly code: string; + readonly code: GameRegistrationCode; /** False when trying again cannot help: a refusal, a closed registration, a bad call. */ readonly retryable: boolean; /** The sign-up's watch lost track: it may still land. */ @@ -27,16 +60,19 @@ export class GameRegistrationError extends ProductIndividualityError { constructor( stage: GameRegistrationStep, - code: string, + code: GameRegistrationCode, options: { retryable: boolean; landingUnknown?: boolean; message?: string; + /** What the error said, kept out of `code`: it goes into the default message. */ + detail?: string; cause?: unknown; }, ) { super( - options.message ?? `game registration failed at ${stage}: ${code}`, + options.message ?? + `game registration failed at ${stage}: ${code}${options.detail ? ` (${options.detail})` : ""}`, options.cause === undefined ? undefined : { cause: options.cause }, ); this.name = "GameRegistrationError"; @@ -65,19 +101,20 @@ export class GameSeatBindError extends GameRegistrationError { constructor( readonly refusal: GameSeatBindRefusal, - code: string, + code: GameRegistrationCode, /** For `"pinned-elsewhere"`, the account the seat is pinned to. */ readonly account?: string, - options: { landingUnknown?: boolean } = {}, + options: { landingUnknown?: boolean; detail?: string; cause?: unknown } = {}, ) { const permanent = bindIsPermanent(refusal, code); super("bind", code, { retryable: !permanent, landingUnknown: options.landingUnknown, + cause: options.cause, message: refusal === "pinned-elsewhere" ? `lite bind refused: the person's free seat is on ${account ?? "another account"}` - : `lite bind failed: ${code}`, + : `lite bind failed: ${code}${options.detail ? ` (${options.detail})` : ""}`, }); this.name = "GameSeatBindError"; this.permanent = permanent; @@ -115,24 +152,52 @@ export function registrationRetryDelay(failures: number): number { } /** - * The name of a chain error: the innermost `type` of a dispatch or validity - * error (read through PAPI's `InvalidTxError.error`), else the message. + * An error's code: the innermost `type` of a dispatch or validity error (read + * through PAPI's `InvalidTxError.error`), `Custom(n)` for a numbered custom + * one, else one of this package's own codes, never the message. */ -export function errorName(error: unknown): string { +export function errorName(error: unknown): GameRegistrationCode { let node: unknown = error; let name: string | undefined; + let custom: number | undefined; for (let depth = 0; depth < 8 && node !== null && typeof node === "object"; depth += 1) { const { type, value, error: inner, } = node as { type?: unknown; value?: unknown; error?: unknown }; - if (typeof type === "string") name = type; + if (typeof type === "string") { + name = type; + custom = type === "Custom" && typeof value === "number" ? value : undefined; + } node = value !== undefined ? value : inner; } - if (name) return name; - if (error instanceof Error && error.message) return error.message; - return typeof error === "string" && error ? error : "DispatchError"; + if (name) return custom === undefined ? name : `Custom(${custom})`; + return ownCode(error); +} + +/** One of this package's codes, for an error that names no chain error. */ +function ownCode(error: unknown): GameRegistrationOwnCode { + if (error instanceof Error && error.name === "AbortError") return "Aborted"; + const text = + error instanceof Error + ? `${error.name} ${error.message}` + : typeof error === "string" + ? error + : ""; + if (/signing was rejected|SigningRejected/i.test(text)) return "SigningRejected"; + if (/timed? ?out|timeout|no answer/i.test(text)) return "Timeout"; + if (/tracking stopped|disconnect|socket|connection|network|closed/i.test(text)) + return "Network"; + if (/HostRejected|rejected|refused|denied|not allowed|permission/i.test(text)) + return "HostRefused"; + return "Unknown"; +} + +/** What an error said, for a failure's message: never its code. */ +export function errorDetail(error: unknown): string | undefined { + if (error instanceof Error) return error.message || undefined; + return typeof error === "string" && error ? error : undefined; } export function asRegistrationError( @@ -140,9 +205,9 @@ export function asRegistrationError( error: unknown, ): GameRegistrationError { if (error instanceof GameRegistrationError) return error; - const aborted = error instanceof Error && error.name === "AbortError"; - return new GameRegistrationError(step, aborted ? "Aborted" : errorName(error), { + return new GameRegistrationError(step, errorName(error), { retryable: true, + detail: errorDetail(error), cause: error, }); } @@ -167,7 +232,39 @@ if (import.meta.vitest) { value: { type: "Game", value: { type: "NoRegistration" } }, }), ).toBe("NoRegistration"); - expect(errorName(new Error("socket closed"))).toBe("socket closed"); + }); + + test("keeps a numbered custom error as one code", () => { + expect(errorName({ type: "Invalid", value: { type: "Custom", value: 172 } })).toBe( + "Custom(172)", + ); + }); + + test("never returns free text: an error with no chain name gets one of our codes", () => { + const abort = Object.assign(new Error("stop"), { name: "AbortError" }); + expect(errorName(abort)).toBe("Aborted"); + expect(errorName(new Error("Transaction signing was rejected."))).toBe( + "SigningRejected", + ); + expect(errorName(new Error("ReadTimeoutError: no answer after 15 seconds"))).toBe( + "Timeout", + ); + expect(errorName(new Error("WebSocket closed at 0x1234…"))).toBe("Network"); + expect(errorName(new Error("Tracking stopped"))).toBe("Network"); + expect(errorName(new Error("HostRejectedError: Host rejected the request"))).toBe( + "HostRefused", + ); + expect(errorName(new Error("something odd at block 0xabc"))).toBe("Unknown"); + expect(errorName("plain text")).toBe("Unknown"); + expect(errorName(undefined)).toBe("Unknown"); + }); + + test("a failure keeps what the error said in its message and cause, not its code", () => { + const cause = new Error("WebSocket closed at 0x1234…"); + const failure = asRegistrationError("seat", cause); + expect(failure.code).toBe("Network"); + expect(failure.message).toContain("WebSocket closed at 0x1234…"); + expect(failure.cause).toBe(cause); }); test("reads PAPI's InvalidTxError through its error, not its JSON message", () => { diff --git a/product-sdk/packages/individuality/src/register-game-types.ts b/product-sdk/packages/individuality/src/register-game-types.ts index baa0ccc7..987ce591 100644 --- a/product-sdk/packages/individuality/src/register-game-types.ts +++ b/product-sdk/packages/individuality/src/register-game-types.ts @@ -10,6 +10,7 @@ import type { AirdropChain } from "./airdrop-read.js"; import type { LiteAliasBindChain } from "./bind-lite-alias.js"; import type { GameChain } from "./game-read.js"; import type { RingVRFProof } from "./origin-extension.js"; +import type { GameRegistrationCode } from "./register-game-errors.js"; import type { RingLocation, ScoreContextChain } from "./rings.js"; import type { SeatPath, SeatRoute, TimestampChain } from "./seat.js"; import type { @@ -118,8 +119,12 @@ export type SignUpSubmitResult = | { ok: true } | { ok: false; - /** The chain's error name, or what stopped it before the chain. */ - code: string; + /** The chain's error name, or one of the package's own codes: never free text. */ + code: GameRegistrationCode; + /** What the error said, for a log: kept out of `code`. */ + message?: string; + /** The error itself. */ + cause?: unknown; /** The player declined to sign. Never retried. */ refused?: boolean; /** The watch lost track of it: it may still land. Read the seat before trying again. */ @@ -227,12 +232,19 @@ export interface GameRegistrationReport { liteKeyLookupFailed?: string; bind?: | { outcome: BindGameSeatOutcome } - | { outcome: "refused"; refusal: GameSeatBindRefusal; code: string; permanent: boolean }; + | { + outcome: "refused"; + refusal: GameSeatBindRefusal; + code: GameRegistrationCode; + permanent: boolean; + }; route?: SeatRoute; draws?: DrawOutcome; signUp?: { outcome: "registered" | "already-registered" | "failed"; - code?: string; + code?: GameRegistrationCode; + /** What the failure said, for a log: never a tag */ + message?: string; landingUnknown?: boolean; }; } diff --git a/product-sdk/packages/individuality/src/register-game.ts b/product-sdk/packages/individuality/src/register-game.ts index 4c79a495..430c2ae6 100644 --- a/product-sdk/packages/individuality/src/register-game.ts +++ b/product-sdk/packages/individuality/src/register-game.ts @@ -40,6 +40,7 @@ import { import { ProductIndividualityError } from "./errors.js"; import { asRegistrationError, + errorDetail, errorName, GameRegistrationError, GameSeatBindError, @@ -391,12 +392,17 @@ async function runRegistration( "the binding is stale, and nothing here can make the proof that re-points it", }); } + // The re-point's proof is made when the signer asks for it, so + // the personhood read it needs can only fail the sign step, + // never the draws. + const prover = capabilities.personhood; signer = withLiteAlias( capabilities.signer, - stale + stale && prover ? { tag: "AliasWithAccountRevised", - createProof: liteProof(capabilities.personhood!, await facts()), + createProof: async (message) => + liteProof(prover, await facts())(message), } : { tag: "AliasWithAccount" }, ); @@ -478,6 +484,7 @@ async function runRegistration( } catch (error) { throw new GameRegistrationError("sign", "InvalidSignUp", { retryable: false, + detail: errorDetail(error), cause: error, }); } @@ -502,11 +509,14 @@ async function runRegistration( report.signUp = { outcome: "failed", code: result.code, + ...(result.message ? { message: result.message } : {}), ...(result.landingUnknown ? { landingUnknown: true } : {}), }; throw new GameRegistrationError("sign", result.code, { retryable: result.refused !== true && !NOT_RETRYABLE_SIGN_UP.has(result.code), landingUnknown: result.landingUnknown, + detail: result.message, + cause: result.cause, }); } report.signUp = { outcome: "registered" }; @@ -639,7 +649,7 @@ if (import.meta.vitest) { expect.objectContaining({ litePerson: false }), ); expect(outcome).toMatchObject({ kind: "registered", route: "deposit" }); - expect(outcome.report.liteKeyLookupFailed).toBe("Rejected"); + expect(outcome.report.liteKeyLookupFailed).toBe("HostRefused"); expect(calls).toHaveLength(1); }); @@ -701,6 +711,31 @@ if (import.meta.vitest) { }); }); + test("a stale binding asks for its proof only when signing, so a failed personhood read never fails the draws", async () => { + const facts = vi.fn(async (): Promise => { + throw new Error("score context unreadable"); + }); + const onDraws = vi.fn(); + const { submit, calls } = fakeSubmit(); + const personhood = { holds: async () => true, prove: vi.fn() }; + const outcome = await run( + capabilities({ submit, personhood }), + { + seat: async () => seatOf({ path: "free" }), + requirement: async () => drawRequirement({ blockers: tagged("StaleAlias") }), + facts, + }, + { onDraws }, + ); + expect(onDraws).toHaveBeenCalledOnce(); + expect(outcome.kind).toBe("registered"); + // read once by the seat step's lite-key check (absorbed there), never by the + // draws: the fake submit never signs, so the proof's own read never ran + expect(facts).toHaveBeenCalledOnce(); + expect(outcome.report.liteKeyLookupFailed).toBeDefined(); + expect(calls[0]!.call).toBe("sign_up_with_account_lite_invite"); + }); + test("reads the requirement by the chain's clock", async () => { const requirement = vi.fn(async () => drawRequirement()); await run(capabilities(), { now: async () => 4_242, requirement }); @@ -892,6 +927,25 @@ if (import.meta.vitest) { expect(calls[0]!.options.signal).toBe(abort.signal); }); + test("a failed sign-up keeps its code a name, and what the host said in the message and report", async () => { + const cause = new Error("WebSocket closed at 0x12…"); + const { submit } = fakeSubmit({ + ok: false, + code: "Network", + message: "WebSocket closed at 0x12…", + cause, + }); + const error = await failure(run(capabilities({ submit }))); + expect(error).toMatchObject({ stage: "sign", code: "Network", retryable: true }); + expect(error.message).toContain("WebSocket closed at 0x12…"); + expect(error.cause).toBe(cause); + expect(error.report?.signUp).toMatchObject({ + outcome: "failed", + code: "Network", + message: "WebSocket closed at 0x12…", + }); + }); + test("passes waitFor through when asked", async () => { const { submit, calls } = fakeSubmit(); await run(capabilities({ submit }), {}, { waitFor: "finalized" }); diff --git a/product-sdk/pending-changesets/individuality-register-for-game.md b/product-sdk/pending-changesets/individuality-register-for-game.md index 4cfebfec..9474f4f9 100644 --- a/product-sdk/pending-changesets/individuality-register-for-game.md +++ b/product-sdk/pending-changesets/individuality-register-for-game.md @@ -6,8 +6,8 @@ Add game registration in one call, so every product that signs players up for the weekly game (the game's app and chat, the personhood product) runs the same steps and gives the same answers. - `registerForGame(chain, capabilities, options)` reads the seat, binds the lite alias when the free route needs it, decides the prize-draw entries at the best block, then hands the call, signed under the origin its route needs, to a `submit` capability. Registration is judged by the chain's clock, and the sign-up resolves at the best block unless `waitFor: "finalized"` is asked for. The draws never block the sign-up: a draw that is not open yet, or a read that fails, costs the tickets, and the outcome says why. An abort before the broadcast sends nothing. -- Every outcome, and every `GameRegistrationError`, carries a report and names the step that failed and whether a retry can help; `registrationRetryDelay` gives the backoff. A declined signature is never asked for again. +- Every outcome, and every `GameRegistrationError`, carries a report and names the step that failed and whether a retry can help; `registrationRetryDelay` gives the backoff. A declined signature is never asked for again. A failure's `code` is always a name (the chain's error name, or one of `GameRegistrationOwnCode` such as `Timeout`, `Network` or `HostRefused`), never free text; what the error said is in `message` and `cause`. - `readGameSeat` and the pure `deriveSeat` give the route (`returning`, `free`, `deposit` or `unfunded`), so every product shows the same answer. - `bindGameSeat` runs the bind on its own: a current binding and a bind that cannot succeed ask for no proof, the `Game.LiteInvites` pin is checked before it goes out, and a bind no block holds within `timeoutMs` fails with `landingUnknown`. - `GAME_PRODUCT_NAME`, `GAME_ACCOUNT_INDEX` and `gameProductId` name the game account in one place. -- The new `@parity/product-sdk-individuality/host` entry builds the capabilities from a host with `hostGameRegistrationCapabilities`, submitting through `@parity/product-sdk-tx`. The main entry imports neither. +- The new `@parity/product-sdk-individuality/host` entry builds the capabilities from a host with `hostGameRegistrationCapabilities({ tld, accountIndex? })`, submitting through `@parity/product-sdk-tx`; `accountIndex` defaults to `GAME_ACCOUNT_INDEX`. It finds the personhood keys by chain and collection id, whatever pallet index the host lists them under, so no index is hard-coded. The main entry imports neither. The umbrella reaches it as `@parity/product-sdk/individuality/host`. diff --git a/product-sdk/skills/product-sdk-individuality/SKILL.md b/product-sdk/skills/product-sdk-individuality/SKILL.md index 076efc55..0abbe522 100644 --- a/product-sdk/skills/product-sdk-individuality/SKILL.md +++ b/product-sdk/skills/product-sdk-individuality/SKILL.md @@ -470,11 +470,13 @@ switch (outcome.value.kind) { > **RETRY BY CALLING AGAIN.** Every step reads the chain before it acts, so a second call resumes: a bind that landed is not sent again and a sign-up that landed is `already-registered`. Check `retryable` first (a declined signature, a closed registration or a pinned seat never are), wait `registrationRetryDelay(failures)` (5 s, 15 s, 45 s, 2 min, 5 min), and never pass a cached `seat` on a retry. -> **ONE ACCOUNT FOR EVERY PRODUCT.** The game account is `gameProductId(tld)` at `GAME_ACCOUNT_INDEX` (0, the host's default product account). The host adapter asks for exactly that. A product other than the game product signs as it only under the game product's `context` grant, and proves with the personhood keys only under the personhood product's. +> **ONE ACCOUNT FOR EVERY PRODUCT.** The game account is `gameProductId(tld)` at `GAME_ACCOUNT_INDEX` (0, the host's default product account). The host adapter asks for that unless you pass `accountIndex`; every product that signs the same player up must use the same index, or they sign up two different accounts. A product other than the game product signs as it only under the game product's `context` grant, and proves with the personhood keys only under the personhood product's. > **THE IDENTIFIER IS WHOEVER REGISTERS.** `identifierKey` is rewritten on every sign-up, and no call changes it afterwards. A product that registers for the game must pass a key whose private half the game product can use at game time. -> **CAPABILITIES, NOT A HOST.** The main entry imports neither `@parity/product-sdk-host` nor `@parity/product-sdk-tx`. `GameRegistrationCapabilities` carries the account, its signer, `submit`, `signVrf`, an optional `verifyVrf`, and the personhood prover. `hostGameRegistrationCapabilities` builds them with `tx`'s `submitAndWatch` as `submit`; a worker that cannot load `tx` passes its own. +> **CAPABILITIES, NOT A HOST.** The main entry imports neither `@parity/product-sdk-host` nor `@parity/product-sdk-tx`. `GameRegistrationCapabilities` carries the account, its signer, `submit`, `signVrf`, an optional `verifyVrf`, and the personhood prover. `hostGameRegistrationCapabilities` builds them with `tx`'s `submitAndWatch` as `submit`; a worker that cannot load `tx` passes its own. Through the umbrella it is `@parity/product-sdk/individuality/host`. + +> **A CODE IS A NAME, NEVER FREE TEXT.** A `GameRegistrationError`'s `code` (and the report's `signUp.code` and bind `code`) is the chain's own error name (`NoRegistration`, `AccountInUse`, `Custom(172)`) or one of the package's: `Timeout`, `Aborted`, `SigningRejected`, `HostRefused`, `Network`, `Dropped`, `InvalidSignUp`, `StaleAliasNeedsLiteKey`, `NoPersonhoodProver`, `Unknown`. Group telemetry and branch on it; what the error said is in `message`, the original in `cause`. ## Proof Contexts and Ring Locations @@ -524,6 +526,8 @@ const ring = litePeopleRing(genesisHash); No `PalletInstance` junction is emitted, and that is deliberate: every host resolves the `Members` pallet by name, and treats the junction as an optional cross-check when present. +> **A LISTED KEY MAY SPELL ITS RING THE OTHER WAY.** `listRingVrfKeys` returns each key's rings as declared, and the Android host declares the personhood keys with the `Members` pallet's index in front, read from its runtime. `findRingVrfKeyHandle` compares exactly, so a bare ring misses them. The `/host` adapter matches on chain and `CollectionId` instead, prefers an exact match, picks no key when the matches name two different pallets, and proves under the ring as the key declares it. Never hard-code the pallet index: it moves with the runtime. + ### Checking the chain agrees `readScoreContext` reads `Score.score_context` and checks it is the product derivation of `peopl./Index(0)`. A runtime publishing something else publishes a context no stock host can mint, and that arrives as `NotProductDerived` on the **ok** channel — an answer, not a failure. From 0338a044167a5cb7cd99c1aa8ab3468a88153b36 Mon Sep 17 00:00:00 2001 From: Nemanya8 Date: Mon, 19 Oct 2026 18:19:01 +0200 Subject: [PATCH 7/7] refactor(individuality): share the tracking check and pull out the draws wait --- .../individuality/src/bind-game-seat.ts | 6 +- .../packages/individuality/src/host.ts | 4 +- .../individuality/src/register-game-errors.ts | 9 +++ .../individuality/src/register-game.ts | 57 +++++++++++++------ 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/product-sdk/packages/individuality/src/bind-game-seat.ts b/product-sdk/packages/individuality/src/bind-game-seat.ts index 10a91687..86c4aa9f 100644 --- a/product-sdk/packages/individuality/src/bind-game-seat.ts +++ b/product-sdk/packages/individuality/src/bind-game-seat.ts @@ -15,6 +15,7 @@ import { errorDetail, errorName, GameSeatBindError, + isTrackingStopped, type GameRegistrationCode, } from "./register-game-errors.js"; import type { @@ -92,11 +93,6 @@ type Settled = | { ok: true } | { ok: false; error: GameRegistrationCode; message?: string; landingUnknown?: boolean }; -/** PAPI's words when a watch lost its blocks before the transaction's fate was known. */ -function isTrackingStopped(error: unknown): boolean { - return error instanceof Error && /tracking stopped/i.test(error.message); -} - /** How long the bind waits for a best block to hold it, from submission. */ const DEFAULT_BIND_TIMEOUT_MS = 120_000; diff --git a/product-sdk/packages/individuality/src/host.ts b/product-sdk/packages/individuality/src/host.ts index d61c4fef..a660adb9 100644 --- a/product-sdk/packages/individuality/src/host.ts +++ b/product-sdk/packages/individuality/src/host.ts @@ -50,7 +50,7 @@ import { AccountId } from "polkadot-api"; import { ProductIndividualityError } from "./errors.js"; import { GAME_ACCOUNT_INDEX, gameProductId } from "./game-account.js"; -import { errorName } from "./register-game-errors.js"; +import { errorName, isTrackingStopped } from "./register-game-errors.js"; import type { GameRegistrationCapabilities, PersonhoodProver, @@ -210,7 +210,7 @@ export const hostSubmitSignUp: SubmitSignUp = async (tx, signer, options) => { ok: false, code: errorName(error), ...said, - ...(/tracking stopped/i.test(error.message) ? { landingUnknown: true } : {}), + ...(isTrackingStopped(error) ? { landingUnknown: true } : {}), }; }; diff --git a/product-sdk/packages/individuality/src/register-game-errors.ts b/product-sdk/packages/individuality/src/register-game-errors.ts index aa46dd2d..5f7a009d 100644 --- a/product-sdk/packages/individuality/src/register-game-errors.ts +++ b/product-sdk/packages/individuality/src/register-game-errors.ts @@ -145,6 +145,10 @@ export const GAME_REGISTRATION_RETRY_MS: readonly number[] = [ 5_000, 15_000, 45_000, 120_000, 300_000, ]; +/** + * For a product that retries a failed registration itself: nothing in this + * package calls it, since `registerForGame` never retries. + */ export function registrationRetryDelay(failures: number): number { const index = Math.min(Math.max(Math.floor(failures), 1), GAME_REGISTRATION_RETRY_MS.length) - 1; @@ -200,6 +204,11 @@ export function errorDetail(error: unknown): string | undefined { return typeof error === "string" && error ? error : undefined; } +/** PAPI's words when a watch lost its blocks before the transaction's fate was known. */ +export function isTrackingStopped(error: unknown): boolean { + return error instanceof Error && /tracking stopped/i.test(error.message); +} + export function asRegistrationError( step: GameRegistrationStep, error: unknown, diff --git a/product-sdk/packages/individuality/src/register-game.ts b/product-sdk/packages/individuality/src/register-game.ts index 430c2ae6..9c52ec92 100644 --- a/product-sdk/packages/individuality/src/register-game.ts +++ b/product-sdk/packages/individuality/src/register-game.ts @@ -92,6 +92,40 @@ function sleep(ms: number, signal: AbortSignal | undefined): Promise { }); } +/** + * Asks `decide` for the draw entries again every `retryMs` while it says to + * wait, until `maxMs` from `startedAt` or until `closing` says registration is + * about to close. Giving up keeps the skip and says why. + */ +async function waitForDraws( + decide: () => Promise, + closing: () => Promise, + wait: { startedAt: number; maxMs: number; retryMs: number }, + signal: AbortSignal | undefined, +): Promise<{ draws: DrawEntries; retries: number }> { + let retries = 0; + let draws = await decide(); + while (draws.entries === undefined && draws.wait) { + const isClosing = await closing(); + if (Date.now() - wait.startedAt >= wait.maxMs || isClosing) { + const why = isClosing ? ": registration is closing" : ""; + return { + draws: { + ...draws, + skipped: `${draws.skipped} (gave up waiting${why})`, + wait: false, + ...(isClosing ? { kind: "closing" as const } : {}), + }, + retries, + }; + } + await sleep(wait.retryMs, signal); + retries += 1; + draws = await decide(); + } + return { draws, retries }; +} + /** * The signer, made to drop a signature that arrives after `signal` aborts. The * transaction goes out only with the signed bytes, so an abort while the signing @@ -349,7 +383,6 @@ async function runRegistration( const retryMs = wait.retryMs ?? DEFAULT_DRAW_RETRY_MS; const closingSeconds = wait.closingSeconds ?? DEFAULT_DRAW_CLOSING_S; const startedAt = Date.now(); - let retries = 0; // The origin the route signs under; a read below may revise it (a stale binding). let signer = route === "free" @@ -434,22 +467,12 @@ async function runRegistration( } }; - let draws = await decide(); - while (draws.entries === undefined && draws.wait) { - const isClosing = await closing(); - if (Date.now() - startedAt >= maxMs || isClosing) { - draws = { - ...draws, - skipped: `${draws.skipped} (gave up waiting${isClosing ? ": registration is closing" : ""})`, - wait: false, - ...(isClosing ? { kind: "closing" as const } : {}), - }; - break; - } - await sleep(retryMs, signal); - retries += 1; - draws = await decide(); - } + const { draws, retries } = await waitForDraws( + decide, + closing, + { startedAt, maxMs, retryMs }, + signal, + ); // An abort during the minting comes back as a skip; it is an abort. signal?.throwIfAborted(); const outcome: DrawOutcome = {