Skip to content

feat(encryption): support ERC-7730 encryption schemes - #56

Merged
manuelwedler merged 4 commits into
mainfrom
feat/erc7730-encryption
Jul 29, 2026
Merged

feat(encryption): support ERC-7730 encryption schemes#56
manuelwedler merged 4 commits into
mainfrom
feat/erc7730-encryption

Conversation

@manuelwedler

Copy link
Copy Markdown
Member

Implements the ERC-7730 encryption field annotation. Encrypted field values are decrypted by the wallet, then rendered with the field's regular format — an fhevm-encrypted bytes32 amount becomes "1 cUSDC" instead of an opaque handle.

  • New ExternalDataProvider.resolveDecryptedValue — optional, scheme-specific callback. Decryption stays in the wallet because schemes need a live connection, a user signature, and an access-control check. Returns the plaintext as 0x-hex of its big-endian bytes; the library re-interprets those bytes via the descriptor's declared plaintextType, so one encoding covers every scheme and type.
  • Field pipeline (fields.ts) — decrypts before visibility evaluation and rendering, so ifNotIn/mustMatch and the format handler all see the plaintext. Adds plaintextTypeToFieldType (canonical Solidity type → FieldType), reusing the existing bytesSliceToFieldType for the bytes → typed coercion.
  • Graceful fallback — when decryption is unavailable, the field renders the descriptor's fallbackLabel (or a generic [Encrypted]) with a DECRYPTION_FAILED warning rather than being dropped. The ciphertext is never shown as the value. A malformed encryption annotation is instead fatal (INVALID_DESCRIPTOR), consistent with the other descriptor checks.
  • New DisplayField.rawEncryptedValue — the raw encrypted value, reported whether or not decryption succeeded. The spec RECOMMENDS wallets show it beside the placeholder.
  • New typesDecryptedValueResult, DescriptorFieldEncryptionScheme, and the DECRYPTION_FAILED warning code.
  • Tests — a Zama ConfidentialWrapper registry case using the descriptor and calldata from registry#2595 (whose own test cases only cover the fallback path), plus unit tests for the coercion and both failure branches.
  • Docs — new DECRYPTION.md covering the scheme-agnostic contract and the fhevm (Zama Protocol) integration, the only scheme ERC-7730 currently defines. src/, README.md, and GUIDE.md stay scheme-agnostic.

Also fixes stale AGENTS.md claims found along the way: calldata was listed as unimplemented (it isn't), and eip712.ts was credited with a resolveFieldType that doesn't exist.

🤖 Generated with Claude Code

Comment thread src/fields.ts Outdated
};
}

