Skip to content
Merged
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
249 changes: 249 additions & 0 deletions docs/foreign_chain_transactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
# Foreign Chain Transaction Verification (Design Proposal)

Status: Draft (based on PR #1851 / branch `read-foreign-chain`)

## Purpose & Motivation

This feature lets the MPC network sign payloads only after verifying a specific foreign-chain transaction, so NEAR contracts can react to external chain events without a trusted relayer. Primary use cases:

- Omnibridge inbound flow (foreign chain -> NEAR) where Chain Signatures are required to attest that a foreign transaction finalized successfully.
- Broader chain abstraction: a single MPC network verifies foreign chain state and signs conditional payloads.

## Scope

- In scope: contract-level API for verify+sign requests, node-side verification via configured RPC providers, deterministic provider selection, and extensible per-chain verifiers.
- Out of scope: on-chain light clients / cryptographic proofs, multi-round MPC consensus on verification results, and non-ECDSA schemes for verify_foreign_transaction (initially ECDSA only).
Copy link
Contributor

Choose a reason for hiding this comment

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

as used below:

Suggested change
- Out of scope: on-chain light clients / cryptographic proofs, multi-round MPC consensus on verification results, and non-ECDSA schemes for verify_foreign_transaction (initially ECDSA only).
- Out of scope: on-chain light clients / cryptographic proofs, multi-round MPC consensus on verification results, and non-ECDSA schemes for `verify_foreign_transaction` (initially ECDSA only).


## Overview

At a high level:

1. A user submits a `verify_foreign_transaction` request with a chain-specific verification config.
2. MPC nodes verify the foreign transaction via configured RPC providers.
Copy link
Contributor

Choose a reason for hiding this comment

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

how does this trust relation work? Does the node need to trust the RPC provider, or the information obtained can be itself verified against some ground truth, for example a fixed public key?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No, we trust the RPC providers. But this trust is diluted by the fact that multiple RPC providers need to return the same result.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see, so colluding RPC providers could easily break the system. I forgot to mention, one thing I missed from the doc is a concrete attacker model, where this details would become explicit

3. If verified, MPC signs `sha256(tx_id_bytes)` with the derived domain key and returns the signature on-chain.
Copy link
Contributor

Choose a reason for hiding this comment

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

are we planning to use this with existing domains?

I guess sha256(tx_id_bytes) is some application dependent step in the signature process, but for the application we will support initially it is the correct one?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, this will work with any existing ECDSA domains according to the current proposal. I'm a bit questioning if we should have dedicated domains for this or some sort of separation between this and the sign use case though.

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess there is a trade-off. Using existing domains is more efficient, simple and risky, but having a different domain is more cumbersome and secure

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Edit: We want to enforce separate domains for this. (discussed on Slack).


### User Flow: Verify a Foreign Transaction

```mermaid
---
title: Foreign Chain Verification - System Context
---
flowchart TD
DEV["**Developer / Bridge Service**
_Submits verify_foreign_transaction requests._"]

SC["**MPC Signer Contract**
_On-chain policy + pending requests._"]

MPC["**MPC Nodes**
_Verify foreign tx status and sign._"]

RPC["**RPC Providers**
_JSON-RPC endpoints._"]

FC["**Foreign Chain**
_Solana, future chains._"]

DEV -->|"1. verify_foreign_transaction()"| SC
MPC -->|"3. respond_verify_foreign_tx()"| SC
MPC -->|"2. query tx status"| RPC
RPC -->|"read chain state"| FC

DEV@{ shape: manual-input}
SC@{ shape: db}
MPC@{ shape: proc}
RPC@{ shape: proc}
FC@{ shape: cylinder}
```

With the user flow in mind, the on-chain interface is:

### Contract Interface (Request/Response)

```rust
// Contract methods
verify_foreign_transaction(request: VerifyForeignTxRequestArgs) -> VerifyForeignTxResponse // Through a promise
respond_verify_foreign_tx({ request, response }) // Respond method for signers
```

```rust
// Contract DTOs
pub struct VerifyForeignTxRequestArgs {
pub chain: ForeignChain,
pub tx_id: TransactionId, // TxID is the payload we're signing
Copy link
Contributor

Choose a reason for hiding this comment

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

I find it strange that we call this transaction id, but at the same time it is the transaction payload

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh right, ForeignTransactionId would be a better name for this. This is the payload we're signing, but it's a transaction ID on another chain.

Copy link
Contributor

Choose a reason for hiding this comment

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

so basically all we are signing is a transaction ID from another chain, not really an arbitrary payload?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes exactly. The feature is basically just signing a transaction ID right now. Though we might need to change that.

pub path: String, // Key derivation path
pub domain_id: Option<DomainId>, // Defaults to 0 (legacy ECDSA)
Copy link
Contributor

Choose a reason for hiding this comment

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

why do we need the Option for legacy here? Is there any requirement about compatibility with the current sign method?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good observation. Let's make this required.

}

pub struct VerifyForeignTxRequest {
// Constructed from the args
pub chain: ForeignChainRpcRequest,
pub tx_id: TransactionId,
pub tweak: Tweak,
pub domain_id: DomainId,
}

pub struct VerifyForeignTxResponse {
pub verified_at_block: BlockId,
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't have this for SignatureResponse. Could you explain the rational over this one? Wouldn't the block id be attached to the response transaction anyway?

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 is the block on the foreign chain afaik, which makes sense just for verifiability for observers. I'd assume bridges could make use of this information as well, if they for any reason would like to keep track of finalized block heights of foreign chains.

Copy link
Contributor

Choose a reason for hiding this comment

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

makes sense, then calling ForeignBlockId will remove the ambiguity

pub signature: SignatureResponse, // Signature over `sha256(tx_id_bytes)` where `tx_id_bytes` are chain-native bytes (e.g., Solana 64-byte signature).
}

pub enum ForeignChainRpcRequest {
Solana(SolanaRpcRequest),
Bitcoin(BitcoinRpcRequest),
// Future chains...
}

pub struct SolanaRpcRequest {
pub tx_id: SolanaTxId,
pub finality: Finality, // Optimistic or Final
}

pub struct BitcoinRpcRequest {
pub tx_id: BitcoinTxId,
pub confirmations: usize, // required confirmations before considering final
}

pub enum Finality{
Optimistic,
Final,
}
```

### Contract state (Foreign Chain Policy)

The contract maintains a *foreign chain policy* that defines which chains and RPC providers are allowed.

```rust
pub struct ForeignChainPolicy {
pub chains: BTreeSet<ForeignChainConfig>,
}

pub struct ForeignChainConfig {
pub chain: ForeignChain,
pub providers: NonEmptyVec<RpcProviderName>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Where do we store the actual RpcProviderInfo? I expected to see it here

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good question. This is the configuration in the smart contract. The actual RPC information would reside in the node configurations. I guess this could be clarified.

Copy link
Contributor

Choose a reason for hiding this comment

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

yeah only found out when I read a bit further 😄

}

pub enum ForeignChain {
Solana,
Bitcoin,
// Future chains...
}

pub struct RpcProviderName(String);

pub struct ForeignChainPolicyVotes {
// Each authenticated participant has one active vote for a proposal.
pub proposal_by_account: BTreeMap<AccountId, ForeignChainPolicy>,
}
```

### Failure and Timeout Behavior

- Nodes **abstain** if verification fails (RPC error, tx not found, or not finalized).
- A failed verification does **not** produce an on-chain failure response. The request eventually times out and fails with the standard timeout error.

For operators, policy updates control which chains/providers are allowed:

### Operator Flow: Policy Updates (New Chains / Providers)

```mermaid
---
title: Foreign Chain Policy Updates - High Level
---
flowchart TD
NODE["**MPC Node**
_Local config + API keys._"]

SC["**MPC Signer Contract**
_Foreign chain policy._"]

COMP["**Compare**
_Local config vs policy._"]

UPDATED["**Policy Updated**
_Unanimous vote reached._"]

NODE -->|"1. read policy"| SC
NODE -->|"2. compare"| COMP
COMP -->|"3. vote if different"| SC
SC -->|"4. update policy on unanimity"| UPDATED

NODE@{ shape: proc}
SC@{ shape: db}
COMP@{ shape: proc}
UPDATED@{ shape: proc}
```

### Contract Policy State (Types)
See "Contract state (Foreign Chain Policy)" above.

### Node Configuration and Policy Updates

- Node config contains chain RPC providers and timeouts (API keys stay local).
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm I understand now. So the contract contains only the RpcProvider names, while the node store the actual details, so that they are detached.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, exactly. Good to see this is clear.

- On startup, nodes compare local config to the on-chain policy.
- If different, a node submits a vote for the policy derived from its local config.
Comment on lines +184 to +185
Copy link
Contributor

Choose a reason for hiding this comment

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

are we expecting many policy changes? By this we are moving away from the current manual process to vote for anything, which is good, but at the same time will add complexity to the process, so there is a trade-off there

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, the reason for nodes to automatically vote is to ensure they have the actual configuration they are voting for. We originally considered having normal operator votes, but it felt like it would be easy for operators to forget updating their configuration before casting there votes for example.

Copy link
Contributor

Choose a reason for hiding this comment

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

So what would happen if they don't? The node would simply not start? How would the operator change this config when the node is running in a TEE?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Config updates in TEE is a bit of a pain point. The node would still operate even if it disagrees on the config, but I imagine it wouldn't collaborate on requests that it hasn't voted on.

I think we'll need to add better ways of dynamically updating node configuration once we've shipped the MVP of this feature.

- Policy updates are applied only when all current participants vote for the same proposal.
- Pending proposals and vote counts are visible via `get_foreign_chain_policy_proposals()`.

Provider selection is deterministic across nodes:

### Deterministic Provider Selection

Each node selects a provider using a deterministic hash of:

```
hash = sha256(participant_id || request_id || provider_name)
```

Providers are sorted by this hash to build a deterministic ordering:

- **Primary provider** = first in the ordering.
- **Fallback** = subsequent providers in order.
- Each provider can include backup URLs for failover.

This ensures different nodes query different providers for the same request while preserving determinism.
Comment on lines +193 to +205
Copy link
Contributor

Choose a reason for hiding this comment

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

This does not really describe completely how it avoids that two nodes query the same provider, and at the same time is not very efficient. We might want to iterate a bit on this before settling it

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, this is a very lean and not exact formulation. I started working on expanding it but found a precise explanation to be a bit too much and I don't think this is important for the first iteration as it's not user-facing. The important part here is that it's possible to derive a consistent ordering and using this ordering to ensure nodes vote for different providers.

Basically if we have the providers P1 P2 P3 P4 and nodes N1 N2 N3 N4 N5, we'd have N1 -> P1, N2 -> P2, N3 -> P3, N4 -> P4, N5 -> P1.


### Configuration (Node)

Example config snippet:

```yaml
foreign_chains:
solana:
timeout_sec: 30
max_retries: 3
providers:
alchemy:
rpc_url: "https://solana-mainnet.g.alchemy.com/v2/"
api_key:
env: ALCHEMY_API_KEY
quicknode:
rpc_url: "https://your-endpoint.solana-mainnet.quiknode.pro/"
api_key:
val: "<your-api-key-here>"
```

The contract policy references providers by **name**, and nodes must have matching
provider entries in config (including API keys) to satisfy the policy.

## Risks

- **RPC trust and correctness**: Verification relies on centralized RPC providers. A malicious
or faulty provider could return incorrect status for a subset of nodes.
- **No additional consensus**: Nodes independently verify and abstain on failure. If a threshold
of nodes are misled by providers, the network could sign invalid payloads.
- **Provider availability**: Outages or rate limits can cause verification failures and reduced
signing availability.
- **Finality semantics**: Finality definitions differ across chains; mapping them correctly is critical.
- **Operational friction**: Unanimous voting for policy updates may slow rollouts and hot fixes.
- **Config drift**: Nodes missing required provider keys will fail startup validation.
Copy link
Contributor

Choose a reason for hiding this comment

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

is this accurate? It didn't seem to be the case by the description above?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah I'll remove it


## Discussion points
- Why do we return a signature? Can't we just return a bool.
- A signature suggests this is a "proof" that can be validated by someone else than the caller, but currently it seems like this proof could easily be forged by just calling the normal "sign" method.
- Finality interface right now diverges from the original PR. Are we okay with this new structure?
Copy link
Contributor

Choose a reason for hiding this comment

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

We can avoid this collision by tweaking the key derivation properly

- Can we assume all RPC providers take API keys as bearer tokens?
- Should we identify RPC providers by a base URL instead of an arbitrary name?
- Should the policy vote threshold stay **unanimous**, or be configurable (e.g., threshold)?
- Startup validation: when policy is empty, nodes skip config validation and can still boot/vote an initial policy. Is this the desired operational behavior?
50 changes: 50 additions & 0 deletions prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
Hey! Can you help draft an initial design doc in `docs/foreign_chain_transactions.md`? Heres a description of the issue:

### Background

In https://github.com/near/mpc/pull/1851 a new feature has been proposed to extend the MPC network to allow MPC nodes to verify foreign chain transactions.

Since this is a big feature, it would be very helpful to compile a design proposal to facilitate effective design conversations and ensure we can make effective progress on getting this merged.

### User Story

As a developer I'd like to have key design decisions documented to ensure we're aligned and allowing us to proceed and focus on implementation details.

### Acceptance Criteria

We have a design doc for the foreign transaction validation feature. The design doc should contain the following:

1. **Motivation.** Why is this feature important? What are the use-cases we want to support?
2. **High level component design.** What are the major components we're implementing, and how are they interacting? How does the flow look end to end when using this feature? Mermaid charts following the c4 model would be helpful here, as well as sequence diagrams for user flows and the voting flows (config updates + proposing new chains) etc.
3. **Risks**. What are the major risks if we implement this feature? Can we migrate pieces of it, or will this cause a big maintenance burden going forward.
4. **Alternatives considered.** Outline some of the alternatives to the design we've considered, and why we chose to proceed with the existing design.

### Resources & Additional Notes

Prototype implementation PR: https://github.com/near/mpc/pull/1851

Meeting notes from a discussion on this:
- We'll start small with only supporting foreign transaction status verification.
- This is sufficient for the bridge use cases.
- This will not help us migrate the Hot wallet use case.
- Hot bridge should be able to work with this, but it would require significant refactors on their end.
- Supported RPC providers should be configured in the MPC contract.
- We'll require a threshold number of votes to add a new RPC provider.
- Nodes, not operators, will vote for the RPC providers as soon as they see a proposal they have configured API keys for.
- Each MPC node will call a single RPC provider, determined using consistent hashing similar to how we do leader election.

**Use case: Omnibridge**
See this quote from Bowen - this feature is key to allow using the MPC network to move assets from other chains to near.

> Chain Signatures is used in Omnibridge starting from Day 1. Near → Foreign Chain always uses chain signatures, whether the destination chain is Bitcoin, Zcash, Solana, Ethereum, etc. The other direction (foreign chain to Near) uses a variety of proving mechanisms including light clients and wormhole. However, we are also working on migrating that entirely to chain signatures.

PR description summary
This PR introduces a new MPC signing flow that conditionally signs only after independently verifying that a foreign-chain transaction has succeeded. Users submit a verification request containing a transaction hash, target chain, and finality level. Each MPC node independently verifies the transaction via RPC before participating in signing, with no additional consensus round required—nodes simply abstain if verification fails.

The initial implementation supports Solana and is designed to be easily extensible to additional chains. The contract exposes a new `verify_foreign_transaction` function that derives the signing payload from the transaction ID (SHA-256) and supports ECDSA domains. On-chain policy controls which foreign chains and RPC providers are allowed, with unanimous voting required for policy changes. Nodes automatically validate and synchronize their local configuration against this policy on startup.

Verification uses deterministic RPC provider selection based on participant ID and request ID, ensuring that different nodes query different providers for the same request, reducing reliance on any single RPC endpoint. Fallback to alternate providers is deterministic, improving resilience against faulty or malicious RPC responses.

This design enables secure, trust-minimized cross-chain signing and significantly extends Omnibridge’s multi-chain capabilities, providing a robust foundation for supporting additional foreign chains in the future.

The #1851 PR os on branch `read-foreign-chain`. Please inspect the diff to understand the current idea better. Also please look at key files to understand how the system works around these changes.