Skip to content

Feature request: signDigest() — raw 32-byte hash signing for protocol SDKs #131

@pay-skill

Description

@pay-skill

Summary

OWS exposes four signing operations: sign() (transaction bytes), signAndSend(), signMessage(), and signTypedData(). There is no operation for signing a raw 32-byte digest directly.

Use Case

Payment and DeFi protocol SDKs commonly compute EIP-712 struct hashes client-side (using language-native libraries) and then need to sign the resulting 32-byte digest. This is the standard pattern for SDKs in Go, Rust, Java, C#, Ruby, Swift, and Elixir — languages where the SDK handles EIP-712 hashing internally and only needs a "sign this hash" primitive.

Example from a Go payment SDK:

// SDK computes the EIP-712 struct hash using Go's crypto libraries
digest := eip712.HashStructData(domain, types, message) // returns [32]byte

// Need: sign this digest with the OWS-managed key
// Today: no OWS API for this
sig, err := ows.SignDigest(walletId, "evm", digest[:])

The TypeScript and Python SDKs can use signTypedData() (passing the full EIP-712 JSON), but the other 7 languages in our SDK matrix cannot — they compute hashes locally and need a raw digest signer.

The Internal Capability Exists

The Rust ChainSigner::sign() trait already accepts a pre-hashed 32-byte message for secp256k1 chains. The gap is only in the public API surface — there is no signDigest operation exposed through the SDK bindings or CLI.

Proposal

Add a signDigest operation to the signing interface:

interface SignDigestRequest {
  walletId: WalletId;
  chainId: ChainId;         // "evm" — secp256k1 chains
  digest: string;           // hex-encoded 32-byte hash
}

// Returns: SignResult { signature, recoveryId }

This would:

  • Expose the existing internal capability as a public API
  • Enable protocol SDKs in all languages to integrate with OWS
  • Require no changes to the signing device, policy engine, or key isolation model
  • Follow the same auth/policy flow as other signing operations

Policy Considerations

A signDigest operation is lower-level than signTypedData — the policy engine cannot inspect the typed data fields (because they've already been hashed). Policy evaluation would be limited to:

  • Caller authentication (API key scope)
  • Rate limiting (spending caps based on external tracking)
  • Chain restriction

This is a reasonable trade-off: protocols that compute their own hashes accept responsibility for the semantic content. The policy engine still controls who can sign and how often, just not what the hash represents.

Alternatives Considered

  1. Require all SDKs to pass full EIP-712 JSON via signTypedData — forces every language to serialize to JSON and back, duplicating work the SDK already did natively. Also couples SDK implementations to OWS's JSON format.

  2. Wrap digest in a signMessage callsignMessage applies chain-specific prefixes (EIP-191 \x19Ethereum Signed Message:\n), which produces wrong signatures for EIP-712 digests.

  3. Use sign() with a synthetic transactionsign() expects serialized transaction bytes, not arbitrary hashes.

None of these cleanly solve the problem.

Happy to contribute a PR if there's interest — the Rust-side change is minimal (expose the existing ChainSigner::sign path through the public API).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions