Skip to content
Draft
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
14 changes: 14 additions & 0 deletions .changeset/decrypt-fn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@cofhe/react': minor
'@cofhe/sdk': minor
---

**@cofhe/react**

- New `useCofheDecryptFn`: a function that decrypts one handle on demand, for a job over many handles (a history to sum, a set of bids to reveal) run from a `useMutation`. Each call is the same query `useCofheDecrypt` runs — same cache key, same ACP gate, same persistence, same activity row — so a handle decrypted either way is decrypted once. Calls run at most `concurrency` at a time (4 by default). [Docs](https://cofhesdk.fhenix.io/react/decrypt#usecofhedecryptfn--a-job-over-many-handles)
- New `useCofheDecryptForTx`: a mutation hook around `client.decryptForTx`, shaped like `useCofheEncrypt` (`decryptForTxAsync`, `isDecrypting`); `acp: null` decrypts a publicly allowed handle without an ACP. [Docs](https://cofhesdk.fhenix.io/react/decrypt#usecofhedecryptfortx--a-plaintext-with-proof)
- `useCofheDecrypt` takes `timeoutMs`, the decrypt's whole budget.

**@cofhe/sdk**

- New `.setTimeout(timeoutMs)` on `decryptForView` and `decryptForTx`: the whole budget of one decrypt, submit retries and status polling together. Previously it was fixed at 5 minutes, which stays the default. [Docs](https://cofhesdk.fhenix.io/sdk/decrypt-to-view#settimeouttimeoutms--optional)
174 changes: 124 additions & 50 deletions packages/react/src/hooks/useCofheDecrypt.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import { useEffect } from 'react';
import { useCofheContext, useInternalQuery, useInternalQueryClient } from '@/providers';
import { removeDecryptsOfACPs } from '@/providers/acpDecryptCache';
import { isHandleOutOfScope, useCofheEffectiveACP, type CofheACPInput } from './useCofheACPScope';
import {
isHandleOutOfScope,
useCofheEffectiveACP,
type CofheACPInput,
type CofheEffectiveACP,
} from './useCofheACPScope';
import { useCofheChainId } from './useCofheConnection';
import { CofheError, FheTypes, type DecryptPollCallbackFunction, type UnsealedItem } from '@cofhe/sdk';
import {
CofheError,
FheTypes,
type CofheClient,
type DecryptPollCallbackFunction,
type UnsealedItem,
} from '@cofhe/sdk';
import type { ACP } from '@cofhe/sdk/acps';
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { assert } from 'ts-essentials';
import type { EncryptedReturnTypeByUtype } from '@cofhe/abi';
import type { CofheDecryptMeta } from '@/meta';
import type { CofheDecryptMeta, CofheQueryMeta } from '@/meta';

/**
* The cache key of one decrypt: a ciphertext handle decrypted on one chain, shaped like the read
Expand All @@ -26,12 +38,99 @@ export function constructCofheDecryptQueryKey(params: {
return ['decryptCiphertext', params.chainId, params.ctHash, params.utype, params.acpHash];
}

/** Structural context from the originating read (address/method), for recognition in activity views. */
export type CofheDecryptContext = { chainId?: number; address?: string; functionName?: string };

/**
* The default retry policy of a decrypt query: a `CofheError` is the threshold network's (or the
* SDK's) final answer and is not retried; anything else (a network hiccup) is retried 3 times.
*/
export function defaultDecryptRetry(failureCount: number, error: Error): boolean {
if (error instanceof CofheError) return false;
return failureCount < 3;
}

/**
* The query one decrypt runs as — its key, its `queryFn` and its `meta` — shared by
* `useCofheDecrypt` and `useCofheDecryptFn`, so a value decrypted either way is the same cache
* entry, persisted under the same rule and listed once in the activity panel.
*/
export function cofheDecryptQuery<U extends FheTypes>(params: {
client: CofheClient;
input: EncryptedReturnTypeByUtype<U> | undefined;
/** The chain the decrypt runs on: the caller's `chainId`, else the connected chain. */
decryptChainId: number | undefined;
/** The caller's explicit `chainId`, set on the builder when given. */
chainId?: number;
/** The ACP that decrypts, and whether it was chosen explicitly (a hook option or a scope). */
acp: ACP | undefined;
scoped: boolean;
onPoll?: DecryptPollCallbackFunction;
timeoutMs?: number;
meta?: CofheDecryptMeta;
context?: CofheDecryptContext;
}): {
queryKey: readonly unknown[];
queryFn: () => Promise<UnsealedItem<U>>;
// Also a plain record: that is what `fetchQuery` and `useQuery` type `meta` as.
meta: Record<string, unknown> & CofheQueryMeta & { persist: boolean };
} {
const { client, input, decryptChainId, chainId, acp, scoped, onPoll, timeoutMs, meta, context } = params;
const scopedACP = scoped ? acp : undefined;
return {
queryKey: constructCofheDecryptQueryKey({
ctHash: input?.ctHash.toString(),
utype: input?.utype,
chainId: decryptChainId,
acpHash: acp?.hash,
}),
queryFn: async () => {
assert(input, 'a decrypt query only runs with an input');
const builder = client.decryptForView(input.ctHash, input.utype);
if (chainId !== undefined) builder.setChainId(chainId);
if (scopedACP) builder.withACP(scopedACP);
if (onPoll) builder.onPoll(onPoll);
if (timeoutMs !== undefined) builder.setTimeout(timeoutMs);
return builder.execute();
},
meta: {
// Persist only what the user own ACP decrypts, whether chosen or active: plaintext decrypted
// with a shared ACP must not outlive the share, so it stays in memory.
persist: acp?.type === 'self',
kind: 'cofheDecrypt',
ctHash: input?.ctHash?.toString(),
chainId: context?.chainId ?? decryptChainId,
address: context?.address,
functionName: context?.functionName,
consumer: meta,
},
};
}

/**
* An ACP that is no longer valid (e.g. expired) takes its plaintext with it: a disabled query would
* otherwise keep serving the value decrypted while it was valid. The user own active ACP keeps its
* values (they are the user own data, and persisted).
*/
export function useDropDecryptsOfInvalidACP(effectiveACP: CofheEffectiveACP): void {
const queryClient = useInternalQueryClient();
const decryptACP = effectiveACP.acp;
const invalidHash =
decryptACP && !effectiveACP.isValid && (effectiveACP.scoped || decryptACP.type !== 'self')
? decryptACP.hash
: undefined;
useEffect(() => {
if (invalidHash) removeDecryptsOfACPs(queryClient, new Set([invalidHash]));
}, [invalidHash, queryClient]);
}

/**
* Hook to decrypt a ciphertext using the Cofhe client.
* @param input - Ciphertext and FHE type
* @param onPoll - Optional callback fired once per decryption poll attempt
* @param chainId - Chain the ciphertext lives on (default: the connected chain)
* @param acp - ACP to decrypt with (default: the enclosing `<CofheACPScope>`, else the active ACP)
* @param timeoutMs - The decrypt's whole budget, submit retries and polling together (default: 5 minutes)
* @param queryOptions - Optional React Query options
* @returns Decrypted balance as bigint
*/
Expand All @@ -43,13 +142,14 @@ export function useCofheDecrypt<U extends FheTypes, TSeletedData = UnsealedItem<
context,
chainId,
acp,
timeoutMs,
}: {
input?: EncryptedReturnTypeByUtype<U>;
onPoll?: DecryptPollCallbackFunction;
/** Consumer-supplied metadata for debug/activity views. */
meta?: CofheDecryptMeta;
/** Structural context from the originating read (address/method), for recognition. */
context?: { chainId?: number; address?: string; functionName?: string };
context?: CofheDecryptContext;
/**
* The chain the ciphertext lives on — the connected chain by default. Gates on and decrypts
* with THAT chain's active ACP, e.g. for a value read through a chain-pinned read.
Expand All @@ -60,6 +160,8 @@ export function useCofheDecrypt<U extends FheTypes, TSeletedData = UnsealedItem<
* `<CofheACPScope>` or the active ACP. The active ACP is not changed.
*/
acp?: CofheACPInput;
/** The decrypt's whole budget in milliseconds, submit retries and polling together (5 minutes by default). */
timeoutMs?: number;
},
queryOptions?: Omit<UseQueryOptions<UnsealedItem<U>, Error, TSeletedData>, 'queryKey' | 'queryFn'>
): UseQueryResult<TSeletedData, Error> {
Expand All @@ -75,65 +177,37 @@ export function useCofheDecrypt<U extends FheTypes, TSeletedData = UnsealedItem<
// to the read hook.
const effectiveACP = useCofheEffectiveACP({ acp, chainId });
const scopedACP = effectiveACP.scoped ? effectiveACP.acp : undefined;

const decryptACP = effectiveACP.acp;

// An ACP that is no longer valid (e.g. expired) takes its plaintext with it: the disabled query
// would otherwise keep serving the value decrypted while it was valid. The user own active ACP
// keeps its values (they are the user own data, and persisted).
const queryClient = useInternalQueryClient();
const invalidHash =
decryptACP && !effectiveACP.isValid && (effectiveACP.scoped || decryptACP.type !== 'self')
? decryptACP.hash
: undefined;
useEffect(() => {
if (invalidHash) removeDecryptsOfACPs(queryClient, new Set([invalidHash]));
}, [invalidHash, queryClient]);
useDropDecryptsOfInvalidACP(effectiveACP);

const {
enabled: userEnabled,
meta: optionMeta,
retry = (failureCount: number, error: Error) => {
if (error instanceof CofheError) return false; // don't retry decryption errors

// default retry behavior - 3 retries
return failureCount < 3;
},
retry = defaultDecryptRetry,
...restQueryOptions
} = queryOptions || {};
// A SNAPSHOT share that does not list this handle can only fail: never send it.
const outOfScope = isHandleOutOfScope(scopedACP, input?.ctHash);
const enabled =
!!input && BigInt(input.ctHash) > 0n && !!client && effectiveACP.isValid && !outOfScope && (userEnabled ?? true);

const query = cofheDecryptQuery<U>({
client,
input,
decryptChainId,
chainId,
acp: effectiveACP.acp,
scoped: effectiveACP.scoped,
onPoll,
timeoutMs,
meta,
context,
});

return useInternalQuery({
enabled,
queryKey: constructCofheDecryptQueryKey({
ctHash: input?.ctHash.toString(),
utype: input?.utype,
chainId: decryptChainId,
acpHash: decryptACP?.hash,
}),
queryFn: async () => {
assert(input, 'input is guaranteed to be defined by enabled condition');
const builder = client.decryptForView(input.ctHash, input.utype);
if (chainId !== undefined) builder.setChainId(chainId);
if (scopedACP) builder.withACP(scopedACP);
if (onPoll) builder.onPoll(onPoll);
return builder.execute();
},
meta: {
// Persist only what the user own ACP decrypts, whether chosen or active: plaintext decrypted
// with a shared ACP must not outlive the share, so it stays in memory.
persist: decryptACP?.type === 'self',
kind: 'cofheDecrypt',
ctHash: input?.ctHash?.toString(),
chainId: context?.chainId ?? decryptChainId,
address: context?.address,
functionName: context?.functionName,
consumer: meta,
...optionMeta,
},
queryKey: query.queryKey,
queryFn: query.queryFn,
meta: { ...query.meta, ...optionMeta },
...restQueryOptions,
retry,
});
Expand Down
Loading
Loading