Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Next #9

Open
wants to merge 14 commits into
base: adutit-commit
Choose a base branch
from
Open

Next #9

wants to merge 14 commits into from

Conversation

roberts-pumpurs
Copy link
Member

No description provided.

roberts-pumpurs and others added 13 commits January 16, 2025 16:30
* refactor: update how program logs handle whitespace
* chore: linter
* chore: updaet gas service program id
Also remove the need of passing bump in constructor.
* feat: completes governance module docs

* fix: clarify the large message payload feature on GMP messages

* fix: use shareable links for diagrams

* fix: clarify only one execution can happen
If this is not done, anyone can initialize the ITS root config and
assign the operator role to anyone.

Signed-off-by: Guilherme Felipe da Silva <[email protected]>
* docs(axelar-solana-its): add README

* docs(axelar-solana-gas-service): add empty README

This will be populated later.

Signed-off-by: Guilherme Felipe da Silva <[email protected]>

* docs(axelar-solana-its): add PDA owner information

---------

Signed-off-by: Guilherme Felipe da Silva <[email protected]>
* feat: only deployer can init governance program

* refactor: use fqdns
* chore: change gov program id

* feat: add test helper for finding multiple logs

* fix: flaky tests, we need to expect more than one error

* chore: update to gov module to a new id

* chore: use new test helper in more code
* Add LICENSE, CONTRIBUTING, CODE_OF_CONDUCT

---------

Co-authored-by: Piotr Olszewski <[email protected]>

## Exceptions of the `accounts[]` rule: ITS & Governance

[Interchain Token Service](https://github.com/axelarnetwork/interchain-token-service/blob/main/DESIGN.md#interchain-tokens) and [Governance contract](https://github.com/axelarnetwork/axelar-gmp-sdk-solidity/blob/432449d7b330ec6edf5a8e0746644a253486ca87/test/utils.js#L24-L44) have a legacy ABI interface that must be respected. This means that we cannot enforce arbitrary new encoding for the existing protocols; we only need them to be able to interact with Solana. As a result, the Relayer has special handling when interacting with ITS & Governance contracts; it will decode the `abi` encoded messages, introspect into the message contents and attempt to deterministically derive all the desired accounts for the action the message wants to make. This approach only works when the message layout is known beforehand (meaning that the Relayer can decode it) AND the Relayer has hardcoded custom logic to derive the accounts. This means that this special handling is not possible for the generic case.
Copy link

Choose a reason for hiding this comment

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

why does interchain token service need special handling? Surely this is not the only existing app that will make calls to Solana.

Copy link
Member Author

Choose a reason for hiding this comment

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

why does interchain token service need special handling?

  • Because the abi spec for ITS needs to be consistent across all chains (any evm chain needs to be able to talk to our Solana ITS and vice-versa).
  • This means that we cannot force arbitrary accounts[] data to be provided by the source chain. e.g The INTERCHAIN_TOKEN does not have a place to provide such info
  • Without knowing the accounts[] on the relayer, we cannot create a transaction to send (hence why we even have the extra necessity of an encoding layer)

So, how do we solve this for ITS to get the accounts, to construct a tx?

We, on the relayer, introspect into the ITS payload and query the node state. We have developed ITS in a way that allows us to deterministically compute the accounts based on the message contents. This is ITS-specific handling that allows us to not break the existing interface. Meaning that all EVM ITSs will be able to communicate with the Solana ITS.

Surely this is not the only existing app that will make calls to Solana

We can handle the generic case as long as the source chain also provides the accounts[] in the message payload like our relayer can parse it. Our Memo program is a good example:

Otherwise, it would require adding a patch to the relayer to add another special-case handling -- assuming the special-case handling can even be done, as it requires designing the Solana program with that in mind up-front. In many cases, the accounts cannot be computed deterministically as we did for ITS.

Copy link

Choose a reason for hiding this comment

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

also how does the relayer know to perform this logic versus the other? Are the ITS contract addresses whitelisted?

Copy link
Member Author

Choose a reason for hiding this comment

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

Exactly, relayer has special handling based on the destination address specified in the GMP message:

    match destination_address {
        axelar_solana_its::ID => {
            let ix = its_instruction_builder::build_its_gmp_instruction(
                signer,
                gateway_incoming_message_pda,
                gateway_message_payload_pda,
                message.clone(),
                payload,
                solana_rpc_client,
            )
            .await?;

            send_transaction(solana_rpc_client, keypair, ix).await?
        }
        _ => {
            let ix = axelar_executable::construct_axelar_executable_ix(
                message,
                &payload,
                gateway_incoming_message_pda,
                gateway_message_payload_pda,
            )?;
            send_transaction(solana_rpc_client, keypair, ix).await?
        }
    };

Copy link

Choose a reason for hiding this comment

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

After thinking more, I think this needs to be a standard part of the protocol that apps can opt out of passing the accounts. Maybe in that case they use their own relayer, or we have some system that can figure out the accounts in the general case. But our design should be flexible, this sort of seems like right now the only usable apps are the whitelisted apps.

Copy link
Member Author

Choose a reason for hiding this comment

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

that apps can opt out of passing the accounts

Our Gateway does not expect the accounts to be present (it makes no assumption about that), and all account validation happens fully within the axelar-executable crate (meaning it's on the destination contract not on the Gateway).

This means that any app can fully opt out of this. The only issue is that Axelar protocol does not specify which relayer should be used when sending a message, hence why we have this generic case. But essentially this is all off-chain work (relayer / amplifier api) to support this case, to allow people to run their own relayers.

Our axelar-executable even exposes a function for calling validate_call to the Gateway without validating the accounts (this is what ITS uses).

or we have some system that can figure out the accounts in the general case

The accounts can be fully arbitrary, there is no "generic silver bullet" that can do this. Account computation is fully application-dependant; and there are even cases where the user can create his own "user owned accounts", where he generates a keypair locally -- that is impossible to compute via some seeds from a payload. Even now, our ITS is unable to work with user-owned accounts because of this restriction. By forcing the accounts to be computed deterministically, we're handicapping the Solana dapp developers.

But our design should be flexible,

Agreed

this sort of seems like right now the only usable apps are the whitelisted apps.

I disagree with this statement, as that implies that Solana apps in general are unusable because the accounts need to be specified up-front. But the Solana dapp ecosystem is thriving.

Here are the reasons why I believe that accounts should be specified on the source chain in a payload:

  1. we can support arbitrary user-owned accounts (where the publickey for an account cannot be computed deterministically from the payload).
  2. Any developer who builds a dapp for Solana will also have to interact with the accounts model, it's just a part of the transaction payload that you need to send. Same as in Solidity you need to specify the method name you want to call, in Solana you need to specify the accounts. It's just a fundamental primitive that cannot be hidden from the tx payload. By hiding the accounts on the source chain (e.g. EVM chain):
    • a dapp developer still needs to compute the accounts somewhere; you are just forcing the computation over to the relayer -- which the dapp developer now also needs to build and also host
    • you are eliminating user-owned accounts from the equation
    • you are forcing the "payload" to contain all the fields that would allow for deterministic computation
    • The Solana dapp now needs to do on-chain validation of the accounts (more expensive to compute) to ensure that the ones that were provided in the tx args are indeed the same ones that it expects for a given payload, making the app development even more difficult.
  3. it is less-secure, because if account validation on the destination Solana program has a bug then you can easily exploit the whole cross-chain Solana dapp.

Essentially, a dapp developer still needs a way to compute the accounts, and by removing it from the source chain you handicap the Solana dapp developers, you force arbitrary relayer development and you make the whole GMP processing less secure because each app developer also needs to worry about deterministic account computation, which makes the GMP payload design much more difficult.


Now I will touch on what I meant by "expensive on-chain validation of accounts".
I want to bring to your attention that the accounts model in Solana is the backbone of the whole on-chain program security model. Let's take a simplified example of a "token mint" from the SPL-token codebase

image

As you can see, the payload is essentially empty, all the "juicy parts" are part of the accounts model aka the storage slots. The recipient cannot be computed deterministically in the generic case because it can also be a user-owned account. For the sake of example, lets assume that the recipient can be computed based on payload params.

So if we want to build a GMP integration that has the same interface, all of the data about which accounts to use still needs to be present in the GMP payload, so now we either prefix the accounts like we do for our current axelar-executable generic case, or we move it inside the payload in some kind of structured way that allows for deterministic computation (something that we do in our Solana ITS).

So, when our destination program receives the "MintTo" message and the accounts array via its executable() interface, how can it know that the provided accounts[] is indeed the one that should correspond to the GMP message? In our example, account[1] is supposed to be the "deterministically computed recipient" of the mint operation. How can the Solana contract know that a malicious relayer didn't just provide random accounts that it pleases? The on-chain logic now needs to replicate exactly the same steps that the relayer did to compute the accounts:

  • it needs to introspect the message payload on-chain and re-compute all the accounts from the GMP message, and compare them with the accounts[] array that was passed in as args. This prevents malicious relayers from sending payloads that match the source chain with different accounts[] attached.
  • if the accounts need to be computed on-chain only via deterministic seeds, then you will quickly make the transaction more and more expensive, because using Pubkey::find_program_address([seeds_from_payload], program_id) is a costly operation. So you are making the whole dapp do unnecessary calculations and more expensive.

Whereas in the generic case, when the accounts are provided as part of the payload, the destination contract (via axelar-executable crate) can do a simple comparison that the accounts provided as tx args are the same ones that are specified in the payload:

image

Choose a reason for hiding this comment

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

What I mean by making this a standard part of the protocol is the payload should have some sort of encoding scheme where the caller can specify whether the accounts are included or not, instead of the relayer switching on the destination address. This would allow us to possibly build a generic solution to this problem in the future, instead of having to hardcode logic in the relayer. Imagine there exists a service that the relayer can call into dynamically to get the affected accounts. When a payload is received that doesn't specify the accounts, the relayer tries to fetch the accounts from this service. We don't need to build this now, but I'd like to design our system to be able to build something like this in the future.

Copy link
Member Author

Choose a reason for hiding this comment

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

ok, I see your point.
The first byte is fully configurable:

#[repr(u8)
enum EncodingType { borsh = 0, abi = 1, externalCall = 2 }

so we can add a third option in the future, which the relayer can interpret and fetch the accounts from some given axelar-owned-service. It shouldn't be an issue with the current scheme to do that. But then there's the trust property of how the accounts get validated and if they get validated -- that's an extra burden to the app devs tho.


This means that the base interface for GMP messages defined by the Axelar protocol is not expressive enough for communicating with Solana programs - because there’s no place to put the account arrays. Therefore a workaround is needed, where we need to provide the information about the accounts within the scope of the existing `ContractCall` data structure.

Solanas `axelar-executable` now expects that the `payload` emitted on the source chain also includes all the `account[]` data for the Relayer to create a proper transaction. If the `account[]` is absent on the GMP call, the Relayer cannot know what accounts to provide when calling the destination contract.
Copy link

Choose a reason for hiding this comment

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

how exactly is the caller supposed to get this information in practice?

Copy link
Member Author

Choose a reason for hiding this comment

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

traditionally the frontend for the user computes this. Every client interacting with a Solana contract needs to be aware of the intricacies of the programs memory layout, pre-compute the accounts and provide them up-front when creating the transaction.

Example from the docs on how to create a transaction in JS

The same applies to the querying state. You don't query the state in Solana by interacting with the contract. You fetch the raw bytes at a given account address from the chain, and then the app must know how to deserialize the contents. And it's the app's responsibility to figure out the account it even needs to fetch.


In practice all of the computations are completely app dependant and completely arbitrary. Same as in Solidy you may compute memory slots up-front (link to AxelarAmplifierGateway), you must do the same on Solana for every piece of storage, and the end-user must provide it in the tx args.


Example from the Solana spl-token npm package -- they have client side logic to compute the accounts that must be provided up-front when making the transaction. This logic must be equivalent to the one we have inside the contracts, as it's fundamental to the on-chain security checks.

Copy link

Choose a reason for hiding this comment

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

also can you remind me how wormhole handles this situation?

Copy link
Member Author

Choose a reason for hiding this comment

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

Wormhole.

The source chain is fully responsible for producing a payload that can be interpreted by the relayer (eg hosted by the dapp, or the frontend that performs manual relaying), including all of the accounts.

Because Wormhole can run a custom relayer for each app, it also means that the encoding of the message is entirely up to the relayer / app as well (figuring out how to retrieve the necessary accounts for the interaction).

For their token bridging, the "manual relaying", where the user is responsible for sending all txs on both chains, they have the following implementation:


Technically, it's up to the relayer to figure out how to interpret the binary data published on the source chain -- it can very well be a bincoded Solana tx that only requires signing & publishing (a bincoded solana tx already includes all the accounts inside of it + the payload).

  • But a downside of pre-encoding via bincode on the source chain is that you still end up encoding all the data and the accounts (no benefit there), and you are forced to use bincode, which Solidity does not support, meaning you cannot produce a payload fully like that on-chain. Pro: relayer only signs arbitrary data & sends the tx away; simpler code.


| action | tx count | description |
| - | - | - |
| Relayer calls [`Approve Message` (link to the processor)](https://github.com/eigerco/solana-axelar/blob/c73300dec01547634a80d85b9984348015eb9fb2/solana/programs/axelar-solana-gateway/src/processor/approve_message.rs). | For each GMP message in the `ExecuteData` | <ol><li>Validating that a `message` is part of a `payload digest` using Merkle Proof.</li><li>Validating that the `payload digest` corresponds to `Signature Verification PDA`, and it has reached its quorum.</li><li>Validating that the `message` has not already been initialized</li><li>Initializes a new PDA (called `Incoming Message PDA`) responsible for tracking a message's `approved`/`executed` state. The core seed of this PDA is `command_id`. You can read more about `command_id` in the [EVM docs #replay prevention section](https://github.com/axelarnetwork/axelar-gmp-sdk-solidity/blob/main/contracts/gateway/INTEGRATION.md#replay-prevention); our implementation is the same.</li><li>This action emits a log for the Relayer to capture.</li><li>Repeat this tx for every `message` in a batch.</li></ol> |
Copy link

Choose a reason for hiding this comment

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

Shouldn't command_id be message_id? command_id is legacy, most everywhere in amplifier uses message_id

Copy link
Member Author

Choose a reason for hiding this comment

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

During a refactoring ~8 months ago, the term command_id changed its meaning; you may be still referring to the old one?

While it's true that the amplifier (ampd and cosmwasm contracts) uses message_id, the reference implementation uses the term command_id for referring to a hash of source_chain + message_id.

Extract from the reference:

Amplifier guarantees that the combination of source chain and message id will always be unique and fixed for a unique message. Hence, a deterministic command id can be derived based on the source chain and message id, and mark as executed to prevent replay.

The EVM gateway derives the command id as follows. External gateways for other chains can choose to use their own encoding scheme if preferred, as long as it's deterministic and includes the same values as below.

// sourceChain is guaranteed to not contain `_` character
bytes32 commandId = keccak256(bytes(string.concat(sourceChain, '_', messageId)));

Comment on lines +88 to +89
| Relayer calls `Initialize Payload Verification Session` on the Gateway [[link to the processor]](https://github.com/eigerco/solana-axelar/blob/c73300dec01547634a80d85b9984348015eb9fb2/solana/programs/axelar-solana-gateway/src/processor/initialize_payload_verification_session.rs) | 1 | This creates a new PDA that will keep track of the verified signatures. The `payload digest` is used as the core seed parameter for the PDA. This is safe because a `payload digest` will only be duplicated if the `verifier set` remains the same (this is often the case) AND all of the messages are the same. Even if all the messages remain the same, `Axelar Solana Gateway` has idempotency on a per-message level, meaning duplicate execution is impossible. |
| The Relayer sends a tx [`VerifySignature` (link to the processor)](https://github.com/eigerco/solana-axelar/blob/c73300dec01547634a80d85b9984348015eb9fb2/solana/programs/axelar-solana-gateway/src/processor/verify_signature.rs). | For each `verifier` + Signature in the `ExecuteData` that signed the payload digest | The core logic is that we: <ol><li>ensure that the `verifier` is part of the `verifier set` that signed the data using Merkle Proof. </li><li>check if the `signature` is valid for a given `payload digest` and if it matches the given `verifier` (by performing ECDSA recovery).</li><li>update the `signature verification PDA` to track the current weight of the verifier that was verified and the index of its Signature</li><li>repeat this tx for every `signature` until the `quorum` has been reached</li></ol> |
Copy link

Choose a reason for hiding this comment

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

Can a malicious relayer mess up this process? i.e. submitting bad signatures, bad proofs, some other operation, while the honest relayer is trying to submit all of the correct data?

Copy link
Member Author

Choose a reason for hiding this comment

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

All of the operations made by the malicious relayer will fail:

  • a bad signature will revert the transaction by the malicious relayer
  • a bad proof will not verify against none of the Merkle roots

as a result, the on-chain tracker PDA state will not be affected, and the honest relayer can continue sending the proper data without disturbances.


| Gateway Instruction | Use Case | Caveats |
| - | - | - |
| [Call Contract](https://github.com/eigerco/solana-axelar/blob/bf3351013ccf5061aaa1195411e2430c67250ec8/solana/programs/axelar-solana-gateway/src/instructions.rs#L52-L67) | When you can create the data fully on-chain. Or When the data is small enough to fit into tx arguments | Even if you can generate all the data on-chain, the Solana tx log is limited to 10kb. And if your program logs more than that, there won't be any error on the transaction level. The log will be truncated, and the message will be malformed. **Please be careful when making this API call.** |
Copy link

Choose a reason for hiding this comment

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

Will the payload hash a least be logged in this situation (truncated log)?

Copy link
Member Author

Choose a reason for hiding this comment

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

We can make no such guarantees. The 10kb hash is shared for the whole transaction log not a single log line. A log on the same tx can technically exhaust the whole limit, altho even reaching the 10kb limit via just on-chain actions is hard to achieve but not impossible.

But yes, in cases where a user accidentally sends 10kb, we will log the payload hash and other metadata first and only then the actual payload.

image

- On Solana, there is no `msg.sender` concept as in Solidity.
- On Solana `program_id`'s **cannot** be signers.
- On Solana, only PDAs can sign on behalf of a program. The only way for programs to send messages is to create PDAs that use [`invoke_signed()`](https://docs.rs/solana-cpi/latest/solana_cpi/fn.invoke_signed.html) and sign over the CPI call.
- The interface of `axelar_solana_gateway::GatewayInstruction::CallContract` instruction defines that the first account in the `accounts[]` must be the `program_id` that is sending the GMP payload.
Copy link

Choose a reason for hiding this comment

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

Is this validated somehow? Like what if a contract puts a different account in the first slot, and effectively spoofs the sender. Is this possible?

Copy link

Choose a reason for hiding this comment

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

oh it looks like the next line talks about the validation, but it's unclear to me how this works exactly

Copy link
Member Author

Choose a reason for hiding this comment

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

You can imagine it like this:

  1. Gateway has logic that says "if any contract wants to do CallContract you need to generate a PDA where the seed for the account is "GATEWAY_CALL_CONTRACT""
image
  1. The source Solana contract is then responsible for generating this PDA and generating a signature with this PDA, then sends away the call to Gateway
  2. The Gateway in its "call contract" logic checks: was the PDA derived in the correct way (you can see from the code screenshot above that the derivation is deterministic based on the seed and the source program)
  3. if the PDA was derived correctly, then the gateway checks if the PDA was also the signer. In Solana, PDAs are scoped to a specific program, and in this case, it's scoped to the source Solana program, meaning that the source program is the only program that can produce a signature for its own PDAs. Effectively achieving msg.sender check via on-chain signatures.

fix(its): revert removal of accounts validation

This was removed by mistake.

Signed-off-by: Guilherme Felipe da Silva <[email protected]>
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.

4 participants