Skip to content

feat(auth): Privy embedded wallet → Cosmos signer adapter#23

Merged
Sentinel-Bluebuilder merged 1 commit into
masterfrom
feat/privy-cosmos-signer
Apr 27, 2026
Merged

feat(auth): Privy embedded wallet → Cosmos signer adapter#23
Sentinel-Bluebuilder merged 1 commit into
masterfrom
feat/privy-cosmos-signer

Conversation

@Sentinel-Bluebuilder
Copy link
Copy Markdown
Owner

Summary

Privy provides embedded EVM/Solana wallets but has no native Cosmos signer. This PR adds an adapter that bridges a Privy-held key to a cosmjs OfflineDirectSigner, so consumers can use Privy for auth/onboarding while still calling every Sentinel SDK helper that takes a wallet (connect, broadcast, broadcastWithFeeGrant, operator helpers, etc.).

Two strategies

Picked by the mode field on createPrivyCosmosSigner.

Mode A — mnemonic (seed-import)

Consumer triggers Privy's exportWallet() once. The adapter re-derives a Cosmos secp256k1 key on the standard m/44'/118'/0'/0/0 path and wraps it in DirectSecp256k1HdWallet. Trust model is identical to a normal mnemonic wallet.

const signer = await PrivyCosmosSigner.fromMnemonic({ mnemonic: privyExportedSeed });

Mode B — rawSign (custody-preserving)

Seed never leaves Privy. Consumer supplies the compressed pubkey + a signRawSecp256k1(digest32) callback. The adapter computes sha256(makeSignBytes(signDoc)) locally and ships only the 32-byte digest. Privy's raw secp256k1 sign endpoint returns (r||s); the adapter normalizes to low-S (cosmos-sdk rejects high-S since v0.42).

const signer = await PrivyCosmosSigner.fromRawSign({
  pubkey: privyDerivedCompressedPubkey,
  signRawSecp256k1: async (digest32) => privy.signRawHash({ hash: digest32, curve: 'secp256k1' }),
});

Both modes derive the same sent1... address from the same seed.

What's exported

  • PrivyCosmosSigner (static facade with fromMnemonic, fromRawSign, create, derivePubkeyFromMnemonic)
  • PrivyRawSignDirectSigner (the OfflineDirectSigner class)
  • privyCosmosSignerFromMnemonic, privyCosmosSignerFromRawSign, createPrivyCosmosSigner
  • deriveCosmosPubkeyFromMnemonic — precompute the sent1... address during Privy onboarding before the signer is wired up

All wired through index.js.

Tests

test/privy-cosmos-signer.test.mjs — 20 assertions covering:

  • Mode A address parity with createWallet()
  • deriveCosmosPubkeyFromMnemonic matches Mode A
  • Mode B address parity with Mode A using the same seed
  • signDirect produces a signature that verifies against the pubkey on sha256(makeSignBytes(signDoc))
  • High-S signatures returned by the callback are normalized to low-S, and the normalized form still verifies
  • signerAddress mismatch is rejected with a helpful message
  • Unified factory routes correctly and rejects unknown modes
  • Static facade delegates to the underlying functions

No network, no Privy SDK in the test — the raw-sign callback is simulated locally with cosmjs Secp256k1.createSignature. The contract this PR establishes is what a real Privy raw-sign endpoint must satisfy.

20 passed, 0 failed
401 exports OK

Docs

docs/PRIVY-INTEGRATION.md — usage for both modes, address parity guarantees, requirements on the Privy callback (raw 32-byte digest, no eth_sign-style prefixing, must use Cosmos coinType 118), failure-mode table.

Test plan

  • node test/privy-cosmos-signer.test.mjs — 20/20 pass
  • node -e "import('./index.js').then(...)" — 401 exports
  • node test/smoke.js — no new failures (only the pre-existing SDK_VERSION is 1.0.0 failure unrelated to this change)
  • Live-chain: optional follow-up, since the unit test verifies the signature against the pubkey end-to-end

Privy provides embedded EVM/Solana wallets but no native Cosmos signer.
This adapter bridges a Privy-held key to a cosmjs OfflineDirectSigner so
consumers can use Privy for auth/onboarding while still calling every
Sentinel SDK helper that takes a wallet (connect, broadcast, fee-grants,
operator helpers).

Two strategies, picked by mode:

* mode='mnemonic' — consumer triggers Privy exportWallet() once, hands
  the seed to the adapter, which derives a Cosmos secp256k1 key on the
  standard m/44'/118'/0'/0/0 path. Trust model is identical to a normal
  mnemonic wallet.

* mode='rawSign' — seed never leaves Privy. Consumer supplies the
  compressed pubkey plus a signRawSecp256k1(digest32) callback. The
  adapter computes sha256(makeSignBytes(signDoc)) locally and ships
  only the digest. Returned (r||s) signature is normalized to low-S
  (cosmos-sdk rejects high-S since v0.42).

Both modes derive the SAME sent1 address from the same seed.
deriveCosmosPubkeyFromMnemonic is exposed for pre-computing the address
during Privy onboarding before the signer is wired up.

Tests: 20 assertions covering address parity across modes, SignDoc
digest derivation, signature verification against the pubkey, low-S
normalization, signerAddress-mismatch rejection, factory routing, and
static facade delegation. No network or Privy SDK required — the
raw-sign callback is simulated locally with cosmjs Secp256k1.
@Sentinel-Bluebuilder Sentinel-Bluebuilder merged commit 5845169 into master Apr 27, 2026
2 checks passed
@Sentinel-Bluebuilder Sentinel-Bluebuilder deleted the feat/privy-cosmos-signer branch April 27, 2026 09:45
Sentinel-Bluebuilder added a commit that referenced this pull request Apr 27, 2026
…adcast) (#24)

The PrivyCosmosSigner adapter shipped in #23 produced a cosmjs OfflineDirectSigner
but consumers had to hand-build a SigningStargateClient to use it. This wires it
into SentinelClient directly:

  new SentinelClient({ signer: privySigner, rpcUrl })
  await client.getBalance()                // works — uses signer's address
  await client.getClient()                  // works — passes signer to SigningStargateClient

getWallet() now resolves in this order:
  1. per-call mnemonic (override)
  2. constructor-supplied signer (Privy raw-sign, Keplr, etc.)
  3. constructor-supplied mnemonic (the original path)

Tunnel handshake constraint: connect() / autoConnect() / connectPlan() throw a
helpful "VPN connect/disconnect requires a mnemonic" error when only a signer is
supplied, because the WireGuard/V2Ray handshake signs locally with the raw
secp256k1 privkey before any chain TX. Privy's raw-sign endpoint cannot reach
into that primitive without a handshake refactor (deferred — out of scope here).
The error message points to docs/PRIVY-INTEGRATION.md for the full table.

Tests: test/privy-client-integration.test.mjs — 12 assertions covering
signer-mode getWallet shape, address parity with mnemonic mode, helpful error
for missing auth, and rejection of all three connect entry points in
signer-only mode. Existing privy-cosmos-signer.test.mjs (20) still passes; smoke
test 670/671 passing (the one pre-existing SDK_VERSION mismatch is unrelated).

Docs: docs/PRIVY-INTEGRATION.md gains a "Tunnel connect/disconnect — Mode A only"
section with the operation-by-mode table, plus a SentinelClient usage example.

Co-authored-by: Human and Agent dVPN <[email protected]>
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.

1 participant