Account keys and addresses derivation standard

Objective

On Ethereum the address of an EOA account is derived from the associated public key. And multiple keys can be derived from a single secret (seed phrase).

There is thus a deterministic relation between this unique secret and a sequence of accounts, i.e. if you know the secret you can derive a sequence of account addresses.

There is also a deterministic relation between this unique secret and the private keys controlling this sequence of accounts, i.e. if you know the secret you can derive the sequence of private keys controlling these accounts.

This allows for great interoperability between wallets as one can simply import the secret from one wallet to another and immediately recover the account addresses and the controlling private keys.

On StarkNet we have account abstraction which means that the address of an account is computed as a smart contract with no direct relation to the key(s) controlling the account. By default users must thus keep track of their account addresses, and loosing an account address means loosing access to the account.

Furthermore, the key(s) controlling the account can potentially be changed at any point in time depending on the logic implemented by the account.

The objective of this proposition is to allow for some interoperability between StarkNet wallets by standardising the relation between a unique secret and a sequence of accounts such that the addresses of the accounts can be deterministically recovered from the secret.

Depending on the account implementations it might also create a deterministic relation between the secret and the sequence of keys controlling these accounts.

Proposition

Step 1: Derive a sequence of starkPair from a secret

A sequence of keys are derived from a unique secret using the Hierarchical Deterministic (HD) Wallet standard:

We can use standard BIP-44 derivation path:

m /44' /9004' /0' /0 /index

We apply on the derived private key, a grinding method inspired by

In summary, for a given secret and index, the starkPair is given by:

const masterNode = HDNode.fromSeed(secret)

const path = m/44'/9004'/0'/0/${index}

const childNode = masterNode.derivePath(path)

const groundKey = grindKey(childNode.privateKey)
const starkPair = ec.getKeyPair(groundKey)

Step 2: Initialise each account with a different starkPair

The address of an account on StarkNet is defined in

https://docs.starknet.io/docs/Contracts/contract-address/

contract_address := pedersen(
		“STARKNET_CONTRACT_ADDRESS”,
		caller_address,
		salt,
		pedersen(contract_code),
		pedersen(constructor_calldata))

For each starkPair derived in step 1 we can thus create a unique account by passing the public key in the constructor calldata.

We can make the account address recoverable is we require the account to emit a standard event upon creation:

@event
func account_created(account: felt, key: felt):
end

where

  • account is the address of the created account (note that this is not technically needed since the account is emitting the event, but could make searching easier depending on how events will be indexed in the future)
  • key is the public key of the starkPair.

Since the signature of StarkNet events only depends on the method name, each implementation can decide to pass additional parameters to the account_created event. The only requirement is that the first parameter be the account and the second the key .

Recovering account addresses

Given a secret , a wallet can easily derive the starkPair associated to index 0, 1, 2, 3, … and get all the event_created events whose key matches one of the derived keys. The wallet automatically has the list of associated account addresses.

Note

  • This proposition is relatively loose in purpose. It does not try to agree on a common bytecode (e.g. using a standard Proxy) or a common method to compute the salt. This could be the work of extending standards targeting more specific use cases. Its main objective is to have a simple method to recover the sequence of account addresses with minimal input from the user (i.e. only the secret) for maximum interoperability.
  • While the goal of the standard is to recover account addresses from the secret , it can also recover the associated private keys for EOA like account implementation (see e.g. https://github.com/OpenZeppelin/cairo-contracts/blob/main/src/openzeppelin/account/Account.cairo) if the derived key is used as the signer.
  • There is no guarantee that a matching account_created event corresponds to the correct account, i.e. malicious contracts could emit the same event. However, wallets can perform additional checks on the account to identify the legit ones.
  • Since StarkNet uses CREATE2, it is possible to have gaps in the sequence of accounts. For example, there may be an account for index 0, 1, 3, 4. In such case account 2 was counterfactually created and it may not be possible to recover its address unless the bytecode, salt, and constructor arguments are fully known.
56 Likes

I like it! Agree on keeping things loose and flexible.

Should the salt also be made available in the event so it’s feasible to compute the address and compare it with the emitting contract?

I also wonder if it makes sense to split this proposal into different tracks: stark secret derivation and another account_created event one so it also applies to Ethereum-keys kind of accounts. Probably a third one for contract_address calculation, if it makes sense to squeeze the “ETH_CREATE” opcode idea.

30 Likes

@juniset tnx for this post!
Interoperability between wallets on StarkNet is important and the proposition looks solid.

However, it is worth addressing the case in which a user replaced the public key stored in the account contract to a new key.
e.g.: https://github.com/OpenZeppelin/cairo-contracts/blob/c15e9b7c6ccb0ca719eed7a3a93865c9d7c39581/src/openzeppelin/account/Account.cairo#L65

To support this we should also add the same event (maybe consider changing its name to something more generic, such as account_key_binding) to the function that changes the public key (in the OZ case above, it is set_public_key):

@event
func account_key_binding(account: felt, key: felt):
end

This way the account can be recovered, even if the key was changed after the account was created, by simply looking for the account_key_binding event, with the new key.

As a side note, it might be worth considering a near-term solution for recovery that does not rely on an indexer, if we see that Alchemy and alike are delayed to deploy a solution.

Another thing worth discussing and penciling-down in this context, is the minimal set of account contract API/functionality that all account contract should support in order to allow basic interoperability.

We suggest to align to the current OZ contracts and support either the basic implementation API:

func get_public_key
func get_nonce
func supportsInterface
func get_impl_version
func set_public_key
func constructor 
func is_valid_signature
func execute

Or the upgradeable proxy pattern:

func get_public_key
func get_nonce
func supportsInterface
func get_impl_version
func set_public_key
func initializer
func upgrade
func is_valid_signature
func execute

On top of that each wallet can add its unique features and extended functionality.

30 Likes

@martriay @realcrypt-Braavos thanks for the feedback!

In my opinion the proposition should be about one thing (and one thing only): given a secret, how can a wallet create a deterministic sequence of accounts such that any other wallet can “discover” the same sequence of deployed accounts when provided with the secret only.

So it’s really about “discovering” account addresses, and less about “discovering” the keys that control these accounts.

On Ethereum you get both (discovering account address and private key) from a single key derivation standard, but with account abstraction they are two separate problems. The second one cannot be solved for all possible account implementations (think of multisig where there is no notion of “the” signing key of the account) so that’s why I think we should focus on discovering account addresses first then extend the standard for more specific use cases.

For example, and as noted at the end of the proposition, we could/should create a second standard for the subset of account implementations that have only a single signer (e.g. EOA like accounts) in which case the keys associated to these accounts can also be derived from the secret. @realcrypt-Braavos I think your propositions should be discussed as part of this second standard since they focus on accounts with a single signing key.

@martriay Given the goal above, I believe the key derivation and the account_created event are needed together in a single standard. I have no clear opinion on passing the salt to the event, but not clear yet if it will really help.

25 Likes

Hi,

As far as I have understood, EIP-2645 already defines a way to derive multiple Starknet key pairs from a root seed. Why can’t we use it ?
Regarding the computation of the address of an account smart contract, even if the public key is updated, its address won’t change. So even with a contract embedding a single public key (easiest use case), we can have several key pairs associated with the same contract address and in that case, the sequence of accounts also refers to a sequence of keys. Actually, the only way to define uniquely an account is the pair (contract address, Starknet pub key).

21 Likes

As far as I know, Starkware is discouraging developers from using EIP-2645

19 Likes

thanks for the detailed response. although now I’m a bit confused: if the idea is only to recover the address, a few questions arise:

  1. what will we do with the recovered address, if we cannot control the underlying account?
  2. in order to generate the address we need to generate the keys, so we have both address and keys anyhow. why not use them?

my thought was that: given the seed, we can generate keys, then find out the address by searching the events, then allow user to take ownership of this address using any wallet.
of course, not all set of features of the original wallet may work, but we should make sure that the basic functionality of a wallet may be transferred to another wallet, along with the assets linked to that address.
and in fact, your very good suggestion allows it - it only lacks the firing of event in case of seed (keys) change. seed/keys change is a big advantage of a smart-contract wallet, as if the user wishes to replace the keys, the user’s address may remain (along with all of its assets), by simply updating the public keys in the existing accounts.

and in case of proxy accounts (which I guess all of the ecosystem will adapt) - we can even allow the user to switch the implementation while retaining the keys.
it will bring freedom and value to all Starknet users, which is a value we should aspire to.

21 Likes

Great writeup Julien! I think we’re on the right track with this. Some thoughts:

This can be solved through a normal “20 address lookup” explained in BIP-44 (link). If the user lost their wallet and tries to recover their funds, the wallet should precisely account for these gaps by checking 20 addresses ahead and stop if there are no funds there.

I guess we could solve this through a simple “sign message” at derivation to ascertain the user is indeed deriving the correct account.

18 Likes

At present, the Argent X wallet has two sets of addresses in the main network and the test network. Can one address be used in the main network and the test network at the same time.

16 Likes

The goal of this proposition is to have 1 standard that can work for all types of accounts. If we start talking about signing keys then that is not possible. For example if we include your suggestions then it will not work for multisigs where there is no concept of a single signing key. For the same reason the IAccount interface has no method related to keys (e.g. there is no get_signing_key method) to remain general.

But of course in practice you want to recover the address of your account and the control of your account (typically the key). So what you are suggesting is relevant for accounts that have a main signer (the OZ and the Argent account today) and it should be part of another standard that we can write on top of this one.

This is a very broad and generic proposition that we can use as a starting point for other more specific standards.

18 Likes

@realcrypt-Braavos @martriay Irrespective of the discussion about signing keys, do you guys see any issue with the derivation path or account_created event?

We will need to change our contracts (proxy and implementation) to support 0.9 and I want to take that opportunity to start aligning with the proposition above, i.e. use the Bip-44 derivation path and emit the account_created event when the account is deployed.

19 Likes

Hi @juniset , if I understand the ArgentX implementation correctly, there is an additional derivation from seed that you do that Braavos wallet does not.
In ArgentX:
ethers.Wallet.fromMnemonic(seedPhrase) is used to create a private key (which internally uses HDNode.fromMenmonic ) and then on top of that your master node is being created using HDNode.fromSeed
In Braavos the master node is derived directly from HDNode.fromMenmonic per BIP-32

For basic interoperability I believe this should be changed to have only a single Master Seed → Master Node derivation (this will also save some CPU cycles)

Regarding account_changed, while we used account_initialized we can align on some convention - note that OpenZeppelin moved to CamelCase events.

Also post Cairo v1.0, we should define the public_key as a key so we will be able to query starknet_getEvents on this event properly

One gap that we currently have is data-model incompatibility - the biggest issue is Proxy impl, while Braavos adopted OpenZeppelin implementation, ArgentX have a custom implementation meaning that the upgrade entry point looks at irrelevant storage variables. In this aspect we can agree that post re-genesis we can all align to OpenZeppelin’s Proxy impl, WDYT?

24 Likes