return { value: bytesSliceToFieldType(bytes, fieldType) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

before returning this, should we validate the plaintext type vs the bytes lenght ?

for ex by adding :

  const maxWidth = plaintextByteWidth(plaintextType);   // ex: "uint64" → 8; bytes/string → undefined
  if (maxWidth !== undefined && bytes.length > maxWidth) {
    return {
      warning: warn(
        "DECRYPTION_FAILED",
        `Decrypted '${scheme}' value is ${bytes.length} bytes, exceeds ${plaintextType}`,
      ),
    };
  }

with plaintextByteWidth a helper function that returns how many bytes a plaintext type should be at most

@melanciani

Copy link
Copy Markdown

hey @manuelwedler , any updates on this ?

@manuelwedler
manuelwedler force-pushed the feat/erc7730-encryption branch from c156778 to c8dda18 Compare July 28, 2026 14:42
manuelwedler and others added 2 commits July 28, 2026 18:54
Implements the `encryption` field annotation: encrypted field values are
decrypted via a new wallet-provided `resolveDecryptedValue` callback, then
rendered with the field's regular format. Falls back to the descriptor's
`fallbackLabel` when decryption is unavailable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A decrypted value wider than its `plaintextType` was accepted silently — a
32-byte value declared `uint64` would render as a wildly wrong amount. Treat it
as a failed decryption instead, so the field falls back to its placeholder.

Leading zeros stay insignificant for integers, addresses and bools, so a wallet
returning a zero-padded 32-byte ABI word is still accepted; `bytesN` compares
raw length since every byte is part of the value.

Folds the width into `parsePlaintextType` (renamed from
`plaintextTypeToFieldType`) so the type string is parsed in one place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@manuelwedler
manuelwedler force-pushed the feat/erc7730-encryption branch from c8dda18 to c17ac19 Compare July 28, 2026 17:00
@manuelwedler
manuelwedler marked this pull request as ready for review July 28, 2026 17:00
@manuelwedler

Copy link
Copy Markdown
Member Author

Addressed your comment and refined a few things

@melanciani

melanciani commented Jul 28, 2026

Copy link
Copy Markdown

I've made claude audit the PR and one issue has been found (+ 1 minor), could you have a look at it ? it looks legit to me:

🔴 Signed intN plaintext can decode to the wrong value

bytesSliceToFieldType's int case calls bytesToSignedBigInt(bytes) without the bits argument, so the sign is inferred from the returned byte length rather than the declared type width:

case "int":
  return { type: "int", value: bytesToSignedBigInt(bytes) };   // bitLen = bytes.length * 8

For a declared plaintextType: "int64" with value 200, the reference toHexPlaintext in DECRYPTION.md produces the minimal form "0xc8" (it only pads to even length, not to the declared width). That then:

  1. passes exceedsPlaintextWidthstripLeadingZeros(0xc8) is 1 byte ≤ maxBytes 8 ✅
  2. hits bytesToSignedBigInt with bitLen = 8, sign bit set → 200 - 256

Verified against the current code:

bytesSliceToFieldType(hexToBytes("0xc8"), "int").value              -> -56n   ❌
bytesSliceToFieldType(hexToBytes("0x00000000000000c8"), "int").value -> 200n   ✅

So a signed field can silently render a wrong, negative number. This is latent-but-newly-reachable: in the calldata path bytes always come from a fixed-width ABI slot, so the length is never caller-chosen — this PR is the first place a wallet picks the byte length.

parsePlaintextType already returns maxBytes, so the fix is small — thread the declared width into the signed decode (bytesToSignedBigInt(bytes, maxBytes * 8)), or alternatively narrow the documented contract to "intN must be returned at its full declared width."

Scope: uint is unaffected (no sign bit), and fhevm — the only scheme the spec defines — uses unsigned euint types, so this only bites a descriptor declaring a signed intN.

🟡 Minor: sign-extended negatives are rejected while zero-padded positives are accepted

stripLeadingZeros only skips 0x00, so a negative returned ABI-style (-1 as 0xff…ff) keeps all 32 bytes and trips the width check, whereas the zero-padded positive equivalent is deliberately accepted. Cosmetic asymmetry rather than a bug: the documented toHexPlaintext can't produce a sign-extended negative anyway ((-1n).toString(16)"-1" → invalid hex), and the minimal form decodes correctly (0xff → -1n). Worth a comment noting the intent, or handling 0xff sign-extension for symmetry.

`bytesSliceToFieldType` inferred an `intN`'s width from the returned byte
length, so a positive value whose minimal encoding has the top bit set decoded
as negative — an `int64` of 200 returned as `0xc8` rendered `-56`.

The length is the value's own width for byte slices and ABI words, but a wallet
picks it independently when returning a decrypted plaintext, so `decryptFieldValue`
now passes the width the descriptor declares.

Signed values must therefore be returned at their full declared width: `-1` as an
`int64` is `0xffffffffffffffff`, not `0xff`, whose meaning depends on the width
it is read at. Positives are unaffected either way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@manuelwedler

Copy link
Copy Markdown
Member Author

Fixed in 572b84b. Confirmed the signed one — an int64 returned as 0xc8 rendered -56 instead of 200. decryptFieldValue now passes the descriptor's declared width into the signed decode; byte slices and ABI words keep inferring it from length, where the length genuinely is the width.

One consequence: signed values must come back at their full declared width (-1 as an int64 is 0xffffffffffffffff, not 0xff — the short form is ambiguous). Documented in DECRYPTION.md.

Left the minor one as-is: rejecting a sign-extended negative shows the fallback rather than a wrong number, and full width is the contract now anyway. Added a comment noting the intent.

…scriptor

Adds registry-level coverage for a zero-padded full ABI word, a top-bit-set
uint64 (the unsigned counterpart of the signed-width bug, which this descriptor
cannot exercise since it declares uint64), an over-wide plaintext, and a
provider that declines — the last being a distinct cause from having no
provider at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@manuelwedler
manuelwedler merged commit 9a2d00c into main Jul 29, 2026
2 checks passed
@manuelwedler
manuelwedler deleted the feat/erc7730-encryption branch July 29, 2026 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants