Skip to content

feat(sdk): offline signing — prepare / sign / broadcast for institutional custody (SDK-75)#345

Draft
ghermet wants to merge 45 commits into
prereleasefrom
feature/sdk-75-deferred-signing-v2
Draft

feat(sdk): offline signing — prepare / sign / broadcast for institutional custody (SDK-75)#345
ghermet wants to merge 45 commits into
prereleasefrom
feature/sdk-75-deferred-signing-v2

Conversation

@ghermet

@ghermet ghermet commented May 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the offline-signing pipeline for SDK-75 — separates prepare, sign, and broadcast for institutional custody, HSM, and policy-engine workflows where the three phases cannot run synchronously in a single Promise.

Closes SDK-75.

Atomic call sites are unchanged. Online signers keep their writeContract path and existing flows work identically. The new surface is a parallel route for signers that expose signTransaction instead, exposed via a dedicated sdk.offline.* sub-client.

What ships

sdk.offline.* sub-client

The pipeline lives on a domain sub-client, mirroring the "transfer / sign-and-broadcast / sign-only" three-tier vocabulary custody platforms expose:

sdk.offline.prepare(request, options?)        // → PreparedTransaction | PreparedPermitFor<K>
sdk.offline.sign(prepared)                    // → signed Hex (in-process synchronous signer)
sdk.offline.broadcast(prepared, signedTx)     // → { txHash, receipt }
sdk.offline.signAndBroadcast(request, opts?)  // bundled tier-2 for transactions
sdk.offline.signAndRegister(request)          // bundled tier-2 for credential permits
sdk.offline.registerPermit(prepared, sig)     // persist an externally-signed permit
sdk.offline.resume(prepared, txHash)          // continue lifecycle after out-of-process broadcast
sdk.offline.refresh(prepared, options?)       // re-stamp nonce/fees/gas before signing

Backed by OfflineSigningService. The atomic path (Token.confidentialTransfer, etc.) is not on this client — it remains on Token for online-signer call sites. The surface is provider-neutral (no vendor lock-in in JSDoc); industry vocabulary is the anchor.

Signer capability bag

  • GenericSigner.writeContract is optional; new optional signTransaction(unsignedTx) covers the deferred path.
  • BaseSigner is the public base class; wrap a custodian's API client by extending it. Keys never enter the SDK.
  • assertWriteContract / assertSignTransaction TS-asserts guards in signer/capabilities.ts. Capability errors are typed via SignerCapabilityError.

Provider

  • GenericProvider.sendRawTransaction(signedTx) — required. Wired in viem, ethers, wagmi.
  • GenericProvider.prepareTransaction({ from, call }) — required. Builds RLP-encoded EIP-1559 unsigned tx (encode + estimateGas + getTransactionCount + estimateFeesPerGas + serializeTransaction). Wired in all three adapters.
  • Bug fix (EthersProvider.prepareTransaction): resolve overloaded ABI entries by name + arity. Bare iface.encodeFunctionData(name, args) throws on ERC-7984's confidentialTransfer(address,bytes32) vs confidentialTransfer(address,bytes32,bytes) because ethers' overload-disambiguation treats the last value as a potential overrides object.

Eleven transaction kinds wired

ConfidentialTransfer, ConfidentialTransferFrom, SetOperator, Unwrap, UnwrapAll, FinalizeUnwrap, ApproveUnderlying, Wrap, TransferAndCall, DelegateDecryption, RevokeDelegation. FinalizeUnwrap does the publicDecrypt round-trip during prepare. UnwrapAll reads the on-chain balance handle instead of encrypting.

Token-level sugar

WrappedToken.prepareShield(amount, options?) — multi-step planner that mirrors the atomic shield routing (1363 → single TransferAndCall step, otherwise ApproveUnderlying + Wrap). Returns a ShieldPlan whose steps each carry a PreparedFor<K> payload ready to feed into sdk.offline.sign/broadcast (or signAndBroadcast).

EIP-712 permits

sdk.offline.prepare({ kind: "CredentialPermit", contracts, delegator? }) returns the typed-data envelope; sign it via the custodian (in-process or out-of-process) and call sdk.offline.registerPermit(prepared, signature). The bundled sdk.offline.signAndRegister(request) does both in one call for signers available in-process.

DFNS integration test

packages/sdk/src/services/__tests__/dfns.integration.test.ts — real DFNS + real Sepolia + real FHEVM relayer. Demonstrates the canonical cross-process custody flow with DFNS's async policy-approval API. Env-gated via zod, skips cleanly without creds. pnpm test:integration runs the suite (with dotenv/config autoload). Satisfies SDK-75 acceptance criterion #7 (reference example against a real custodian environment).

What's deliberately not in this PR

  • Atomic auto-routing for offline signers (transparent token.confidentialTransfer(...) against DFNS). Scoped out — atomic stays unchanged for existing callers.
  • pending-block-tag nonce reads in prepareTransaction. Sequential broadcasts wait for receipt (slow but correct). Flagged as a follow-up for pipelined custodian flows.
  • Custodian guide / shield-and-unshield examples in deferred mode. Phase 5 in the plan, not in this PR.

How callers use it

Two integration patterns, picked by whether the custodian's adapter handles the SDK transparently or you need to model policy approval explicitly.

Path A — in-process via the custodian's viem/ethers adapter

Use when the custodian ships a viem WalletClient or ethers Signer adapter and policy approval is either disabled or instantaneous. Atomic Token methods just work — no SDK changes.

import { ZamaSDK } from "@zama-fhe/sdk";
import { createConfig, ViemProvider, ViemSigner } from "@zama-fhe/sdk/viem";

const sdk = new ZamaSDK(createConfig({
  chains: [mainnet], relayer,
  provider: new ViemProvider({ publicClient }),
  signer: new ViemSigner({ walletClient }),
}));

await token.confidentialTransfer({ to: recipient, amount: 1000n });

Path B — cross-process deferred (the canonical custodian pattern)

Web initiator runs the SDK without a signer. Back-end signer service holds the custodian's API client. Zama SDK is not on the back-end — it only deals with bytes.

Transactions — using resume since the custodian broadcasts via its own infrastructure:

// Web initiator (signer-absent)
const sdk = new ZamaSDK(createConfig({ chains, relayer, provider }));

const prepared = await sdk.offline.prepare({
  kind: "ConfidentialTransfer",
  from: userAddress,
  token: tokenAddress, to: recipient, amount: 1000n,
});

const { txHash } = await fetch("/api/sign-and-broadcast", {
  method: "POST",
  body: JSON.stringify({ unsignedTx: prepared.unsignedTx, chainId: prepared.chainId }),
}).then(r => r.json());

const result = await sdk.offline.resume(prepared, txHash);

Credential permits (EIP-712) — same shape, different leaf method:

const prepared = await sdk.offline.prepare({
  kind: "CredentialPermit",
  from: userAddress,
  contracts: [tokenAddress],
});

const { signature } = await fetch("/api/sign-typed-data", {
  method: "POST",
  body: JSON.stringify({ typedData: prepared.typedData }),
}).then(r => r.json());

await sdk.offline.registerPermit(prepared, signature);

Air-gapped tooling is a special case of Path B with manual byte transfer (the back-end queue becomes a USB stick).

React hooks

// Tier 2 — bundled in-process flow
const signAndBroadcast = useSignAndBroadcast();
await signAndBroadcast.mutateAsync({
  request: { kind: "ConfidentialTransfer", from, token, to, amount },
});

const signAndRegister = useSignAndRegister();
await signAndRegister.mutateAsync({
  request: { kind: "CredentialPermit", from, contracts: [tokenAddress] },
});

// Tier 3 — phase control for custodian approval UIs
const prepare = usePrepare();
const broadcast = useBroadcast();
const prepared = await prepare.mutateAsync({
  request: { kind: "ConfidentialTransfer", from, token, to, amount },
});
// ... await custodian approval, get signedTx from back-end ...
await broadcast.mutateAsync({ prepared, signedTx });

For the cross-process custodian pattern where the back-end broadcasts directly, swap useBroadcast for useResume({ prepared, txHash }).

Test plan

  • pnpm typecheck — clean across the workspace
  • pnpm test:run — 1525 pass, 5 skipped
  • DFNS integration suite passes end-to-end on Sepolia (transaction + permit, both via the policy-approval API)
  • API extractor snapshots regenerated; zero ae-forgotten-export warnings
  • Atomic path regression on a Sepolia browser dApp (existing examples)

Risks

  • GenericProvider gains two new required methods. Custom adapter implementers (none known in the wild) must implement both; viem / ethers / wagmi adapters are wired here.
  • GenericSigner.writeContract flipped from required → optional. Any third-party signer adapter typed via implements GenericSigner is unaffected; one typed via a structural assignment will now narrow writeContract to optional and may need callers to handle the absent case (or just throw via the existing assertWriteContract guard).
  • PreparedTransaction.request carries bigint fields on several kinds — naive JSON.stringify on the prepared payload throws. Documented in the type's JSDoc.
  • Sub-client carve-out is a public-API shape change: sdk.prepare/sign/broadcast/etc. moved under sdk.offline.*. Since the deferred-signing surface hasn't shipped yet, no real-world callers exist.

🤖 Generated with Claude Code

ghermet and others added 5 commits May 11, 2026 20:04
Phase 1 of the deferred-signing v2 refactor.

- GenericSigner.writeContract is now optional. Add optional
  signTransaction(unsignedTx) → Hex for the deferred custodian path.
- Add Broadcaster type and BroadcastSigner adapter (capability-bag signer
  for institutional custody, HSM, and policy-engine workflows). Keys never
  enter the SDK; the broadcaster owns signing material.
- GenericProvider.sendRawTransaction(signedTx) → Hex required. Wired in the
  viem, ethers, and wagmi adapters.
- Add assertWriteContract / assertSignTransaction capability guards using
  TS asserts predicates so atomic call sites narrow without casts.
- SignerCapabilityError carries the missing capability for typed handling
  at call sites.
- All atomic write sites (Token methods, DelegationService) now run the
  capability assertion before invoking writeContract.

Behavioural change: zero. Online signers keep their writeContract method
and atomic flows work as before. Broadcast-only signers get a typed
SignerCapabilityError instead of a runtime TypeError when atomic methods
are called — Phase 4 will replace those throws with a transparent fallback
through prepare + sign + broadcast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of deferred-signing v2. Adds the prepare → sign → broadcast pipeline
and exposes it on ZamaSDK as five composable methods. Atomic call sites
unchanged.

- New OfflineSigningService (services/offline-signing-service.ts) owns the
  full deferred pipeline: build unsigned tx, sign via signer.signTransaction,
  broadcast via provider.sendRawTransaction, await receipt, emit *Submitted
  event. Lazy-instantiated alongside CredentialService and DecryptionService
  when a signer is configured.
- ZamaSDK gains five public methods: prepare, sign, broadcast, execute
  (overloaded — PreparedTransaction | TransactionPrepareRequest |
  CredentialPermitRequest), completeFromTxHash. All require a configured
  signer; methods that need signTransaction surface SignerCapabilityError
  via the asserts predicate from Phase 1.
- GenericProvider gains prepareTransaction({ from, call }) returning RLP
  unsigned-tx bytes. Wired in viem (encodeFunctionData +
  estimateFeesPerGas + serializeTransaction), ethers v6
  (Transaction.from(...).unsignedSerialized), and wagmi (via getPublicClient).
- New discriminated types in types/prepared-tx.ts: TransactionKind,
  TransactionPrepareRequest, PreparedTransaction<K>, PreparedFor<K>,
  ConfidentialTransferRequest, CredentialPermitRequest, ExecuteRequest.
  Phase 2 wires the ConfidentialTransfer kind end-to-end; Phase 3 adds the
  remaining ERC-7984 write ops and Token-level convenience methods.
- CredentialPermit kind uses the existing CredentialService.allow path —
  the same EIP-712 typed-data flow the atomic path runs, but reachable
  through execute({ kind: "CredentialPermit", contracts }) so a custodian
  signer (signTypedData via Broadcaster) gets the same shape as an EOA.
- Round-trip tests in services/__tests__/offline-signing-service.test.ts
  cover prepare/sign/broadcast/execute(prepared)/execute(request)/
  completeFromTxHash/execute(permit) via BroadcastSigner + mock provider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(SDK-75)

Phase 3 of deferred-signing v2. Extends the prepare → sign → broadcast
pipeline to every ERC-7984 write op and lifts the SDK-level surface onto
Token via paired prepareX / completeX methods. No behavioural change for
atomic call sites.

- types/prepared-tx.ts gains 10 new discriminated request types:
  ConfidentialTransferFromRequest, SetOperatorRequest, UnwrapRequest,
  UnwrapAllRequest, FinalizeUnwrapRequest, ApproveUnderlyingRequest,
  WrapRequest, TransferAndCallRequest, DelegateDecryptionRequest,
  RevokeDelegationRequest. TransactionKind / TransactionPrepareRequest
  cover the full union.
- PreparedTransaction is now non-generic (covers the wide union);
  PreparedFor<K> derives the kind-specific narrowing as an intersection so
  any narrow form is assignable to the wide form. Removes the variance
  rejection that came from `request: Extract<...>` being invariant in K.
- OfflineSigningService dispatches all 11 kinds via per-kind #buildX
  methods, owning encryption, balance-handle reads (UnwrapAll), and the
  publicDecrypt round-trip required by FinalizeUnwrap. SUBMITTED_EVENT_
  BY_KIND + ERROR_OPERATION_BY_KIND maps are exhaustive.
- Token gains prepareX / completeX for every single-tx kind plus
  prepareShield(amount, options?) — the multi-step planner that mirrors
  the atomic shield routing (TransferAndCall single-step on 1363,
  ApproveUnderlying + Wrap on non-1363). Caller iterates the plan,
  preparing each step right before signing to keep nonces fresh.
- Unshield two-phase stays caller-orchestrated: prepareUnwrap or
  prepareUnwrapAll, broadcast, wait for the UnwrapRequested event, then
  prepareFinalizeUnwrap with the handle.
- Tests: 17 new — 10 cover each new kind's calldata builder (via
  BroadcastSigner + the offline-signing service), 7 cover Token-level
  sugar and the ShieldPlan routing for both 1363 and approve+wrap paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (SDK-75)

Tightens the post-Phase-3 deferred-signing surface based on integration
review. No new public API; behavioural changes are additive guards on
existing paths.

- broadcast/completeFromTxHash re-check chain alignment between the
  prepared tx and the current provider chain before submitting. The gap
  between prepare and broadcast is the whole point of deferred signing,
  and the user may have switched chains in between.
- broadcast splits pre-submit failures (chain mismatch, RPC reject) from
  post-submit ones. Post-submit errors preserve the txHash in the message
  so callers can resume via completeFromTxHash without losing the in-flight
  tx.
- execute checks CredentialPermit first so the discriminator lookup is
  safe on the wide ExecuteRequest type; isPreparedTransaction tightened to
  check all required fields (kind/to/chainId), no longer matches arbitrary
  shapes that happen to carry `unsignedTx`.
- FinalizeUnwrap's publicDecrypt error is wrapped through wrapDecryptError
  for consistent diagnostic shape.
- BroadcastSigner validates the broadcaster's returned signature shape via
  isHex and throws SigningFailedError instead of letting a malformed
  response surface as an opaque RPC reject downstream.
- prepareShield's payable-path recipientData mirrors the atomic shield's
  encoding: `0x` for self-shield (wrapper falls back to from), raw 20-byte
  address otherwise — drops the redundant lowercase-pad-40 normalization.
- ShieldPlan exported from packages/sdk and the token barrel for
  custodian-side use.
- Docs cleanup across types/signer.ts, types/prepared-tx.ts, and the
  service: drop Phase-N labels, document JSON-serialisation gotcha for
  bigint fields on PreparedTransaction.request, document the
  PreparedFor<K> intersection rationale (variance of Extract<>), and note
  the USDT-style two-step approve caveat on ApproveUnderlyingRequest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…SDK-75)

Round-2 polish on top of the deferred-signing surface — replaces lingering
bare error throws with typed `ZamaError` subclasses and fixes an isPayable
cache footgun that could silently downgrade 1363-capable tokens.

- `Token.isPayable()` now only caches successful results. Contract reverts
  (via `isContractCallError`) still cache `false`, but transport errors
  bubble up so a transient RPC blip during the probe doesn't permanently
  route a 1363 token through the slower approve+wrap path.
- `OfflineSigningService.sign()` wraps broadcaster rejections (HTTP errors,
  policy denials, timeouts) in `SigningFailedError` with cause and emits a
  `TransactionError` event. Already-typed `ZamaError` causes pass through.
- `EthersProvider` swaps four bare `new Error(...)` for `ConfigurationError`
  (block/timestamp/fee data) and `TransactionRevertedError` (receipt not
  found). `WagmiProvider` does the same for the two "no public client"
  cases. These now survive the `if (error instanceof ZamaError) throw error;`
  branch in `broadcast()` instead of being re-wrapped into generic
  `TransactionRevertedError("Broadcast failed for …")`.
- `prepareShield` / `prepareApproveUnderlying` now call
  `requireAlignedWalletAccount` to fail fast on a wrong-chain signer before
  any custodian ceremony runs.
- Lifts the two-phase unshield orchestration WHY out of a hidden `//` block
  comment into proper JSDoc on `prepareUnwrap`, `prepareUnwrapAll`, and
  `prepareFinalizeUnwrap` — now surfaces in IDE hover.
- Tests: shield.test.ts uses `ContractFunctionRevertedError`-named errors
  for revert paths and adds a new test locking in the "transport errors are
  NOT cached" contract. ethers.test.ts message-substring assertions match
  the new typed-error messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cla-bot cla-bot Bot added the cla-signed label May 12, 2026
@github-actions

