sdk/js-sdk/DRAFT_RFC_016.md
Protocol v14 introduces a new unified EIP-712 payload for user decryption.
Before v14, user decryption uses two EIP-712 shapes:
KmsUserDecryptEip712KmsDelegatedUserDecryptEip712In v14, these are replaced by a unified shape:
KmsUserDecryptEip712V2The current user-facing API is:
signDecryptionPermitToday, this API produces the v13-and-below permit format. Starting with protocol v14, the preferred permit format changes: the inner EIP-712 payload should be KmsUserDecryptEip712V2.
The name signDecryptionPermit is the best user-facing name. It describes the user's intent without exposing protocol details.
However, the meaning of the permit changes between protocol versions:
Both formats must coexist for some time because protocol v13 will remain deployed on-chain while protocol v14 is introduced.
The SDK therefore needs a migration strategy that:
signDecryptionPermit as the natural high-level API.The dApp SDK user should be able to call one function.
The SDK should dynamically resolve the protocol version and produce the correct permit format under the hood. Moving the protocol version check and branching logic to the dApp is not ideal.
In other words, this is not only a naming problem. It is also an API responsibility problem:
The SDK is missing an explicit protocol version resolver:
getProtocolVersion(...): Promise<string>
This function should resolve the active host-contract/protocol version for the target chain or contract context. The returned string should follow the SemVer specification.
Examples:
"0.13.0""0.14.0""0.14.1"It should be used by:
signDecryptionPermit, to decide whether to produce a v1 or v2 permit;This keeps protocol detection centralized instead of duplicating version checks across SDK features.
For now, protocol version resolution can be derived from the ACL host-contract version.
The compatibility table currently uses 0.x.0 protocol versions, and the ACL minor version is enough to determine the protocol version precisely.
Current mapping:
| ACL version | Protocol version |
|---|---|
0.2.0 | 0.11.0 |
0.3.0 | 0.12.0 |
0.4.0 | 0.13.0 |
0.5.0 | 0.14.0 |
As long as this invariant holds, the mapping can be expressed as:
protocolVersion = `0.${aclVersion.minor + 9}.0`;
The resolver should still fail loudly if the ACL version shape stops matching this assumption.
function protocolVersionFromAclVersion(aclVersion): string {
if (aclVersion.contractName !== 'ACL') {
throw new Error(`Expected ACL version, got ${aclVersion.contractName}.`);
}
if (aclVersion.major !== 0 || aclVersion.patch !== 0) {
throw new Error(`Unsupported ACL version ${aclVersion.version}.`);
}
return `0.${aclVersion.minor + 9}.0`;
}
getProtocolVersion should require only:
ethereum module;It should not depend on the encrypt/decrypt modules or on any resolved WASM version.
CoreFhevmImpl should store the resolved protocol version using the same pattern as tfheVersion and tkmsVersion.
Add:
type ProtocolVersion = string;
type WithProtocolVersion = {
readonly protocolVersion: ProtocolVersion;
};
Then make resolved WASM-version contexts include the protocol version:
type WithTfheVersion = WithProtocolVersion & {
readonly tfheVersion: TfheVersion;
};
type WithTkmsVersion = WithProtocolVersion & {
readonly tkmsVersion: TkmsVersion;
};
This reflects the runtime model:
Add helpers mirroring TFHE/TKMS:
getResolvedProtocolVersion(fhevm): ProtocolVersion | undefined
setResolvedProtocolVersion(fhevm, protocolVersion): void
resolveFhevmProtocolVersion(fhevm): Promise<ProtocolVersion>
HyperWasmResolver should stop owning ACL-version detection directly.
Instead, it should use the protocol version resolver:
const protocolVersion = await resolveFhevmProtocolVersion(fhevm);
Then it can map protocol version to WASM versions:
if (isSemverStrictlyBefore(protocolVersion, '0.13.0')) {
return '1.5.3';
}
return '1.6.1';
for TFHE, and similarly:
if (isSemverStrictlyBefore(protocolVersion, '0.13.0')) {
return '0.13.10';
}
return '0.13.20-0';
for TKMS.
This keeps the compatibility policy centralized:
Use versioned functions for explicit protocol-specific behavior:
signDecryptionPermitV1signDecryptionPermitV2signDecryptionPermitV3signDecryptionPermitVxEach versioned function must be stable. Its behavior should never change once published.
Use the unversioned function as the recommended high-level API:
signDecryptionPermitThis function should not be a simple permanent alias to the latest version if the SDK needs to support multiple deployed protocol versions at the same time.
Instead, it should likely be a protocol-aware dispatcher:
During the transition, the API could look like this:
signDecryptionPermit: current v13-and-below behavior.signDecryptionPermitV2: new v14 unified behavior.signDecryptionPermit: protocol-aware default behavior.signDecryptionPermitV1: explicit v13-and-below behavior.signDecryptionPermitV2: explicit v14 unified behavior.signDecryptionPermit: protocol-aware default behavior.signDecryptionPermitV1: still available for explicit legacy usage, possibly deprecated.signDecryptionPermitV2: still available for explicit v14 usage.signDecryptionPermit: protocol-aware default behavior.Should signDecryptionPermit return a discriminated union such as:
SignedDecryptionPermitV1 | SignedDecryptionPermitV2or should the SDK hide that difference behind a common signed permit abstraction?
The answer depends on how much downstream code needs to inspect the permit internals.
Expose one high-level protocol-aware function:
signDecryptionPermit(parameters): Promise<SignedDecryptionPermit>
Expose explicit protocol-specific helpers:
signDecryptionPermitV1(parameters): Promise<SignedDecryptionPermitV1>
signDecryptionPermitV2(parameters): Promise<SignedDecryptionPermitV2>
Future protocol-specific helpers can follow the same pattern:
signDecryptionPermitV3(parameters): Promise<SignedDecryptionPermitV3>
dApp users should normally call:
const permit = await fhevm.signDecryptionPermit({
userAddress,
publicKey,
handles,
allowedContracts,
durationSeconds,
});
The SDK should resolve the protocol version and route internally:
async function signDecryptionPermit(parameters): Promise<SignedDecryptionPermit> {
const protocolVersion = await resolveProtocolVersion(parameters);
if (isSemverStrictlyBefore(protocolVersion, '0.14.0')) {
return signDecryptionPermitV1(parameters);
}
return signDecryptionPermitV2(parameters);
}
type SignDecryptionPermitCommonParameters = {
readonly contractAddresses: readonly string[];
readonly startTimestamp: number;
readonly durationDays: number;
readonly signerAddress: string;
readonly signer: NativeSigner;
readonly transportKeyPair: TransportKeyPair;
};
export type SignSelfDecryptionPermitParameters = SignDecryptionPermitCommonParameters & {
readonly delegatorAddress?: undefined;
};
export type SignDelegatedDecryptionPermitParameters = SignDecryptionPermitCommonParameters & {
readonly delegatorAddress: string;
};
export type SignDecryptionPermitParameters =
| SignSelfDecryptionPermitParameters
| SignDelegatedDecryptionPermitParameters;
To avoid as much breaking changes as possible, we could introduce:
type SignDecryptionPermitParameters = {
readonly contractAddresses: readonly string[];
readonly startTimestamp: number;
// Conversion to seconds if v14 or later
readonly durationDays: number;
// An unsigned integer value.
// Missing: what is the max supported value ?
// (See backend rust code for more info on that)
readonly durationSeconds?: number | bigint;
readonly signerAddress: string;
readonly signer: NativeSigner;
// `delegatorAddress` could be renamed as `encryptedDataOwnerAddress`
// to follow the new `ownerAddress` naming in unified EIP712
readonly delegatorAddress?: string | undefined;
readonly transportKeyPair: TransportKeyPair;
};
Type validation should be performed according to Protocol Version which can be accessed using:
client.protocolVersion or context.protocolVersion
The protocolVersion property is now resolved at client init time.
The files impacted:
Versioned helpers are useful for:
They should not dynamically switch behavior.
signDecryptionPermitV1(parameters);
Always produces the v13-and-below permit format:
KmsUserDecryptEip712KmsDelegatedUserDecryptEip712signDecryptionPermitV2(parameters);
Always produces the v14 unified permit format:
KmsUserDecryptEip712V2The protocol-aware return type can be a discriminated union:
type SignedDecryptionPermit = SignedDecryptionPermitV1 | SignedDecryptionPermitV2;
Each permit should expose a stable discriminator:
type SignedDecryptionPermitV1 = {
readonly version: 1;
readonly eip712: KmsUserDecryptEip712 | KmsDelegatedUserDecryptEip712;
readonly signature: string;
};
type SignedDecryptionPermitV2 = {
readonly version: 2;
readonly eip712: KmsUserDecryptEip712V2;
readonly signature: string;
};
This keeps the high-level API simple while still allowing downstream code to inspect the exact permit shape when needed.
The high-level function may accept a protocol override for tests and advanced users:
type SignDecryptionPermitParameters = {
readonly protocolVersion?: 'auto' | string;
};
Default:
protocolVersion: 'auto';
If this override is added, it should be documented as an advanced option. The normal user path should remain automatic.
The old permit format uses durationDays.
The v14 unified permit format uses durationSeconds.
The high-level signDecryptionPermit API can support both as user input, but they should be mutually exclusive:
type DecryptionPermitDuration =
| {
readonly durationDays: number;
readonly durationSeconds?: never;
}
| {
readonly durationSeconds: number | bigint;
readonly durationDays?: never;
};
This gives users a smooth transition:
durationDays.durationSeconds.Suggested behavior:
durationDays is kept as-is.durationSeconds is accepted only if it is a whole number of days.durationSeconds is kept as-is.durationDays is converted to seconds.This conversion can live in an internal helper:
function resolveDecryptionPermitDuration(
parameters: DecryptionPermitDuration,
protocolVersion: string,
): { readonly durationDays: number } | { readonly durationSeconds: bigint } {
if ('durationDays' in parameters) {
if (isSemverStrictlyBefore(protocolVersion, '0.14.0')) {
return { durationDays: parameters.durationDays };
}
return {
durationSeconds: BigInt(parameters.durationDays) * 86_400n,
};
}
const durationSeconds = BigInt(parameters.durationSeconds);
if (isSemverStrictlyBefore(protocolVersion, '0.14.0')) {
if (durationSeconds % 86_400n !== 0n) {
throw new Error('Protocol v13 requires durationSeconds to be a whole number of days.');
}
return {
durationDays: Number(durationSeconds / 86_400n),
};
}
return { durationSeconds };
}
This helper should probably stay internal unless users need duration normalization outside permit signing.
The unversioned function is intent-based:
signDecryptionPermit;
The versioned functions are format-based:
signDecryptionPermitV1;
signDecryptionPermitV2;
Once published, a versioned function's behavior should never change. Only the internal dispatch behavior of signDecryptionPermit may evolve as new protocol versions are introduced.
in order to access the protocol version, the new base client must be initialized following decrypt and encrypt clients behaviours. Protocol Version computation will call on-chain view functions.
Example:
This is ok:
const client = createFhevmBaseClient(args);
await client.init();
const protocolVersion = client.protocolVersion;
This is not ok:
const client = createFhevmBaseClient(args);
// throw an exception, missing call to init!
const protocolVersion = client.protocolVersion;
client.signDecryptionPermitThe signDecryptionPermit should start with a lazy init if needed
/////////////////////
// Existing code
/////////////////////
export async function signDecryptionPermit(
context: SignDecryptionPermitContext,
parameters: SignDecryptionPermitParameters,
): Promise<SignedDecryptionPermit> {
const kmsSignersContext = await readKmsSignersContext(context, {
address: context.chain.fhevm.contracts.kmsVerifier.address as ChecksummedAddress,
});
const extraData: BytesHex = kmsSignersContextToExtraData(kmsSignersContext);
...
}
/////////////////////
// New code
/////////////////////
export async function signDecryptionPermit(
context: SignDecryptionPermitContext,
parameters: SignDecryptionPermitParameters,
): Promise<SignedDecryptionPermit> {
const protocolVersion = await ensureResolvedProtocolVersion(context);
const kmsSignersContext = await readKmsSignersContext(context, {
address: context.chain.fhevm.contracts.kmsVerifier.address as ChecksummedAddress,
});
const extraData: BytesHex = kmsSignersContextToExtraData(kmsSignersContext);
...
}