-
Notifications
You must be signed in to change notification settings - Fork 389
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
WIP: Implement Mailbox Contract for Sealevel-Based Chains #1345
base: main
Are you sure you want to change the base?
WIP: Implement Mailbox Contract for Sealevel-Based Chains #1345
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think a lot of these were answered / talked about in our call just now, but sharing before I sign off today
|
||
let id = message.id(); | ||
outbox.tree.ingest(id); | ||
msg!("Hyperlane outbox: {}", &formatted); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it easy to index these from old blocks?
Just thinking of how Wormhole did it and wondering if you felt like the writing to an account was overkill and that logs are sufficient
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
web socket clients can subscribe to get updates so easy to keep pace but allowing a new node to catch up would be more difficult currently afaik. Would likely need to store off chain for longer periods of time, write to an account (will need to do that for large messages at some point, though probably would not make sense to keep the message around for that long), or similar
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Imo we should just write this to an account like this guy solana-labs/solana#23653 (comment) also went with
Opened this to track it #1590
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense - also encountered the noop cpi recently. Will look into that
let mut serialized = vec![]; | ||
message.write_to(&mut serialized)?; | ||
|
||
// The hyperlane validator's indexer subscribes to transaction logs for this account and |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: not really important but fwiw the validators just need to poll the latest checkpoint and sign it if it's change, and the relayers are the one that actually need to index messages
nonce: count, | ||
origin_domain: config.local_domain, | ||
sender: H256(dispatch.sender.to_bytes()), | ||
destination_domain: dispatch.destination_domain.into(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: might be wrong but I think the into
isn't needed as they're both u32
s
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
will run clippy and fix
let (formatted, message_len) = committed_message | ||
.format() | ||
.map_err(|_| ProgramError::from(Error::MalformattedCommittedMessage))?; | ||
if message_len > MAX_MESSAGE_BODY_BYTES { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: we've been enforcing this on the message body and not the full encoded message size (see https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/solidity/contracts/Mailbox.sol#L110)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good catch, will fix
|
||
// The hyperlane validator's indexer subscribes to transaction logs for this account and | ||
// records the serialized messages. | ||
let committed_message = RawCommittedMessage { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
heads up that I think this type's now removed for v2
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Generally now that main
is v2, probably worth pulling from main)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
will rebase fix that
} | ||
let config = ConfigAccount::fetch(&mut &config_account.data.borrow_mut()[..])?.into_inner(); | ||
|
||
let message = AbacusMessageV2::read_from(&mut std::io::Cursor::new(process.message)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I think you can get away without needing the Cursor by using a slice? May be wrong tho
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
will double check on that
UnsupportedDomain = 4, | ||
#[error("Incorrect destination domain")] | ||
IncorrectDestinationDomain = 5, | ||
#[error("Message has already been dispatched/processed")] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dispatched messages should always be unique because of the nonce, so this should only apply for processing. Maybe worth a rename to something closer to MessageAlreadyProcessed
or something
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense, will rename
#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq)] | ||
pub struct InboxProcess { | ||
pub root: H256, | ||
pub index: [u8; 32], // FIXME what is this again? Can this be a u32? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah this can be a u32
- this is supposed to be the "checkpoint index" which is equal to the tree's count - 1. You can think of the root and the index logically as 1 entity
But now that we have ISMs in v2, this whole struct could be reduced to:
pub struct InboxProcess {
// Arbitrary bytes that are passed into the ISM -
// encoded in here would be the root, index, signatures, the proof, etc
// but it all depends on the ISM. The relayer will know how to
// format this data correctly. See here for the format in Solidity
// that should apply similarly here
// https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/solidity/contracts/libs/MultisigIsmMetadata.sol#L4
pub metadata: Vec<u8>,
pub message: Vec<u8>, // Encoded message
}
Ofc this is also in a world where we don't need to worry about hitting the max message size, so maybe they need to be split up or something into 2 separate txs, one to verify with the ISM and one to actually process the message? Guess we'll chat soon
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
okay perfect, will change that datatype and move to the more generic process instruction data
Also great job - super impressed with the progress |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mostly questions or small things
No need to have strict answers for many of the FIXMEs but before merging you should grab some of the new v2 code from main
for a bunch of the copied over abacus-core
stuff
Also a lot of these things we've talked a little bit about before, so apologies for duplicating conversations. A lot of this is just to be able to refer to later & for my own learning
) -> Result<(), ProgramError> { | ||
let accounts_iter = &mut accounts.iter(); | ||
|
||
// FIXME need some way to tie the config account to the inbox account so that we don't allow |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First things that come to mind for me for this - would love your thoughts to help me understand where I'm probably very wrong:
- Create the config account as a PDA when initializing this program - iiuc the pubkey of PDAs are deterministic, so as long as they're deterministically generated based off the current
program_id
and some seed like"config"
, we should be able to verify the authenticity of the config account?
I see that the config / inbox / outbox are currently PDAs but their base pubkey is thepayer
- I'm wondering if the base pubkey should be the mailbox's program id (if that's possible) - The config account could be created before deploying the Mailbox, and then the config account pubkey could be hardcoded in the code of the mailbox. Is this possible / a common practice? Feels janky though
if inbox_account.owner != program_id { | ||
return Err(ProgramError::IncorrectProgramId); | ||
} | ||
let mut inbox = InboxAccount::fetch(&mut &inbox_account.data.borrow_mut()[..])?.into_inner(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thinking about how we'll need:
- Protection against someone supplying a malicious inbox account that allows them to pretend like a message hasn't already been processed
- How access control on the recipient's
handle
function will work
Can this be done by making the inbox account a PDA? Where we use invoke_signed
when calling a recipient's handle
, and they have access control on their side to ensure that their handle function can only be called when the Inbox PDA has been signed? With the similar idea of having the inbox account's PDA's pubkey be deterministic and based off the Mailbox's program_id
(I may be way off here)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I think using a PDA here is the the way to ensure both that the inbox account's data was actually written by the inbox contract and to also endure that the ism and recipient can only be called by the inbox contract. Not sure if the jargon was used 100% correctly there but we're on the same page about what needs to happen afaict
program_id: &Pubkey, | ||
accounts: &[AccountInfo], | ||
process: InboxProcess, | ||
) -> Result<[u8; 32], ProgramError> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we need to return the message ID when processing (though if you were thinking about a particular use case I'm open to keeping it)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
will remove
pub use abacus_core; | ||
|
||
// FIXME Read these in at compile time? | ||
solana_program::declare_id!("8TibDpWMQfTjG6JxvF85pxJXxwqXZUCuUx3Q1vwojvRh"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How does this work? I assume this is what the deployed program's pubkey is? Is this something that can be chosen before deploying?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, it's the program id
Yeah, ideally I'd like to put in the machinery to define it in a config file - experimented with that real quick the other day but was unsuccessful. might need to extend the macro but will have to take another look
// FIXME Read these in at compile time? | ||
solana_program::declare_id!("8TibDpWMQfTjG6JxvF85pxJXxwqXZUCuUx3Q1vwojvRh"); | ||
|
||
// FIXME set a sane default - seems like this is intended to be dynamic? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
correct - I think this FIXME is resolved?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i'd keep the TODO to define a sane default - the current default is a dummy ism
pub const CONFIG_ACCOUNT_SIZE: usize = 1024; | ||
|
||
// FIXME 10MB account size limits the number of messages we can store to ~30k. Figure out how to | ||
// extend this. This might get expensive... |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A couple questions:
- would we need to pay up from for 10MB of rent-exempt storage on this account? or can you expand account sizes
- would the alternative be a PDA for each message where the seed is the message ID and the data in the PDA specifies if it's been delivered?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
whoops this should have said ~300k entries fit in the hash set while staying under 10MB.
Accounts can be resized up to the 10MB limit. So if we need to permanently keep track of all the message hashes, we will eventually need to consider what happens when we fill up an entire account.
I think (2) might work.
Also thinking about storing both a hash set and the address of the previously filled up account in the inbox account. The awkward thing there would be how to inform relayers of the currently used inbox account and how to avoid having an ever expanding list of accounts that could be used by the inbox process transaction... So probably not a good solution.
I'd be curious if it'd make sense to put some sort of expiration date on messages that are to be delivered as to not use an unbounded amount of storage
|
||
/// Account data structure wrapper type that handles initialization and (de)serialization. | ||
/// | ||
/// (De)serialization is done with borsh and the "on-disk" format is as follows: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it sufficient to assume that the presence of some bytes (i.e. the account's data not being empty) implies initialization?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah that's probably sufficient however it would likely be wise to borrow from the anchor framework here and introduce a magic byte sequence that indicates the data type to help guard against deserializing garbage data: https://github.com/coral-xyz/anchor/blob/master/lang/src/lib.rs
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
well actually no, probably not in some cases
Consider the case where we store an 32 bit integer in the account. How would we discern value = 0 from account has been created but no value has been set yet?
you'd have to require that all data stored in the account has a valid default value, on creation
sovereign_data: ism_data, // FIXME is this all we need to pass in? Need proof too? | ||
message: message.message_body.clone(), | ||
}; | ||
if config.ism != *next_account_info(accounts_iter)?.key { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Eventually we'll want to get the ISM specified by the recipient and fall back to the default ISM if the recipient returns some kind of empty value (like a "zero" account)
But ofc we can do that in a follow up, just wanted to flag to not forget
use crate::types::H256; | ||
use sha3::{Digest, Keccak256}; | ||
use sha3::digest::Update; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Recommend taking a look at the new types in main
(which includes v2 now) and copying those over, as I think some are stale
e9336ad
to
7f7a5aa
Compare
b15b152
to
c8290a9
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is awesome! This PDA stuff is super cool, great job!
I reviewed all of processor.rs but will spend some time reviewing everything else as well, just wanted to share my comments so far.
Atm I'm thinking it may make sense to make a feature branch or something with the sealevel contracts, which we can turn this branch into. I can break down what I think the minimal next steps are to have a working version (e.g. with #1363, #1371, and #1590 in mind), I can share this I think on Monday. Then maybe we can chat synchronously about the path forward?
program_id, | ||
), | ||
&[ | ||
system_program.clone(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably dumb questions, but I was looking at this example here https://docs.rs/solana-program/latest/solana_program/program/fn.invoke_signed.html#examples
- I see some asserts, e.g:
assert!(payer.is_writable);
assert!(payer.is_signer);
assert!(vault_pda.is_writable);
assert_eq!(vault_pda.owner, &system_program::ID);
Are those payer ones required? Or is it implied in this situation that that's the case?
And then for the pda stuff - are these required? I see assert_eq!(vault_pda.owner, &system_program::ID);
as possibly a way to check if the pda has been created already, or is that also implied by the create_account
instruction?
- In that example they don't pass in the
system_program
as an account here, any idea why?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The conditions in the payer asserts are implied and the program should fail in other places if they are not true but it probably wouldn't hurt to add those checks.
With regards to checking the PDA owner prior to creating it, I'm assuming that is done to ensure that the account doesn't already exist and is not owned by some malicious account. I will have to dig into that a bit more however because I'm not sure about the behavior around yet to be created accounts.
Actually not sure on why create_account()
doesn't take the system_program
in that example - the example code I was referring to does. I will follow up on this one as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Passing in system_program
to create_account
is unnecessary as long as the calling instruction has been passed system_program
. The system program doesn't need itself passed in as an account
ism: InboxSetDefaultModule, | ||
) -> ProgramResult { | ||
let accounts_iter = &mut accounts.iter(); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar question on the possibility of consolidating some of e.g. here through 364 into a get_inbox
fn or something to share between inbox_.*
functions
for pubkey in &inbox.ism_accounts { | ||
let meta = AccountMeta { | ||
pubkey: *pubkey, | ||
// TODO this should probably be provided up front... |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wdym by this?
oh is this saying that sometimes the accounts may actually be a signer or writable and we should support that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, we may want to allow an ism to write to some accounts
invoke_signed(&recieve, &recp_accounts, &[auth_seeds])?; | ||
msg!("Hyperlane inbox: {:?}", id); | ||
|
||
InboxAccount::from(inbox).store(inbox_account, true)?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should this be before the invoke_signed
calls? Storing the update to the delivered
map after the invokes feels like it's susceptible to reentrancy (though maybe this isn't of concern in the Solana world as it is in the EVM?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would argue the opposite in fact. If we recurse prior to storing, then we will burn through the whole compute budget rather than error out with something like message already delivered.
// TODO add more strict checks on permissions for all accounts that are passed in, e.g., bail if | ||
// we expected an account to be read only but it is writable. Could build this into the AccountData | ||
// struct impl and provide more fetch methods. | ||
fn inbox_process( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On the note of reentrancy - are reentrancy guards used in Solana? We have a reentrancy guard on our process function in the EVM
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah so the recipient cannot be the mailbox contract? We could add one however there is a finite compute budget and if the payer sends a txn that will cause the contract to recurse infinitely, it will burn through the compute budget. It may make more sense to leave as is to disincentivize making recursive calls like that.
Feature branch sounds like the right approach to me. I'll rebase and PR to that branch when you push it |
c8290a9
to
a3649ff
Compare
Description
What's included in this PR?
Bare bones implementation of mailbox contracts for Sealevel VM based chains (Solana).
Still some remaining FIXMEs, but wanted to put this up as a draft prior to our call tomorrow for visibility.
Drive-by changes
Are there any minor or drive-by changes also included?
Copy pasted and extended part of abacus-core to get around dependency conflicts with ethers-rs.
Related issues
#1283
Depends on this PR:
paritytech/parity-common#698
Backward compatibility
Are these changes backward compatible?
Yes. Additive only.
Are there any infrastructure implications, e.g. changes that would prohibit deploying older commits using this infra tooling?
None
Testing