diff --git a/product-sdk/packages/individuality/src/as-lite-alias-codec.ts b/product-sdk/packages/individuality/src/as-lite-alias-codec.ts new file mode 100644 index 00000000..f028f12d --- /dev/null +++ b/product-sdk/packages/individuality/src/as-lite-alias-codec.ts @@ -0,0 +1,313 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * The `PeopleLiteAuth` transaction extension, encoded from the chain's own + * metadata. + * + * `PeopleLiteAuth` is the lite-personhood peer of `AsPerson`: the same + * pipeline, the same `Option`-of-an-info-enum layout, and the same + * `(proof, ringIndex, revision, context)` tuple on its proof variants. All + * three traps documented at the top of `as-person-codec.ts` apply here + * unchanged, including the field-list one: the deployed runtimes carry a + * `RevisionIndex` in both proof variants that the devnet blob predates, so + * every value is round-tripped through the codec built from the blob actually + * being signed against, and a chain declaring a different field list is a loud + * `AsPersonError` rather than a structurally plausible wrong encoding. + * + * One variant is deliberately absent. `AsLitePerson` authenticates the + * canonical lite account itself, which stays in host custody, so no + * product-side signer can ever be that origin. + */ +import { bytesToHex } from "@parity/product-sdk-utils"; + +import { + type ExtensionPipeline, + checkContext, + checkProof, + encodeChecked, +} from "./as-person-codec.js"; + +/** Metadata identifier of the extension this module encodes. */ +export const PEOPLE_LITE_AUTH = "PeopleLiteAuth"; + +/** + * The `PeopleLiteAuthData` value to put in the extension, before encoding. + * + * Byte fields are `Uint8Array` throughout, including the 32-byte context. The + * split PAPI wants between bytes and hex strings is an encoding detail and is + * handled in {@link encodePeopleLiteAuthInfo}. + */ +export type PeopleLiteAuthValue = + /** Signed by an account already bound to the lite alias. Needs no proof. */ + | { tag: "AsLiteAliasWithAccount"; nonce: number } + /** No signature. The chain accepts this only for `PeopleLite.set_alias_account`. */ + | { + tag: "AsLiteAliasWithProof"; + proof: Uint8Array; + ringIndex: number; + revision: number; + context: Uint8Array; + } + /** Signed, and moves the stored alias binding to the ring revision in force now. */ + | { + tag: "AsLiteAliasWithAccountRevised"; + nonce: number; + proof: Uint8Array; + ringIndex: number; + revision: number; + context: Uint8Array; + }; + +/** A PAPI dynamic-codec enum value: variant name plus positional fields. */ +interface DynamicEnum { + type: string; + value: unknown; +} + +/** Map a domain value onto the positional shape the chain's own codec expects. */ +function toDynamicEnum(value: PeopleLiteAuthValue): DynamicEnum { + // Field order is the metadata's, and the context goes in as hex because the + // chain types it as a fixed-size array. See trap 2 in `as-person-codec.ts`. + switch (value.tag) { + case "AsLiteAliasWithAccount": + return { type: value.tag, value: value.nonce }; + case "AsLiteAliasWithProof": + return { + type: value.tag, + value: [ + checkProof(value.proof), + value.ringIndex, + value.revision, + `0x${bytesToHex(checkContext(value.context))}`, + ], + }; + case "AsLiteAliasWithAccountRevised": + return { + type: value.tag, + value: [ + value.nonce, + checkProof(value.proof), + value.ringIndex, + value.revision, + `0x${bytesToHex(checkContext(value.context))}`, + ], + }; + } +} + +/** + * Encode `Some(PeopleLiteAuthData)` for the extension's declared type. + * + * @throws {AsPersonError} when the chain does not declare `PeopleLiteAuth`, or + * when the value does not round-trip through the chain's own codec — which + * is also how a chain declaring the pre-revision field list rejects the + * revision-carrying variants here. + */ +export function encodePeopleLiteAuthInfo( + pipeline: ExtensionPipeline, + value: PeopleLiteAuthValue, +): Uint8Array { + return encodeChecked( + pipeline.codec(pipeline.slot(PEOPLE_LITE_AUTH).type), + toDynamicEnum(value), + ); +} + +if (import.meta.vitest) { + const { describe, expect, test } = import.meta.vitest; + const { readFileSync } = await import("node:fs"); + const { readExtensionPipeline } = await import("./as-person-codec.js"); + const { AsPersonError } = await import("./errors.js"); + + const blob = (name: string) => + new Uint8Array( + readFileSync( + new URL(`../../descriptors/.papi/metadata/${name}.scale`, import.meta.url), + ), + ); + + const PASEO = readExtensionPipeline(blob("paseo_individuality")); + const PREVIEWNET = readExtensionPipeline(blob("previewnet_individuality")); + // The devnet blob predates the RevisionIndex field on the proof variants, + // so it is a real negative case for the deployed field list. + const DEVNET = readExtensionPipeline(blob("devnet_individuality")); + + /** Local hex formatter, deliberately not the one the code under test uses. */ + const hex = (bytes: Uint8Array) => + `0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`; + + /** 32 distinct non-zero bytes, so a truncated or zeroed context is obvious. */ + const CONTEXT = Uint8Array.from({ length: 32 }, (_, i) => i + 1); + const CONTEXT_HEX = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + const PROOF = Uint8Array.from([0xaa, 0xbb, 0xcc]); + + describe("the pipeline slot", () => { + test("every individuality chain declares PeopleLiteAuth right after GameAsInvited", () => { + for (const pipeline of [PASEO, PREVIEWNET, DEVNET]) { + expect(pipeline.indexOf(PEOPLE_LITE_AUTH)).toBe(6); + expect(pipeline.indexOf("GameAsInvited")).toBe(5); + } + }); + }); + + describe("encodePeopleLiteAuthInfo", () => { + test("encodes AsLiteAliasWithAccount as Some, variant 1, u32 nonce", () => { + // Variant 1, not 0: AsLitePerson holds index 0 even though this + // module never encodes it. + const bytes = encodePeopleLiteAuthInfo(PASEO, { + tag: "AsLiteAliasWithAccount", + nonce: 7, + }); + expect(hex(bytes)).toBe("0x010107000000"); + }); + + test("encodes AsLiteAliasWithProof with the revision index the chain declares", () => { + const bytes = encodePeopleLiteAuthInfo(PASEO, { + tag: "AsLiteAliasWithProof", + proof: PROOF, + ringIndex: 4, + revision: 5, + context: CONTEXT, + }); + // Some, variant 2, compact-3 proof, ring u32, revision u32, 32-byte + // context — byte-identical to the hand encoder verified on + // previewnet (dim2's `encodeLiteAuthWithProof`). + expect(hex(bytes)).toBe(`0x01020caabbcc0400000005000000${CONTEXT_HEX}`); + expect(bytes).toHaveLength(46); + }); + + test("encodes AsLiteAliasWithAccountRevised with the nonce first", () => { + const bytes = encodePeopleLiteAuthInfo(PASEO, { + tag: "AsLiteAliasWithAccountRevised", + nonce: 9, + proof: PROOF, + ringIndex: 4, + revision: 5, + context: CONTEXT, + }); + expect(hex(bytes)).toBe(`0x0103090000000caabbcc0400000005000000${CONTEXT_HEX}`); + }); + + test("previewnet and paseo agree on the encoding", () => { + // Previewnet is the chain the two-transaction lite flow was verified + // on, so its blob is pinned alongside the descriptor chain's. + for (const value of [ + { tag: "AsLiteAliasWithAccount", nonce: 7 }, + { + tag: "AsLiteAliasWithProof", + proof: PROOF, + ringIndex: 4, + revision: 5, + context: CONTEXT, + }, + ] as const) { + expect(hex(encodePeopleLiteAuthInfo(PREVIEWNET, value))).toBe( + hex(encodePeopleLiteAuthInfo(PASEO, value)), + ); + } + }); + + test("carries the full 32-byte context, not a truncated one", () => { + const bytes = encodePeopleLiteAuthInfo(PASEO, { + tag: "AsLiteAliasWithProof", + proof: PROOF, + ringIndex: 4, + revision: 5, + context: CONTEXT, + }); + expect(hex(bytes).endsWith(CONTEXT_HEX)).toBe(true); + }); + + test("rejects a proof variant on a chain without the revision field", () => { + // Devnet's PeopleLiteAuthData predates RevisionIndex. An encoder + // that guessed the field list would emit a structurally plausible + // value there; the round trip through the chain's own codec is what + // turns that into a loud error instead. + for (const value of [ + { + tag: "AsLiteAliasWithProof", + proof: PROOF, + ringIndex: 4, + revision: 5, + context: CONTEXT, + }, + { + tag: "AsLiteAliasWithAccountRevised", + nonce: 9, + proof: PROOF, + ringIndex: 4, + revision: 5, + context: CONTEXT, + }, + ] as const) { + expect(() => encodePeopleLiteAuthInfo(DEVNET, value)).toThrow(AsPersonError); + } + }); + + test("the account variant still encodes on that chain", () => { + // Proves the rejection above is about the field list, not a blob + // this encoder simply cannot work with. + const bytes = encodePeopleLiteAuthInfo(DEVNET, { + tag: "AsLiteAliasWithAccount", + nonce: 7, + }); + expect(hex(bytes)).toBe("0x010107000000"); + }); + + test("throws for a chain that does not declare PeopleLiteAuth", () => { + const assetHub = readExtensionPipeline(blob("paseo_asset_hub")); + expect(() => + encodePeopleLiteAuthInfo(assetHub, { tag: "AsLiteAliasWithAccount", nonce: 1 }), + ).toThrow(AsPersonError); + expect(() => + encodePeopleLiteAuthInfo(assetHub, { tag: "AsLiteAliasWithAccount", nonce: 1 }), + ).toThrow(/does not declare the PeopleLiteAuth/); + }); + + test("rejects a context that is not 32 bytes", () => { + // The round-trip guard provably cannot catch this: PAPI's fixed-size + // codec validates no width. Same guard, same reason as AsPerson. + for (const length of [0, 31, 33, 64]) { + expect(() => + encodePeopleLiteAuthInfo(PASEO, { + tag: "AsLiteAliasWithProof", + proof: PROOF, + ringIndex: 4, + revision: 5, + context: new Uint8Array(length), + }), + ).toThrow(AsPersonError); + } + }); + + test("rejects an empty proof", () => { + expect(() => + encodePeopleLiteAuthInfo(PASEO, { + tag: "AsLiteAliasWithProof", + proof: new Uint8Array(), + ringIndex: 4, + revision: 5, + context: CONTEXT, + }), + ).toThrow(AsPersonError); + }); + + test("never puts the value in the error message", () => { + // A contextual alias is pseudonymous identity. It must not reach a + // log line through a thrown message. + try { + encodePeopleLiteAuthInfo(PASEO, { + tag: "AsLiteAliasWithProof", + proof: PROOF, + ringIndex: 4, + revision: 5, + context: CONTEXT.slice(0, 31), + }); + expect.unreachable("should have thrown"); + } catch (error) { + expect((error as Error).message).not.toContain("0102"); + expect((error as Error).message).not.toContain("aabbcc"); + } + }); + }); +} diff --git a/product-sdk/packages/individuality/src/as-lite-alias-signer.ts b/product-sdk/packages/individuality/src/as-lite-alias-signer.ts new file mode 100644 index 00000000..f8288959 --- /dev/null +++ b/product-sdk/packages/individuality/src/as-lite-alias-signer.ts @@ -0,0 +1,550 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * `withLiteAlias`: run a call under a lite-person origin instead of an account + * origin. + * + * The sibling of `withAsPerson`, for the `PeopleLiteAuth` extension one slot + * further down the same pipeline, and built on the same machinery: the reasons + * this wraps a signer rather than the submitter, and the reasons the value + * cannot be chosen at the call site, are in `as-person-signer.ts` and apply + * here verbatim. So does the order inside `signTx`, which both signers get from + * `withOriginExtension`. Both lite origins are restricted entities, metered + * against an allowance rather than a fee, so the `RestrictOrigins` slot that + * step writes is what keeps the chain from rejecting them outright. + * + * The lite sign-up is two transactions, never one, and this signer serves both + * legs. `AliasWithProof` admits exactly one call, + * `PeopleLite.set_alias_account(account, valid_at_block)`: it binds a lite + * person's alias to `account` in a chain-approved context. Everything after + * that binding rides `AliasWithAccount`, signed by the bound account — for the + * game, `Game.sign_up_with_account_lite_invite`, which is `Pays::No`: + * + * ```ts + * import { submitAndWatch } from "@parity/product-sdk-tx"; + * import { withLiteAlias } from "@parity/product-sdk-individuality"; + * + * const signer = withLiteAlias(accounts.getProductAccountSigner(account), { + * tag: "AliasWithAccount", + * }); + * await submitAndWatch( + * api.tx.Game.sign_up_with_account_lite_invite({ account, identifier_key, airdrops }), + * signer, + * ); + * ``` + * + * Nothing here chooses a chain, a product id or a TLD. The proof context, the + * ring and the member key all live inside the caller's `createProof`, and the + * call and its parameters — `account`, `valid_at_block` — belong to the + * transaction being signed. + * + * Verified against the deployed encoding: the proof-variant bytes this + * produces are byte-identical to the hand encoder that ran the two-transaction + * flow live on previewnet (spec 1000036, individuality v0.12.1). + */ +import type { PolkadotSigner } from "polkadot-api"; + +import { + PEOPLE_LITE_AUTH, + type PeopleLiteAuthValue, + encodePeopleLiteAuthInfo, +} from "./as-lite-alias-codec.js"; +import { AS_PERSON, type ExtensionPipeline, encodeChecked } from "./as-person-codec.js"; +import { + type PapiSignedExtensions, + buildImplication, + implicationMessage, + reviseMessage, +} from "./as-person-implication.js"; +import { AsPersonError } from "./errors.js"; +import { + RESTRICT_ORIGINS, + VERIFY_SIGNATURE, + cachedPipelineReader, + nonceFrom, + requestProof, + type CreateRingVRFProof, + type RingVRFProof, + withOriginExtension, + withSlot, +} from "./origin-extension.js"; + +/** + * Which lite-person origin the transaction should run under. + * + * `AsLitePerson`, the fourth variant on chain, is deliberately absent: it + * authenticates the canonical lite account itself, which stays in host + * custody, so no product-side signer can ever be that origin. + */ +export type LiteAliasInfo = + /** + * Signed by an account already bound to the lite alias, via + * `PeopleLite.set_alias_account`. Needs no proof. + * + * The chain reads `PeopleLite.AccountToAlias`. No binding answers + * `Custom(175)` (`NoAliasBinding`), meaning the bind leg has not landed; a + * stale ring revision answers `Custom(172)` (`StaleAlias`), which + * `AliasWithAccountRevised` fixes. Neither is detectable from here. + */ + | { tag: "AliasWithAccount" } + /** + * No signature: a general transaction with a `None` origin, authorized by + * the proof alone. + * + * The chain accepts this for `PeopleLite.set_alias_account` and nothing + * else, requires the proof's context to be one the runtime allows accounts + * to be bound in, and holds the call's `valid_at_block` to a tolerance + * window measured from the current block. Replay protection is only the + * binding itself: two of these with overlapping validity windows for + * different accounts can replay each other indefinitely, so never keep two + * alive at once. + */ + | { tag: "AliasWithProof"; createProof: CreateRingVRFProof } + /** + * Signed, and moves the stored alias binding to the ring revision in force + * now. + * + * The proof must resolve to the same alias and context the account was + * originally bound to, or the chain answers `Custom(174)` (`AliasMismatch`). + * With no binding at all it answers `Custom(175)`, where the fix is the bind + * leg, not this variant. + */ + | { tag: "AliasWithAccountRevised"; createProof: CreateRingVRFProof }; + +/** + * Build the `PeopleLiteAuth` value for `info`, requesting a proof when the + * variant needs one. + * + * Called after every other slot holds its final value, because two of the + * three variants hash them. Same shape as the `AsPerson` builder, one + * extension further down the pipeline — which matters: the implication slice + * starts after `PeopleLiteAuth`, so the two extensions hash different byte + * ranges of the same transaction. + */ +async function buildValue( + pipeline: ExtensionPipeline, + callData: Uint8Array, + extensions: PapiSignedExtensions, + info: LiteAliasInfo, + aliasAccount: Uint8Array, +): Promise { + switch (info.tag) { + case "AliasWithAccount": + return { + tag: "AsLiteAliasWithAccount", + nonce: nonceFrom(pipeline, extensions), + }; + + case "AliasWithProof": { + const proof = await requestProof( + info.createProof, + implicationMessage(pipeline, callData, extensions, PEOPLE_LITE_AUTH), + ); + return { + tag: "AsLiteAliasWithProof", + proof: proof.proof, + ringIndex: proof.ringIndex, + revision: proof.ringRevision, + context: proof.contextualAlias.context, + }; + } + + case "AliasWithAccountRevised": { + const nonce = nonceFrom(pipeline, extensions); + // This variant binds the implication plus a label, the bound account + // and the nonce — the pallet hashes the tuple + // `(inherited_implication, "revise", account, nonce)`, the same + // construction `AsPerson` uses — so it needs the implication bytes + // rather than the plain message. + const implication = buildImplication(pipeline, callData, extensions, PEOPLE_LITE_AUTH); + const proof = await requestProof( + info.createProof, + reviseMessage(implication, aliasAccount, nonce), + ); + return { + tag: "AsLiteAliasWithAccountRevised", + nonce, + proof: proof.proof, + ringIndex: proof.ringIndex, + revision: proof.ringRevision, + context: proof.contextualAlias.context, + }; + } + + default: + // Unreachable through the typed union, reachable from JavaScript. The + // switch returning `undefined` would encode the extension as `None`, + // which runs the call under a plain account origin: a transaction that + // can succeed while doing the wrong thing. + throw new AsPersonError("unknown PeopleLiteAuth variant"); + } +} + +/** + * Wrap a signer so its transactions run under a lite-person origin. + * + * `signBytes` and `publicKey` pass through untouched. PAPI stamps `publicKey` + * into the extrinsic and uses it to fetch the nonce, so it has to stay the + * inner signer's. + * + * @param signer - the signer to wrap, e.g. from + * `AccountsProvider.getProductAccountSigner`. + * @param info - which lite-person origin to use, and where the proof comes from. + * @returns a `PolkadotSigner` usable anywhere the original was. + */ +export function withLiteAlias(signer: PolkadotSigner, info: LiteAliasInfo): PolkadotSigner { + return withOriginExtension(signer, { + identifier: PEOPLE_LITE_AUTH, + unsigned: info.tag === "AliasWithProof", + encode: encodePeopleLiteAuthInfo, + buildValue: (pipeline, callData, extensions, aliasAccount) => + buildValue(pipeline, callData, extensions, info, aliasAccount), + }); +} + +if (import.meta.vitest) { + const { describe, expect, test, vi } = import.meta.vitest; + const { readFileSync } = await import("node:fs"); + const { readExtensionPipeline } = await import("./as-person-codec.js"); + const { CHECK_NONCE } = await import("./origin-extension.js"); + + /** + * Local hex formatter, deliberately not the one the code under test uses. + * Sharing a formatter with the implementation would hide a bug in it, and it + * keeps this package's fast test loop off a sibling workspace package. + */ + const hex = (bytes: Uint8Array) => + `0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`; + + const METADATA = new Uint8Array( + readFileSync( + new URL("../../descriptors/.papi/metadata/paseo_individuality.scale", import.meta.url), + ), + ); + const PIPELINE = readExtensionPipeline(METADATA); + + const PUBLIC_KEY = Uint8Array.from({ length: 32 }, (_, i) => i + 1); + const CALL_DATA = Uint8Array.from([0x33, 0x01, 0xaa]); + const PROOF_BYTES = Uint8Array.from([0xaa, 0xbb, 0xcc]); + const CONTEXT = Uint8Array.from({ length: 32 }, (_, i) => 0x40 + i); + + const RING_PROOF: RingVRFProof = { + proof: PROOF_BYTES, + contextualAlias: { context: CONTEXT, alias: new Uint8Array(32) }, + ringIndex: 4, + ringRevision: 5, + }; + + /** + * The map PAPI hands to `signTx`: every declared slot except + * `VerifyMultiSignature`, which PAPI drops whenever the host is to sign. + * Values are positional so a wrong slot is readable in a failure. + */ + function papiExtensions(nonce = 7): PapiSignedExtensions { + const nonceBytes = PIPELINE.codec(PIPELINE.slot(CHECK_NONCE).type)[0](nonce); + return Object.fromEntries( + PIPELINE.extensions + .filter((slot) => slot.identifier !== VERIFY_SIGNATURE) + .map((slot, index) => [ + slot.identifier, + { + identifier: slot.identifier, + value: + slot.identifier === CHECK_NONCE ? nonceBytes : Uint8Array.from([index]), + additionalSigned: Uint8Array.from([100 + index]), + }, + ]), + ) as PapiSignedExtensions; + } + + /** An inner signer that records exactly what it was handed. */ + function spySigner() { + const calls: { + callData: Uint8Array; + extensions: PapiSignedExtensions; + atBlockNumber: number; + hasher?: (data: Uint8Array) => Uint8Array; + }[] = []; + const signer: PolkadotSigner = { + publicKey: PUBLIC_KEY, + signBytes: vi.fn(async () => Uint8Array.from([0xff])), + signTx: async (callData, extensions, _metadata, atBlockNumber, hasher) => { + calls.push({ callData, extensions, atBlockNumber, hasher }); + return Uint8Array.from([0xde, 0xad]); + }, + }; + return { signer, calls }; + } + + const sign = async (info: LiteAliasInfo, extensions = papiExtensions()) => { + const { signer, calls } = spySigner(); + const result = await withLiteAlias(signer, info).signTx( + CALL_DATA, + extensions, + METADATA, + 123, + ); + return { result, calls, seen: calls[0].extensions }; + }; + + describe("withLiteAlias, shared behaviour", () => { + test("sets RestrictOrigins to true", () => { + // Both lite origins are restricted entities, and PAPI's default of + // false is an immediate rejection for them. + return sign({ tag: "AliasWithAccount" }).then(({ seen }) => { + expect(hex(seen[RESTRICT_ORIGINS].value)).toBe("0x01"); + }); + }); + + test("hashes the slice after PeopleLiteAuth, not the AsPerson one", async () => { + // The identifier is load-bearing: PeopleLiteAuth sits four slots + // after AsPerson, so a proof taken over the AsPerson implication is + // a bad proof with nothing local to read. + let seenMessage: Uint8Array | undefined; + await sign({ + tag: "AliasWithProof", + createProof: async (message) => { + seenMessage = message; + return RING_PROOF; + }, + }); + + const patched = withSlot( + PIPELINE, + withSlot(PIPELINE, papiExtensions(), RESTRICT_ORIGINS, Uint8Array.from([0x01])), + VERIFY_SIGNATURE, + Uint8Array.from([0x00]), + ); + expect(seenMessage).toEqual( + implicationMessage(PIPELINE, CALL_DATA, patched, PEOPLE_LITE_AUTH), + ); + expect(seenMessage).not.toEqual( + implicationMessage(PIPELINE, CALL_DATA, patched, AS_PERSON), + ); + }); + + test("writes PeopleLiteAuth last, and its value is outside its own hash", async () => { + let seenMessage: Uint8Array | undefined; + const { seen } = await sign({ + tag: "AliasWithProof", + createProof: async (message) => { + seenMessage = message; + return RING_PROOF; + }, + }); + + // PeopleLiteAuth ends up holding the proof (Some, variant 2). + expect(hex(seen[PEOPLE_LITE_AUTH].value).startsWith("0x0102")).toBe(true); + + // Re-hashing the final map, proof and all, reproduces the very + // message the proof was taken over: the proof slot is outside its + // own hash, which is what makes the write order sound. + expect(seenMessage).toEqual( + implicationMessage(PIPELINE, CALL_DATA, seen, PEOPLE_LITE_AUTH), + ); + }); + + test("keeps the map in the order the chain declares", async () => { + const { seen } = await sign({ tag: "AliasWithAccount" }); + const declared = PIPELINE.extensions + .map((slot) => slot.identifier) + .filter((identifier) => identifier in seen); + expect(Object.keys(seen)).toEqual(declared); + }); + + test("passes callData, block number and hasher through untouched", async () => { + const { signer, calls } = spySigner(); + const hasher = (data: Uint8Array) => data; + await withLiteAlias(signer, { tag: "AliasWithAccount" }).signTx( + CALL_DATA, + papiExtensions(), + METADATA, + 456, + hasher, + ); + expect(calls[0].callData).toBe(CALL_DATA); + expect(calls[0].atBlockNumber).toBe(456); + expect(calls[0].hasher).toBe(hasher); + }); + + test("returns whatever the inner signer returned", async () => { + const { result } = await sign({ tag: "AliasWithAccount" }); + expect(hex(result)).toBe("0xdead"); + }); + + test("passes publicKey and signBytes straight through", async () => { + const { signer } = spySigner(); + const wrapped = withLiteAlias(signer, { tag: "AliasWithAccount" }); + expect(wrapped.publicKey).toBe(signer.publicKey); + expect(hex(await wrapped.signBytes(Uint8Array.from([1])))).toBe("0xff"); + expect(signer.signBytes).toHaveBeenCalledOnce(); + }); + + test("an unrecognized tag throws instead of silently encoding None", async () => { + // None would run the call under a plain account origin, so it could + // succeed while doing the wrong thing. Reachable from JavaScript, + // including by passing the on-chain variant name by mistake. + for (const tag of [ + "aliasWithAccount", + "AsLiteAliasWithAccount", + "AsLitePerson", + undefined, + ]) { + await expect(sign({ tag } as unknown as LiteAliasInfo)).rejects.toThrow( + AsPersonError, + ); + } + }); + }); + + describe("AliasWithAccount", () => { + test("encodes variant 1 with the nonce PAPI already put in CheckNonce", async () => { + const { seen } = await sign({ tag: "AliasWithAccount" }, papiExtensions(7)); + // Some, variant 1, then 7 as a plain u32. Taking the nonce from the + // slot PAPI filled is what makes the two impossible to disagree — + // the extension validates its own nonce, so they must be the same + // number in two widths. + expect(hex(seen[PEOPLE_LITE_AUTH].value)).toBe("0x010107000000"); + }); + + test("tracks the nonce rather than assuming one", async () => { + const { seen } = await sign({ tag: "AliasWithAccount" }, papiExtensions(300)); + expect(hex(seen[PEOPLE_LITE_AUTH].value)).toBe("0x01012c010000"); + }); + + test("leaves VerifyMultiSignature absent so the host signs", async () => { + const { seen } = await sign({ tag: "AliasWithAccount" }); + expect(VERIFY_SIGNATURE in seen).toBe(false); + }); + + test("throws when CheckNonce is missing", async () => { + const extensions = Object.fromEntries( + Object.entries(papiExtensions()).filter(([key]) => key !== CHECK_NONCE), + ) as PapiSignedExtensions; + await expect(sign({ tag: "AliasWithAccount" }, extensions)).rejects.toThrow( + AsPersonError, + ); + }); + }); + + describe("AliasWithProof", () => { + const info = (createProof: CreateRingVRFProof = async () => RING_PROOF): LiteAliasInfo => ({ + tag: "AliasWithProof", + createProof, + }); + + test("sets VerifyMultiSignature to Disabled so the origin is None", async () => { + const { seen } = await sign(info()); + expect(hex(seen[VERIFY_SIGNATURE].value)).toBe("0x00"); + expect(hex(seen[VERIFY_SIGNATURE].additionalSigned)).toBe("0x"); + }); + + test("encodes variant 2 from the returned proof, including the revision", async () => { + const { seen } = await sign(info()); + expect(hex(seen[PEOPLE_LITE_AUTH].value)).toBe( + `0x01020caabbcc0400000005000000${hex(CONTEXT).slice(2)}`, + ); + }); + + test("takes the context from the proof, not from the request", async () => { + // Whichever call mints the proof decides the context — for the lite + // ring that is the chain's `Score.score_context`, and it travels + // inside the proof rather than as a parameter here. + const other = Uint8Array.from({ length: 32 }, () => 0x99); + const { seen } = await sign( + info(async () => ({ + ...RING_PROOF, + contextualAlias: { context: other, alias: new Uint8Array(32) }, + })), + ); + expect(hex(seen[PEOPLE_LITE_AUTH].value).endsWith(hex(other).slice(2))).toBe(true); + }); + + test("calls createProof exactly once, with a 32-byte message", async () => { + const createProof = vi.fn(async () => RING_PROOF); + await sign(info(createProof)); + expect(createProof).toHaveBeenCalledOnce(); + expect(createProof.mock.calls[0][0]).toHaveLength(32); + }); + + test("reports a proof that resolves with the wrong shape as this package's error", async () => { + const malformed = [ + async () => undefined, + async () => ({}), + async () => ({ proof: PROOF_BYTES, ringIndex: 1, ringRevision: 1 }), + ] as unknown as CreateRingVRFProof[]; + + for (const createProof of malformed) { + await expect(sign(info(createProof))).rejects.toThrow(AsPersonError); + } + }); + + test("keeps the underlying rejection as the cause, and does not sign", async () => { + const boom = new Error("host unavailable"); + const { signer, calls } = spySigner(); + await withLiteAlias(signer, { + tag: "AliasWithProof", + createProof: async () => { + throw boom; + }, + }) + .signTx(CALL_DATA, papiExtensions(), METADATA, 1) + .then( + () => expect.unreachable("should have thrown"), + (error) => expect((error as Error).cause).toBe(boom), + ); + expect(calls).toHaveLength(0); + }); + }); + + describe("AliasWithAccountRevised", () => { + const info = (createProof: CreateRingVRFProof = async () => RING_PROOF): LiteAliasInfo => ({ + tag: "AliasWithAccountRevised", + createProof, + }); + + test("encodes variant 3 with the nonce first", async () => { + const { seen } = await sign(info(), papiExtensions(9)); + expect(hex(seen[PEOPLE_LITE_AUTH].value)).toBe( + `0x0103090000000caabbcc0400000005000000${hex(CONTEXT).slice(2)}`, + ); + }); + + test("binds the revise message over the PeopleLiteAuth implication", async () => { + let seenMessage: Uint8Array | undefined; + await sign( + info(async (message) => { + seenMessage = message; + return RING_PROOF; + }), + papiExtensions(9), + ); + + const patched = withSlot( + PIPELINE, + papiExtensions(9), + RESTRICT_ORIGINS, + Uint8Array.from([0x01]), + ); + const implication = buildImplication(PIPELINE, CALL_DATA, patched, PEOPLE_LITE_AUTH); + expect(seenMessage).toEqual(reviseMessage(implication, PUBLIC_KEY, 9)); + // The two distinctions that matter: it is not the plain message, and + // it is not the AsPerson implication. + expect(seenMessage).not.toEqual( + implicationMessage(PIPELINE, CALL_DATA, patched, PEOPLE_LITE_AUTH), + ); + expect(seenMessage).not.toEqual( + reviseMessage( + buildImplication(PIPELINE, CALL_DATA, patched, AS_PERSON), + PUBLIC_KEY, + 9, + ), + ); + }); + + test("leaves VerifyMultiSignature absent, because the origin must be signed", async () => { + const { seen } = await sign(info()); + expect(VERIFY_SIGNATURE in seen).toBe(false); + }); + }); +} diff --git a/product-sdk/packages/individuality/src/as-person-codec.ts b/product-sdk/packages/individuality/src/as-person-codec.ts index 6db99c75..37590f88 100644 --- a/product-sdk/packages/individuality/src/as-person-codec.ts +++ b/product-sdk/packages/individuality/src/as-person-codec.ts @@ -200,7 +200,7 @@ const CONTEXT_BYTES = 32; const PROOF_BYTES_MAX = 8 * 1024; /** Reject a context the chain cannot read, before it becomes wrong bytes. */ -function checkContext(context: Uint8Array): Uint8Array { +export function checkContext(context: Uint8Array): Uint8Array { if (context.length !== CONTEXT_BYTES) { // The length, never the value: a contextual alias is pseudonymous // identity and must not reach a log line. @@ -210,7 +210,7 @@ function checkContext(context: Uint8Array): Uint8Array { } /** Reject a proof the chain will not accept, for the reasons on the constant. */ -function checkProof(proof: Uint8Array): Uint8Array { +export function checkProof(proof: Uint8Array): Uint8Array { if (proof.length === 0) { throw new AsPersonError("ring VRF proof is empty"); } diff --git a/product-sdk/packages/individuality/src/as-person-implication.ts b/product-sdk/packages/individuality/src/as-person-implication.ts index 58c13b87..195c6b76 100644 --- a/product-sdk/packages/individuality/src/as-person-implication.ts +++ b/product-sdk/packages/individuality/src/as-person-implication.ts @@ -60,8 +60,8 @@ export type PapiSignedExtensions = Parameters[1]; * @param callData - the SCALE-encoded call, as PAPI passes it to `signTx`. * @param extensions - the signed-extensions map, with every slot already holding * its final value. Patch first, then build: see consequence 2 above. - * @param identifier - which extension's implication to build. Defaults to - * `AsPerson`. + * @param identifier - which extension's implication to build. Required: a + * default silently hashes the wrong slice for any other extension. * @throws {AsPersonError} when the chain does not declare `identifier`, or when * an extension inside the slice is missing from `extensions`. */ @@ -69,7 +69,7 @@ export function buildImplication( pipeline: ExtensionPipeline, callData: Uint8Array, extensions: PapiSignedExtensions, - identifier: string = AS_PERSON, + identifier: string, ): Uint8Array { // `indexOf` throws when the chain does not declare it, which is the check // that keeps a wrong slice from being computed off index -1. @@ -107,7 +107,7 @@ export function implicationMessage( pipeline: ExtensionPipeline, callData: Uint8Array, extensions: PapiSignedExtensions, - identifier: string = AS_PERSON, + identifier: string, ): Uint8Array { return blake2b256(buildImplication(pipeline, callData, extensions, identifier)); } @@ -218,15 +218,20 @@ if (import.meta.vitest) { "030405060708090a0b0c0d0e0f101112131415" + // values 3..21 "6768696a6b6c6d6e6f70717273747576777879"; // implicits 103..121 - expect(hex(buildImplication(PASEO, CALL_DATA, positionalExtensions(PASEO)))).toBe( - expected, - ); + expect( + hex(buildImplication(PASEO, CALL_DATA, positionalExtensions(PASEO), AS_PERSON)), + ).toBe(expected); }); test("excludes AsPerson's own value and everything before it", () => { // The single most important property in this file. Bytes 0x00 to 0x02 // are the slots at indices 0, 1 and 2, and none may appear. - const implication = buildImplication(PASEO, CALL_DATA, positionalExtensions(PASEO)); + const implication = buildImplication( + PASEO, + CALL_DATA, + positionalExtensions(PASEO), + AS_PERSON, + ); const body = implication.slice(1 + CALL_DATA.length); expect(Array.from(body)).not.toContain(0x00); // UnitTransactionExtension @@ -236,7 +241,12 @@ if (import.meta.vitest) { }); test("opens with the pipeline version byte", () => { - const implication = buildImplication(PASEO, CALL_DATA, positionalExtensions(PASEO)); + const implication = buildImplication( + PASEO, + CALL_DATA, + positionalExtensions(PASEO), + AS_PERSON, + ); expect(implication[0]).toBe(PASEO.version); expect(implication[0]).toBe(0); }); @@ -244,7 +254,12 @@ if (import.meta.vitest) { test("values come before implicits, not interleaved", () => { // Interleaving is the other plausible layout and it hashes to // something the node never recomputes. - const implication = buildImplication(PASEO, CALL_DATA, positionalExtensions(PASEO)); + const implication = buildImplication( + PASEO, + CALL_DATA, + positionalExtensions(PASEO), + AS_PERSON, + ); const body = Array.from(implication.slice(1 + CALL_DATA.length)); expect(body.slice(0, 19).every((byte) => byte < 100)).toBe(true); expect(body.slice(19).every((byte) => byte >= 100)).toBe(true); @@ -253,8 +268,18 @@ if (import.meta.vitest) { test("follows the chain's own pipeline length rather than a constant", () => { // The devnet declares one extension more than paseo, so the same call // and the same rule must produce a longer implication there. - const onPaseo = buildImplication(PASEO, CALL_DATA, positionalExtensions(PASEO)); - const onDevnet = buildImplication(DEVNET, CALL_DATA, positionalExtensions(DEVNET)); + const onPaseo = buildImplication( + PASEO, + CALL_DATA, + positionalExtensions(PASEO), + AS_PERSON, + ); + const onDevnet = buildImplication( + DEVNET, + CALL_DATA, + positionalExtensions(DEVNET), + AS_PERSON, + ); expect(PASEO.extensions).toHaveLength(22); expect(DEVNET.extensions).toHaveLength(23); @@ -288,8 +313,12 @@ if (import.meta.vitest) { // reproduce, because the node reads every declared slot. const extensions = withoutSlot(positionalExtensions(PASEO), "CheckNonce"); - expect(() => buildImplication(PASEO, CALL_DATA, extensions)).toThrow(AsPersonError); - expect(() => buildImplication(PASEO, CALL_DATA, extensions)).toThrow(/CheckNonce/); + expect(() => buildImplication(PASEO, CALL_DATA, extensions, AS_PERSON)).toThrow( + AsPersonError, + ); + expect(() => buildImplication(PASEO, CALL_DATA, extensions, AS_PERSON)).toThrow( + /CheckNonce/, + ); }); test("ignores a slot missing from before the slice", () => { @@ -298,47 +327,52 @@ if (import.meta.vitest) { const extensions = positionalExtensions(PASEO); expect( - buildImplication(PASEO, CALL_DATA, withoutSlot(extensions, "VerifyMultiSignature")), - ).toEqual(buildImplication(PASEO, CALL_DATA, extensions)); + buildImplication( + PASEO, + CALL_DATA, + withoutSlot(extensions, "VerifyMultiSignature"), + AS_PERSON, + ), + ).toEqual(buildImplication(PASEO, CALL_DATA, extensions, AS_PERSON)); }); }); describe("implicationMessage", () => { test("is the blake2-256 of the implication", () => { const extensions = positionalExtensions(PASEO); - expect(implicationMessage(PASEO, CALL_DATA, extensions)).toEqual( - blake2b256(buildImplication(PASEO, CALL_DATA, extensions)), + expect(implicationMessage(PASEO, CALL_DATA, extensions, AS_PERSON)).toEqual( + blake2b256(buildImplication(PASEO, CALL_DATA, extensions, AS_PERSON)), ); }); test("is 32 bytes, which is what the proof call expects", () => { - expect(implicationMessage(PASEO, CALL_DATA, positionalExtensions(PASEO))).toHaveLength( - 32, - ); + expect( + implicationMessage(PASEO, CALL_DATA, positionalExtensions(PASEO), AS_PERSON), + ).toHaveLength(32); }); test("changes when any byte inside the slice changes", () => { // The whole point of the hash: a different tip, nonce or era must // produce a different message. const extensions = positionalExtensions(PASEO); - const before = implicationMessage(PASEO, CALL_DATA, extensions); + const before = implicationMessage(PASEO, CALL_DATA, extensions, AS_PERSON); extensions.CheckNonce = { ...extensions.CheckNonce, value: Uint8Array.from([0xff]), }; - expect(implicationMessage(PASEO, CALL_DATA, extensions)).not.toEqual(before); + expect(implicationMessage(PASEO, CALL_DATA, extensions, AS_PERSON)).not.toEqual(before); }); test("does not change when a slot before the slice changes", () => { // AsPerson's own value is outside its own hash. This is what lets the // proof be written back in after the message is computed. const extensions = positionalExtensions(PASEO); - const before = implicationMessage(PASEO, CALL_DATA, extensions); + const before = implicationMessage(PASEO, CALL_DATA, extensions, AS_PERSON); extensions.AsPerson = { ...extensions.AsPerson, value: Uint8Array.from([0x01, 0x00, 0x07, 0x00, 0x00, 0x00]), }; - expect(implicationMessage(PASEO, CALL_DATA, extensions)).toEqual(before); + expect(implicationMessage(PASEO, CALL_DATA, extensions, AS_PERSON)).toEqual(before); }); }); diff --git a/product-sdk/packages/individuality/src/as-person-signer.ts b/product-sdk/packages/individuality/src/as-person-signer.ts index e4245554..b4c10d0d 100644 --- a/product-sdk/packages/individuality/src/as-person-signer.ts +++ b/product-sdk/packages/individuality/src/as-person-signer.ts @@ -31,20 +31,9 @@ * The origin works, the call does not: `sig`, the statement-account proof, is a * bare `blake2_256` hash and the host's `signRaw` always ``-wraps it. * - * Order inside `signTx` is not arbitrary, and getting it wrong produces a bad - * proof with nothing local to read: - * - * 1. `RestrictOrigins` to `true`. It sits after `AsPerson`, so it is inside the - * hash, and the origin-restriction pallet rejects the call outright when it is - * false against a person origin. - * 2. For the proof variant, `VerifyMultiSignature` to `Disabled`, which tells the - * host to assemble an unsigned general transaction. That is what makes the - * origin `None`, which is the only origin that variant accepts. - * 3. Hash the implication, which now covers the final value of every slot after - * `AsPerson`. - * 4. Ask for the proof over that hash. - * 5. Write `AsPerson` last. Its own value is outside its own hash, which is what - * makes steps 3 and 5 orderable at all. + * The order inside `signTx` is `withOriginExtension`'s, in + * `origin-extension.ts`. Getting it wrong produces a bad proof with nothing + * local to read, which is why it lives in one place. */ import type { PolkadotSigner } from "polkadot-api"; @@ -54,7 +43,6 @@ import { type ExtensionPipeline, encodeAsPersonInfo, encodeChecked, - decodeCheckNonce, readExtensionPipeline, } from "./as-person-codec.js"; import { @@ -64,42 +52,22 @@ import { reviseMessage, } from "./as-person-implication.js"; import { AsPersonError } from "./errors.js"; - -/** - * A ring VRF proof and the values the chain needs to verify it. - * - * Structurally compatible with the host's `RingVRFProof`, and declared here - * rather than imported so this package needs no dependency on - * `@parity/product-sdk-host`. Same approach as `IndividualityChain`, and the - * umbrella package asserts the two stay compatible at compile time. - */ -export interface RingVRFProof { - /** Raw ring VRF proof bytes. */ - proof: Uint8Array; - /** The alias the proof commits to, and the 32-byte context it is bound to. */ - contextualAlias: { context: Uint8Array; alias: Uint8Array }; - /** Index of the ring the proof was generated against. */ - ringIndex: number; - /** Revision of that ring at generation time. */ - ringRevision: number; -} - -/** - * Produce a ring VRF proof over `message`. - * - * Wire this to `SignerManager.createRingVRFProof(keyHandle, context, location, - * message)`, or to any other call that returns a proof for the context the chain - * expects. - * - * **The message is computed here and must not be chosen by the caller.** It is - * blake2-256 of the call implication, which depends on the nonce, the era, the - * tip and every other extension after `AsPerson`. A proof over anything else - * fails on chain as a bad proof. - * - * The context is taken from the returned proof, not from the request, so - * whichever call mints the proof decides it. - */ -export type CreateRingVRFProof = (message: Uint8Array) => Promise; +import { + CHECK_NONCE, + RESTRICT_ORIGINS, + VERIFY_SIGNATURE, + cachedPipelineReader, + nonceFrom, + requestProof, + type CreateRingVRFProof, + type RingVRFProof, + withOriginExtension, + withSlot, +} from "./origin-extension.js"; + +// Re-exported from the shared plumbing, so the types stay importable from where +// consumers found them before `withLiteAlias` moved them to `origin-extension.ts`. +export type { CreateRingVRFProof, RingVRFProof } from "./origin-extension.js"; /** * Which person origin the transaction should run under. @@ -140,101 +108,6 @@ export type AsPersonInfo = */ | { tag: "AliasWithAccountRevised"; createProof: CreateRingVRFProof }; -/** Metadata identifier of the extension that carries the host's signature. */ -const VERIFY_SIGNATURE = "VerifyMultiSignature"; - -/** Metadata identifier of the origin-restriction extension. */ -const RESTRICT_ORIGINS = "RestrictOrigins"; - -/** Metadata identifier of the nonce extension. */ -const CHECK_NONCE = "CheckNonce"; - -/** - * Set one slot's value, keeping the map in the order the chain declares. - * - * The order matters less than it looks: the host resolves V5 extension slots by - * name. But a V4 body is a plain concatenation, where a reordered map shifts - * every slot after the first difference, so the order is preserved rather than - * relied upon not to matter. - * - * The implicit half is carried through untouched when the slot already exists, - * and encoded from the chain's own declared type when it does not. That second - * case is `VerifyMultiSignature`, which PAPI omits entirely whenever the host is - * the one signing. - */ -function withSlot( - pipeline: ExtensionPipeline, - extensions: PapiSignedExtensions, - identifier: string, - value: Uint8Array, -): PapiSignedExtensions { - const slot = pipeline.slot(identifier); - const existing = extensions[identifier]; - const additionalSigned = - existing?.additionalSigned ?? - // Throws unless the declared implicit is empty, which is the only case - // this package can fill on the chain's behalf. - encodeChecked(pipeline.codec(slot.implicit), undefined); - - const next: PapiSignedExtensions = { - ...extensions, - [identifier]: { identifier, value, additionalSigned }, - }; - - return Object.fromEntries( - pipeline.extensions - .filter((declared) => declared.identifier in next) - .map((declared) => [declared.identifier, next[declared.identifier]]), - ) as PapiSignedExtensions; -} - -/** Read the account nonce out of the slot PAPI already filled. */ -function nonceFrom(pipeline: ExtensionPipeline, extensions: PapiSignedExtensions): number { - const supplied = extensions[CHECK_NONCE]; - if (!supplied) { - throw new AsPersonError( - "signed extension CheckNonce is missing, so the account nonce cannot be read", - ); - } - return decodeCheckNonce(pipeline, supplied.value); -} - -/** - * Ask for a proof, and report both ways it can fail as this package's own error. - * - * `createProof` is the one input a caller has to write themselves, and it usually - * adapts a host call that returns a `Result` into a promise of a plain object, so - * resolving with `undefined` or a partial object is a likelier mistake than - * rejecting. Without the shape check below that surfaces as - * `TypeError: Cannot read properties of undefined`, which names neither this - * package nor the callback. - */ -async function requestProof( - createProof: CreateRingVRFProof, - message: Uint8Array, -): Promise { - let proof: RingVRFProof; - try { - proof = await createProof(message); - } catch (cause) { - // No message bytes and no proof bytes: both identify a person. - throw new AsPersonError("ring VRF proof request failed", { cause }); - } - - if ( - !(proof?.proof instanceof Uint8Array) || - !(proof?.contextualAlias?.context instanceof Uint8Array) || - typeof proof?.ringIndex !== "number" || - typeof proof?.ringRevision !== "number" - ) { - // Which field is missing is not named: the values are pseudonymous - // identity, and listing the present ones leaks by omission. - throw new AsPersonError("ring VRF proof is missing a field the extension needs"); - } - - return proof; -} - /** * Build the `AsPerson` value for `info`, requesting a proof when the variant * needs one. @@ -259,7 +132,7 @@ async function buildValue( case "AliasWithProof": { const proof = await requestProof( info.createProof, - implicationMessage(pipeline, callData, extensions), + implicationMessage(pipeline, callData, extensions, AS_PERSON), ); return { tag: "AsPersonalAliasWithProof", @@ -275,7 +148,7 @@ async function buildValue( // This variant binds the implication plus a label, the alias account // and the nonce, so it needs the implication bytes rather than the // plain message. - const implication = buildImplication(pipeline, callData, extensions); + const implication = buildImplication(pipeline, callData, extensions, AS_PERSON); const proof = await requestProof( info.createProof, reviseMessage(implication, aliasAccount, nonce), @@ -312,68 +185,13 @@ async function buildValue( * @returns a `PolkadotSigner` usable anywhere the original was. */ export function withAsPerson(signer: PolkadotSigner, info: AsPersonInfo): PolkadotSigner { - // Decoding the metadata is the expensive part of reading the pipeline, around - // 7 ms for a 435 KB blob, and PAPI hands the same array for every signature - // until the runtime upgrades. Cached on identity rather than content, so a - // runtime upgrade brings a different array and cannot be served a stale - // pipeline. Per closure, not module level, so two wrapped signers on two - // chains cannot share an entry. - let cached: { metadata: Uint8Array; pipeline: ExtensionPipeline } | undefined; - const pipelineFor = (metadata: Uint8Array): ExtensionPipeline => { - if (cached?.metadata !== metadata) { - cached = { metadata, pipeline: readExtensionPipeline(metadata) }; - } - return cached.pipeline; - }; - - return { - publicKey: signer.publicKey, - signBytes: (data) => signer.signBytes(data), - async signTx(callData, signedExtensions, metadata, atBlockNumber, hasher) { - const pipeline = pipelineFor(metadata); - let extensions = signedExtensions; - - // Step 1. Inside the hash, and false is an immediate rejection for a - // person origin. Skipped only when the chain has no such extension. - if (pipeline.extensions.some((slot) => slot.identifier === RESTRICT_ORIGINS)) { - extensions = withSlot( - pipeline, - extensions, - RESTRICT_ORIGINS, - encodeChecked(pipeline.codec(pipeline.slot(RESTRICT_ORIGINS).type), true), - ); - } - - // Step 2. Taking over the authorization slot is what makes the host - // return an unsigned general transaction, so the origin is `None`. - // The other two variants need a signed origin and so must leave the - // slot alone, which is also PAPI's default: it omits it entirely. - if (info.tag === "AliasWithProof") { - extensions = withSlot( - pipeline, - extensions, - VERIFY_SIGNATURE, - encodeChecked(pipeline.codec(pipeline.slot(VERIFY_SIGNATURE).type), { - type: "Disabled", - value: undefined, - }), - ); - } - - // Steps 3 and 4. - const value = await buildValue(pipeline, callData, extensions, info, signer.publicKey); - - // Step 5. Last, because its own value is outside its own hash. - extensions = withSlot( - pipeline, - extensions, - AS_PERSON, - encodeAsPersonInfo(pipeline, value), - ); - - return signer.signTx(callData, extensions, metadata, atBlockNumber, hasher); - }, - }; + return withOriginExtension(signer, { + identifier: AS_PERSON, + unsigned: info.tag === "AliasWithProof", + encode: encodeAsPersonInfo, + buildValue: (pipeline, callData, extensions, aliasAccount) => + buildValue(pipeline, callData, extensions, info, aliasAccount), + }); } if (import.meta.vitest) { @@ -487,7 +305,9 @@ if (import.meta.vitest) { VERIFY_SIGNATURE, Uint8Array.from([0x00]), ); - expect(seenMessage).toEqual(implicationMessage(PIPELINE, CALL_DATA, patched)); + expect(seenMessage).toEqual( + implicationMessage(PIPELINE, CALL_DATA, patched, AS_PERSON), + ); }); test("writes AsPerson last, and its value is outside its own hash", async () => { @@ -508,7 +328,7 @@ if (import.meta.vitest) { // whole ordering rests on: writing the proof into AsPerson cannot // invalidate the proof, because AsPerson is outside its own hash. If // this ever became an inequality the design would be circular. - expect(seenMessage).toEqual(implicationMessage(PIPELINE, CALL_DATA, seen)); + expect(seenMessage).toEqual(implicationMessage(PIPELINE, CALL_DATA, seen, AS_PERSON)); }); test("keeps the map in the order the chain declares", async () => { @@ -788,10 +608,12 @@ if (import.meta.vitest) { RESTRICT_ORIGINS, Uint8Array.from([0x01]), ); - const implication = buildImplication(PIPELINE, CALL_DATA, patched); + const implication = buildImplication(PIPELINE, CALL_DATA, patched, AS_PERSON); expect(seenMessage).toEqual(reviseMessage(implication, PUBLIC_KEY, 9)); // The distinction that matters: it is not the plain message. - expect(seenMessage).not.toEqual(implicationMessage(PIPELINE, CALL_DATA, patched)); + expect(seenMessage).not.toEqual( + implicationMessage(PIPELINE, CALL_DATA, patched, AS_PERSON), + ); }); test("uses the signer's own public key as the alias account", async () => { @@ -811,7 +633,11 @@ if (import.meta.vitest) { ); const wrongAccount = Uint8Array.from({ length: 32 }, () => 0x07); expect(seenMessage).not.toEqual( - reviseMessage(buildImplication(PIPELINE, CALL_DATA, patched), wrongAccount, 9), + reviseMessage( + buildImplication(PIPELINE, CALL_DATA, patched, AS_PERSON), + wrongAccount, + 9, + ), ); }); diff --git a/product-sdk/packages/individuality/src/contexts.ts b/product-sdk/packages/individuality/src/contexts.ts index 57ab2e13..b8333f5a 100644 --- a/product-sdk/packages/individuality/src/contexts.ts +++ b/product-sdk/packages/individuality/src/contexts.ts @@ -3,11 +3,12 @@ /** * Product-scoped ring-VRF proof contexts (RFC-0004 / RFC-0022 / RFC-0024). * - * A host never lets a product choose its proof context: it derives it from the - * product's identity, so a product can prove personhood only in its own - * namespace. Since individuality `0fec7071` ("Use product-owned personhood - * contexts") the chain derives its own contexts the same way — every context a - * runtime accepts is + * A product asks the host for a proof in a context it names, and the chain + * decides whether that context is one it will accept: each extension holds an + * allowlist, so the personhood ones only ever admit `peopl.` entries. Since + * individuality `0fec7071` ("Use product-owned personhood contexts") the chain + * derives those the same way as a host does, so every context a runtime accepts + * is * * ``` * context = blake2b-256("product/" ++ product_id ++ "/" ++ suffix_bytes) @@ -132,9 +133,10 @@ const MAX_TLD_BYTES = 16; * The personhood product's context for `name` on the network issuing `.` * names: `productContext("peopl.", Index(PERSONHOOD_CONTEXT_INDEX[name]))`. * - * The TLD belongs to the network (`"test"` on previewnet, `"paseo"` on the - * paseo networks), so the same context name on two networks is two different - * values — which is why it is a parameter and never a default. + * The TLD belongs to the network (`"paseo"` on the paseo networks, `"test"` on + * previewnet until truapi 0.13.0 renames it to `"testnet"`), so the same context + * name on two networks is two different values, which is why it is a parameter + * and never a default. * * @param tld - a single lower-case DotNS label, without the leading dot. * @throws ProductIndividualityError on a tld the runtime cannot represent, or a @@ -234,9 +236,14 @@ if (import.meta.vitest) { "peopleAirdrops", "0xeee07f0e4030bb780f4eb72ecc4f724a522919fb487d58fe9cad4ed69125911f", ], - ] as const)("derives the %s context previewnet publishes", (name, onChain) => { - expect(hex(personhoodContext("test", name))).toBe(onChain); - }); + ] as const)( + "derives the %s context previewnet published at spec 1000036", + (name, onChain) => { + // Pinned to the `test` suffix, not to whatever previewnet serves today: + // truapi 0.13.0 renames that TLD to `testnet`, which moves all three. + expect(hex(personhoodContext("test", name))).toBe(onChain); + }, + ); test("derives the Resources context previewnet expects", () => { // Not readable from metadata; produced by the derivation the cases diff --git a/product-sdk/packages/individuality/src/errors.ts b/product-sdk/packages/individuality/src/errors.ts index 6a21c6c8..ef37478e 100644 --- a/product-sdk/packages/individuality/src/errors.ts +++ b/product-sdk/packages/individuality/src/errors.ts @@ -54,7 +54,8 @@ export class IndividualityDecodeError extends ProductIndividualityError { } /** - * Building the `AsPerson` transaction extension failed. + * Building an origin-modifying transaction extension — `AsPerson` or its + * lite-personhood peer `PeopleLiteAuth` — failed. * * Raised when the chain does not declare the extension, declares a pipeline * version this package cannot encode, or when a value does not survive a round diff --git a/product-sdk/packages/individuality/src/index.ts b/product-sdk/packages/individuality/src/index.ts index 8c899314..98e7f407 100644 --- a/product-sdk/packages/individuality/src/index.ts +++ b/product-sdk/packages/individuality/src/index.ts @@ -7,8 +7,8 @@ * Two halves. The **read** half goes in both directions: for a DotNS username or * an account, what is that person's personhood state, as of one pinned finalized * block? And for an account, what usernames does it hold? The **write** half is - * `withAsPerson`, which wraps a signer so a call dispatches under a person origin - * instead of an account origin. + * `withAsPerson` and `withLiteAlias`, which wrap a signer so a call dispatches + * under a person or lite-person origin instead of an account origin. * * ```ts * import { getChainAPI } from "@parity/product-sdk-chain-client"; @@ -326,14 +326,16 @@ export type { export { withAsPerson } from "./as-person-signer.js"; export type { AsPersonInfo, CreateRingVRFProof, RingVRFProof } from "./as-person-signer.js"; -// The metadata-driven pieces underneath stay internal on purpose. They are -// written generically, taking an extension identifier rather than hard-coding -// `AsPerson`, so the other origin-modifying extensions on this chain can reuse -// them, and #291b should. But they are implementation details of `withAsPerson` -// today, and two of their types are shapes chosen to suit it rather than to be a -// public contract. Widening a surface later never breaks anyone; narrowing one -// after it ships does. Export them when something outside this package actually -// reaches for them. +// Its lite-personhood sibling: wrap a signer so the call runs under a lite-person +// origin via the PeopleLiteAuth extension -- the alias-bound sign-up leg and the +// unsigned, proof-authorized bind leg of the two-transaction lite sign-up. +export { withLiteAlias } from "./as-lite-alias-signer.js"; +export type { LiteAliasInfo } from "./as-lite-alias-signer.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 +// this package reaches for them. // Errors. `UsernameUnowned` is not one of them — it travels on the success // channel as a `PersonhoodResult`. `AsPersonError` is the write half's, and diff --git a/product-sdk/packages/individuality/src/origin-extension.ts b/product-sdk/packages/individuality/src/origin-extension.ts new file mode 100644 index 00000000..d8630f3d --- /dev/null +++ b/product-sdk/packages/individuality/src/origin-extension.ts @@ -0,0 +1,340 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Shared plumbing for signers that write an origin-modifying transaction + * extension. + * + * `withAsPerson` and `withLiteAlias` differ only in which extension they fill + * and how its value is built. Everything else — patching a slot while keeping + * the chain's declared order, reading the nonce back out of `CheckNonce`, + * requesting a ring VRF proof defensively, caching the decoded pipeline — is + * identical, and identical in ways that are easy to get subtly wrong, so it + * lives here once. Internal to the package on purpose: these are + * implementation details of the two signers, not a public contract. + * + * The errors are `AsPersonError` throughout. It is the write half's error + * class, predates the second extension, and callers already catch it; a + * parallel class per extension would split one failure domain in two. + */ +import type { PolkadotSigner } from "polkadot-api"; + +import { + type ExtensionPipeline, + decodeCheckNonce, + encodeChecked, + readExtensionPipeline, +} from "./as-person-codec.js"; +import type { PapiSignedExtensions } from "./as-person-implication.js"; +import { AsPersonError } from "./errors.js"; + +/** Metadata identifier of the extension that carries the host's signature. */ +export const VERIFY_SIGNATURE = "VerifyMultiSignature"; + +/** Metadata identifier of the origin-restriction extension. */ +export const RESTRICT_ORIGINS = "RestrictOrigins"; + +/** Metadata identifier of the nonce extension. */ +export const CHECK_NONCE = "CheckNonce"; + +/** + * A ring VRF proof and the values the chain needs to verify it. + * + * Structurally compatible with the host's `RingVRFProof`, and declared here + * rather than imported so this package needs no dependency on + * `@parity/product-sdk-host`. Same approach as `IndividualityChain`, and the + * umbrella package asserts the two stay compatible at compile time. + */ +export interface RingVRFProof { + /** Raw ring VRF proof bytes. */ + proof: Uint8Array; + /** The alias the proof commits to, and the 32-byte context it is bound to. */ + contextualAlias: { context: Uint8Array; alias: Uint8Array }; + /** Index of the ring the proof was generated against. */ + ringIndex: number; + /** Revision of that ring at generation time. */ + ringRevision: number; +} + +/** + * Produce a ring VRF proof over `message`. + * + * Wire this to `SignerManager.createRingVRFProof(keyHandle, context, location, + * message)`, or to any other call that returns a proof for the context the chain + * expects. + * + * **The message is computed by the wrapping signer and must not be chosen by + * the caller.** It is blake2-256 of the call implication, which depends on the + * nonce, the era, the tip and every other extension after the one being filled. + * A proof over anything else fails on chain as a bad proof. + * + * The context is taken from the returned proof, not from the request, so + * whichever call mints the proof decides it. + */ +export type CreateRingVRFProof = (message: Uint8Array) => Promise; + +/** + * Set one slot's value, keeping the map in the order the chain declares. + * + * The order matters less than it looks: the host resolves V5 extension slots by + * name. But a V4 body is a plain concatenation, where a reordered map shifts + * every slot after the first difference, so the order is preserved rather than + * relied upon not to matter. + * + * The implicit half is carried through untouched when the slot already exists, + * and encoded from the chain's own declared type when it does not. That second + * case is `VerifyMultiSignature`, which PAPI omits entirely whenever the host is + * the one signing. + */ +export function withSlot( + pipeline: ExtensionPipeline, + extensions: PapiSignedExtensions, + identifier: string, + value: Uint8Array, +): PapiSignedExtensions { + const slot = pipeline.slot(identifier); + const existing = extensions[identifier]; + const additionalSigned = + existing?.additionalSigned ?? + // Throws unless the declared implicit is empty, which is the only case + // this package can fill on the chain's behalf. + encodeChecked(pipeline.codec(slot.implicit), undefined); + + const next: PapiSignedExtensions = { + ...extensions, + [identifier]: { identifier, value, additionalSigned }, + }; + + return Object.fromEntries( + pipeline.extensions + .filter((declared) => declared.identifier in next) + .map((declared) => [declared.identifier, next[declared.identifier]]), + ) as PapiSignedExtensions; +} + +/** Read the account nonce out of the slot PAPI already filled. */ +export function nonceFrom(pipeline: ExtensionPipeline, extensions: PapiSignedExtensions): number { + const supplied = extensions[CHECK_NONCE]; + if (!supplied) { + throw new AsPersonError( + "signed extension CheckNonce is missing, so the account nonce cannot be read", + ); + } + return decodeCheckNonce(pipeline, supplied.value); +} + +/** + * Ask for a proof, and report both ways it can fail as this package's own error. + * + * `createProof` is the one input a caller has to write themselves, and it usually + * adapts a host call that returns a `Result` into a promise of a plain object, so + * resolving with `undefined` or a partial object is a likelier mistake than + * rejecting. Without the shape check below that surfaces as + * `TypeError: Cannot read properties of undefined`, which names neither this + * package nor the callback. + */ +export async function requestProof( + createProof: CreateRingVRFProof, + message: Uint8Array, +): Promise { + let proof: RingVRFProof; + try { + proof = await createProof(message); + } catch (cause) { + // No message bytes and no proof bytes: both identify a person. + throw new AsPersonError("ring VRF proof request failed", { cause }); + } + + if ( + !(proof?.proof instanceof Uint8Array) || + !(proof?.contextualAlias?.context instanceof Uint8Array) || + typeof proof?.ringIndex !== "number" || + typeof proof?.ringRevision !== "number" + ) { + // Which field is missing is not named: the values are pseudonymous + // identity, and listing the present ones leaks by omission. + throw new AsPersonError("ring VRF proof is missing a field the extension needs"); + } + + return proof; +} + +/** + * A per-signer pipeline reader that decodes each metadata blob once. + * + * Decoding the metadata is the expensive part of reading the pipeline, around + * 7 ms for a 435 KB blob, and PAPI hands the same array for every signature + * until the runtime upgrades. Cached on identity rather than content, so a + * runtime upgrade brings a different array and cannot be served a stale + * pipeline. One reader per wrapped signer, not module level, so two wrapped + * signers on two chains cannot share an entry. + */ +export function cachedPipelineReader(): (metadata: Uint8Array) => ExtensionPipeline { + let cached: { metadata: Uint8Array; pipeline: ExtensionPipeline } | undefined; + return (metadata) => { + if (cached?.metadata !== metadata) { + cached = { metadata, pipeline: readExtensionPipeline(metadata) }; + } + return cached.pipeline; + }; +} + +/** Named fields, because a bare `true` in argument position says nothing. */ +export interface OriginExtensionSpec { + identifier: string; + /** Takes over `VerifyMultiSignature`, which is what makes the origin `None`. */ + unsigned: boolean; + encode: (pipeline: ExtensionPipeline, value: Value) => Uint8Array; + /** Called once every other slot holds its final value; the proof variants hash them. */ + buildValue: ( + pipeline: ExtensionPipeline, + callData: Uint8Array, + extensions: PapiSignedExtensions, + aliasAccount: Uint8Array, + ) => Promise; +} + +/** + * Wrap a signer so its transactions run under the origin `spec.identifier` grants. + * + * The step order below is the fragile part, and getting it wrong produces a bad + * proof with nothing local to read. `spec.identifier` goes last because its own + * value is outside its own hash, which is what makes the proof step orderable. + */ +export function withOriginExtension( + signer: PolkadotSigner, + spec: OriginExtensionSpec, +): PolkadotSigner { + const pipelineFor = cachedPipelineReader(); + + return { + publicKey: signer.publicKey, + signBytes: (data) => signer.signBytes(data), + async signTx(callData, signedExtensions, metadata, atBlockNumber, hasher) { + const pipeline = pipelineFor(metadata); + let extensions = signedExtensions; + + if (pipeline.extensions.some((slot) => slot.identifier === RESTRICT_ORIGINS)) { + extensions = withSlot( + pipeline, + extensions, + RESTRICT_ORIGINS, + encodeChecked(pipeline.codec(pipeline.slot(RESTRICT_ORIGINS).type), true), + ); + } + + if (spec.unsigned) { + extensions = withSlot( + pipeline, + extensions, + VERIFY_SIGNATURE, + encodeChecked(pipeline.codec(pipeline.slot(VERIFY_SIGNATURE).type), { + type: "Disabled", + value: undefined, + }), + ); + } + + const value = await spec.buildValue(pipeline, callData, extensions, signer.publicKey); + + extensions = withSlot( + pipeline, + extensions, + spec.identifier, + spec.encode(pipeline, value), + ); + + return signer.signTx(callData, extensions, metadata, atBlockNumber, hasher); + }, + }; +} + +if (import.meta.vitest) { + const { describe, expect, test } = import.meta.vitest; + const { readFileSync } = await import("node:fs"); + + const METADATA = new Uint8Array( + readFileSync( + new URL("../../descriptors/.papi/metadata/paseo_individuality.scale", import.meta.url), + ), + ); + const PIPELINE = readExtensionPipeline(METADATA); + + /** Every declared slot except VerifyMultiSignature, as PAPI hands the map over. */ + function papiExtensions(): PapiSignedExtensions { + return Object.fromEntries( + PIPELINE.extensions + .filter((slot) => slot.identifier !== VERIFY_SIGNATURE) + .map((slot, index) => [ + slot.identifier, + { + identifier: slot.identifier, + value: Uint8Array.from([index]), + additionalSigned: Uint8Array.from([100 + index]), + }, + ]), + ) as PapiSignedExtensions; + } + + describe("withSlot", () => { + test("keeps the map in the order the chain declares", () => { + // Filling the one slot PAPI omits must not append it at the end, + // because a V4 body is a positional concatenation. + const patched = withSlot( + PIPELINE, + papiExtensions(), + VERIFY_SIGNATURE, + Uint8Array.from([0x00]), + ); + const declared = PIPELINE.extensions + .map((slot) => slot.identifier) + .filter((identifier) => identifier in patched); + expect(Object.keys(patched)).toEqual(declared); + expect(Object.keys(patched)[1]).toBe(VERIFY_SIGNATURE); + }); + + test("carries an existing slot's implicit through untouched", () => { + const before = papiExtensions(); + const patched = withSlot(PIPELINE, before, RESTRICT_ORIGINS, Uint8Array.from([0x01])); + expect(patched[RESTRICT_ORIGINS].additionalSigned).toBe( + before[RESTRICT_ORIGINS].additionalSigned, + ); + expect(patched[RESTRICT_ORIGINS].value).toEqual(Uint8Array.from([0x01])); + }); + + test("encodes an absent slot's implicit from the chain's declared type", () => { + // VerifyMultiSignature's implicit is (), which encodes to nothing. + const patched = withSlot( + PIPELINE, + papiExtensions(), + VERIFY_SIGNATURE, + Uint8Array.from([0x00]), + ); + expect(patched[VERIFY_SIGNATURE].additionalSigned).toHaveLength(0); + }); + }); + + describe("nonceFrom", () => { + test("reads back the compact nonce PAPI put in the body", () => { + const nonceBytes = PIPELINE.codec(PIPELINE.slot(CHECK_NONCE).type)[0](300); + const extensions = withSlot(PIPELINE, papiExtensions(), CHECK_NONCE, nonceBytes); + expect(nonceFrom(PIPELINE, extensions)).toBe(300); + }); + + test("throws this package's error when the slot is missing", () => { + const extensions = Object.fromEntries( + Object.entries(papiExtensions()).filter(([key]) => key !== CHECK_NONCE), + ) as PapiSignedExtensions; + expect(() => nonceFrom(PIPELINE, extensions)).toThrow(AsPersonError); + }); + }); + + describe("cachedPipelineReader", () => { + test("decodes a blob once and re-decodes on a new array", () => { + const read = cachedPipelineReader(); + const first = read(METADATA); + expect(read(METADATA)).toBe(first); + // A different array means a different runtime, so the cache must miss. + expect(read(new Uint8Array(METADATA))).not.toBe(first); + }); + }); +} diff --git a/product-sdk/pending-changesets/individuality-with-lite-alias.md b/product-sdk/pending-changesets/individuality-with-lite-alias.md new file mode 100644 index 00000000..321e6f4b --- /dev/null +++ b/product-sdk/pending-changesets/individuality-with-lite-alias.md @@ -0,0 +1,20 @@ +--- +"@parity/product-sdk-individuality": minor +"@parity/product-sdk": minor +--- + +**`withLiteAlias` runs a call under a lite-person origin, the way `withAsPerson` runs one under a person origin.** + +Wrap a signer and the `PeopleLiteAuth` transaction extension is filled inside `signTx`, where the nonce and the extension pipeline exist and are still patchable. Three variants: `AliasWithAccount` for calls signed by an account already bound to the lite alias (the free game sign-up leg, `Game.sign_up_with_account_lite_invite`), `AliasWithProof` for the unsigned, ring-VRF-authorized `PeopleLite.set_alias_account` bind leg, and `AliasWithAccountRevised` to refresh a stale binding. Proof messages are computed from the chain's own metadata — blake2-256 of the implication after `PeopleLiteAuth`, or the pallet's `(implication, "revise", account, nonce)` tuple — and never chosen by the caller. + +```ts +const signer = withLiteAlias(accounts.getProductAccountSigner(account), { + tag: "AliasWithAccount", +}); +await submitAndWatch( + api.tx.Game.sign_up_with_account_lite_invite({ account, identifier_key, airdrops }), + signer, +); +``` + +The machinery under `withAsPerson` was already generic over the extension identifier; the slot patching, nonce read-back, proof-request guards and pipeline cache it kept file-private now live in an internal shared module, along with the ordered `signTx` body itself, so both signers run the same steps rather than two copies of them. Encoding is still round-tripped through the metadata of the blob being signed against, which is load-bearing here too: the devnet runtime declares the proof variants without the `RevisionIndex` field the deployed runtimes carry, and that mismatch is a thrown `AsPersonError` rather than a structurally plausible wrong encoding. No behaviour change for `withAsPerson`. diff --git a/product-sdk/skills/product-sdk-individuality/SKILL.md b/product-sdk/skills/product-sdk-individuality/SKILL.md index b919385e..d861a491 100644 --- a/product-sdk/skills/product-sdk-individuality/SKILL.md +++ b/product-sdk/skills/product-sdk-individuality/SKILL.md @@ -15,7 +15,10 @@ description: > cannot currently support. Also covers which 32-byte context a ring-VRF proof must be minted in and which ring it comes from: productContext, personhoodContext and the five personhood allocations, peopleRing and litePeopleRing, and readScoreContext for checking the chain - derives its contexts the product way before any proof is built. + derives its contexts the product way before any proof is built. Also covers the lite + personhood flow end to end: withLiteAlias for a lite-person origin, the two-transaction + bind then sign-up sequence into the game pallet, which context the bind proof must be + minted in and why only the personhood product can mint it. --- # Product SDK Individuality @@ -24,7 +27,7 @@ Two halves, and the read half goes both ways: - **Read a person** - for a DotNS username or an account address, what is that person's personhood state on the individuality chain, as of one pinned finalized block? - **Read an account** - for an account, what usernames does it hold, via `lookupUsername`? -- **Write** - send a call that dispatches under a *person* origin instead of an account origin, via `withAsPerson`. +- **Write** - send a call that dispatches under a *person* origin via `withAsPerson`, or under a *lite-person* origin via `withLiteAlias`. Package: `@parity/product-sdk-individuality` (also re-exported from `@parity/product-sdk/individuality`) @@ -548,13 +551,50 @@ const signer = withAsPerson(innerSigner, { ### If you are building the extension yourself -`withAsPerson` is the whole public surface for this, alongside `AsPersonInfo`, `CreateRingVRFProof`, `RingVRFProof` and `AsPersonError`. The metadata-driven pieces underneath are deliberately not exported: they are implementation details today, and widening a public surface later is easy where narrowing it is not. If you need them for another origin-modifying extension on this chain, they are written generically and take an extension identifier, so ask for them to be exported rather than writing a second copy. +`withAsPerson` is the whole public surface for this, alongside `AsPersonInfo`, `CreateRingVRFProof`, `RingVRFProof` and `AsPersonError`. The metadata-driven pieces underneath are deliberately not exported: they are implementation details today, and widening a public surface later is easy where narrowing it is not. A second origin-modifying extension already uses them: `withLiteAlias` shares the ordered `signTx` body through the internal `withOriginExtension`, so a third would extend that rather than copy it. Two things to know either way, because they are the traps that cost the most time here: - **Encode from the runtime metadata, never from a hand-written type.** The deployed `AsPersonInfo` and the upstream `polkadot-sdk` one both have a variant called `AsPersonalAliasWithProof` with *different field lists* — the deployed one carries a revision index. An upstream-derived encoder emits plausible bytes with a field missing and no index mismatch to signal it. - **PAPI wants different JavaScript for two byte fields that look alike.** A `BoundedVec` takes a `Uint8Array`; a `[u8; 32]` takes a `0x` string. Hand either the other form and it encodes *without throwing*, producing wrong bytes. A round trip through the chain's own codec catches that, but it cannot catch a wrong *length* on a fixed-size field, because PAPI validates no width on encode or decode. So the context length and the proof length are both checked explicitly, and a proof or context of the wrong size throws `AsPersonError` rather than building an extrinsic the node rejects. +## The Lite Personhood Flow (Write) + +The lite sign-up puts a **lite person** into the game pallet. It is the flow dim2 uses, and it is two transactions in a fixed order, never one. + +| Leg | Variant | Signed | Call | Effect | +|---|---|---|---|---| +| 1. bind | `AliasWithProof` | no, origin `None` | `PeopleLite.set_alias_account` | writes `AccountToAlias[account]` | +| 2. sign up | `AliasWithAccount` | yes | `Game.sign_up_with_account_lite_invite` | reads `AccountToAlias[account]` | + +```ts +import { withLiteAlias } from "@parity/product-sdk-individuality"; + +const bindSigner = withLiteAlias(accounts.getProductAccountSigner(account), { + tag: "AliasWithProof", + createProof: (message) => mintProof(message), +}); +``` + +> **BOTH LEGS ARE THE SAME SIGNER, DIFFERENT VARIANTS.** `withLiteAlias` fills the `PeopleLiteAuth` slot for whichever variant you pass. `AliasWithAccountRevised` is the third: it re-points an existing binding at the current ring revision. + +> **ORDER IS NOT OPTIONAL.** Leg 2 reads what leg 1 wrote. Running it first answers `Custom(175)` (`NoAliasBinding`), and `AliasWithAccountRevised` answers the same, because that arm also starts from `AccountToAlias`. The fix is the bind leg, not the revised variant. + +### Before you can mint the proof + +Two things block the bind leg, and neither is visible from the API. + +**Which context.** The chain keeps an allowlist of the contexts an account may be bound in, and for the lite extension it holds exactly two: `personhoodContext(tld, "peopleLiteAuth")` and `personhoodContext(tld, "score")`. Anything else is rejected as `InvalidTransaction::Call`. The runtime publishes the first as the `PeopleLite.auth_context` constant, but **only on previewnet**: paseo and devnet do not publish it at all, so deriving it client-side is the only route there. The `tld` is yours to supply, and there is no default. + +**Who may mint it.** The host does not restrict which context you ask for: `createRingVRFProof(keyHandle, { productId, suffix }, ...)` takes the product id from you, and the host's own check is that you own the key handle. The restriction is the chain's allowlist above, and both entries are in the `peopl` namespace. So a **dim2** product cannot use a `dim2.` context here, and in practice the proof comes from the personhood product: the lite sign-up is a cross-product handoff, with peopl minting and dim2 carrying it into the sign-up. `withLiteAlias` never mints one, which is why `createProof` is yours to provide. + +### Lite Flow Gotchas + +- **The bind leg has no replay protection beyond the binding.** Two bind transactions with overlapping `valid_at_block` windows for different accounts can replay each other indefinitely. Never keep two alive at once. +- **`AsLitePerson` is not implemented, on purpose.** That variant authenticates the canonical lite account, which stays in host custody, so no product-side signer can ever be that origin. +- **Failures throw `AsPersonError`, not a lite-specific error.** It is the write half's error class and covers both extensions. +- **A rejected leg costs no fee.** Every failure lands in `validate`, before the transaction enters a block. + ## Common Mistakes 1. **Forgetting to check `result.ok` first** — the answer is inside `result.value`, and a `result.tag` check on the outer object is always undefined. @@ -574,4 +614,7 @@ Two things to know either way, because they are the traps that cost the most tim 15. **Assuming `AliasWithAccount` works for a stale ring revision** — the chain answers `BadSigner`, and `AliasWithAccountRevised` is the variant that fixes it. It cannot be detected client-side without reading the ring root. 16. **Hardcoding the TLD in a product id** — `peopl.test` and `peopl.paseo` are different 32-byte contexts, so a hardcoded `.dot` mints proofs no chain accepts. There is no default, on purpose. 17. **Calling `readScoreContext` inside a composed read** — it pins its own block when the suffix comes from storage, and Root can move that value. Use `runScoreContextRead(chain, options, snapshot)` with the block you already pinned. -18. **Treating `NotProductDerived` as retryable** — it is a hard stop on the ok channel, not a transport failure. Building the proof leg anyway costs a fee and returns `Invalid.Call`. +18. **Running the lite sign-up before the bind leg has landed** — the chain answers `Custom(175)` (`NoAliasBinding`), and `AliasWithAccountRevised` answers it too. Send the `AliasWithProof` bind leg first. +19. **Binding in your own product's context** — the chain allowlists two contexts for the lite bind, `peopleLiteAuth` and `score`, both `peopl.`. A `dim2.` context is rejected as `InvalidTransaction::Call`, so plan for the handoff from the personhood product. +20. **Reading `PeopleLite.auth_context` off paseo or devnet** — neither publishes it. Derive it with `personhoodContext(tld, "peopleLiteAuth")`. +21. **Treating `NotProductDerived` as retryable** — it is a hard stop on the ok channel, not a transport failure. Building the proof leg anyway costs a fee and returns `Invalid.Call`.