Skip to content

unify contract callsites#3727

Draft
kevindeforth wants to merge 10 commits into
mainfrom
kd/use-call-args
Draft

unify contract callsites#3727
kevindeforth wants to merge 10 commits into
mainfrom
kd/use-call-args

Conversation

@kevindeforth

@kevindeforth kevindeforth commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Disclaimer: This is a proposal, subject to discussion, not intended for merge yet

Background

We currently have four different components that call the MPC contract:

  1. devnet
  2. e2e test
  3. contract sandbox tests
  4. MPC node

Each component follows its own, unique call-pattern and constructs the json function call arguments.

This has two downsides:

  1. A developer in this repository needs to know four distinct patterns for achieving the same thing. That creates mental overhead not only during coding, but also during review.
  2. If a contract API changes, it requires changes to all four repositories above, having a huge blast radius. Since a lot of the contract methods are not used by external users, but internal to node and operator calls, such changes do occur.

This PR is a proposal on how to unify the pattern across our crates. It centralizes the MPC contract API in contract-interface/src/call_args and introduces a new trait CallContract:

pub trait CallContract {
    /// Backend-specific successful call outcome.
    type Output;

    fn call_contract(
        &self,
        contract_id: &AccountId,
        call_args: FunctionCallArgs,
    ) -> impl Future<Output = Result<Self::Output, CallError>> + Send;
}

The idea is to have a central source of truth for constructing contract call arguments, and allowing each component (devnet, e2e test, contract sandbox tests and MPC node), to implement CallContract for their own types. This way, no matter the crate, a call to the contract will always read similar:

let sign_request_future = send_sign_request(contract_caller, contract_account_id, sign_request_args);

Examples

Currently, each one of these components has their own call-pattern. Some examples:

Propose Contract Update:

devenet:

let result = proposer
.any_access_key()
.await
.submit_tx_to_call_function(
&contract,
method_names::PROPOSE_UPDATE,
&borsh::to_vec(&ProposeUpdateArgs {
contract: Some(contract_code),
config: None,
})
.unwrap(),
300,
self.deposit_near * ONE_NEAR,
near_primitives::views::TxExecutionStatus::Final,
false,
)
.await
.into_return_value()
.expect("Failed to propose update");

e2e test:

let proposer_client = self.operator_client_for(PROPOSER_NODE_INDEX)?;
let outcome = self
.contract
.call_from_borsh_with_deposit(
&proposer_client,
method_names::PROPOSE_UPDATE,
propose_args,
CONTRACT_UPDATE_GAS,
CONTRACT_UPDATE_DEPOSIT,
)
.await
.context("failed to call propose_update")?;

sandbox:

let execution = mpc_signer_accounts[0]
.call(contract.id(), method_names::PROPOSE_UPDATE)
.args_borsh((ProposeUpdateArgs {
code: Some(vec![0; 1536 * 1024 - 400]), //3900 seems to not work locally
config: None,
},))
.max_gas()
.deposit(NearToken::from_near(40))
.transact()
.await
.unwrap();

Signature Request:

devnet:

} else if let Some(domain_id) = self.domain_id {
let contract_state = read_contract_state(&config.rpc, &mpc_account).await;
let domain_config = find_domain_config(&contract_state, DomainId(domain_id))
.expect("require valid domain id");
match domain_config.protocol {
Protocol::ConfidentialKeyDerivation => {
ContractActionCall::Ckd(crate::contracts::RequestActionCallArgs {
mpc_contract: mpc_account,
domain_config,
})
}
Protocol::CaitSith | Protocol::DamgardEtAl | Protocol::Frost => {
ContractActionCall::Sign(crate::contracts::RequestActionCallArgs {
mpc_contract: mpc_account,
domain_config,
})
}
}
} else {
ContractActionCall::LegacySign(crate::contracts::LegacySignActionCallArgs {
mpc_contract: mpc_account,
})
};
let sender: LoadSenderAsyncFn = {
Arc::new(move |key: &mut OperatingAccessKey| {
let action_call = make_actions(contract_action.clone());
let rpc_clone = rpc_clone.clone();
async move {
let signed_tx = key.sign_tx_from_actions(action_call).await;
let rpc_response = rpc_clone
.submit(send_tx::RpcSendTransactionRequest {
signed_transaction: signed_tx.clone(),
wait_until: near_primitives::views::TxExecutionStatus::Included,
})
.await
.map_err(|e| anyhow!("error sending tx request: {}", e));
TxRpcResponse {
rpc_response,
signed_tx,
}
}
.boxed()
})
};

e2e-test:

let client = self.user_client(account_id)?;
let args = json!({
"request": {
"domain_id": domain_id,
"path": "test",
"payload_v2": payload,
}
});
self.contract
.call_from_with_deposit(&client, method_names::SIGN, args, SIGN_GAS, SIGN_DEPOSIT)
.await

sandbox:

let status = contract
.call(method_names::SIGN)
.args_json(req.request_json_args())
.max_gas()
.transact_async()
.await?;

The node follows an entirely different pattern, having a registry for the call patterns in indexer/types.rs

/* These arguments are passed to the `respond_ckd` function of the
* chain contract. It takes both the details of the
* original request and the completed ckd.
*/
#[derive(Serialize, Debug, Deserialize, Clone)]
pub struct ChainCKDRespondArgs {
pub request: ChainCKDRequest,
response: ChainCKDResponse,
}
impl ChainRespondArgs for ChainCKDRespondArgs {}
#[derive(Serialize, Debug, Deserialize, Clone)]
pub struct ChainVerifyForeignTransactionRespondArgs {
pub request: ChainVerifyForeignTransactionRequest,
response: ChainVerifyForeignTransactionResponse,
}
impl ChainRespondArgs for ChainVerifyForeignTransactionRespondArgs {}

and the call submission logic in tx_sender:

async fn submit_tx(
tx_signer: Arc<TransactionSigner>,
indexer_state: Arc<IndexerState>,
method: String,
params_ser: String,
gas: Gas,
) -> anyhow::Result<SubmittedTxMetadata> {
let block = indexer_state.view_client.latest_final_block().await?;
let transaction = tx_signer.create_and_sign_function_call_tx(
indexer_state.mpc_contract_id.clone(),
method,
params_ser.into(),
gas,
block.header.hash,
block.header.height,
);
let tx_hash = transaction.get_hash();
let nonce = transaction.transaction.nonce().nonce();
let signature = transaction.signature.clone();
tracing::info!(
target = "mpc",
"sending tx {:?} with ak={:?} nonce={:?}",
tx_hash,
tx_signer.public_key(),
nonce,
);
indexer_state.rpc_handler.submit_tx(transaction).await?;
Ok(SubmittedTxMetadata {
tx_hash,
nonce,
signature,
block_height: block.header.height,
})
}

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant