Skip to content

feat(wallet-ffi): expose stable identity lookup primitives - #509

Open
RomThpt wants to merge 2 commits into
logos-blockchain:mainfrom
RomThpt:feat/wallet-ffi-stable-identity-lookup
Open

feat(wallet-ffi): expose stable identity lookup primitives#509
RomThpt wants to merge 2 commits into
logos-blockchain:mainfrom
RomThpt:feat/wallet-ffi-stable-identity-lookup

Conversation

@RomThpt

@RomThpt RomThpt commented Jun 3, 2026

Copy link
Copy Markdown

Summary

Adds two wallet-ffi entries (and matching WalletCore accessors) that let a client resolve a stable cryptographic identity from a persisted wallet without rotating it at every restart.

  • wallet_ffi_get_first_private_accounts_key(handle, out_keys) — returns the first private accounts key in the wallet (BTreeMap-ordered, deterministic across reopens for the same persisted storage).
  • wallet_ffi_get_private_accounts_key_by_chain_index(handle, chain_index_str, out_keys) — returns the private accounts key at an explicit chain index using the wallet-CLI string format (/, /0, /0/1, …).

Purely additive; no behaviour change for existing callers.

Why

Currently, wallet-ffi exposes:

  • wallet_ffi_create_private_accounts_key — derives a new chain at the next free layered index each time it is called, rotating the NPK at every call.
  • wallet_ffi_get_private_account_keys(account_id) — returns a sub-account key chain (the per-account NPK derived under a parent), not the parent chain master.

Neither lets a downstream application restore the same agent-level NPK after a wallet reopen. The workaround today is to cache the NPK in a sidecar file at the application layer, which is fragile and couples the application to file-layout assumptions that live outside the wallet.

The new accessors close that gap: by anchoring identity on a known chain index ("/" for the seed-root node) the application gets a deterministic identity for the lifetime of the wallet seed.

Motivation

I am building an LP-0008 (autonomous AI module) submission. Agent identity is the agent's NPK; without this patch, the agent's identity rotates every time Basecamp restarts the host process, breaking AgentCards announced to peers, owner-channel routing, and demo reproducibility.

Implementation

  • wallet/src/lib.rs: two new accessors on WalletCore. They thin-wrap existing UserKeyChain::private_account_key_chains (BTreeMap iter, deterministic) and UserKeyChain::private_account_key_chain_by_index.
  • wallet-ffi/src/keys.rs: matching FFI entry points. They use the same FfiPrivateAccountKeys out-shape and lifetime management as the existing wallet_ffi_get_private_account_keys.
  • wallet-ffi/src/lib.rs: bump c_str_to_string visibility to pub(crate) so the new entry can reuse it.
  • wallet-ffi/wallet_ffi.h: regenerated by cbindgen as part of the wallet-ffi build.

Test plan

  • cargo build --release -p wallet-ffi --no-default-features (used downstream in the agent module).
  • Symbol present in the built dylib: nm libwallet_ffi.dylib | grep wallet_ffi_get_private_accounts_key_by_chain_index.
  • Manual round-trip from the agent module: persisted wallet reopened across module restarts returns the identical NPK via the "/" lookup. Verified with NPK 40b5854e7b498cdf91461ed66da64df23e1a47389f752f4f392a5d19c3218371.
  • Unit tests for the new accessors (happy to add if reviewers want them; existing wallet-ffi crate does not have an integration test bench I could plug into directly).

Notes

The header file changes are mechanical (cbindgen output). The visibility bump on c_str_to_string is the minimal change needed to reuse the existing helper; happy to inline a private copy instead if the reviewer prefers.

RomThpt added 2 commits June 3, 2026 22:36
Adds two FFI entries (and the matching WalletCore methods) that let
clients resolve a stable cryptographic identity from a persisted
wallet without rotating it at every restart:

- wallet_ffi_get_first_private_accounts_key(handle, out_keys)
  Reads the first private accounts key in the wallet — useful when
  the client wants any deterministic identity tied to the wallet
  seed without caring about chain position.

- wallet_ffi_get_private_accounts_key_by_chain_index(
      handle, chain_index_str, out_keys)
  Reads the private accounts key at an explicit chain index given
  as the wallet-CLI string format ("/", "/0", "/0/1", ...). Use
  case: applications that anchor identity on a known position in
  the key tree (e.g. agent runtimes that always use "/" for the
  seed-root node so the same NPK survives every reopen).

Background: today's wallet-ffi only exposes
wallet_ffi_create_private_accounts_key (always creates a new chain
node) and wallet_ffi_get_private_account_keys(account_id) which
returns sub-account material. Neither lets a client recover the
same NPK across restarts without a sidecar cache, which is fragile
and couples the application to file-layout assumptions outside
the wallet. The new getters use the existing
UserKeyChain::private_account_key_chain_by_index and the
private_account_key_chains iterator (BTreeMap-ordered, so
deterministic across reopens) — purely additive, no behaviour
change for existing callers.

c_str_to_string visibility bumped to pub(crate) so the new
functions can reuse the existing helper.

Header regenerated via cbindgen as part of the wallet-ffi build.

Motivation: I am building an LP-0008 (autonomous AI module)
submission. Agent identity is the agent's NPK; without this, the
agent's identity rotates every time Basecamp restarts the module,
breaking AgentCards, owner-channel routing, and demos. The
upstream patch lets the agent module drop its sidecar workaround
and rely on a deterministic FFI lookup.
… Schnorr)

Adds a signing primitive to the wallet-ffi surface so downstream
applications can sign application-level material (e.g. an A2A
AgentCard, a JWS, a delegation token) with the same key tree the
wallet uses, without needing to surface the private scalar to the
host.

Signature scheme: BIP-340 Schnorr-over-secp256k1, message is SHA-256
prehashed before signing (matching the existing nssa::Signature::new
convention). The signing scalar is `key_chain.secret_spending_key`
of the private accounts key at the specified chain index.

FFI shape:

  enum WalletFfiError wallet_ffi_sign_message_at_chain_index(
      struct WalletHandle *handle,
      const char *chain_index_str,         // "/", "/0", ...
      const uint8_t *message,
      uintptr_t message_len,
      struct FfiCardSignature *out_sig);   // { sig[64], vpk[32] }

The `verifying_public_key` returned alongside the signature is the
32-byte x-only public key (k256::schnorr::VerifyingKey). It lets a
verifier validate the signature without having to re-derive the
public key from chain state, making the produced signature
self-contained on the wire.

Motivation: this closes the AgentCard signing path in the LP-0008
submission. The agent module now signs its AgentCard with this entry
and embeds the verifying key inside the card's signature object —
cryptographically verifiable A2A AgentCards instead of a stub.

Cargo deps added: k256 (already in the workspace), sha2 = "0.10".
Comment thread wallet/src/lib.rs
Comment on lines +251 to +277
/// Return the KeyChain of the first private accounts key in the wallet.
/// BTreeMap iteration over chain indices is deterministic, so this is
/// stable across wallet reopens for the same persisted storage. Used by
/// clients (e.g. agent runtimes) that need a stable cryptographic
/// identity tied to the wallet seed without rotating it at every
/// create_private_accounts_key call.
#[must_use]
pub fn first_private_accounts_key_chain(&self) -> Option<&KeyChain> {
self.storage
.key_chain()
.private_account_key_chains()
.next()
.map(|(_account_id, key_chain, _chain_index)| key_chain)
}

/// Return the KeyChain of the private accounts key at the given chain
/// index. Lets clients persist a pointer (chain_index, not crypto) and
/// look the same key back up across restarts deterministically.
#[must_use]
pub fn private_accounts_key_chain_by_index(
&self,
chain_index: &ChainIndex,
) -> Option<&KeyChain> {
self.storage
.key_chain()
.private_account_key_chain_by_index(chain_index)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

IMO both of this could be achieved by accessing wallet.storage().key_chain() and no additional wrappers needed.

Currently we indeed have some wrappers in this wallet, but I think this is not a good pattern and it's better not to expand this. Maybe we'll work on reducing it as well

Comment thread wallet-ffi/Cargo.toml
sequencer_service_rpc = { workspace = true, features = ["client"] }

tokio.workspace = true
key_protocol.workspace = true
serde_json.workspace = true
k256.workspace = true
sha2 = "0.10"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We have this in workspace, better use it

Comment thread wallet-ffi/src/keys.rs
Comment on lines +212 to +218
let vpk_ptr = Box::into_raw(vpk_boxed) as *const u8;

unsafe {
(*out_keys).nullifier_public_key.data = npk_bytes;
(*out_keys).viewing_public_key = vpk_ptr;
(*out_keys).viewing_public_key_len = vpk_len;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I can see this intialization became a repeated pattern in existing codebase (this is 3rd time I think). Let's better encapsulate this to a separate function

Comment thread wallet-ffi/src/keys.rs
Comment on lines +275 to +278
let Ok(chain_index) = ChainIndex::from_str(&chain_index_str) else {
print_error(format!("Failed to parse chain index: {chain_index_str}"));
return WalletFfiError::InvalidKeyValue;
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks like it's not a key problem

Comment thread wallet-ffi/src/keys.rs
Comment on lines +398 to +419
let Ok(signing_key) = k256::schnorr::SigningKey::from_bytes(&secret_scalar) else {
print_error("secret_spending_key is not a valid Schnorr signing scalar");
return WalletFfiError::InternalError;
};

// Prehash with SHA-256 to fit the 32-byte BIP-340 message input.
let msg_slice = unsafe { std::slice::from_raw_parts(message, message_len) };
let mut hasher = Sha256::new();
hasher.update(msg_slice);
let prehash: [u8; 32] = hasher.finalize().into();

let mut aux_random = [0_u8; 32];
use k256::elliptic_curve::rand_core::{OsRng, RngCore as _};
OsRng.fill_bytes(&mut aux_random);

let signature = match signing_key.sign_prehash_with_aux_rand(&prehash, &aux_random) {
Ok(s) => s,
Err(e) => {
print_error(format!("Schnorr signing failed: {e}"));
return WalletFfiError::InternalError;
}
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't understand why you need to manually perform cryptographic operations. I think you should use existing functionality of our keys. Or expand this functionality and use it here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants