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

Add codec for BNS transactions #56

Merged
merged 41 commits into from
Jun 15, 2018
Merged

Add codec for BNS transactions #56

merged 41 commits into from
Jun 15, 2018

Conversation

ethanfrey
Copy link
Contributor

@ethanfrey ethanfrey commented Jun 12, 2018

  • Build protobuf definitions from bov go source repos
  • Create postable transaction bytes for all types
  • Create signable transaction bytes for all types
  • Calculate the transaction id (tx_hash)
  • Parse existing transactions from the blockchain
  • Test self-compatibility (encode/decode)
  • Test against reference testdata exported from bov binary

@ethanfrey ethanfrey changed the title Add codec for BNS transactions WIP: Add codec for BNS transactions Jun 12, 2018
@ethanfrey
Copy link
Contributor Author

Hmmm... I was trying to implement parsing and realized this is impossible as specified.

When we create an @iov/types Transaction object, it contains PublicKeyBundle as identifiers to allow us to encode it in a chain-dependent format:

export interface BaseTx {
  readonly chainId: ChainID;
  readonly fee: FungibleToken;
  readonly signer: PublicKeyBundle;
  readonly ttl?: TTLBytes;
}

export interface SendTx extends BaseTx {
  readonly kind: TransactionKind.SEND;
  readonly amount: FungibleToken;
  readonly recipient: PublicKeyBundle;
}

However, when we parse a transaction, we would only be able to receive the chain-dependent address of the recipient, and not the public key from which it was derived. Since Transaction is designed for the API interface we shouldn't force them to encode this if they only have the public key.

I think this API change should go into another PR, but one thought was to change it to:

  readonly recipient: PublicKeyBundle | AddressBytes;

This would allow parsing code to return a proper tx and we can differentiate between the two in runtime (one a Uint8Array, the other an object). I'm not sure if this is going to seep out and lead to lots of uncertainty in the type system, or if there are other negative effects.

Thoughts?

@ethanfrey ethanfrey requested review from webmaster128 and will-iov and removed request for webmaster128 June 12, 2018 19:32
@ethanfrey
Copy link
Contributor Author

ethanfrey commented Jun 12, 2018

Before you think I am a coding monster... It's called codegen ;)

wc -l src/codec.*
    2295 src/codec.d.ts
    5603 src/codec.js
    7898 total

That's all from protobufjs... only 2k from me

@webmaster128
Copy link
Contributor

I think instead of reusing the type, it would be better to create two different types. If I understood this correctly, we would have:

  • Website to extension via web4: SigningRequest, SendSigningRequest
  • Blockchain to extension directly: BaseTx, SendTx

return out;
}

export function fromHex(hexstring: string): Uint8Array {
Copy link
Contributor

Choose a reason for hiding this comment

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

Please reuse #57 after merge

Copy link
Contributor Author

Choose a reason for hiding this comment

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

gladly

@ethanfrey
Copy link
Contributor Author

I think instead of reusing the type, it would be better to create two different types. If I understood this correctly, we would have:

Website to extension via web4: SigningRequest, SendSigningRequest
Blockchain to extension directly: BaseTx, SendTx

Actually I based the entire design that we always get a public key for the recipient from the BNS. As I am working, I see three flaws.

  1. The BNS may no longer serve public keys but pre-digested addresses (or per-chain public keys)
  2. I may get a chain-specific address from some other (non-web4) source, like a bitcoin address from another wallet.
  3. I may try to parse a prepared tx, either from the blockchain, or when preparing multisig.

I think using Addresses instead of PublicKeyBundle for recipient is actually better.
And I probably should standardize for escrow src and dest as well. (change in go code)

Whether we support PublicKeyBundle at all for recipients depends on outcome of BNS web4 key lookup design sessions.

@ethanfrey ethanfrey force-pushed the bns-codec branch 2 times, most recently from 8440f36 to 685532b Compare June 13, 2018 15:42
@Isabello
Copy link
Contributor

The BNS may no longer serve public keys but pre-digested addresses (or per-chain public keys)
I may get a chain-specific address from some other (non-web4) source, like a bitcoin address from another wallet.
I may try to parse a prepared tx, either from the blockchain, or when preparing multisig.

The BNS will serve the following:

address:pubkey pairs for the BNS specific coin path, that can be safely reused on any compatible chain and any chain implementing bip44
Allow for additional address:pubkey pairs to be added to the NFT holding the human readable name.

I think using Addresses instead of PublicKeyBundle for recipient is actually better.

Addresses should be used for recipient, as nice as publicKey . In the case of escrow, we can use any address we want, all that needs to be the same between transactions is the publickey and the associated signature

@ethanfrey ethanfrey force-pushed the bns-codec branch 3 times, most recently from c5e411c to ecc524a Compare June 13, 2018 18:52
Copy link
Contributor

@webmaster128 webmaster128 left a comment

Choose a reason for hiding this comment

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

First set of comments

"test": "yarn build && yarn test-node",
"prebuild": "yarn lint && yarn format",
"build": "tsc && cp ./src/codec.* ./build/src",
"findProto": "find $GOPATH/src/github.com/confio/weave $GOPATH/src/github.com/iov-one/bcp-demo -name '*.proto' -not -path '*/vendor/*' -not -path '*/examples/*'",
Copy link
Contributor

Choose a reason for hiding this comment

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

We use dash separated yarn subcommands usually

Copy link
Contributor Author

Choose a reason for hiding this comment

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

thanks. i knew something looked funny


const map = Array.prototype.map;
const toNums = (str: string) => map.call(str, (x: string) => x.charCodeAt(0));
export const stringToArray = (str: string) => Uint8Array.from(toNums(str));
Copy link
Contributor

Choose a reason for hiding this comment

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

This method is supposed to be called "asciiEncode", right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it should have ascii in the name.
decodeAscii I believe.... a string has an encoding. bytes are just bytes.
at least in python, I do something like "deadbeef".decode('hex').encode('base64')

Copy link
Contributor

Choose a reason for hiding this comment

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

A string has no encoding. A string is usually a list of unicode codepoints. Now there are different possibilities to encode that string in a binary representation (ascii, utf-8, utf-16, …).

Python 3 (using Python 3 is important here) example:

>>> "abc"
'abc'
>>> "abc".encode('ascii')
b'abc'
>>> "abc".encode('utf16')
b'\xff\xfea\x00b\x00c\x00'
>>> "abc".decode()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'str' object has no attribute 'decode'

Python 2 has no proper differentiation between a string and its binary representation. This is why your example works in Python 2 but not Python 3.

};

const map = Array.prototype.map;
const toNums = (str: string) => map.call(str, (x: string) => x.charCodeAt(0));
Copy link
Contributor

Choose a reason for hiding this comment

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

You can avoid the use of that extra map:

const toNums = (str: string) => str.split('').map((x: string) => x.charCodeAt(0));

Copy link
Contributor Author

Choose a reason for hiding this comment

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

nice....


// append chainID and nonce to the raw tx bytes to prepare for signing
export const appendSignBytes = (bz: Uint8Array, chainID: ChainID, nonce: Nonce) =>
Uint8Array.from([...bz, ...stringToArray(chainID), ...nonce.toBytesBE()]) as SignableBytes;
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we change that format to

format version | chain ID length | chain ID            | nonce | bz
int32          | int32           | utf8 encoded string | int?  | raw data

this would avoid all kind of overlapping fields collisions

Copy link
Contributor Author

Choose a reason for hiding this comment

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

let's discuss this. please open a new issue in confio/weave.

The codec demands compatibility with the blockchain implementation, but you do have a point that the blockchain implementation may need improvement. I think this should not block the PR, but we should discuss a better sign bytes format in weave, then update across the board.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

chainIDlength prefix is good idea.
format version nice for future compatibility

// tendermint hash (will be) first 20 bytes of sha256
// probably only works after 0.21, but no need to import ripemd160 now
export const tendermintHash = (data: Uint8Array) =>
Sha256.digest(data).then((bz: Uint8Array) => bz.slice(0, 20));
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 make Sha256.digest synchronous if the asynchronous interface causes pain for the caller.

sha.js is synchronous and in nodejs we could alternatively use crypto.createHash('sha256').update('alice', 'utf8').digest(), which is synchronous as well

Copy link
Contributor Author

Choose a reason for hiding this comment

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

if there is a good reason, we can keep it async.
my main thought was that encoding is a synchronous action (no IO)
with async/await it is not hard to chain promises or async calls, and it isn't really bad.
however, it forced me to change the API.

not a super strong objection, I just want to see if it causes more pain to have sync sha256, or async encoding

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 can leave as async and then decide if we want to change in another PR.

Copy link
Contributor

Choose a reason for hiding this comment

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

👍 just wanted to offer to change it. I see no need to have sha256 asynchronous at the moment


const { fromHex, toHex } = Encoding;

describe("Control", () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

This test is obsolete now

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍 thanks for that code

Copy link
Contributor

@webmaster128 webmaster128 left a comment

Choose a reason for hiding this comment

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

decodeAscii -> encodeAscii or encodeAsAscii

@@ -30,6 +30,14 @@ export const appendSignBytes = (bz: Uint8Array, chainID: ChainID, nonce: Nonce)
export const tendermintHash = (data: Uint8Array) =>
Sha256.digest(data).then((bz: Uint8Array) => bz.slice(0, 20));

// TODO: verify prefix, make const
export const HashId = decodeAscii("hash/sha256/");
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not a type -> lowecase hashId

@ethanfrey ethanfrey changed the title WIP: Add codec for BNS transactions Add codec for BNS transactions Jun 14, 2018
@ethanfrey
Copy link
Contributor Author

I think I am ready now. Tested everything I could think of.
Please merge if no more complaints, or add more review comments.

verbosity: 4, // (0|false)|1|2|(3|true)|4
listStyle: 'indent', // "flat"|"indent"
activity: true,
emoji: true,
Copy link
Contributor

Choose a reason for hiding this comment

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

😃

Copy link
Contributor

Choose a reason for hiding this comment

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

This is giving you a 👊 when all tests passed ;)

recipient: ensure(msg.dest, "recipient") as AddressBytes,
amount: decodeToken(ensure(msg.amount)),
memo: msg.memo || undefined,
...base,
Copy link
Contributor

Choose a reason for hiding this comment

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

Does it definitely make more sense to put this last rather than first? If base has some extra properties somehow they can override whatever came before.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good point

});
};

export const buildMsg = (tx: UnsignedTransaction): Promise<codec.app.ITx> => {
Copy link
Contributor

Choose a reason for hiding this comment

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

How do you feel about marking all Promise-returning functions as async whether or not they make use of await?

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 dunna, i figured unnecessary.
what does @webmaster128 think?
if you both like it, i'll adopt.

Copy link
Contributor

Choose a reason for hiding this comment

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

It should be unnecessary runtime overhead. Changing a function from synchronous to async is a compatible change when you return a Promise. So you can switch to async later.

public static async bytesToSign(tx: UnsignedTransaction, nonce: Nonce): Promise<SignableBytes> {
// we encode it without any signatures
const built = await buildUnsignedTx(tx);
const bz = codec.app.Tx.encode(built).finish();
Copy link
Contributor

Choose a reason for hiding this comment

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

What does bz stand for?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

bytes.
i mean, bs is also short for bytes, but doens't sound so good (B.S.)

Copy link
Contributor

Choose a reason for hiding this comment

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

in all my years in software development, I never came across any of those two abbreviations. So I'd prefer to use a proper word.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

you clearly never worked at tendermint ;)
but sure, I can use a real word....

}

// we need to create a const to properly type-check the export...
// export const BNSCodec: TxCodec = Codec;
Copy link
Contributor

Choose a reason for hiding this comment

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

What's wrong with export class BNSCodec implements TxCodec { ... }? (Or even export default.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It doesn't implement it somehow, as implements implies an instance of the class implements it, not the static class object....

If you can make it work, just fighting tsc sometimes.
Also, it does no longer match with the async changes.


export const decodeSignature = (signature: codec.crypto.ISignature): SignatureBytes => {
if (signature.ed25519) {
return signature.ed25519 as SignatureBytes;
Copy link
Contributor

Choose a reason for hiding this comment

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

There's an asymmetry here with keys: should we maybe be returning an object with algo/data here 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.

I guess so, not sure why it was defined differently. But it has been for a while.
I would do that change as another PR, but good point.

export const hashIdentifier = async (data: Uint8Array) =>
Uint8Array.from([...HashId, ...(await Sha256.digest(data))]);

// typescript forces us to return number on reduce, so we count how many elements match
Copy link
Contributor

Choose a reason for hiding this comment

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

typescript forces us to return number on reduce

Really?!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Try it, if you can get it to reduce an Uint8Array to a bool, please show me the code.
They should have a few more T generic types in their Uint8Array type definition....

// and make sure it is all
export const arraysEqual = (a: Uint8Array, b: Uint8Array): boolean =>
a.length === b.length &&
a.reduce((acc: number, x: number, i: number) => (x === b[i] ? acc + 1 : acc), 0) === b.length;
Copy link
Contributor

Choose a reason for hiding this comment

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

How about

a.length === b.length && a.every((n: number, i: number): boolean => n === b[i])

Copy link
Contributor Author

Choose a reason for hiding this comment

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

hmm... did you run that one through tsc?

Copy link
Contributor

Choose a reason for hiding this comment

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

In any case, this method needs unittests

Copy link
Contributor

@webmaster128 webmaster128 Jun 15, 2018

Choose a reason for hiding this comment

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

Tested the every implementation by Will, works. I think it is way easier to understand. Here are the tests:

    it("arraysEqual works", () => {
      // simple equality
      expect(arraysEqual(new Uint8Array([]), new Uint8Array([]))).toEqual(true);
      expect(arraysEqual(new Uint8Array([0x11]), new Uint8Array([0x11]))).toEqual(true);
      expect(arraysEqual(new Uint8Array([0xaa, 0x77, 0x99]), new Uint8Array([0xaa, 0x77, 0x99]))).toEqual(true);

      // identity
      const array1 = new Uint8Array([]);
      const array2 = new Uint8Array([0x11]);
      const array3 = new Uint8Array([0xaa, 0x77, 0x99]);
      expect(arraysEqual(array1, array1)).toEqual(true);
      expect(arraysEqual(array2, array2)).toEqual(true);
      expect(arraysEqual(array3, array3)).toEqual(true);

      // unequal length
      expect(arraysEqual(new Uint8Array([]), new Uint8Array([0x11]))).toEqual(false);
      expect(arraysEqual(new Uint8Array([0xaa, 0xbb]), new Uint8Array([0xaa]))).toEqual(false);
      expect(arraysEqual(new Uint8Array([0xaa]), new Uint8Array([0xaa, 0xbb]))).toEqual(false);

      // unequal data (front, middle, end)
      expect(arraysEqual(new Uint8Array([0xaa, 0xbb, 0xcc]), new Uint8Array([0x00, 0xbb, 0xcc]))).toEqual(false);
      expect(arraysEqual(new Uint8Array([0xaa, 0xbb, 0xcc]), new Uint8Array([0xaa, 0x00, 0xcc]))).toEqual(false);
      expect(arraysEqual(new Uint8Array([0xaa, 0xbb, 0xcc]), new Uint8Array([0xaa, 0xbb, 0x00]))).toEqual(false);
    });

Copy link
Contributor Author

Choose a reason for hiding this comment

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

there are a few unit tests in codec.spec.ts, but i will add these tests and update to Will's implementation.

a.reduce((acc: number, x: number, i: number) => (x === b[i] ? acc + 1 : acc), 0) === b.length;

export const isHashIdentifier = (ident: Uint8Array): boolean =>
arraysEqual(HashId, ident.slice(0, HashId.length));
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the slice helpful here? Seems counterproductive to the length check in arraysEqual.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ident will be longer, much longer. we just need to ensure the prefix matches.
i wanted to leave the other function as generic, as a compare should exist

await verify(swapClaimTxJson);
await verify(swapTimeoutTxJson);
} catch (err) {
expect(err).toBe(false);
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like you want .not.toThrow: https://jasmine.github.io/api/3.0/matchers.html#toThrow
(Although I'm not 100% how that works with await.)

// force result into Uint8Array for tests so it passes
// if buffer of correct type as well
expect(encoded.length).toEqual(pubBin.length);
expect(Uint8Array.from(encoded)).toEqual(pubBin);
Copy link
Contributor

Choose a reason for hiding this comment

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

This check makes the length check redundant.

await verify(swapClaimTxJson);
await verify(swapTimeoutTxJson);
} catch (err) {
expect(err).toBe(false);
Copy link
Contributor

Choose a reason for hiding this comment

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

What you want to say here is

} catch (err) {
  fail("Unexpected exception: " + err.message);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

much better

expect(err.toString()).toContain("RangeError");
return;
}
expect(false).toBeTruthy();
Copy link
Contributor

Choose a reason for hiding this comment

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

fail()

Copy link
Contributor Author

Choose a reason for hiding this comment

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

DIdn't know this one....

@webmaster128
Copy link
Contributor

webmaster128 commented Jun 15, 2018

Let me sum up what is left from my point of view:

  • Rename export const HashId to export const hashId
  • arraysEqual implemented using every (see comment by Will)
  • Unittests for arraysEqual (copy/paste from above)
  • expect(err).toBe(false); -> fail()
  • expect(false).toBeTruthy(); -> fail() in multiple places
  • import Long from "long"; -> import * as Long from "long"; nowhere or everywhere. Not sure which one is the final answer

Now I just realized there are tests for arraysEqual, which looks a bit too complicated to me with all the hexing and slicing. Maybe merge our test sets.

@ethanfrey
Copy link
Contributor Author

Okay, I fixed this up with all review comments.

The question about import Long from "long"; -> import * as Long from "long";
I tried the change, and the tsc fails.

The other change I made as tsc failed without the * as Long.
I think it has to do with the require('...').default or no default. I don't understand the deal, but until then I just give the compiler what it wants.
Maybe we can look into this later. (The other one I changed so I passed on karma tests)

@webmaster128 webmaster128 merged commit c74125b into master Jun 15, 2018
@webmaster128 webmaster128 deleted the bns-codec branch June 15, 2018 09:11
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