#On-Chain Multisig and Implementing `__check_auth` in soroban

28 messages · Page 1 of 1 (latest)

lyric kiln
#

We are building our contract to be all on-chain multisig, effectively a propose + vote flow. When a proposal to invoke other soroban contracts passes (final vote gets approved) this approach currently only supports first-level invocations (such as a simple transfer of a SAC asset), but fails when we want something that can call any number of other contracts.

I believe we can get what we need by implementing the CustomAccountInterface via __check_auth , allowing the final vote to allow the potential tree of invocations all on behalf of the contract. Is that a correct understanding? I can't seem to get a multi-leg contract call chain to work and call the entrypoint contract's __check_auth in unit testing, but that may not be possible since there's a specialized testing handler for even checking that method in the first place?

One thing I haven't found a good example of is how to build the transaction on the client side for if we also need to provide some custom data in the user provided Self::Signature , if you could point me to one - that would also be very helpful.

lyric kiln
#

Some rough pseudo contract code, ignoring a handlful of boiler plate things/ttls/etc:

#[contract]
pub struct Contract;

#[contractimpl]
impl Contract {
  pub fn __constructor(env: Env, config: Config) -> Result<(), ContractError> {
    // validate and save config
  }

  pub fn propose(env: Env, caller: Address, fn_name: Symbol, contract: Address, args: Vec<Val>) -> Result<u32, ContractError> {
    caller.require_auth();
    // check the caller is an owner
    // save nested call as Proposal{ caller, votes: vec![&env], call { fn_name, contract, args} }
  }

  pub fn vote(env: Env, caller: Address, proposal: u32, execute: bool) -> Result<(), ContractError> {
    caller.require_auth();
    // check the caller is an owner
    // load proposal, ensure unique voter, etc
    // add a vote to proposal
    if true_votes >= threshold {
      // *** This is where we can only call another contract that won't call others, we want the user to be able to call many others that we won't really have insight into ***
      env.try_invoke_contract::<Val, InvokeError>(contract, fn_name, args)?;
    }

    Ok(())
  }
}

#[contracttype]
pub struct Config {
  pub owners: Vec<Address>,
  pub threshold: u32,
}

#[contracttype]
pub struct Call {
  pub fn_name: Symbol,
  pub address: Address,
  pub args: Vec<Val>
}

#[contracttype]
pub struct Proposal {
  pub submitter: Address,
  pub call: Call,
  pub votes: Vec<bool>,
}
nova mauve
#

Is that a correct understanding?
yes, __check_auth is your custom account entry point and it will be called when a contract calls require_auth for the respective account contract.

I can't seem to get a multi-leg contract call chain to work and call the entrypoint contract's __check_auth in unit testing,
you should use try_invoke_contract_check_auth. e.g. check out this test for an account example: https://github.com/stellar/soroban-examples/blob/33c46ebbf347aa3122824c1e1c61c1c4fe5caf24/account/src/test.rs#L75

One thing I haven't found a good example of is how to build the transaction on the client side
one thing that comes to mind is passkey-kit: https://github.com/kalepail/passkey-kit/blob/aeb04d7b01bd919a2f6cc3511cf5ae7a90c08fea/src/kit.ts#L225. I'm sure there are more examples out there, that's just what I've recalled. the signing algorithm is of course up to your contract implementation. but the general spec for the expected signature payload and credentials format is always going to be the same, so the example I provided should be pretty comprehensive. some general details on auth payloads can be found here: https://developers.stellar.org/docs/learn/fundamentals/contract-development/contract-interactions/stellar-transaction#authorization-data.

lyric kiln
#

I feel like I have read all that and still really am not understanding, certainly a failure on my part where my mental model is getting in the way.

We want the user to be able to approve whatever call tree happens when they vote affirmatively, without us having to know details as to how that call tree is built after our vote(address, u32, bool) passes the threshold and can't really reason about how that fits in.

Would it be as simple as the first Vec<Context> is the root signature (most likely one of our known owners, rejecting otherwise) and we could expect an Address in the signature where we could signature.address.require_auth_for_args(auth_context.first().contract, fn_name, args)?

#

For frame of reference we have an analogous contract on cosmos chains, but once the contract has issued a nested call, the auth is pretty much in the context of that contract (not the end user's account)

#

So that might be getting in the way for me

nova mauve
#

is it a hard requirement to perform call from vote?

lyric kiln
#

It's probably a nice to have, but we could transition the vote into an approved state and let it move to executed in another call if we have to

#

UX suffers a little, but that is okay

nova mauve
#

the flexible/idiomatic way would be to go with non-cryptographic 'signature' for the contract:

  • 'signature' for the custom account is defined as just ProposalId(u32)
  • Proposal contains Vec<Context> to authorize. this is a bit sketchy, because contexts represent visit order of the auth tree (so in theory the mapping is ambiguous), but I don't think this could ever be an issue for non-malicious contracts, and for the case of malicious contract you're probably screwed either way)
  • a vote potentially creates a Proposal under some ProposalId and updates the weight (or do what you did here with separate propose + vote calls, whatever makes more sense ux-wise).
  • vote also can return a bool flag indicating that the threshold has been reached, or emit the respective event
  • after the weight is sufficient, a transaction can be signed using the ProposalId 'signature' (i.e. just a u32). __check_auth will then fetch the proposal under that ID, verify that its context matches the context passed to __check_auth, and delete the proposal to prevent replay. note, that anyone can perform the invocation as soon as the proposal has been voted for (which should be fine by design).
#

you can also create a helper for batching vote and call while using the generic approach described above. here is the pseudocode:

fn vote_and_try_call(voted: Address, account: Address, proposal_id: u32, top_level_call: Call) -> Result<bool> {
  account.vote(voter, proposal, ...);
  if env.try_call(<args infferred from top_levle_cal>).is_ok() {
    Ok(true)
  } else {
    Ok(false)
  }
}

i.e. vote first, then try to perfrom the top-level call. note, that every vote_and_try_call will need to contain a valid auth entry for the call, but since your signature is immutable (just ProposalId), that shouldn't be an issue.

#

I'm pretty sure that's a lot to take in, so please feel free to clarify any point, and I would also recommend to just play with code to get a better hang of how things work.

lyric kiln
#

Proposal contains Vec<Context> to authorize. this is a bit sketchy, because contexts represent visit order of the auth tree (so in theory the mapping is ambiguous), but I don't think this could ever be an issue for non-malicious contracts, and for the case of malicious contract you're probably screwed either way)

So in practice this would be something like:

  1. User proposes a Context(ContractContext) (what I called a Call)
  2. Votes happen, and in the happy case eventually pass (other case it's either pending or rejected and removed anyway)
  3. (a fuzzy thing here for me of how to sign the u32 tx) - contract is now running the proposed call ---> env.try_call(...)- which will eventually call contract1addr.require_auth. We just double check we are still in the call tree that at one point had the initial call to our address with that u32 and the correct proposal state (approved) and then Ok(()). If we are multiple calls deep, will the __check_auth keep getting called? Do we need to keep track of more contexts as this traverses, or is the first enough?
#

I follow on the whole updating the internal state just fine, that all follows pretty much our current design (other than moving where the try_invoke might be)

#

By the way, thank you so much for your time

nova mauve
#

iser proposes a Context(ContractContext) (what I called a Call)
no, they should propose just a Vec<Context>, the same thing that's an input to __check_auth. you can then just compare these with ==

#

to be clear, vote_and_try_call is completely optional. I would recommend figuring things out without it

lyric kiln
#

yeah, let's ignore that one for the moment 🙂

nova mauve
#

in short, it does 2 unrelated contract calls, each with its own auth. it's basically the same as just sending 2 txs: one for vote, and another for performing the actual call. they're just batched in a single call. but from the auth semantics standpoint there is really no difference.

lyric kiln
#

Would the user need to know the full anticipated call tree for that Vec<Context> to work? E.g. if it's a token swap, all the potential legs in the sequence?

#

Or is the top level call enough of swap(vec<Address>, to, amount) enough?

nova mauve
#

Would the user need to know the full anticipated call tree for that Vec<Context> to work? E.g. if it's a token swap, all the potential legs in the sequence?
yeah, so I've left the full user flow aside, but the proposer could run the simulation for the account contract, get the auth tree, and then convert into Vec<Context> via DFS. obviously some program should do the conversion for the user.
using just the top level is not safe, so the full tree of contexts is necessary.

lyric kiln
#

Sorry, what is "DFS"?

nova mauve
#

depth-first search

lyric kiln
#

Okay, yeah - we bomb today simulating the vote::yes when it's the final vote for these types of calls, so we probably need to do that DFS to build out the required contexts

nova mauve
#

a simple example would be a liquidity pool deposit. it normally consists of a deposit() call that performs two transfers from the user to the LP contract. so the auth tree would look like LP.deposit(tokenA, tokenB)->[tokenA.transfer(user, LP), tokenB.transfer(user, LP)], where [] denote children at the same tree level. you would traverse this in pre-order and get the following Vec<Context>: [LP.deposit(tokenA, tokenB),tokenA.transfer(user, LP), tokenB.transfer(user, LP)].

#

and to get the auth tree just run the simulation for the target call and look at the auths in the output.