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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 250 additions & 26 deletions product-sdk/packages/host/src/accounts.ts

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion product-sdk/packages/host/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export {
export type { HostErrorPayload } from "./errors.js";

// Accounts — host wallet accounts, product accounts, Ring VRF, and signers.
export { getAccountsProvider } from "./accounts.js";
export { getAccountsProvider, findRingVrfKeyHandle } from "./accounts.js";
export type {
AccountsProvider,
DerivationIndex,
Expand All @@ -78,6 +78,10 @@ export type {
ProductProofContext,
RingLocation,
RingVRFProof,
RegisteredRingVrfKey,
RingVrfKeyDisclosure,
RingVrfKeyHandle,
RingVrfPublicKey,
VrfSignature,
VrfTranscriptItem,
} from "./accounts.js";
Expand Down
2 changes: 1 addition & 1 deletion product-sdk/packages/host/src/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function adaptPaymentManager(client: TrUApiClient): PaymentManager {
* const payments = await getPaymentManager();
* if (payments) {
* const sub = payments.subscribeBalance((b) => { ... });
* await payments.topUp(1_000_000n, { tag: "ProductAccount", value: { derivationIndex: { tag: "Left", value: 0 } } });
* await payments.topUp(1_000_000n, { tag: "ProductAccount", value: { derivationIndex: { tag: "Index", value: 0 } } });
* const { id } = await payments.requestPayment(500n, "0x…");
* sub.unsubscribe();
* }
Expand Down
3 changes: 3 additions & 0 deletions product-sdk/packages/host/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions):
getUserId: () => okAsync({ primaryUsername }),
requestLogin: () => okAsync("Success"),
getAccount: () => okAsync({ account: { publicKey } }),
registerRingVrfKey: () => okAsync(publicKey),
listRingVrfKeys: () => okAsync([]),
ringVrfSign: () => okAsync(signature),
getAccountAlias: () =>
okAsync({ context: toHex(new Uint8Array([1])), alias: toHex(new Uint8Array([2])) }),
getLegacyAccounts: () => okAsync({ accounts: legacyAccounts }),
Expand Down
4 changes: 2 additions & 2 deletions product-sdk/packages/host/src/truapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,8 @@ export async function createHostPreimageManager(): Promise<PreimageManager | nul
// Resource-allocation / permission types, re-exported verbatim from
// `@parity/truapi` (imported above for the local signatures):
// - `AllocatableResource` — resource types requestable via `requestResourceAllocation`.
// Since truapi 0.6.0 its `SmartContractAllowance` variant carries the tagged
// `DerivationIndex` selector (`{ tag: "Left", value: number }` for a plain index).
// Its `SmartContractAllowance` variant carries the tagged `DerivationIndex`
// selector (`{ tag: "Index", value: number }` for a plain index).
// - `AllocationOutcome` — per-resource outcome, the string union
// `"Allocated" | "Rejected" | "NotAvailable"` (RFC-10).
// - `RemotePermission` — permission the dapp asks the host to grant via `requestPermission`.
Expand Down
6 changes: 3 additions & 3 deletions product-sdk/packages/sdk/src/identity/product-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ const log = createLogger("identity");
* `@parity/product-sdk-signer`. Host-backed and actually signable.
* - The address offline, with no host: `deriveProductAccountPublicKey` from
* `@parity/product-sdk-keys`, the canonical sr25519 soft derivation.
* - An unlinkable per-context alias:
* `SignerManager.getProductAccountAlias(context, location)`, plus
* `createRingVRFProof` for proofs.
* - An unlinkable per-context alias: select a registered ring-VRF key, then call
* `SignerManager.getProductAccountAlias(keyHandle, context, location)` or
* `createRingVRFProof(keyHandle, context, location, message)`.
* - A context-scoped identifier, never used as an account: `blake2b256` from
* `@parity/product-sdk/crypto`. Same bytes, without address packaging that
* invites the mistake.
Expand Down
4 changes: 4 additions & 0 deletions product-sdk/packages/signer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export type {
ContextualAlias,
DerivationIndex,
ProductProofContext,
RegisteredRingVrfKey,
RingVrfKeyDisclosure,
RingVrfKeyHandle,
RingVrfPublicKey,
RingLocation,
RingVRFProof,
VrfSignature,
Expand Down
188 changes: 165 additions & 23 deletions product-sdk/packages/signer/src/providers/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { deriveH160, ss58Encode } from "@parity/product-sdk-address";
import {
getAccountsProvider,
type ProductAccountLookup,
type RegisteredRingVrfKey,
type RingLocation,
type RingVrfKeyDisclosure,
type RingVrfKeyHandle,
type RingVrfPublicKey,
type ProductProofContext,
type RemotePermission,
requestPermission,
Expand Down Expand Up @@ -134,31 +139,19 @@ export interface ContextualAlias {
}

/**
* Location of a Ring VRF ring on-chain: the hosting chain's genesis hash plus
* the junction path addressing the ring within it.
*
* Structurally matches `RingLocation` from `@parity/truapi`, re-exported by
* `@parity/product-sdk-host`. Declared locally with `string` in place of the
* branded hex types so callers can pass plain strings.
*/
export interface RingLocation {
/** Genesis hash of the chain hosting the ring. */
chainId: string;
/** Path addressing the ring within the chain. */
junctions: Array<
{ tag: "PalletInstance"; value: number } | { tag: "CollectionId"; value: string }
>;
}

/**
* Proof-context, account-reference and VRF shapes re-exported from
* Ring VRF key-management and request shapes re-exported from
* `@parity/product-sdk-host`. Host is a hard dependency, so these come from one
* place rather than a structural copy that could drift.
* place rather than structural copies that could drift.
*/
export type {
DerivationIndex,
ProductAccountLookup,
ProductProofContext,
RegisteredRingVrfKey,
RingVrfKeyDisclosure,
RingLocation,
RingVrfKeyHandle,
RingVrfPublicKey,
VrfSignature,
VrfTranscriptItem,
} from "@parity/product-sdk-host";
Expand Down Expand Up @@ -202,12 +195,22 @@ export interface AccountsProvider {
derivationIndex?: number,
) => NeverthrowResultAsync<RawAccount, unknown>;
getProductAccountSigner: (account: ProductAccount) => import("polkadot-api").PolkadotSigner;
registerRingVrfKey: (
index: number,
ring: RingLocation,
) => NeverthrowResultAsync<RingVrfPublicKey, unknown>;
listRingVrfKeys: (
owner: string,
disclosure?: RingVrfKeyDisclosure,
) => NeverthrowResultAsync<RegisteredRingVrfKey[], unknown>;
getProductAccountAlias: (
keyHandle: RingVrfKeyHandle,
context: ProductProofContext,
location: RingLocation,
) => NeverthrowResultAsync<ContextualAlias, unknown>;
getUserId: () => NeverthrowResultAsync<{ primaryUsername: string }, unknown>;
createRingVRFProof: (
keyHandle: RingVrfKeyHandle,
context: ProductProofContext,
location: RingLocation,
message: Uint8Array,
Expand Down Expand Up @@ -421,6 +424,65 @@ export class HostProvider implements SignerProvider {
return this.accountsProvider.getProductAccountSigner(account);
}

/**
* Register a ring-VRF key owned by the calling product.
*
* Call {@link listRingVrfKeys} afterward to obtain the opaque handle used
* by alias and proof requests.
*/
async registerRingVrfKey(
index: number,
ring: RingLocation,
): Promise<Result<RingVrfPublicKey, SignerError>> {
if (!this.accountsProvider) {
return err(new HostUnavailableError("Host provider is not connected"));
}

try {
const publicKey = (await this.accountsProvider.registerRingVrfKey(index, ring).match(
(result) => result,
(error) => {
throw new Error(
`Host rejected ring VRF key registration: ${formatError(error)}`,
);
},
)) as RingVrfPublicKey;
return ok(publicKey);
} catch (cause) {
const message =
cause instanceof Error ? cause.message : "Failed to register ring VRF key";
log.error("failed to register ring VRF key", { error: message });
return err(new HostRejectedError(message));
}
}

/** List an owner's registered ring-VRF keys. */
async listRingVrfKeys(
owner: string,
disclosure: RingVrfKeyDisclosure = "Anonymized",
): Promise<Result<RegisteredRingVrfKey[], SignerError>> {
if (!this.accountsProvider) {
return err(new HostUnavailableError("Host provider is not connected"));
}

try {
const keys = (await this.accountsProvider.listRingVrfKeys(owner, disclosure).match(
(result) => result,
(error) => {
throw new Error(
`Host rejected ring VRF key list request: ${formatError(error)}`,
);
},
)) as RegisteredRingVrfKey[];
return ok(keys);
} catch (cause) {
const message =
cause instanceof Error ? cause.message : "Failed to list registered ring VRF keys";
log.error("failed to list registered ring VRF keys", { error: message });
return err(new HostRejectedError(message));
}
}

/**
* Get a contextual alias for a product account via Ring VRF.
*
Expand All @@ -430,6 +492,7 @@ export class HostProvider implements SignerProvider {
* Requires a prior successful `connect()` call.
*/
async getProductAccountAlias(
keyHandle: RingVrfKeyHandle,
context: ProductProofContext,
location: RingLocation,
): Promise<Result<ContextualAlias, SignerError>> {
Expand All @@ -439,7 +502,7 @@ export class HostProvider implements SignerProvider {

try {
const alias = (await this.accountsProvider
.getProductAccountAlias(context, location)
.getProductAccountAlias(keyHandle, context, location)
.match(
(result) => result,
(error) => {
Expand Down Expand Up @@ -493,13 +556,14 @@ export class HostProvider implements SignerProvider {
/**
* Create a Ring VRF proof for anonymous operations.
*
* Proves that a member of the ring at the given location produced the
* proof without revealing which member the host selects the member key.
* Proves that the explicitly selected registered key belongs to the ring
* at the given location without revealing which member produced the proof.
* Returns the proof plus its verification values ({@link RingVRFProof}).
*
* Requires a prior successful `connect()` call.
*/
async createRingVRFProof(
keyHandle: RingVrfKeyHandle,
context: ProductProofContext,
location: RingLocation,
message: Uint8Array,
Expand All @@ -510,7 +574,7 @@ export class HostProvider implements SignerProvider {

try {
const proof = (await this.accountsProvider
.createRingVRFProof(context, location, message)
.createRingVRFProof(keyHandle, context, location, message)
.match(
(result) => result,
(error) => {
Expand Down Expand Up @@ -908,6 +972,22 @@ if (import.meta.vitest) {
},
}),
getProductAccountSigner: vi.fn().mockReturnValue(mockSigner),
registerRingVrfKey: vi.fn().mockReturnValue({
match: async (onOk: (v: unknown) => unknown, onErr: (e: unknown) => unknown) => {
if (shouldReject) {
return onErr(options.error ?? "Unknown");
}
return onOk(new Uint8Array([3, 4]));
},
}),
listRingVrfKeys: vi.fn().mockReturnValue({
match: async (onOk: (v: unknown) => unknown, onErr: (e: unknown) => unknown) => {
if (shouldReject) {
return onErr(options.error ?? "Unknown");
}
return onOk([]);
},
}),
getProductAccountAlias: vi.fn().mockReturnValue({
match: async (onOk: (v: unknown) => unknown, onErr: (e: unknown) => unknown) => {
if (shouldReject) {
Expand Down Expand Up @@ -1452,6 +1532,68 @@ if (import.meta.vitest) {
});
});

describe("HostProvider ring VRF keys", () => {
test("lists a key and forwards its opaque handle to alias and proof calls", async () => {
const mockProvider = createMockProvider({
accounts: [{ publicKey: new Uint8Array(32).fill(0xa2), name: undefined }],
});
const keyHandle = {
dotNsIdentifier: "people.dot",
derivationIndex: { tag: "Index", value: 0 },
} as unknown as RingVrfKeyHandle;
const ring: RingLocation = {
chainId: "0x01",
junctions: [{ tag: "PalletInstance", value: 67 }],
};
mockProvider.listRingVrfKeys.mockReturnValue({
match: async (
onOk: (value: RegisteredRingVrfKey[]) => unknown,
_onErr: (error: unknown) => unknown,
) => onOk([{ handle: keyHandle, rings: [ring] }]),
});
const provider = new HostProvider({
maxRetries: 1,
loadAccountsProvider: loadProvider(mockProvider),
requestChainSubmitPermissionFn: grantPermission(),
productAccount: { dotNsIdentifier: "myapp.dot", requestName: false },
});
await provider.connect();

const index = 0;
const registered = await provider.registerRingVrfKey(index, ring);
expect(registered.ok).toBe(true);
expect(mockProvider.registerRingVrfKey).toHaveBeenCalledWith(index, ring);
if (registered.ok) {
expect(registered.value).toEqual(new Uint8Array([3, 4]));
}

const listed = await provider.listRingVrfKeys("people.dot", "Anonymized");
expect(listed.ok).toBe(true);
expect(mockProvider.listRingVrfKeys).toHaveBeenCalledWith("people.dot", "Anonymized");
if (!listed.ok) return;

const context: ProductProofContext = {
productId: "myapp.dot",
suffix: { tag: "Index", value: 0 },
};
await provider.getProductAccountAlias(listed.value[0].handle, context, ring);
expect(mockProvider.getProductAccountAlias).toHaveBeenCalledWith(
keyHandle,
context,
ring,
);

const message = new Uint8Array([1, 2, 3]);
await provider.createRingVRFProof(listed.value[0].handle, context, ring, message);
expect(mockProvider.createRingVRFProof).toHaveBeenCalledWith(
keyHandle,
context,
ring,
message,
);
});
});

describe("HostProvider.signVrf", () => {
const account = { dotNsIdentifier: "myapp.dot", derivationIndex: 0 };
const label = new Uint8Array([1, 2, 3]);
Expand Down
4 changes: 4 additions & 0 deletions product-sdk/packages/signer/src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export type {
ContextualAlias,
DerivationIndex,
ProductProofContext,
RegisteredRingVrfKey,
RingVrfKeyDisclosure,
RingVrfKeyHandle,
RingVrfPublicKey,
RingLocation,
RingVRFProof,
} from "./host.js";
Loading
Loading