Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Adding import from seed for wallet recovery web client #710

Open
wants to merge 9 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## 0.8.0 (TBD)

### Features

* Added wallet generation from seed & import from seed on web sdk (#710)
Copy link
Collaborator

Choose a reason for hiding this comment

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

very minor nit: sdk-> SDK


## 0.7.0 (2025-01-28)

### Features
Expand Down
10 changes: 5 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions crates/rust-client/src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ use miden_objects::{account::AuthSecretKey, crypto::rand::FeltRng, Word};

use super::Client;
use crate::{
rpc::domain::account::AccountDetails,
store::{AccountRecord, AccountStatus},
ClientError,
};
Expand Down Expand Up @@ -251,6 +252,20 @@ impl<R: FeltRng> Client<R> {
.await?
.ok_or(ClientError::AccountDataNotFound(account_id))
}

/// Attempts to retrieve an [AccountDetails] by the [AccountId] associated with the account from
/// the rpc.
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: rpc -> RPC (also applies to the line under the errors section)

///
/// # Errors
///
/// - If the key is not found on the rpc from passed `account_id`.
/// - If the underlying rpc operation fails
pub async fn get_account_details(
&mut self,
account_id: AccountId,
) -> Result<AccountDetails, ClientError> {
self.rpc_api.get_account_update(account_id).await.map_err(ClientError::RpcError)
}
Comment on lines +263 to +268
Copy link
Contributor

Choose a reason for hiding this comment

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

If we add something like add_public_account(account_id) method to the client, this method would probably not be needed.

}

// TESTS
Expand Down
7 changes: 7 additions & 0 deletions crates/rust-client/src/rpc/domain/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ impl AccountDetails {
Self::Private(_, summary) | Self::Public(_, summary) => summary.hash,
}
}

pub fn account(&self) -> Option<&Account> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This method should be documented

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agreed, let's document this and the above hash() method, now that AccountDetails is becoming more public as part of the new client API

match self {
Self::Private(..) => None,
Self::Public(account, _) => Some(account),
}
}
}

// ACCOUNT UPDATE SUMMARY
Expand Down
2 changes: 1 addition & 1 deletion crates/web-client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@demox-labs/miden-sdk",
"version": "0.6.1-next.4",
"version": "0.6.1-next.5",
"description": "Polygon Miden Wasm SDK",
"collaborators": [
"Polygon Miden",
Expand Down
62 changes: 62 additions & 0 deletions crates/web-client/src/helpers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use miden_client::{
account::{Account, AccountBuilder, AccountType},
crypto::{RpoRandomCoin, SecretKey},
Client,
};
use miden_lib::account::{auth::RpoFalcon512, wallets::BasicWallet};
use miden_objects::Felt;
use rand::{rngs::StdRng, Rng, SeedableRng};
use wasm_bindgen::JsValue;

use crate::models::account_storage_mode::AccountStorageMode;

pub async fn generate_account(
client: &mut Client<RpoRandomCoin>,
storage_mode: &AccountStorageMode,
mutable: bool,
seed: Option<Vec<u8>>,
) -> Result<(Account, [Felt; 4], SecretKey), JsValue> {
Comment on lines +13 to +18
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems like this function is specifically for generating wallet accounts. If so, I would probably name this generate_wallet() or maybe build_wallet().

Also, it may make sense to change the signature to something like:

pub async fn build_account(
    seed: Vec<u8>,
    storage_mode: AccountStorageMode,
    mutable: bool,
    anchor: &BlockHeader,
) -> Result<(Account, [Felt; 4], SecretKey), JsValue> 

This would simplify the internal logic by making the caller responsible for generating the seed and providing the anchor block (which could be just the genesis block for now).

let mut rng = match seed {
Some(seed_bytes) => {
if seed_bytes.len() == 32 {
let mut seed_array = [0u8; 32];
seed_array.copy_from_slice(&seed_bytes);
let mut std_rng = StdRng::from_seed(seed_array);
let coin_seed: [u64; 4] = std_rng.gen();
&mut RpoRandomCoin::new(coin_seed.map(Felt::new))
} else {
Err(JsValue::from_str("Seed must be exactly 32 bytes"))?
}
},
None => client.rng(),
};
Comment on lines +19 to +32
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: I think we can leverage try_into() conversions here to get slightly a simpler block:

Suggested change
let mut rng = match seed {
Some(seed_bytes) => {
if seed_bytes.len() == 32 {
let mut seed_array = [0u8; 32];
seed_array.copy_from_slice(&seed_bytes);
let mut std_rng = StdRng::from_seed(seed_array);
let coin_seed: [u64; 4] = std_rng.gen();
&mut RpoRandomCoin::new(coin_seed.map(Felt::new))
} else {
Err(JsValue::from_str("Seed must be exactly 32 bytes"))?
}
},
None => client.rng(),
};
let mut rng = match seed {
Some(seed_bytes) => {
// Attempt to convert the seed slice into a 32-byte array.
let seed_array: [u8; 32] = seed_bytes
.try_into()
.map_err(|_| JsValue::from_str("Seed must be exactly 32 bytes"))?;
let mut std_rng = StdRng::from_seed(seed_array);
let coin_seed: [u64; 4] = std_rng.gen();
&mut RpoRandomCoin::new(coin_seed.map(Felt::new))
},
None => client.rng(),
};

let key_pair = SecretKey::with_rng(&mut rng);

let mut init_seed = [0u8; 32];
rng.fill_bytes(&mut init_seed);

let account_type = if mutable {
AccountType::RegularAccountUpdatableCode
} else {
AccountType::RegularAccountImmutableCode
};
Comment on lines +38 to +42
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this be generalized for any AccountType? I'm guessing for the wallet you are mostly interested in doing wallets, but it would be good to generalize it and remove the mutable: bool from the function signature in favor of an account type parameter.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That makes sense. If you don't mind I can add that as an issue for a fast follow, since we'll have to create custom wasm structs for the AccountType enums (they dont exist atm). Just to keep this commit from getting too big


let anchor_block = client.get_latest_epoch_block().await.unwrap();
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should add a TODO or NOTE on import_account_from_seed() to mention the fact that this will work only as long as the account was created in the same epoch in which the network currently is.
So this will probably work for development environments, but not for all cases. Although I believe for a single seed, trying all possible epochs would not be prohibitive (at least in terms of grinding the account ID exclusively).

Copy link
Contributor

Choose a reason for hiding this comment

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

Another option could be to always build the accounts using genesis block as the anchor. This is probably fine for now and would give deterministic results.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This makes sense. Looking at examples for getting the genesis block header, I see that the existing way is to query the rpc api:

        let (genesis_block, _) = self
            .rpc_api
            .get_block_header_by_number(Some(BlockNumber::GENESIS), false)
            .await?;

The web client doesnt have access atm to invoke api calls at will (it has to use an exposed rust client call). So what I'm wondering is if it makes sense to keep it as is for now (acknowleding that it only works well in development cases) and follow up with a more robust rpc api integration into the web client so that we can invoke the above^ ?

Reason I suggest this also is because of the conversation of exposing the get_account_details call on the rust client to get the on-chain account. That wouldn't be necessary if the web client has access to the rpc api struct throughout.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should be able to get the genesis block from the client without the need for RPC calls (i.e., the client would always have the genesis block in its store). @igamigo - let us know if that's correct.

Copy link
Collaborator

Choose a reason for hiding this comment

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

It's not always in the store but if any sync was performed (or if any account was created) it would be. However, you should also be able to do client.get_epoch_block_number(BlockNumber::GENESIS) to retrieve the block header for the first epoch. If for some reason the first block is not on the store, this would also retrieve it and store it for later use.

I initially didn't suggest this because I thought the idea was that new accounts that were created anchored in an old epoch would not be accepted by the network, was that not correct?


let (new_account, account_seed) = match AccountBuilder::new(init_seed)
.anchor((&anchor_block).try_into().unwrap())
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should we remove this unwrap()?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

.try_into yields a result. My understanding of rust is still improving, but is there an alternative / can we just pass in the result in this case?

.account_type(account_type)
.storage_mode(storage_mode.into())
.with_component(RpoFalcon512::new(key_pair.public_key()))
.with_component(BasicWallet)
.build()
{
Ok(result) => result,
Err(err) => {
let error_message = format!("Failed to create new wallet: {:?}", err);
return Err(JsValue::from_str(&error_message));
},
};

Ok((new_account, account_seed, key_pair))
}
47 changes: 46 additions & 1 deletion crates/web-client/src/import.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
use miden_client::auth::AuthSecretKey;
use miden_objects::{account::AccountData, note::NoteFile, utils::Deserializable};
use serde_wasm_bindgen::from_value;
use wasm_bindgen::prelude::*;

use crate::WebClient;
use super::models::account::Account;
use crate::{
helpers::generate_account, models::account_storage_mode::AccountStorageMode, WebClient,
};

#[wasm_bindgen]
impl WebClient {
Expand Down Expand Up @@ -36,6 +40,47 @@ impl WebClient {
}
}

pub async fn import_account_from_seed(
Copy link
Collaborator

@tomyrd tomyrd Feb 3, 2025

Choose a reason for hiding this comment

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

Instead of exposing Client::get_account_details and the using it here, maybe we can add expose this function as Client::import_account_from_seed and then directly calling it from the web

cc @igamigo what do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Although, I would probably make it so that it fails if the note isn't public, we already have a separate function to add accounts given a full Account object (Client::add_account).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

As discussed offline, I'm leaning towards this logic allowing for private notes as well, since "importing" from the seed for a private account is still possible, even if its just the basic wallet gen.

I'm amenable to adding this to the rust client as the base implementation if you think the rust users have this use case. It was designed primarily for web wallet extensions but happy to move the code around

Copy link
Collaborator

Choose a reason for hiding this comment

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

As discussed offline, I'm leaning towards this logic allowing for private notes as well, since "importing" from the seed for a private account is still possible, even if its just the basic wallet gen.

This is not really possible in a practical sense, because you would only be able to grind for an initial account state. The moment the account's nonce increases (ie, there was a state transition registered in the network), you would not be able to reconstruct the state accordingly because it would effectively be lost.

Copy link
Contributor

Choose a reason for hiding this comment

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

If I'm understanding correctly, the intent of this method is to add a public account which already exists on chain to the client. Also, we know only some limited info about the account - e.g., init_seed, storage_mode, and is_mutable), but we don't know the account's ID.

So, the approach is:

  1. Use init_seed, storage_mode, and is_mutable to get the initial state of the account and derive account_id from that.
  2. Use this account_id to download the account state from the chain.

If this is correct, then I think we could probably split these tasks in a few different ways. One approach could be:

  • We add a method to the Client to add a public account by its ID. This may be a good function to have on the client for other purposes as well.
  • We encapsulate the code that derives the ID from the seed etc. into a helper function. Ideally, this would be a "pure" function (i.e., not a part of the WebClient interface) that would be used specifically by the wallet.

Copy link
Collaborator

@igamigo igamigo Feb 5, 2025

Choose a reason for hiding this comment

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

I agree with the approach, but currently, due to the fact that the account's public key is part of the account's storage you do need to provide the seed for the SecretKey generation as well in order to get the correct ID. So you either need to handle 2 seeds or assume the seed is the same for both things (which might be implied the approach you explained).

Copy link
Contributor

Choose a reason for hiding this comment

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

Good point! I think we'll need to provide the public key into the build_wallet() function as well.

To make things convenient, we could also wrap this function in a function that contain the logic for deriving public-private key pair and then calls the appropriate functions. Basically, this "outer" function could do the following:

  • Take the user's seed as a parameter.
  • Derive the key pair for the Falcon signature.
  • Derive the account seed.
  • Get the genesis block header.
  • Call build_wallet() to compute the account ID.
  • Call Client::add_public_account() to retrieve the account from the chain.

&mut self,
init_seed: Vec<u8>,
storage_mode: &AccountStorageMode,
mutable: bool,
) -> Result<Account, JsValue> {
let client = self.get_mut_inner().ok_or(JsValue::from_str("Client not initialized"))?;

let (generated_acct, account_seed, key_pair) =
generate_account(client, storage_mode, mutable, Some(init_seed)).await?;

if storage_mode.is_public() {
// If public, fetch the data from chain
let account_details =
client.get_account_details(generated_acct.id()).await.map_err(|err| {
JsValue::from_str(&format!("Failed to get account details: {}", err))
})?;

let on_chain_account = account_details
.account()
.ok_or(JsValue::from_str("Account not found on chain"))?;

client
.add_account(on_chain_account, None, &AuthSecretKey::RpoFalcon512(key_pair), false)
.await
.map_err(|err| JsValue::from_str(&format!("Failed to import account: {:?}", err)))
.map(|_| on_chain_account.into())
} else {
// Simply re-generate the account and insert it, without fetching any data
Comment on lines +70 to +71
Copy link
Collaborator

Choose a reason for hiding this comment

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

While this works for accounts that have not been registered in the network, as mentioned in my other comment, any account that is not new would not be usable because of the fact that the hash of its latest state would not be the same as the one on the network.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, this would not work for private accounts. I think the scope of this function should be for public accounts only as private accounts need to be imported as full Account objects.

client
.add_account(
&generated_acct,
Some(account_seed),
&AuthSecretKey::RpoFalcon512(key_pair),
false,
)
.await
.map_err(|err| JsValue::from_str(&format!("Failed to import account: {:?}", err)))
.map(|_| generated_acct.into())
}
}
pub async fn import_note(&mut self, note_bytes: JsValue) -> Result<JsValue, JsValue> {
if let Some(client) = self.get_mut_inner() {
let note_bytes_result: Vec<u8> = from_value(note_bytes).unwrap();
Expand Down
1 change: 1 addition & 0 deletions crates/web-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use wasm_bindgen::prelude::*;

pub mod account;
pub mod export;
pub mod helpers;
pub mod import;
pub mod models;
pub mod new_account;
Expand Down
6 changes: 6 additions & 0 deletions crates/web-client/src/models/account_storage_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ impl From<&AccountStorageMode> for NativeAccountStorageMode {
storage_mode.0
}
}

impl AccountStorageMode {
pub fn is_public(&self) -> bool {
self.0 == NativeAccountStorageMode::Public
}
}
41 changes: 7 additions & 34 deletions crates/web-client/src/new_account.rs
Original file line number Diff line number Diff line change
@@ -1,58 +1,31 @@
use miden_client::{
account::{
component::{BasicFungibleFaucet, BasicWallet, RpoFalcon512},
AccountBuilder, AccountType,
},
account::{AccountBuilder, AccountType},
auth::AuthSecretKey,
crypto::SecretKey,
Felt,
};
use miden_lib::account::{auth::RpoFalcon512, faucets::BasicFungibleFaucet};
use miden_objects::asset::TokenSymbol;
use wasm_bindgen::prelude::*;

use super::models::{account::Account, account_storage_mode::AccountStorageMode};
use crate::WebClient;
use crate::{helpers::generate_account, WebClient};

#[wasm_bindgen]
impl WebClient {
pub async fn new_wallet(
&mut self,
storage_mode: &AccountStorageMode,
mutable: bool,
init_seed: Option<Vec<u8>>,
) -> Result<Account, JsValue> {
if let Some(client) = self.get_mut_inner() {
let key_pair = SecretKey::with_rng(client.rng());

let mut init_seed = [0u8; 32];
client.rng().fill_bytes(&mut init_seed);

let account_type = if mutable {
AccountType::RegularAccountUpdatableCode
} else {
AccountType::RegularAccountImmutableCode
};

let anchor_block = client.get_latest_epoch_block().await.unwrap();

let (new_account, seed) = match AccountBuilder::new(init_seed)
.anchor((&anchor_block).try_into().unwrap())
.account_type(account_type)
.storage_mode(storage_mode.into())
.with_component(RpoFalcon512::new(key_pair.public_key()))
.with_component(BasicWallet)
.build()
{
Ok(result) => result,
Err(err) => {
let error_message = format!("Failed to create new wallet: {:?}", err);
return Err(JsValue::from_str(&error_message));
},
};

let (new_account, account_seed, key_pair) =
generate_account(client, storage_mode, mutable, init_seed).await?;
match client
.add_account(
&new_account,
Some(seed),
Some(account_seed),
&AuthSecretKey::RpoFalcon512(key_pair),
false,
)
Expand Down
70 changes: 70 additions & 0 deletions crates/web-client/test/import.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { expect } from "chai";
import { testingPage } from "./mocha.global.setup.mjs";
import {
clearStore,
createNewFaucet,
createNewWallet,
fundAccountFromFaucet,
getAccountBalance,
StorageMode,
} from "./webClientTestUtils";

const importWalletFromSeed = async (
walletSeed: Uint8Array,
storageMode: StorageMode,
mutable: boolean
) => {
const serializedWalletSeed = Array.from(walletSeed);
return await testingPage.evaluate(
async (_serializedWalletSeed, _storageMode, _mutable) => {
const client = window.client;
const _walletSeed = new Uint8Array(_serializedWalletSeed);

const accountStorageMode =
_storageMode === "private"
? window.AccountStorageMode.private()
: window.AccountStorageMode.public();

await client.import_account_from_seed(
_walletSeed,
accountStorageMode,
_mutable
);
},
serializedWalletSeed,
storageMode,
mutable
);
};

describe("import from seed", () => {
it("should import same public account from seed", async () => {
const walletSeed = new Uint8Array(32);
crypto.getRandomValues(walletSeed);

const mutable = false;
const storageMode = StorageMode.PUBLIC;

const initialWallet = await createNewWallet({
storageMode,
mutable,
walletSeed,
});
const faucet = await createNewFaucet();

const result = await fundAccountFromFaucet(initialWallet.id, faucet.id);
const initialBalance = result.targetAccountBalanace;

// Deleting the account
await clearStore();

await importWalletFromSeed(walletSeed, storageMode, mutable);

const restoredBalance = await getAccountBalance(
initialWallet.id,
faucet.id
);

expect(restoredBalance.toString()).to.equal(initialBalance);
});
});
10 changes: 2 additions & 8 deletions crates/web-client/test/mocha.global.setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { spawn } from "child_process";

import { register } from "ts-node";
import { env } from "process";
import { clearStore } from "./webClientTestUtils.js";

chai.use(chaiAsPromised);

Expand Down Expand Up @@ -180,14 +181,7 @@ before(async () => {
});

beforeEach(async () => {
await testingPage.evaluate(async () => {
// Open a connection to the list of databases
const databases = await indexedDB.databases();
for (const db of databases) {
// Delete each database by name
indexedDB.deleteDatabase(db.name);
}
});
await clearStore();
});

after(async () => {
Expand Down
Loading