github-actions Bot commented May 12, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 92.12% (🎯 80%) 3368 / 3656
🔵 Statements 92.19% 3463 / 3756
🔵 Functions 92.25% (🎯 80%) 1108 / 1201
🔵 Branches 84.28% (🎯 80%) 1309 / 1553
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/react-sdk/src/offline/use-broadcast.ts 100% 100% 100% 100%
packages/react-sdk/src/offline/use-prepare.ts 100% 100% 100% 100%
packages/react-sdk/src/offline/use-refresh-prepared.ts 100% 100% 100% 100%
packages/react-sdk/src/offline/use-register-permit.ts 100% 100% 100% 100%
packages/react-sdk/src/offline/use-resume.ts 100% 100% 100% 100%
packages/react-sdk/src/offline/use-sign.ts 100% 100% 100% 100%
packages/react-sdk/src/wagmi/wagmi-provider.ts 66.66% 60% 50% 66.66% 53-87, 150-153
packages/react-sdk/src/wagmi/wagmi-signer.ts 76.92% 100% 66.66% 76.92% 55-74
packages/sdk/src/zama-sdk.ts 94.59% 83.33% 100% 94.59% 80, 183
packages/sdk/src/credentials/credential-service.ts 95.45% 83.33% 100% 95.32% 73, 180, 283-289, 294-296, 378
packages/sdk/src/errors/base.ts 100% 100% 100% 100%
packages/sdk/src/errors/signer.ts 100% 83.33% 100% 100%
packages/sdk/src/ethers/ethers-provider.ts 89.58% 76.47% 83.33% 89.58% 95-97, 152, 154, 175-178, 196-199
packages/sdk/src/ethers/ethers-signer.ts 80.55% 69.44% 88.88% 80.55% 78, 88, 93, 104-107, 128, 145, 168-170, 176, 183, 187
packages/sdk/src/namespaces/offline-signing.ts 100% 100% 100% 100%
packages/sdk/src/query/broadcast.ts 100% 100% 100% 100%
packages/sdk/src/query/prepare.ts 100% 100% 100% 100%
packages/sdk/src/query/refresh-prepared.ts 100% 100% 100% 100%
packages/sdk/src/query/register-permit.ts 100% 100% 100% 100%
packages/sdk/src/services/delegation-service.ts 88.15% 86.48% 100% 88.15% 59-61, 75-77, 93-94, 97-99, 136-137, 206, 266
packages/sdk/src/services/offline-signing-service.ts 94.06% 88.09% 96.55% 94.06% 266, 376-380, 435-440, 521, 589, 636
packages/sdk/src/signer/base-signer.ts 81.81% 75% 60% 81.81% 31, 45
packages/sdk/src/signer/capabilities.ts 83.33% 75% 100% 83.33% 41
packages/sdk/src/signer/util.ts 0% 0% 0% 0% 25-30
packages/sdk/src/test-fixtures/constants.ts 100% 100% 100% 100%
packages/sdk/src/test-fixtures/provider.ts 100% 100% 100% 100%
packages/sdk/src/test-fixtures/signer.ts 100% 100% 100% 100%
packages/sdk/src/token/token.ts 87.35% 67.53% 100% 86.9% 164, 266, 284, 296-308, 314-315, 362-364, 374, 398, 406-412, 466, 476, 482-492, 602, 749-751
packages/sdk/src/token/wrapped-token.ts 96.45% 89.83% 100% 96.4% 101, 195, 597-602
packages/sdk/src/types/offline.ts 100% 100% 100% 100%
packages/sdk/src/types/provider.ts 100% 100% 100% 100%
packages/sdk/src/types/signer.ts 100% 100% 100% 100%
packages/sdk/src/utils/assertions.ts 100% 100% 100% 100%
packages/sdk/src/utils/submit-transaction.ts 100% 100% 100% 100%
packages/sdk/src/viem/viem-provider.ts 93.75% 83.33% 87.5% 93.75% 119-122
packages/sdk/src/viem/viem-signer.ts 100% 100% 90% 100%
Generated in workflow #2589 for commit 4895521 by the Vitest Coverage Report Action

ghermet and others added 17 commits May 12, 2026 09:26
…h (SDK-75)

Follow-on test expansion + small refactors after the deferred-signing
pipeline shipped. No new public API, no behavioural change.

- packages/react-sdk/src/wagmi/__tests__/wagmi-provider.test.ts: new file
  covering WagmiProvider.sendRawTransaction and prepareTransaction —
  happy paths plus the ConfigurationError thrown when no public client is
  configured for the active chain.
- packages/sdk/src/token/__tests__/capability-atomic.test.ts: new file
  asserting Token atomic methods throw SignerCapabilityError on a
  broadcast-only signer, with the message pointing callers at the
  deferred prepare*/complete* surface.
- Tighten existing offline-signing-service, prepare-token, and shared
  test-fixtures coverage for the deferred pipeline (mock broadcaster
  helpers, hex-shape assertions).
- Provider polish: viem, ethers, and wagmi adapters get small doc and
  error-shape refinements to keep their deferred-signing methods aligned
  with the GenericProvider contract.
- Token + BaseSigner: minor doc and type polish surfaced by the new tests.
- types/prepared-tx.ts + types/signer.ts: clarifying JSDoc additions for
  custodian integrators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gner

Replaces the anonymous capability-bag arms (WriteContractCapability /
SignTransactionCapability) with two named, exported interfaces so adapter
authors get a TS class-level error at the declaration when they forget a
capability method.

- `OnlineSigner` — `writeContract` required, `signTransaction` optional.
- `OfflineSigner` — `signTransaction` required, `writeContract` optional.
- `GenericSigner = OnlineSigner | OfflineSigner` — unchanged consumer surface;
  every SDK API still accepts the union and gates the wrong-flavour case at
  runtime via `SignerCapabilityError`.
- Concrete adapters now declare intent via `implements`:
  `ViemSigner`, `EthersSigner`, `WagmiSigner` → `OnlineSigner`;
  `BroadcastSigner` → `OfflineSigner`.
- `SignerCore` renamed to `CoreSigner` for naming consistency with the two
  flavour-named interfaces.

No runtime behaviour changes; no test changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…deferred-signing-v2

# Conflicts:
#	packages/sdk/src/token/token.ts
#	packages/sdk/src/zama-sdk.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Architectural reframe of the deferred-signing path. Real custodian flows
split between in-process via viem/ethers adapter (atomic, unchanged) and
true cross-process where the SDK process holds no signer at all — the
back-end signer service uses the custodian's raw API directly. The
synchronous-broker BroadcastSigner shape modeled a third pattern that
doesn't exist in production.

Breaking changes (alpha.35 → next):
- Delete BroadcastSigner + Broadcaster. Custodian-side integration is
  either viem/ethers adapter (OnlineSigner) or cross-process with no
  SDK-level signer. In-process OfflineSigner use cases subclass
  BaseSigner directly; ensureHexSignature moves to signer/util.ts.
- Add required from: Address to every PrepareRequest variant. prepare
  reads from from the request; a configured signer that disagrees throws
  SignerAddressMismatchError. Rename ConfidentialTransferFromRequest.from
  to owner to free the slot for the tx-sender.
- prepare/broadcast/registerPermit/completeFromTxHash/refreshPrepared
  drop their signer-required guards — they work signer-optional, which
  is the canonical shape for cross-process flows. sign/execute keep
  their guards (in-process flows for tests, dev tools).
- Drop execute(PreparedTransaction) overload. Callers chain
  await broadcast(prepared, await sign(prepared)) so call shapes stay
  visible at the call site.

Additions:
- sdk.registerPermit(prepared, signature) — kind-restricted to
  PermitKind; lands the back-end-of-flow for typed-data permits
  separately from broadcast (different destination + result shape).
- sdk.refreshPrepared(prepared) — re-stamps nonce + fees from current
  chain state, leaves the original prepared immutable.
- OfflineSigningOptions gains nonce, maxFeePerGas, maxPriorityFeePerGas,
  gasLimit overrides; threaded through to GenericProvider.prepareTransaction.
- GenericProvider.prepareTransaction args type soft-extended with the
  same optional fields; viem/ethers/wagmi adapters honor them.
- New types: PreparedCredentialPermit, PreparedPermitFor<K>,
  CredentialPermitContext, CredentialPermitResult, PermitKind, TxKind,
  SignerAddressMismatchError.

react-sdk deferred-signing hooks (under packages/react-sdk/src/broadcast/):
- Tier-1: useExecute(request) — generic mutation mirroring atomic-hook
  ergonomics for the deferred path.
- Tier-2: usePrepare, useSign, useBroadcast, useRegisterPermit,
  useCompleteFromTxHash, useRefreshPrepared — generic mutations for
  callers needing explicit phase control (custodian approval UIs,
  air-gapped tooling). All three-layer (core action → options factory
  → hook); cache invalidation flows from SDK events, not duplicated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Collapse CoreSigner + OnlineSigner + OfflineSigner + GenericSigner into one
flat GenericSigner with optional writeContract and signTransaction. Reverses
the type-axis split from 1b61033 — the union was paying compile-time rent
for a single "at-least-one-capability" guarantee, and the Online/Offline
naming mapped poorly to deployment topology (Dfns viem WalletClient is
"online" in the type sense but signs off-process at the custodian).

Runtime capability guards (assertWriteContract, assertSignTransaction) and
SignerCapabilityError remain the source of truth for wrong-flavour-for-the-
method errors; the type system was never narrowing them anyway.

Updates ViemSigner, EthersSigner, WagmiSigner, and BaseSigner accordingly,
prunes the public re-exports, and refreshes the api.md snapshots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The deferred-signing surface now lives exclusively on ZamaSDK. The
ticket's Layering note is explicit that the build/sign/broadcast
separation is a separate layer from Token's multi-step orchestrators
(prepareShield, prepareUnshield, prepareUnshieldAll) and that the two
concerns should not be conflated. The v2 plan's original "open question
1" resolved the same way; a later commit re-added the sugar; this
reverses that.

Removes 9 prepareX/completeX pairs and the #senderAddress private
helper from Token, plus their direct test cases in prepare-token.test.ts
(prepareConfidentialTransfer, prepareDelegateDecryption blocks).
prepareShield and its routing tests stay — those are layer-1.

Callers wanting deferred-flow ergonomics on Token use
sdk.prepare({ kind, from, ... }) directly; the React-SDK query layer
already targets that surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signing belongs to the caller, not the SDK. The ticket's invariant
"keys never enter the SDK — the SDK hands out an unsigned transaction
and accepts a signed one back" describes a two-method shape: hand-out
(prepare) and accept-back (broadcast). Sign is performed between those
by whatever holds the keys — the SDK's job is to build and submit, not
to invoke signing on the caller's behalf.

ZamaSDK.sign() was a thin wrapper around signer.signTransaction() that
added no SDK capability. Callers in the deferred flow now compose

  const prepared = await sdk.prepare(request);
  const signedTx = await signer.signTransaction(prepared.unsignedTx);
  const result  = await sdk.broadcast(prepared, signedTx);

— making the prepare → caller-signs → broadcast call shape visible at
the call site, which matches the ticket's "build, sign, and broadcast
steps are independently composable" framing.

execute() keeps its bundled-flow shape and continues to call
signer.signTransaction internally; that's implementation detail for the
atomic-shape convenience entry point, not a public signing surface.

Removes:
- ZamaSDK.sign() public method
- signMutationOptions / SignParams from @zama-fhe/sdk/query
- useSign hook and its test
- 2 sign-specific tests; refactors the SignerNotConfiguredError
  signer-absent test to drive through execute()

Updates JSDoc in zama-sdk, usePrepare, useExecute, useRefreshPrepared
to point at signer.signTransaction(prepared.unsignedTx) instead of
sdk.sign / useSign.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oval

Captures the public surface narrowing from Q2 (Token per-op
prepareX/completeX gone) and Q7 (sdk.sign + useSign + signMutationOptions
gone) in the API extractor snapshots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After removing sdk.sign() from the public API, three JSDoc @link
references to it remained dangling on prepare(), refreshPrepared(),
and the service's prepare(). Replace with explicit
\`signer.signTransaction(...)\` wording so the docs reflect the actual
flow (caller signs externally; SDK builds and submits).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores sdk.sign + useSign with a refined JSDoc making its scope
explicit: in-process convenience that delegates to
signer.signTransaction with capability checks and event/error
integration. The keys-never-enter-the-SDK invariant is structural
(this runs in the caller's process against the caller's signer
object) and stated in the JSDoc.

The Q7 removal traded ergonomics for naming clarity. The trade wasn't
worth it: KMS-can't-sign and cross-process custody callers naturally
route to "prepare → external sign → broadcast" because sdk.sign()
throws SignerNotConfiguredError / SignerCapabilityError in those
cases — i.e. they already weren't using it. The removal only hurt the
in-process case, where the directed errors + event emission +
SigningFailedError wrapping are real value.

Restores:
- ZamaSDK.sign(prepared, options?) with refined JSDoc clarifying scope
  (cross-process custody and permit-only KMS use prepare → external
  sign → broadcast instead; this method is the in-process convenience)
- signMutationOptions + SignParams in @zama-fhe/sdk/query
- useSign hook + test
- {@link sign} / {@link useSign} JSDoc cross-references on prepare,
  refreshPrepared, usePrepare, useExecute, useRefreshPrepared, service
  prepare
- Two service tests that exercised sign behavior (success path +
  SigningFailedError wrapping on HSM rejection)
- Refreshed api.md snapshots

Reverts: 1aa4179 + 64596e4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rmit/completeFromTxHash

These four methods carried an _options?: OfflineSigningOptions parameter
that no code path consumed — a forward-compatibility hedge that just
added boilerplate at every call site and tightened the public surface
without buying anything. Removed at the service, ZamaSDK, query-helper,
and mutation-params layers.

prepare(), execute(), and refreshPrepared() keep their options arg —
those actually use the nonce/fee/gas overrides for custodian flows.

API surface tightening for callers; existing code that passed
{ ...options } to broadcast/sign/etc. will get a typecheck error and a
trivial mechanical fix (drop the third arg).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The error class previously embedded a switch on capability that named
specific SDK call shapes ("Use the deferred prepare*/complete* path",
"BaseSigner subclass"). That coupled the error to API surface — every
public-API rename risked rotting the message in errors/signer.ts.

Hoist the per-capability guidance into capabilities.ts as
WRITE_CONTRACT_HINT / SIGN_TRANSACTION_HINT constants — they live next
to the assertion helpers that consume them, which is the file someone
editing the capability surface already touches. SignerCapabilityError
becomes structural:

- accepts hint?: string as a constructor arg (passed by the assertion
  helpers, or by callers wanting context-specific guidance)
- exposes readonly hint: string | undefined for programmatic readers
  (UIs, telemetry) that want to render headline + suggestion separately
- folds hint into .message when present, so console.error stays useful

cause stays reserved for actual error chaining (SigningFailedError
wrapping the underlying signer's throw, etc.). Decoupling guidance from
the error class doesn't change behaviour — same message text reaches
the caller, same instanceof checks work — but means future API renames
update one file, not two.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The field exposed the hint string for hypothetical programmatic readers
(UIs rendering headline + suggestion separately), but no code in the
SDK or React-SDK actually reads error.hint — it was speculative API.
The hint is still folded into .message via the constructor, so the
user-facing behaviour is unchanged. YAGNI cleanup; the structured-field
shape can come back when there's a real consumer.

Also trims the verbose JSDoc on the error class to one sentence — the
design rationale (decoupling from call-shape vocabulary) lives in the
commit history, not in public API docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fixtures exposed three parallel signer abstractions for tests:

  broadcaster:     SignerThunks (mock thunk pair)
  broadcastSigner: TestOfflineSigner extends BaseSigner (wraps thunks)
  signer:          GenericSigner from createMockSigner (plain mock)

After the signer flatten removed OfflineSigner, the TestOfflineSigner
class was a structurally-broken vestige (its implements clause pointed
at a type that no longer existed; tsc happens to ignore unresolved
interfaces in implements). The class added nothing tests couldn't get
from createMockSigner — both produce a value that satisfies
GenericSigner; only the latter does so without an unused BaseSigner
subclass + thunks indirection.

Deletes:
- TestOfflineSigner class
- SignerThunks interface
- createMockSignerThunks helper
- (the broadcaster + broadcastSigner fixture properties were already
  removed in a prior edit; this completes the cleanup)

Renames in 3 test files (~120 references): broadcastSigner -> signer,
broadcaster.X -> signer.X (assertions now run against the unified
signer fixture's vi.fn() mocks).

createMockSigner now mocks signTransaction by default so tests
exercising the deferred path get a fully-capable signer without
overrides. Tests that need to assert capability errors (e.g.
capability-atomic.test.ts) construct sign-only signers explicitly via
createMockSigner({ writeContract: undefined }).

Net: test-fixtures.ts loses ~45 lines; three test files lose three
import paths and a layer of indirection; the conceptual model
collapses from three signer-shapes to one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SDK-75 deferred-signing additions (registerPermit, refreshPrepared,
new query factories, signer-optional plumbing) pushed brotli sizes
~3 kB over budget. Raise ESM 66→72 kB and CJS 57→64 kB to give ~3 kB
headroom; react-sdk stays at 15 kB (well under).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
api-extractor flagged tsdoc-code-span-missing-delimiter because the
backtick-quoted `wrapper.unwrap(...)` call wrapped across two lines.
Reflow the docstring so the code span stays on a single line and
refresh the sdk-query api.md snapshot for the shifted chunk hash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 12, 2026

Copy link
Copy Markdown

Public API Changes

react-sdk.api.md
--- a/react-sdk.api.md
+++ b/react-sdk.api.md
@@ -9,11 +9,13 @@
 import { BatchBalancesResult } from '@zama-fhe/sdk';
 import { BatchDecryptAsOptions } from '@zama-fhe/sdk';
 import { BatchDecryptBalancesAsParams } from '@zama-fhe/sdk/query';
+import { BroadcastParams } from '@zama-fhe/sdk/query';
 import { ClearValues } from '@zama-fhe/relayer-sdk/web';
 import { ClearValueType } from '@zama-fhe/relayer-sdk/web';
 import { ConfidentialSetOperatorParams } from '@zama-fhe/sdk/query';
 import { ConfidentialTransferFromParams } from '@zama-fhe/sdk/query';
 import { ConfidentialTransferParams } from '@zama-fhe/sdk/query';
+import { CredentialPermitResult } from '@zama-fhe/sdk';
 import { DecryptBalanceAsParams } from '@zama-fhe/sdk/query';
 import { DecryptResult } from '@zama-fhe/sdk/query';
 import { DelegatedDecryptValuesMutationParams } from '@zama-fhe/sdk/query';
@@ -23,16 +25,26 @@
 import { EncryptParams } from '@zama-fhe/sdk';
 import { EncryptResult } from '@zama-fhe/sdk';
 import { FinalizeUnwrapParams } from '@zama-fhe/sdk/query';
+import { Hex } from '@zama-fhe/sdk';
 import { JSX } from 'react';
 import { PaginatedResult } from '@zama-fhe/sdk';
+import { PermitKind } from '@zama-fhe/sdk';
+import { PreparedFor } from '@zama-fhe/sdk';
+import { PreparedPermitFor } from '@zama-fhe/sdk';
+import { PrepareParams } from '@zama-fhe/sdk/query';
 import { PropsWithChildren } from 'react';
+import { RefreshPreparedParams } from '@zama-fhe/sdk/query';
+import { RegisterPermitParams } from '@zama-fhe/sdk/query';
+import { ResumeParams } from '@zama-fhe/sdk/query';
 import { ResumeUnshieldParams } from '@zama-fhe/sdk/query';
 import { RevokeDelegationParams } from '@zama-fhe/sdk/query';
 import { ShieldParams } from '@zama-fhe/sdk/query';
+import { SignParams } from '@zama-fhe/sdk/query';
 import { Token } from '@zama-fhe/sdk';
 import { TokenMetadata } from '@zama-fhe/sdk/query';
 import { TokenWrapperPair } from '@zama-fhe/sdk';
 import { TokenWrapperPairWithMetadata } from '@zama-fhe/sdk';
+import { TransactionKind } from '@zama-fhe/sdk';
 import { TransactionResult } from '@zama-fhe/sdk';
 import { UnshieldAllParams } from '@zama-fhe/sdk/query';
 import { UnshieldParams } from '@zama-fhe/sdk/query';
@@ -53,6 +65,9 @@
 export function useBatchDecryptBalancesAs(tokens: Token[], options?: UseMutationOptions<Map<Address, bigint>, Error, BatchDecryptBalancesAsParams>): UseMutationResult<Map<`0x${string}`, bigint>, Error, BatchDecryptAsOptions, unknown>;
 
 // @public
+export function useBroadcast<TContext = unknown>(options?: UseMutationOptions<TransactionResult, Error, BroadcastParams, TContext>): UseMutationResult<TransactionResult, Error, BroadcastParams, TContext>;
+
+// @public
 export function useClearCredentials(options?: UseMutationOptions<void>): UseMutationResult<void, Error, void, unknown>;
 
 // @public
@@ -203,6 +218,18 @@
 export function useMetadataSuspense(tokenAddress: Address): UseSuspenseQueryResult<TokenMetadata, Error>;
 
 // @public
+export function usePrepare<TContext = unknown>(options?: UseMutationOptions<PreparedFor<TransactionKind> | PreparedPermitFor<PermitKind>, Error, PrepareParams, TContext>): UseMutationResult<PreparedFor<TransactionKind> | PreparedPermitFor<PermitKind>, Error, PrepareParams, TContext>;
+
+// @public
+export function useRefreshPrepared<TContext = unknown>(options?: UseMutationOptions<PreparedFor<TransactionKind>, Error, RefreshPreparedParams, TContext>): UseMutationResult<PreparedFor<TransactionKind>, Error, RefreshPreparedParams, TContext>;
+
+// @public
+export function useRegisterPermit<TContext = unknown>(options?: UseMutationOptions<CredentialPermitResult, Error, RegisterPermitParams, TContext>): UseMutationResult<CredentialPermitResult, Error, RegisterPermitParams, TContext>;
+
+// @public
+export function useResume<TContext = unknown>(options?: UseMutationOptions<TransactionResult, Error, ResumeParams, TContext>): UseMutationResult<TransactionResult, Error, ResumeParams, TContext>;
+
+// @public
 export function useResumeUnshield(address: Address, options?: UseMutationOptions<TransactionResult, Error, ResumeUnshieldParams, Address>): UseMutationResult<TransactionResult, Error, ResumeUnshieldParams, `0x${string}`>;
 
 // @public
@@ -221,6 +248,9 @@
 }
 
 // @public
+export function useSign<TContext = unknown>(options?: UseMutationOptions<Hex, Error, SignParams, TContext>): UseMutationResult<Hex, Error, SignParams, TContext>;
+
+// @public
 export function useToken(address: Address): Token;
 
 // @public
sdk-ethers.api.md
--- a/sdk-ethers.api.md
+++ b/sdk-ethers.api.md
@@ -50,8 +50,19 @@
     // (undocumented)
     getChainId(): Promise<number>;
     // (undocumented)
+    prepareTransaction<const TAbi extends ContractAbi, TFunctionName extends WriteFunctionName<TAbi>, const TArgs extends WriteContractArgs<TAbi, TFunctionName>>(args: {
+        from: Address;
+        call: WriteContractConfig<TAbi, TFunctionName, TArgs>;
+        nonce?: number;
+        maxFeePerGas?: bigint;
+        maxPriorityFeePerGas?: bigint;
+        gasLimit?: bigint;
+    }): Promise<Hex>;
+    // (undocumented)
     readContract<const TAbi extends Abi | readonly unknown[], TFunctionName extends ContractFunctionName<TAbi, "pure" | "view">, const TArgs extends ContractFunctionArgs<TAbi, "pure" | "view", TFunctionName>>(config: ReadContractConfig<TAbi, TFunctionName, TArgs>): Promise<ContractFunctionReturnType<TAbi, "pure" | "view", TFunctionName, TArgs>>;
     // (undocumented)
+    sendRawTransaction(signedTx: Hex): Promise<Hex>;
+    // (undocumented)
     waitForTransactionReceipt(hash: Hex): Promise<TransactionReceipt>;
 }
 
@@ -63,7 +74,7 @@
 };
 
 // @public
-export class EthersSigner extends BaseSigner {
+export class EthersSigner extends BaseSigner implements GenericSigner {
     constructor(config: EthersSignerConfig);
     // (undocumented)
     protected onDispose(): void;
sdk-query.api.md
--- a/sdk-query.api.md
+++ b/sdk-query.api.md
@@ -80,6 +80,17 @@
 export type BatchDecryptBalancesAsParams = BatchDecryptAsOptions;
 
 // @public
+export function broadcastMutationOptions(sdk: ZamaSDK): MutationFactoryOptions<readonly ["zama.broadcast"], BroadcastParams, TransactionResult>;
+
+// @public
+export interface BroadcastParams {
+    // (undocumented)
+    readonly prepared: PreparedTransaction;
+    // (undocumented)
+    readonly signedTx: Hex;
+}
+
+// @public
 export function clearCredentialsMutationOptions(sdk: ZamaSDK): MutationFactoryOptions<readonly ["zama.clearCredentials"], void, void>;
 
 // @public
@@ -428,9 +439,10 @@
     dispose?(): void;
     refreshWalletAccount?(): Promise<WalletAccount | undefined>;
     requireWalletAccount(operation: string): WalletAccount;
+    signTransaction?(unsignedTx: Hex): Promise<Hex>;
     signTypedData(typedData: EIP712TypedData): Promise<Hex>;
     readonly walletAccount: WalletAccountStore;
-    writeContract<const TAbi extends ContractAbi, TFunctionName extends WriteFunctionName<TAbi>, const TArgs extends WriteContractArgs<TAbi, TFunctionName>>(config: WriteContractConfig<TAbi, TFunctionName, TArgs>): Promise<Hex>;
+    writeContract?<const TAbi extends ContractAbi, TFunctionName extends WriteFunctionName<TAbi>, const TArgs extends WriteContractArgs<TAbi, TFunctionName>>(config: WriteContractConfig<TAbi, TFunctionName, TArgs>): Promise<Hex>;
 }
 
 // @public
@@ -540,6 +552,20 @@
 // @public
 export type OnChainEvent = ConfidentialTransferEvent | WrappedEvent | UnwrapRequestedEvent | UnwrapFinalizedEvent | UnwrappedStartedEvent;
 
+// @public
+export function prepareMutationOptions(sdk: ZamaSDK): MutationFactoryOptions<readonly ["zama.prepare"], PrepareParams, PrepareResult>;
+
+// @public
+export interface PrepareParams {
+    // (undocumented)
+    readonly options?: OfflineSigningOptions;
+    // (undocumented)
+    readonly request: TransactionPrepareRequest | CredentialPermitRequest;
+}
+
+// @public
+export type PrepareResult = PreparedFor<TransactionKind> | PreparedPermitFor<PermitKind>;
+
 // @public (undocumented)
 export interface QueryClientLike {
     // (undocumented)
@@ -574,11 +600,44 @@
 }
 
 // @public
+export function refreshPreparedMutationOptions(sdk: ZamaSDK): MutationFactoryOptions<readonly ["zama.refreshPrepared"], RefreshPreparedParams, PreparedFor<TransactionKind>>;
+
+// @public
+export interface RefreshPreparedParams {
+    // (undocumented)
+    readonly options?: OfflineSigningOptions;
+    // (undocumented)
+    readonly prepared: PreparedFor<TransactionKind>;
+}
+
+// @public
+export function registerPermitMutationOptions(sdk: ZamaSDK): MutationFactoryOptions<readonly ["zama.registerPermit"], RegisterPermitParams, CredentialPermitResult>;
+
+// @public
+export interface RegisterPermitParams {
+    // (undocumented)
+    readonly prepared: PreparedPermitFor<PermitKind>;
+    // (undocumented)
+    readonly signature: Hex;
+}
+
+// @public
 export interface RelayerSDK extends FheOperations {
     getAclAddress(): Promise<Address>;
     terminate(): void;
 }
 
+// @public
+export function resumeMutationOptions(sdk: ZamaSDK): MutationFactoryOptions<readonly ["zama.resume"], ResumeParams, TransactionResult>;
+
+// @public
+export interface ResumeParams {
+    // (undocumented)
+    readonly prepared: PreparedTransaction;
+    // (undocumented)
+    readonly txHash: Hex;
+}
+
 // @public (undocumented)
 export function resumeUnshieldMutationOptions(token: WrappedToken): MutationFactoryOptions<readonly ["zama.resumeUnshield", Address], ResumeUnshieldParams, TransactionResult>;
 
@@ -649,6 +708,15 @@
     type: typeof ZamaSDKEvents.ShieldSubmitted;
 }
 
+// @public
+export function signMutationOptions(sdk: ZamaSDK): MutationFactoryOptions<readonly ["zama.sign"], SignParams, Hex>;
+
+// @public
+export interface SignParams {
+    // (undocumented)
+    readonly prepared: PreparedTransaction;
+}
+
 // @public (undocumented)
 export type StrippedQueryOptionKeys = "gcTime" | "staleTime" | "enabled" | "select" | "refetchInterval" | "refetchOnMount" | "refetchOnWindowFocus" | "refetchOnReconnect" | "retry" | "retryDelay" | "retryOnMount" | "queryFn" | "queryKey" | "queryKeyHashFn" | "initialData" | "initialDataUpdatedAt" | "placeholderData" | "structuralSharing" | "throwOnError" | "meta" | "query" | "pollingInterval";
 
@@ -944,9 +1012,13 @@
 // @public
 export class WrappedToken extends Token {
     allowance(owner: Address): Promise<bigint>;
+    // (undocumented)
     approveUnderlying(amount?: bigint): Promise<TransactionResult>;
     finalizeUnwrap(unwrapRequestIdOrAmount: EncryptedValue): Promise<TransactionResult>;
     isPayable(): Promise<boolean>;
+    prepareShield(amount: bigint, options?: {
+        recipient?: Address;
+    }): Promise<ShieldPlan>;
     resumeUnshield(unwrapTxHash: Hex, callbacks?: UnshieldCallbacks): Promise<TransactionResult>;
     shield(amount: bigint, options?: ShieldOptions): Promise<TransactionResult>;
     underlying(): Promise<Address>;
@@ -1192,6 +1264,7 @@
     // @internal
     emitEvent(input: ZamaSDKEventInput, tokenAddress?: Address): void;
     encrypt(params: EncryptParams): Promise<EncryptResult>;
+    readonly offlineSigning: OfflineSigning;
     // @internal
     onWalletAccountChange(listener: WalletAccountListener): () => void;
     readonly permits: Permits;
sdk-viem.api.md
--- a/sdk-viem.api.md
+++ b/sdk-viem.api.md
@@ -82,8 +82,19 @@
     // (undocumented)
     getChainId(): Promise<number>;
     // (undocumented)
+    prepareTransaction<const TAbi extends ContractAbi, TFunctionName extends WriteFunctionName<TAbi>, const TArgs extends WriteContractArgs<TAbi, TFunctionName>>(args: {
+        from: Address;
+        call: WriteContractConfig<TAbi, TFunctionName, TArgs>;
+        nonce?: number;
+        maxFeePerGas?: bigint;
+        maxPriorityFeePerGas?: bigint;
+        gasLimit?: bigint;
+    }): Promise<Hex>;
+    // (undocumented)
     readContract<const TAbi extends Abi | readonly unknown[], TFunctionName extends ContractFunctionName<TAbi, "pure" | "view">, const TArgs extends ContractFunctionArgs<TAbi, "pure" | "view", TFunctionName>>(config: ReadContractConfig<TAbi, TFunctionName, TArgs>): Promise<ContractFunctionReturnType<TAbi, "pure" | "view", TFunctionName, TArgs>>;
     // (undocumented)
+    sendRawTransaction(signedTx: Hex): Promise<Hex>;
+    // (undocumented)
     waitForTransactionReceipt(hash: Hex): Promise<TransactionReceipt>;
 }
 
@@ -93,7 +104,7 @@
 }
 
 // @public (undocumented)
-export class ViemSigner extends BaseSigner {
+export class ViemSigner extends BaseSigner implements GenericSigner {
     constructor(config: ViemSignerConfig);
     // (undocumented)
     protected onDispose(): void;
sdk.api.md
--- a/sdk.api.md
+++ b/sdk.api.md
@@ -352,6 +352,20 @@
     readonly args: readonly [`0x${string}`, bigint];
 };
 
+// @public
+export interface ApproveUnderlyingRequest {
+    // (undocumented)
+    readonly amount: bigint;
+    // (undocumented)
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "ApproveUnderlying";
+    // (undocumented)
+    readonly spender: Address;
+    // (undocumented)
+    readonly underlying: Address;
+}
+
 // @public (undocumented)
 export interface ApproveUnderlyingSubmittedEvent extends BaseEvent {
     step: "reset" | "approve";
@@ -531,7 +545,7 @@
 }
 
 // @public
-export abstract class BaseSigner implements GenericSigner, Disposable {
+export abstract class BaseSigner implements Disposable {
     // (undocumented)
     [Symbol.dispose](): void;
     constructor(initial?: WalletAccount);
@@ -545,8 +559,6 @@
     abstract signTypedData(typedData: EIP712TypedData): Promise<Hex>;
     // (undocumented)
     readonly walletAccount: MutableWalletAccountStore;
-    // (undocumented)
-    abstract writeContract<const TAbi extends ContractAbi, TFunctionName extends WriteFunctionName<TAbi>, const TArgs extends WriteContractArgs<TAbi, TFunctionName>>(config: WriteContractConfig<TAbi, TFunctionName, TArgs>): Promise<Hex>;
 }
 
 // @public
@@ -5924,6 +5936,30 @@
 };
 
 // @public
+export interface ConfidentialTransferFromRequest {
+    // (undocumented)
+    readonly amount: bigint;
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "ConfidentialTransferFrom";
+    readonly owner: Address;
+    // (undocumented)
+    readonly to: Address;
+    // (undocumented)
+    readonly token: Address;
+}
+
+// @public
+export interface ConfidentialTransferRequest {
+    readonly amount: bigint;
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "ConfidentialTransfer";
+    readonly to: Address;
+    readonly token: Address;
+}
+
+// @public
 export class ConfigurationError extends ZamaError {
     constructor(message: string, options?: ErrorOptions);
 }
@@ -5945,6 +5981,40 @@
     readonly permits: readonly Permission[];
 }
 
+// Warning: (ae-internal-missing-underscore) The name "CredentialPermitContext" should be prefixed with an underscore because the declaration is marked as @internal
+//
+// @internal
+export interface CredentialPermitContext {
+    // (undocumented)
+    readonly chainId: number;
+    // (undocumented)
+    readonly chunk: readonly Address[];
+    // (undocumented)
+    readonly delegatorAddress: Address;
+    // (undocumented)
+    readonly keypairPublicKey: Hex;
+    // (undocumented)
+    readonly signerAddress: Address;
+    // (undocumented)
+    readonly startTimestamp: number;
+}
+
+// @public
+export interface CredentialPermitRequest {
+    readonly contracts: readonly Address[];
+    readonly delegator?: Address;
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "CredentialPermit";
+}
+
+// @public
+export interface CredentialPermitResult {
+    readonly contracts: readonly Address[];
+    readonly durationDays: number;
+    readonly startTimestamp: number;
+}
+
 // @public
 export function decimalsContract(tokenAddress: Address): {
     readonly address: `0x${string}`;
@@ -6236,6 +6306,21 @@
 }
 
 // @public
+export interface DelegateDecryptionRequest {
+    // (undocumented)
+    readonly aclAddress: Address;
+    // (undocumented)
+    readonly contractAddress: Address;
+    // (undocumented)
+    readonly delegateAddress: Address;
+    readonly expirationDate?: Date;
+    // (undocumented)
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "DelegateDecryption";
+}
+
+// @public
 export interface DelegatedForUserDecryptionEvent {
     readonly contractAddress: Address;
     readonly delegate: Address;
@@ -6479,6 +6564,9 @@
 }
 
 // @public
+export function ensureHexSignature(value: unknown, method: string): Hex;
+
+// @public
 export const ERC1363_INTERFACE_ID: "0xb0202a11";
 
 // @public
@@ -6493,6 +6581,9 @@
 export const ERC7984_WRAPPER_INTERFACE_ID: "0x1f1c62b2";
 
 // @public
+export type ExecuteRequest = TransactionPrepareRequest | CredentialPermitRequest;
+
+// @public
 export interface FheChain<TId extends number = number> {
     // (undocumented)
     readonly aclContractAddress: Address;
@@ -7515,6 +7606,17 @@
     readonly args: readonly [`0x${string}`, bigint, `0x${string}`];
 };
 
+// @public
+export interface FinalizeUnwrapRequest {
+    // (undocumented)
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "FinalizeUnwrap";
+    readonly unwrapRequestIdOrAmount: EncryptedValue;
+    // (undocumented)
+    readonly wrapper: Address;
+}
+
 // @public (undocumented)
 export interface FinalizeUnwrapSubmittedEvent extends BaseEvent {
     // (undocumented)
@@ -7551,7 +7653,16 @@
 export interface GenericProvider {
     getBlockTimestamp(): Promise<bigint>;
     getChainId(): Promise<number>;
+    prepareTransaction<const TAbi extends ContractAbi, TFunctionName extends WriteFunctionName<TAbi>, const TArgs extends WriteContractArgs<TAbi, TFunctionName>>(args: {
+        from: Address;
+        call: WriteContractConfig<TAbi, TFunctionName, TArgs>;
+        nonce?: number;
+        maxFeePerGas?: bigint;
+        maxPriorityFeePerGas?: bigint;
+        gasLimit?: bigint;
+    }): Promise<Hex>;
     readContract<const TAbi extends ContractAbi, TFunctionName extends ReadFunctionName<TAbi>, const TArgs extends ReadContractArgs<TAbi, TFunctionName>>(config: ReadContractConfig<TAbi, TFunctionName, TArgs>): Promise<ReadContractReturnType<TAbi, TFunctionName, TArgs>>;
+    sendRawTransaction(signedTx: Hex): Promise<Hex>;
     waitForTransactionReceipt(hash: Hex): Promise<TransactionReceipt>;
 }
 
@@ -7560,9 +7671,10 @@
     dispose?(): void;
     refreshWalletAccount?(): Promise<WalletAccount | undefined>;
     requireWalletAccount(operation: string): WalletAccount;
+    signTransaction?(unsignedTx: Hex): Promise<Hex>;
     signTypedData(typedData: EIP712TypedData): Promise<Hex>;
     readonly walletAccount: WalletAccountStore;
-    writeContract<const TAbi extends ContractAbi, TFunctionName extends WriteFunctionName<TAbi>, const TArgs extends WriteContractArgs<TAbi, TFunctionName>>(config: WriteContractConfig<TAbi, TFunctionName, TArgs>): Promise<Hex>;
+    writeContract?<const TAbi extends ContractAbi, TFunctionName extends WriteFunctionName<TAbi>, const TArgs extends WriteContractArgs<TAbi, TFunctionName>>(config: WriteContractConfig<TAbi, TFunctionName, TArgs>): Promise<Hex>;
 }
 
 // @public
@@ -11684,6 +11796,31 @@
 }
 
 // @public
+export class Offline {
+    constructor(offlineSigningService: OfflineSigningService);
+    broadcast(prepared: PreparedTransaction, signedTx: Hex): Promise<TransactionResult>;
+    prepare<K extends TransactionKind>(request: Extract<TransactionPrepareRequest, {
+        kind: K;
+    }>, options?: OfflineSigningOptions): Promise<PreparedFor<K>>;
+    // (undocumented)
+    prepare<K extends PermitKind>(request: CredentialPermitRequest, options?: OfflineSigningOptions): Promise<PreparedPermitFor<K>>;
+    refresh<K extends TransactionKind>(prepared: PreparedFor<K>, options?: OfflineSigningOptions): Promise<PreparedFor<K>>;
+    registerPermit<K extends PermitKind>(prepared: PreparedPermitFor<K>, signature: Hex): Promise<CredentialPermitResult>;
+    resume(prepared: PreparedTransaction, txHash: Hex): Promise<TransactionResult>;
+    sign(prepared: PreparedTransaction): Promise<Hex>;
+}
+
+// @public
+export interface OfflineSigningOptions {
+    readonly gasLimit?: bigint;
+    readonly maxFeePerGas?: bigint;
+    readonly maxPriorityFeePerGas?: bigint;
+    readonly nonce?: number;
+    // (undocumented)
+    readonly signal?: AbortSignal;
+}
+
+// @public
 export type OnChainEvent = ConfidentialTransferEvent | WrappedEvent | UnwrapRequestedEvent | UnwrapFinalizedEvent | UnwrappedStartedEvent;
 
 // @public
@@ -11708,6 +11845,9 @@
 export type Permission = z.infer<typeof PermissionSchema>;
 
 // @public
+export type PermitKind = "CredentialPermit";
+
+// @public
 export class Permits {
     // @internal
     constructor(opts: {
@@ -11726,6 +11866,51 @@
 }
 
 // @public
+export interface PreparedCredentialPermit {
+    // (undocumented)
+    readonly chainId: number;
+    // @internal
+    readonly context: CredentialPermitContext;
+    // (undocumented)
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "CredentialPermit";
+    // (undocumented)
+    readonly request: CredentialPermitRequest;
+    // (undocumented)
+    readonly typedData: EIP712TypedData | null;
+}
+
+// @public
+export type PreparedFor<K extends TransactionKind> = PreparedTransaction & {
+    readonly kind: K;
+    readonly request: Extract<TransactionPrepareRequest, {
+        kind: K;
+    }>;
+};
+
+// @public
+export type PreparedPermitFor<K extends PermitKind> = PreparedCredentialPermit & {
+    readonly kind: K;
+};
+
+// @public
+export interface PreparedTransaction {
+    // (undocumented)
+    readonly chainId: number;
+    // (undocumented)
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: TransactionKind;
+    // (undocumented)
+    readonly request: TransactionPrepareRequest;
+    // (undocumented)
+    readonly to: Address;
+    // (undocumented)
+    readonly unsignedTx: Hex;
+}
+
+// @public
 export interface PublicKeyData {
     // (undocumented)
     publicKey: Uint8Array;
@@ -13256,6 +13441,20 @@
     readonly args: readonly [`0x${string}`, `0x${string}`];
 };
 
+// @public
+export interface RevokeDelegationRequest {
+    // (undocumented)
+    readonly aclAddress: Address;
+    // (undocumented)
+    readonly contractAddress: Address;
+    // (undocumented)
+    readonly delegateAddress: Address;
+    // (undocumented)
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "RevokeDelegation";
+}
+
 // @public (undocumented)
 export interface RevokeDelegationSubmittedEvent extends BaseEvent {
     // (undocumented)
@@ -14602,6 +14801,20 @@
     readonly args: readonly [`0x${string}`, number];
 };
 
+// @public
+export interface SetOperatorRequest {
+    // (undocumented)
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "SetOperator";
+    // (undocumented)
+    readonly operator: Address;
+    // (undocumented)
+    readonly token: Address;
+    // (undocumented)
+    readonly until?: number;
+}
+
 // @public (undocumented)
 export interface SetOperatorSubmittedEvent extends BaseEvent {
     // (undocumented)
@@ -14625,6 +14838,15 @@
 // @public
 export type ShieldPath = "transferAndCall" | "approveAndWrap";
 
+// @public
+export type ShieldPlan = {
+    readonly path: "transferAndCall";
+    readonly steps: readonly [TransferAndCallRequest];
+} | {
+    readonly path: "approveAndWrap";
+    readonly steps: readonly [WrapRequest] | readonly [ApproveUnderlyingRequest, WrapRequest] | readonly [ApproveUnderlyingRequest, ApproveUnderlyingRequest, WrapRequest];
+};
+
 // @public (undocumented)
 export interface ShieldSubmittedEvent extends BaseEvent {
     shieldPath: ShieldPath;
@@ -14635,6 +14857,28 @@
 }
 
 // @public
+export class SignerAddressMismatchError extends ZamaError {
+    constructor(params: {
+        requested: Address;
+        configured: Address;
+        operation: string;
+    }, options?: ErrorOptions);
+    // (undocumented)
+    readonly configured: Address;
+    // (undocumented)
+    readonly operation: string;
+    // (undocumented)
+    readonly requested: Address;
+}
+
+// @public
+export class SignerCapabilityError extends SignerRequiredError {
+    constructor(operation: string, capability: SignerCapability, hint?: string, options?: ErrorOptions);
+    // (undocumented)
+    readonly capability: SignerCapability;
+}
+
+// @public
 export class SignerNotConfiguredError extends SignerRequiredError {
     constructor(operation: string, options?: ErrorOptions);
 }
@@ -14909,9 +15153,15 @@
 }
 
 // @public
+export type TransactionKind = TransactionPrepareRequest["kind"];
+
+// @public
 export type TransactionOperation = keyof typeof transactionOperationMetadata;
 
 // @public
+export type TransactionPrepareRequest = ConfidentialTransferRequest | ConfidentialTransferFromRequest | SetOperatorRequest | UnwrapRequest | UnwrapAllRequest | FinalizeUnwrapRequest | ApproveUnderlyingRequest | WrapRequest | TransferAndCallRequest | DelegateDecryptionRequest | RevokeDelegationRequest;
+
+// @public
 export interface TransactionReceipt {
     readonly logs: readonly RawLog[];
 }
@@ -14954,6 +15204,22 @@
 };
 
 // @public
+export interface TransferAndCallRequest {
+    // (undocumented)
+    readonly amount: bigint;
+    // (undocumented)
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "TransferAndCall";
+    // (undocumented)
+    readonly recipientData?: Hex;
+    // (undocumented)
+    readonly underlying: Address;
+    // (undocumented)
+    readonly wrapper: Address;
+}
+
+// @public
 export interface TransferCallbacks {
     onEncryptComplete?: () => void;
     onTransferSubmitted?: (txHash: Hex) => void;
@@ -14981,6 +15247,9 @@
 }
 
 // @public
+export type TxKind = TransactionKind;
+
+// @public
 export function underlyingContract(wrapperAddress: Address): {
     readonly address: `0x${string}`;
     readonly abi: readonly [{
@@ -16007,6 +16276,18 @@
 }
 
 // @public
+export interface UnwrapAllRequest {
+    // (undocumented)
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "UnwrapAll";
+    // (undocumented)
+    readonly to: Address;
+    // (undocumented)
+    readonly token: Address;
+}
+
+// @public
 export function unwrapContract(encryptedErc20: Address, from: Address, to: Address, encryptedAmount: EncryptedValue, inputProof: Hex): {
     readonly address: `0x${string}`;
     readonly abi: readonly [{
@@ -18672,6 +18953,17 @@
 }
 
 // @public
+export interface UnwrapRequest {
+    readonly amount: bigint;
+    // (undocumented)
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "Unwrap";
+    readonly to: Address;
+    readonly token: Address;
+}
+
+// @public
 export interface UnwrapRequestedEvent {
     readonly encryptedAmount: EncryptedValue;
     // (undocumented)
@@ -19733,9 +20025,13 @@
 // @public
 export class WrappedToken extends Token {
     allowance(owner: Address): Promise<bigint>;
+    // (undocumented)
     approveUnderlying(amount?: bigint): Promise<TransactionResult>;
     finalizeUnwrap(unwrapRequestIdOrAmount: EncryptedValue): Promise<TransactionResult>;
     isPayable(): Promise<boolean>;
+    prepareShield(amount: bigint, options?: {
+        recipient?: Address;
+    }): Promise<ShieldPlan>;
     resumeUnshield(unwrapTxHash: Hex, callbacks?: UnshieldCallbacks): Promise<TransactionResult>;
     shield(amount: bigint, options?: ShieldOptions): Promise<TransactionResult>;
     underlying(): Promise<Address>;
@@ -19784,6 +20080,20 @@
 }
 
 // @public
+export interface WrapRequest {
+    // (undocumented)
+    readonly amount: bigint;
+    // (undocumented)
+    readonly from: Address;
+    // (undocumented)
+    readonly kind: "Wrap";
+    // (undocumented)
+    readonly to: Address;
+    // (undocumented)
+    readonly wrapper: Address;
+}
+
+// @public
 export type WriteContractArgs<TAbi extends ContractAbi = ContractAbi, TFunctionName extends WriteFunctionName<TAbi> = WriteFunctionName<TAbi>> = ContractFunctionArgs<TAbi, "nonpayable" | "payable", TFunctionName>;
 
 // @public
@@ -19894,7 +20204,9 @@
     readonly ChainMismatch: "CHAIN_MISMATCH"; /** Operation requires a signer but none is configured. */
     readonly SignerNotConfigured: "SIGNER_NOT_CONFIGURED"; /** Operation requires a connected wallet account. */
     readonly WalletNotConnected: "WALLET_NOT_CONNECTED"; /** Wallet account discovery is still resolving. */
-    readonly WalletAccountNotReady: "WALLET_ACCOUNT_NOT_READY";
+    readonly WalletAccountNotReady: "WALLET_ACCOUNT_NOT_READY"; /** Signer lacks a capability required by the requested operation. */
+    readonly SignerMissingCapability: "SIGNER_MISSING_CAPABILITY"; /** A configured signer's wallet address does not match `request.from`. */
+    readonly SignerAddressMismatch: "SIGNER_ADDRESS_MISMATCH";
 };
 
 // @public
@@ -19913,6 +20225,7 @@
     // @internal
     emitEvent(input: ZamaSDKEventInput, tokenAddress?: Address): void;
     encrypt(params: EncryptParams): Promise<EncryptResult>;
+    readonly offlineSigning: Offline;
     // @internal
     onWalletAccountChange(listener: WalletAccountListener): () => void;
     readonly permits: Permits;

@ghermet ghermet requested a review from enitrat May 12, 2026 15:06
ghermet and others added 4 commits May 13, 2026 07:59
Narrow CredentialService.registerSignedPermit's keypair param to
Pick<StoredKeypair, "publicKey"> so OfflineSigningService can pass a
real object instead of casting reconstructed fields through `as never`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace backtick-wrapped symbol references with `{@link}` tags across
SDK and React-SDK tsdoc so API doc generators can resolve them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rtHex

Add `assertHex` to utils/assertions and use it in
OfflineSigningService.registerPermit so the boundary-input check
follows the same TypeError-throwing convention as assertBigint et al.
Changes the documented `@throws` from SigningFailedError to TypeError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…erload bug

Adds a vitest integration test that exercises the cross-process custody
flow against real DFNS, real Sepolia, and real FHEVM relayer:
prepare → DFNS policy-engine approval → broadcast / registerPermit.
Env vars are validated through zod/mini; dotenv/config is loaded as a
vitest setupFile so `.env` is picked up automatically.

Fixes a latent overload-resolution bug surfaced by the test:
EthersProvider.prepareTransaction used iface.encodeFunctionData(name)
which throws on ERC-7984's overloaded confidentialTransfer (2-arg vs
3-arg). Now resolves the fragment by name + arity via forEachFunction
before encoding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ghermet and others added 6 commits May 13, 2026 13:36
The wallet wrapper from @dfns/lib-ethersjs6 was only used to discover
the DFNS wallet address; its signing methods were abandoned when the
test switched to the policy-aware async signing API. Fetch the address
directly via `dfnsClient.wallets.getWallet`, drop the dependency, and
share the evmAddress schema with other env validators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (SDK-75)

Move prepare / sign / broadcast / execute / registerPermit /
completeFromTxHash / refreshPrepared off ZamaSDK and onto a dedicated
sub-client at sdk.offline, paired with OfflineClient.

The "offline" naming axis matches the ecosystem-standard term ("offline
signing", as used by ethers, web3.py, and HSM vendors) and aligns with
the existing OnlineSigner / OfflineSigner capability split. Atomic call
sites (Token.confidentialTransfer, etc.) are unchanged and still flow
through the OnlineSigner path on Token.

Also renames the react-sdk broadcast/ directory to offline/ for the
same reason — the directory holds the entire offline-signing hook
surface, not just useBroadcast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lary

Reshapes the offline-signing surface on `OfflineClient`,
`OfflineSigningService`, and the query/hook layers to match the
"transfer / sign-and-broadcast / sign-only" three-tier vocabulary
common to custody platforms.

- Split `execute` into `signAndBroadcast` (transactions) and
  `signAndRegister` (credential permits). Each has its own mutation
  factory, hook, and tests; the old umbrella `execute` / `useExecute`
  is gone.
- Rename `completeFromTxHash` → `attach` and `refreshPrepared` →
  `refresh` on the service and client. The mutation factory and hook
  for refresh keep their current names since they remain unique.
- Move per-kind prepare request types from `types/prepared-tx.ts` to
  the more accurate `types/offline.ts`.
- Strip vendor-specific names from JSDoc (Dfns, Fireblocks, Fordefi,
  Turnkey) — the surface is provider-neutral; vendor mentions stay
  only in the DFNS integration test file. Fix stale `{@link execute}`
  references introduced by the split.

API extractor reports regenerated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…deferred-signing-v2

# Conflicts:
#	docs/llm/corpus-manifest.json
#	packages/react-sdk/etc/react-sdk.api.md
#	packages/sdk/etc/sdk-query.api.md
#	packages/sdk/etc/sdk.api.md
#	packages/sdk/src/index.ts
#	packages/sdk/src/token/__tests__/shield.test.ts
#	packages/sdk/src/token/index.ts
#	packages/sdk/src/token/token.ts
#	pnpm-lock.yaml
Matches the caller-intent vocabulary of sibling methods (prepare, sign,
broadcast, refresh) and frames the cross-process flow as a lifecycle
resumption rather than a debugger-style attach. Touches the React hook
(useAttach → useResume), the query factory (attachMutationOptions →
resumeMutationOptions, AttachParams → ResumeParams, mutation key
zama.attach → zama.resume), and downstream JSDoc / error-message
references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regenerates etc/*.api.md after the attach → resume rename and reflows
two JSDoc comments where the `prepare → sign → broadcast` code span
spanned two lines — TSDoc treats each comment line independently, so the
opening backtick was flagged as unclosed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ghermet ghermet changed the title feat(sdk): deferred signing — prepare / sign / broadcast for institutional custody (SDK-75) feat(sdk): offline signing — prepare / sign / broadcast for institutional custody (SDK-75) May 13, 2026
…deferred-signing-v2

# Conflicts:
#	packages/sdk/etc/sdk-query.api.md
#	packages/sdk/src/services/delegation-service.ts
#	packages/sdk/src/token/token.ts
#	packages/sdk/src/token/wrapped-token.ts
@ghermet ghermet closed this May 18, 2026
@ghermet ghermet reopened this May 19, 2026
ghermet and others added 8 commits May 19, 2026 10:05
…deferred-signing-v2

# Conflicts:
#	docs/llm/corpus-manifest.json
#	packages/sdk/src/credentials/credential-service.ts
#	packages/sdk/src/errors/index.ts
#	packages/sdk/src/errors/signer.ts
#	packages/sdk/src/index.ts
#	packages/sdk/src/namespaces/index.ts
#	packages/sdk/src/query/index.ts
#	packages/sdk/src/test-fixtures/constants.ts
#	packages/sdk/src/test-fixtures/index.ts
#	packages/sdk/src/test-fixtures/provider.ts
#	packages/sdk/src/test-fixtures/signer.ts
#	packages/sdk/src/token/token.ts
#	packages/sdk/src/token/wrapped-token.ts
#	packages/sdk/src/zama-sdk.ts
…deferred-signing-v2

# Conflicts:
#	docs/llm/corpus-manifest.json
#	package.json
#	packages/react-sdk/etc/react-sdk.api.md
#	packages/sdk/package.json
Refreshes API reports for sdk, sdk-query, and react-sdk to reflect the
combined surface after merging prerelease — drops the removed FHE primitive
hooks and EIP712 helpers, picks up the deferred-signing-v2 additions.
Renames the public `offline` namespace property to `offlineSigning` for
clarity (it's the offline-signing pipeline, not generic offline support).
The underlying class file is renamed `offline.ts` -> `offline-signing.ts`
and re-exported as `Offline` to keep the published class name stable.

All internal call sites, queries, hooks, tests, docstrings, and
api-extractor reports are updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
oxlint flags the leading underscore as a dangling identifier. Renaming to
`unhandled` preserves the compile-time exhaustiveness check while passing
lint.
…deferred-signing-v2

# Conflicts:
#	docs/llm/corpus-manifest.json
#	package.json
#	packages/sdk/etc/sdk-query.api.md
#	packages/sdk/etc/sdk.api.md
#	packages/sdk/src/credentials/credential-service.ts
#	packages/sdk/src/index.ts
#	pnpm-lock.yaml
@ghermet ghermet self-assigned this Jun 15, 2026
@ghermet ghermet added the do not merge This is not ready to be merged, waiting on someone else's work label Jun 15, 2026
ghermet and others added 4 commits June 16, 2026 15:49
…deferred-signing-v2

# Conflicts:
#	docs/llm/corpus-manifest.json
#	packages/react-sdk/etc/react-sdk.api.md
#	packages/sdk/etc/sdk-ethers.api.md
#	packages/sdk/etc/sdk-query.api.md
#	packages/sdk/etc/sdk-viem.api.md
#	packages/sdk/etc/sdk.api.md
#	packages/sdk/package.json
#	packages/sdk/src/index.ts
#	packages/sdk/src/token/token.ts
#	pnpm-lock.yaml
…SDK-75]

ViemProvider and EthersProvider pass `blockTag: "pending"` / the `"pending"`
second arg to `getTransactionCount` in `prepareTransaction`. Parallel
custodian queues with multiple in-flight approvals against the same wallet
no longer collide on a stale "latest" count; callers that own their own
nonce manager keep the `options.nonce` bypass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…K-75]

The bundled "sign-and-broadcast" tier was one-call sugar over
prepare → sign → broadcast, with no flow it unlocked: in-process atomic
signing is already covered by Token.* methods, and cross-process custody
needs the phase-separated surface anyway. Custody platforms (DFNS,
Fireblocks, Fordefi, Turnkey) commonly expose this middle tier; we
deliberately omit it to keep the offline-signing surface to two
surfaces — atomic in-process (Token.*) and phase-separated.

Removes signAndBroadcast/signAndRegister from OfflineSigning namespace
and OfflineSigningService, the sign-and-broadcast/sign-and-register
query mutations and React hooks, and the corresponding tests
(coverage preserved by rewriting CredentialPermit tests as explicit
prepare → signTypedData → registerPermit chains). DFNS review doc
updated to reflect the two-surface model and the omission rationale.
API extractor reports and LLM corpus manifest regenerated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… caveats [SDK-75]

Both warnings were referenced as JSDoc-documented by the DFNS partnership
review doc but only lived in the prose. Move them to the canonical
OfflineSigning namespace and OfflineSigningService JSDoc so out-of-process
custody integrators (DFNS, Fireblocks, Fordefi, Turnkey, HSM adapters)
encounter them at the call site:

- resume(prepared, txHash): the SDK takes the caller's word that txHash
  corresponds to prepared.unsignedTx; no on-chain check confirms the
  broadcaster signed this payload rather than a different one from the
  same from.
- refresh(prepared): identity is not stable — returned unsignedTx bytes
  and tx hash differ; callers keying external approvals by the
  unsigned-tx bytes must treat the refreshed payload as a new submission.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed do not merge This is not ready to be merged, waiting on someone else's work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant