aa-schnorr-multisig-sdk
TypeScript icon, indicating that this package has built-in type declarations

1.0.6 • Public • Published

Account Abstraction Schnorr Signatures

A typescript library for creating ERC-4337 Account Abstraction which utilizes Schnorr Signatures for multi signatures.

About

  • ERC-4337 Account Abstraction
    • MultiSigAccountAbstraction class extends Alchemys's BaseSmartContractAccount. It allows to interact with the Smart Contract Account.
    • MultiSigAccountSigner class extends Alchemys's AccountSigner and is designed to build and send multi-sig user operations.
  • Schnorr Signatures
    • Schnorkell is the key element of the package. It manages signature's nonces and has methods for signing messages, like: sign() and multiSigSign().
    • SchnorrSigner extends Schnorkell and manages key pairs (private and public) to generate Schnorr Signatures.
    • MultiSigUserOpWithSigners class has to be used to create a single multi-signature transaction. Signers, User Operation Hash and User Operation Request data have to be known upfront to initialize the transaction signing process.

Requirements:

  • Node: >=18.0.0, <20.0.0
  • npm (Node.js package manager): v9.x.x

Installation

git clone https://github.com/RunOnFlux/aa-schnorr-multisig-sdk.git
cd aa-schnorr-multisig-sdk
npm i

Important notice

Before signing any multi-sig transaction signers have to exchange their publicKey and publicNonces. Nonces are one-time generated random numbers used to create and validate the signature. It's absolutely crucial to delete the nonces once a signature has been crafted with them. Nonce reuse will lead to private key leakage!

Example usage

0. Deploy MultiSigSmartAccountFactory and create Account Abstraction

MultiSigSmartAccountFactory should be deployed first from aa-schnorr-multisig package. If already deployed, the address can be found in the deployments folder.

const smartAccountFactory = MultiSigSmartAccountFactory__factory.connect(
  <MUSIG_ACCOUNT_FACTORY_ADDRESS>,
  signer
)

accountAddress is Account Abstraction Address deployed with MultiSigSmartAccountFactory contract's method createAccount.

const saltHash = saltToHex(salt)
const createAccountTxHash = await smartAccountFactory.createAccount(combinedAddress, saltHash)

Notice!
combinedAddress can be generated with getAllCombinedAddrFromSigners() function from schnorr-helpers.

const x = 2 // nr of signers needed for valid signature, here 2/3
combinedAddress: string[] = getAllCombinedAddrFromSigners([signer1, signer2, signer3], x)

It is also possible to generate with signers' public keys with getAllCombinedAddrFromKeys()

combinedAddress: string[] = getAllCombinedAddrFromKeys([pubKey1, pubKey2, pubKey3], x)

Smart Account Address prediction

If MultiSigSmartAccountFactory was deployed then the deterministic Account address can be predict with helpers in preffered way:

1. Onchain prediction

const predictedAddress = await predictAccountAddrOnchain(smartAccountFactory combinedAddress, salt, ethersSignerOrProvider)

accountImplementationAddress can be taken from MultiSigSmartAccountFactory contract by calling accountImplementation(). This is done also by the helper function which can be used as below:

const implementationAddress = await getAccountImplementationAddress(factoryAddress, ethersSignerOrProvider)

2. Fully offchain prediction!

const predictedAddress = await predictAccountAddrOffchain(factoryAddress, accountImplementationAddress, combinedAddress, salt)

factoryAddress as well as accountImplementationAddress can be also predicted fully offchain with:

  • predictFactoryAddrOffchain()
  • predictAccountImplementationAddrOffchain.
// predict Smart Account Factory address using salt
const saltFactory = saltToHex("aafactorysalttest")
const predictedFactory = predictFactoryAddrOffchain(saltFactory, ENTRYPOINT_ADDRESS)

// predict Smart Account Implementation address
const predictedImplementation = predictAccountImplementationAddrOffchain(
  saltFactory,
  predictedFactory,
  ENTRYPOINT_ADDRESS
)

1. Create Schnorr Signers out of private keys

The private key has to be hex value, so e.g. 0x123456....

Warning! Never disclose your private key!

const signer1 = createSchnorrSigner(<PRIVATE_KEY_HEX_1>)
const signer2 = createSchnorrSigner(<PRIVATE_KEY_HEX_2>)

2. Create Account Signer

  • Create Provider. It can be e.g. AlchemyProvider.
const alchemy = new Alchemy({
  apiKey: <ALCHEMY_API_KEY>,
  network: <network>,
})
const alchemyProvider = await alchemy.config.getProvider()
  • Connect the Provider to the MultiSig Account Abstraction
const accountProvider = EthersProviderAdapter.fromEthersProvider(alchemyProvider)

const accountSigner = accountProvider.connectToAccount((rpcClient) => {
  const smartAccount = new MultiSigAccountAbstraction({
    chain: <CHAIN>,
    accountAddress: <SMART_ACCOUNT_ADDRESS>,
    factoryAddress: <MUSIG_ACCOUNT_FACTORY_ADDRESS>,
    rpcClient,
    combinedAddress: combinedAddress[],
    salt: utils.formatBytes32String(<SALT_STRING>),
  })

  smartAccount.getDeploymentState().then((result: unknown) => {
    console.log("===> [useAccountSigner] deployment state", result)
  })
  smartAccount.isAccountDeployed().then((deployed: unknown) => {
    console.log("===> [useAccountSigner] deployed", deployed)
  })

  return smartAccount
})
  • chain can be get from Alchemy SDK
const chain = getChain(chainId)
  • accountAddress is Account Abstraction Address deployed with MultiSigSmartAccountFactory contract method createAccount
const saltBytes = stringToBytes(<SALT_STRING>, { size: 32 })
const _createTx = await smartAccountFactory.createAccount(combinedAddress, saltBytes)
  • factoryAddress is the address of MultiSigSmartAccountFactory. If already deployed, can be found in deployments folder of aa-schnorr-multisig package
  • combinedAddress can be generated with getAllCombinedAddrFromSigners() function from schnorr-helpers. Signers have to be the same as used for signing transactions.
const x = 2 // nr of signers needed for valid signature, here 2/3
combinedAddress: string[] = getAllCombinedAddrFromSigners([signer1, signer2, signer3], x)
  • salt is a string used to specify the deterministic address of the Account Abstraction
const saltBytes = stringToBytes(<SALT_STRING>, { size: 32 })

where stringToBytes imported from viem encodes a UTF-8 string into a 32-byte array

  • optional parameter EntryPoint by default is Alchemy's deterministic address and is the same for every chain. It can be get from Alchemy SDK:
// default: "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"
const entryPointAddress = getDefaultEntryPointAddress(chain)

3. Create MultiSig Account Signer out of Account Signer

Use multiSigAccountSigner to extend accountSigner with multi-signature methods.

const multiSigAccountSigner = createMultiSigAccountSigner(accountSigner)

4. Construct User Operation CallData

User Operation CallData is just wrapped standard transaction calldata.

  • encodeFunctionData imported from viem encodes the function name and parameters into an ABI encoded value

  • smart contract's ABI, like ERC20_abi, can be imported from aa-schnorr-multisig or defined within the function, e.g.

const AlchemyTokenAbi = [
  {
    inputs: [{ internalType: "address", name: "recipient", type: "address" }],
    name: "mint",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
];

CallData construction examples

ERC20 Transfer

const uoCallData: UserOperationCallData = encodeFunctionData({
        abi: ERC20_abi,
        args: [toAddress, amount],
        functionName: "transfer",
      })

Transfer ETH

const uoCallData: UserOperationCallData = {
  data: "0x",
  target: <toAddress> as Hex,
  value: <amount>,
}

Upgrade MultiSigSmartAccount contract

const newImplementation = <newImplementationAddress> as string
const data = ""
const uoCallData: UserOperationCallData = encodeFunctionData({
  abi: MultiSigSmartAccount_abi,
  args: [newImplementation, ""],
  functionName: "upgradeToAndCall",
})

Withdraw MultiSigSmartAccount deposit

const uoCallData: UserOperationCallData = encodeFunctionData({
  abi: MultiSigSmartAccount_abi,
  args: [toAddress, amount],
  functionName: "withdrawDepositTo",
})

5. Build User Operation

Use MultiSigAccountSigner's method with gas estimator buildUserOpWithGasEstimator().

const { opHash, request } = await multiSigAccountSigner.buildUserOpWithGasEstimator(
  {
    data: uoCallData,
    target: targetAddress as Hex,
  },
  {
    preVerificationGas: 2000000,
  }
)

targetAddress can be ERC20 Token address (e.g. for token transfer) or MultiSigSmartAccount address for upgrade call.

6. Initialize Multi-Sig Schnorr Transaction

Use signers (or signers' public keys and public nonces), opHash and request generated above.

Every instance of MultiSigUserOpWithSigners is created once for single transaction (and designed signers combination, like 2/3) and uses one-time nonces, so transactions can't be re-signed or reused!

const msUserOp = new MultiSigUserOpWithSigners([signer1, signer2], opHash, request)

If Signers can not be entirely passed as arguments it is possible to build User Operation out of signers' publicKeys and publicNonces.

const msUserOp = new MultiSigUserOp(publicKeys, publicNonces, opHash, userOpRequest)

7. Sign the transaction with every defined signer

msUserOp.signMultiSigHash(signer)

8. Send the transaction

To do so use MultiSigAccountSigner's method sendMultiSigTransaction().

In this step signatures (signed before by each signer) are collected and combined within the MultiSigUserOpWithSigners instance. This "summed-signature" is then sent and validated on-chain. If it's ok - transaction can be finished.

const txHash = await multiSigAccountSigner.sendMultiSigTransaction(msUserOp)

Associated package

Package Sidebar

Install

npm i aa-schnorr-multisig-sdk

Weekly Downloads

8

Version

1.0.6

License

MIT

Unpacked Size

1.18 MB

Total Files

372

Last publish

Collaborators

  • karczu