Skip to content
This repository has been archived by the owner on Nov 15, 2023. It is now read-only.

Initial erasure-coding of availability data #56

Merged
merged 13 commits into from Jan 24, 2019
Merged

Conversation

rphmeier
Copy link
Contributor

@rphmeier rphmeier commented Dec 14, 2018

related: #51
This is most of step 2 in that issue -- the remaining bit is that the merkle root should be added to the candidate receipt.

This introduces the polkadot-erasure-coding crate, which will be used to create erasure-codings of all parachain data which much be kept available. With n validators, f maximum faulty, we create a coding of n pieces where any f+1 can be used to recover the data.

The crate for now contains utilities for:

  • creating and reconstructing erasure-coded data
  • merkleizing the chunks into a mapping of row_number -> hash(row_data)
  • checking merkle branches

Currently this has a limit on the number of supported validators at 65536, due to the fact that the underlying reed-solomon-erasure crate only supports GF(2^16). This limit is unlikely to prove to be an issue for some time.

@rphmeier rphmeier added A0-please_review Pull request needs code review. and removed A1-onice labels Jan 10, 2019

[dependencies]
polkadot-primitives = { path = "../primitives" }
reed-solomon-erasure = { git = "https://github.com/paritytech/reed-solomon-erasure", branch = "rh-usable-gf16" }
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We will switch to "0.4" when that is published.

@rphmeier
Copy link
Contributor Author

I don't know why it says the travis build failed; if you go to the site it says it passed.

@gavofyork
Copy link
Member

would be good to get a review from someone familiar with the underlying algorithm (like jeff?). code looks clean enough from my pov.

@rphmeier
Copy link
Contributor Author

(erasure-coding logic was reviewed by @drskalman outside of github)

@rphmeier rphmeier merged commit a976729 into master Jan 24, 2019
impl CodeParams {
// the shard length needed for a payload with initial size `base_len`.
fn shard_len(&self, base_len: usize) -> usize {
(base_len / self.data_shards) + (base_len % self.data_shards)
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd expect (base_len-1) / self.data_shards + 1 here.

Copy link
Contributor

Choose a reason for hiding this comment

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

After the confusion below I'd expect (base_len-1+8) / self.data_shards + 1 here. And modify the last shard to state its length at the end. And @drskalman points out that parity codec just ads the length too.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

8 seems very arbitrary?

Copy link
Contributor Author

@rphmeier rphmeier Feb 19, 2019

Choose a reason for hiding this comment

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

base_len / self.data_shards + (base_len % self.data_shards != 0) as usize

(base_len / self.data_shards) + (base_len % self.data_shards)
}

fn make_shards_for(&self, payload: &[u8]) -> Vec<WrappedShard> {
Copy link
Contributor

Choose a reason for hiding this comment

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

According to @drskalman parity codec handled the length so I'd expect very roughly:

 pub(crate) struct WrappedShard<B: Borrow<[u8]>> {
	inner: B,
}

fn make_shards_for<'a>(&self, payload: &'a [u8]) -> impl Iterator<Item=WrappedShard<Cow<'a,[u8]>>> {
    let shard_len = self.shard_len(payload.len());
    payload.chunks(shard_len).map(|c| {
        if c.len() == shard_len {
            WrappedShard { inner: Cow::Borrowed(c) }
        } else {
            let mut moo = vec![0; shard_len];
            let l = c.len();
            moo[..l].copy_from_slice(&c[..l]);
            WrappedShard { inner: Cow::Owned(moo) }
        }
    })
}

but I suppose Cows are complete overkill here, so probably just Vec and the else branch for all.

Copy link
Contributor

Choose a reason for hiding this comment

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

I suppose the existing code works fine actually. I just expected to need to change this fn after shortening shard_len above.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh I see! You need the total payload length encoded. Sorry I didn't read very carefully.

In any case, your current shard_len function will give a bunch of zero len shards, but the current code works I think.

Copy link
Contributor

Choose a reason for hiding this comment

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

So I suggest to use payload.encode() instead of payload and then don't worry about the length as all shards are the same size and we recover the total length using SCALE codec.

Copy link
Contributor Author

@rphmeier rphmeier Feb 19, 2019

Choose a reason for hiding this comment

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

The reason we prepend the length to each shard is explained in the comment above. It's because lists of element of GF(2^16), when treated as byte-slices, always have even length. However, our shard length might be odd. So we need to signal whether there is a trailing zero byte that we should skip when decoding, and prepending the length to each shard has been an easy way to do that (although not the most efficient). I filed #88 to address that.

I don't think prepending the length to the payload actually helps at all.

Copy link
Contributor Author

@rphmeier rphmeier Feb 19, 2019

Choose a reason for hiding this comment

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

What I will do, I think, is round up shard_len to the next even number. Then, the last shards will have (cumulatively) n_validators more zero bytes, but for long payloads this doesn't really matter much. And it's easier to decode anyway, since we don't have to reason about the extra byte at the end.

return Err(Error::BadPayload);
}

let mut shards = params.make_shards_for(&encoded[..]);
Copy link
Contributor

@burdges burdges Feb 19, 2019

Choose a reason for hiding this comment

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

Ahh okay no reason in Cows here, even if the ones outside my house are cute. ;) And no reason for the complexity of arenas here.

// make a reed-solomon instance.
fn make_encoder(&self) -> ReedSolomon {
ReedSolomon::new(self.data_shards, self.parity_shards)
.expect("this struct is not created with invalid shard number; qed")
Copy link
Contributor

Choose a reason for hiding this comment

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

double negative in this expect string

-> Result<(BlockData, Extrinsic), Error>
where I: IntoIterator<Item=(&'a [u8], usize)>
{
let params = code_params(n_validators)?;
Copy link
Contributor

Choose a reason for hiding this comment

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

We might worry about n_validators being the same on both sides here. We also cannot trust the encoded data for n_validators because then an adversary can manipulate it, so I believe this is fine but maybe the comment should emphasize that n_validators must be correct or else we create invalid slashing attacks.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also I think you meant more than 256 here

Copy link
Contributor

Choose a reason for hiding this comment

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

I think the generator of the erasure code should append n to each shard along side with the Merkle root and sign on it. I'll talk to Al to update the protocol.

Copy link
Contributor

Choose a reason for hiding this comment

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

Al says the number of Validators changes in order of once a year. And it is off course retrievable from state root. He also reiterate there is no harm to add this the pieces. Your call then.

where I: IntoIterator<Item=(&'a [u8], usize)>
{
let params = code_params(n_validators)?;
let mut shards: Vec<Option<WrappedShard>> = vec![None; n_validators];
Copy link
Contributor

Choose a reason for hiding this comment

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

You could maybe avoid the option by doing map and collect below, but whatever.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I haven't looked at this code for a while, but I think the Option is necessary because of the API of erasure-coding.

let params = code_params(n_validators)?;
let mut shards: Vec<Option<WrappedShard>> = vec![None; n_validators];
let mut shard_len = None;
for (chunk_data, chunk_idx) in chunks.into_iter().take(n_validators) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd probably do

let mut chunks = chunks.into_iter();
for (chunk_data, chunk_idx) in chunks.by_ref().take(n_validators) {
    ...
}
if chunks.next() != None { return Err(...); }

@burdges
Copy link
Contributor

burdges commented Feb 19, 2019

Just curious, I take it both Branches and fns like reconstruct get called from another crate, so presumably reconstruct does not quite work as a method on Branches or whatever? Not that this matters much. :)

@burdges
Copy link
Contributor

burdges commented Feb 19, 2019

I'm happy with this modulo wasting 4 bytes per shard and CodeParams::shard_len increasing the size by more than the required 8 bytes, or 4 bytes per shard with the current code. Also, some comments could be improved, especially any warnings around n_validators being wrong. I have not looked at https://github.com/paritytech/reed-solomon-erasure as much as I'd like.

impl CodeParams {
// the shard length needed for a payload with initial size `base_len`.
fn shard_len(&self, base_len: usize) -> usize {
(base_len / self.data_shards) + (base_len % self.data_shards)
Copy link
Contributor

Choose a reason for hiding this comment

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

base_len=19 data_shards = 10 logically shard of length 2 should do but now we end up with 19/10 + 9= 10 shard of length 10? So what @burdges says: (base_len-1) / self.data_shards + 1

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think that's right, though.

base_len = 19 data_shards = 5. We need shard length 4 to fit the whole payload.

18 / 6 = 3.

Copy link
Contributor

Choose a reason for hiding this comment

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

the formula is

		((base_len -1)/ self.data_shards) + 1

(divison has higher priority than addition) or simpler

(base_len + self.data_shards - 1)/self.data_shards

but as you said we should avoid odd shard length so check if it is odd and add one to it.

(base_len / self.data_shards) + (base_len % self.data_shards)
}

fn make_shards_for(&self, payload: &[u8]) -> Vec<WrappedShard> {
Copy link
Contributor

Choose a reason for hiding this comment

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

So I suggest to use payload.encode() instead of payload and then don't worry about the length as all shards are the same size and we recover the total length using SCALE codec.

/// The indices of the present chunks must be indicated. If too few chunks
/// are provided, recovery is not possible.
///
/// Works only up to 256 validators, and `n_validators` must be non-zero.
Copy link
Contributor

Choose a reason for hiding this comment

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

So this needs to be 65536

-> Result<(BlockData, Extrinsic), Error>
where I: IntoIterator<Item=(&'a [u8], usize)>
{
let params = code_params(n_validators)?;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the generator of the erasure code should append n to each shard along side with the Merkle root and sign on it. I'll talk to Al to update the protocol.


/// Obtain erasure-coded chunks, one for each validator.
///
/// Works only up to 256 validators, and `n_validators` must be non-zero.
Copy link
Contributor

Choose a reason for hiding this comment

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

The bound should be 65536

-> Result<(BlockData, Extrinsic), Error>
where I: IntoIterator<Item=(&'a [u8], usize)>
{
let params = code_params(n_validators)?;
Copy link
Contributor

Choose a reason for hiding this comment

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

Al says the number of Validators changes in order of once a year. And it is off course retrievable from state root. He also reiterate there is no harm to add this the pieces. Your call then.


assert_eq!(proofs.len(), 10);

for (i, proof) in proofs.into_iter().enumerate() {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure, I'm getting this correctly. I think each Merkle proof should contains the hash value of the siblings of all nodes in your branch. So you can compute your hash way up to the root. Am I missing something?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we are just including all the nodes on the path -- that's how you generally do merkle proofs for 16-radix (which we are using here)

Copy link
Contributor

Choose a reason for hiding this comment

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

I verified with @burdges, you need the co-path not the path. On a binary tree it is all the other children of the nodes on the path.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@burdges why do we need the co-path? the path seems to work (although can be optimized by omitting the hash of the child we are traversing to from branches)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

(have chatted with @burdges in Riot and confirmed that it is correct as-is for the patricia trie)

@burdges
Copy link
Contributor

burdges commented Feb 21, 2019

In a Merkle tree, we always use siblings of the actual path, which I've once or twice heard referred to as a copath. I have not yet understood at what point TrieDB, etc. actually hashes the siblings inside https://github.com/paritytech/trie/blob/master/trie-db/src/triedbmut.rs#L850 but maybe it's buried inside this DB crate, except they looked like just hash maps.

Just to clarify terminology:

If I have a Merkle tree with four elements then I compute the root like

root = H(H(x[0] ++ x[1]), H(x[2] ++ x[3]))

so a proof of inclusion for x[1] has the form p = [ Right(x[0]), Left(H(x[2] ++ x[3])) ] so the proof can be checked by testing root == H(H(p[0], x[1]), p[1]).

In usual terminology, the "branch" containing x[1], or equivalently the "path" from x[1] to the root, would instead be b = [ x[1], H(x[0] ++ x[1]), root ].

Both sequences do end with the root, but the branch provides no inclusion proof. All values in the branch are computed in verifying the inclusion proof, but the two important values in the inclusion proof cannot be computed from the branch.

I presume this module should produce the inclusion proofs somewhere?

@burdges
Copy link
Contributor

burdges commented Feb 21, 2019

Rob explained that each level should contain all 16 children, which explains the extra nested Vec too probably. We could improve proof size by a factor of 4 by hashing as if the patricia trie was a binary tree, but I'm unsure how deep that goes into this crate hierarchy, or if the code is optimized for cases that do not require proofs.

@burdges
Copy link
Contributor

burdges commented Feb 21, 2019

I'm satisfied. It's a strange library design heavily optimized for other things, but whatever.

@burdges
Copy link
Contributor

burdges commented Feb 23, 2019

At present, we verify a proof by inserting a branch into a patricia trie database using trie::HashDB::insert, which presumably arranges the inserted nodes into children according to their hashes. It's important to provide the nodes in order so the trie gets built correctly, but one could likely provide more than one branch. We might find this convenient if we needed to provide more than one proof together, but risks could emerge from providing multiple proofs together too. Afaik, any attack requires manipulating validator's perception of their own index position, which breaks everything anyways, but it's worth emphasizing the sensitiveness of the index position.

Also, we can compress these proofs by a factor of like 8 by using 16 byte hashes and doing the hashing as a binary tree instead of doing 16 fragments at each depth.

@rphmeier
Copy link
Contributor Author

rphmeier commented Feb 23, 2019

@burdges

At present, we verify a proof by inserting a branch into a patricia trie database using trie::HashDB::insert, which presumably arranges the inserted nodes into children according to their hashes. It's important to provide the nodes in order so the trie gets built correctly

this is not correct. MemoryDB is just a lookup from hash to node data, and in fact is implemented with a HashMap internally -- whose order is non-deterministic.

The TrieDB struct starts at the root and traverses downwards down the tree by looking up nodes in the HashDB.

Check out the definition of the patricia trie -- I'm not sure that the binary tree hashing method you describe is actually more efficient, because we are currently only doing 1 hash for each node we traverse.

The only optimization I am aware that you can do is a space optimization where you inline the nodes in the path that you are interested in and transmit everything inline.

@rphmeier rphmeier deleted the rh-erasure-coding branch February 23, 2019 15:51
@burdges
Copy link
Contributor

burdges commented Feb 23, 2019

The TrieDB struct starts at the root and traverses downwards down the tree by looking up nodes in the HashDB.

Ok but the point remains: Your adversary can build anything they like here, which appears harmless but sounds less fault tolerant elsewhere.

I'm not sure that the binary tree hashing method you describe is actually more efficient, because we are currently only doing 1 hash for each node we traverse.

A (virtual) binary tree for hashing is 8 times more space efficient. As you consume 8 times less data, it'll have similar performance for creation if you optimize the hash function choice, but verification should be much faster my way. If you look into the blake2b analysis, you might find that chacha alone suffices to hash 2 x 16 bytes into 16 bytes, so creation costs 14 chacha runs vs like 16ish now, but verification cost only like 3 chacha vs the same 16ish the current way. You could reduce to 16 bytes without doing binary tree hashing, but verification remains slower.

@rphmeier
Copy link
Contributor Author

rphmeier commented Feb 23, 2019

Yeah, the weakness in this code right now is that someone providing a proof could provide a bunch of extra data that is not in the lookup path.

Because we know about the trie structure and the fact that it is a u16 -> [u8; 32], we can set upper bounds on both the number of nodes and number of bytes per node. Anything higher than that can be rejected and any peer serving us that can be disconnected (although we haven't yet done the networking portion of this service).

imstar15 pushed a commit to imstar15/polkadot that referenced this pull request Aug 25, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
A0-please_review Pull request needs code review.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants