feat(textual): Add Tx envelope Value Renderer (#13600)

This commit is contained in:
Amaury 2023-01-11 13:25:13 +01:00 committed by GitHub
parent f9cc31fd6a
commit d5bcfb3f3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 5250 additions and 415 deletions

View File

@ -9,6 +9,8 @@
* Sep 07, 2022: Add custom `Msg`-renderers.
* Sep 18, 2022: Structured format instead of lines of text
* Nov 23, 2022: Specify CBOR encoding.
* Dec 01, 2022: Link to examples in separate JSON file.
* Dec 06, 2022: Re-ordering of envelope screens.
* Dec 14, 2022: Mention exceptions for invertability.
## Status
@ -176,29 +178,30 @@ We define "transaction envelope" as all data in a transaction that is not in the
```
Chain ID: <string>
Account number: <uint64>
*Public Key: <hex_string>
Sequence: <uint64>
<TxBody> // See #8.
Fee: <coins> // See value renderers for coins encoding.
*Fee payer: <string> // Skipped if no fee_payer set
*Fee granter: <string> // Skipped if no fee_granter set
Memo: <string> // Skipped if no memo set
Address: <string>
*Public Key: <Any>
This transaction has <int> Message(s) // Pluralize "Message" only when int>1
> Message (<int>/<int>): <Any> // See value renderers for Any rendering.
End of Message
Memo: <string> // Skipped if no memo set.
Fee: <coins> // See value renderers for coins rendering.
*Fee payer: <string> // Skipped if no fee_payer set.
*Fee granter: <string> // Skipped if no fee_granter set.
Tip: <coins> // Skippted if no tip.
Tipper: <string>
*Gas Limit: <uint64>
*Timeout Height: <uint64> // Skipped if no timeout_height set
Tipper: <string> // If there's a tip
Tip: <string>
*This transaction has <int> body extension: // Skipped if no body extension options
*<repeated Any>
*This transaction has <int> body non-critical extensions: // Skipped if no body non-critical extension options
*<repeated Any> // See value renderers for Any and array encoding.
*This transaction has <int> body auth info extensions: // Skipped if no auth info extension options
*<repeated Any>
*This transaction has <int> other signers: // Skipped if there is only one signer
*Signer (<int>/<int>):
*Public Key: <hex_string>
*Sequence: <uint64>
*Timeout Height: <uint64> // Skipped if no timeout_height set.
*Other signer: <int> SignerInfo // Skipped if the transaction only has 1 signer.
*> Other signer (<int>/<int>): <SignerInfo>
*End of other signers
*Hash of raw bytes: <hex_string> // Hex encoding of bytes defined in #10, to prevent tx hash malleability.
*Extension options: <int> Any: // Skipped if no body extension options
*> Extension options (<int>/<int>): <Any>
*End of extension options
*Non critical extension options: <int> Any: // Skipped if no body non critical extension options
*> Non critical extension options (<int>/<int>): <Any>
*End of Non critical extension options
*Hash of raw bytes: <hex_string> // Hex encoding of bytes defined, to prevent tx hash malleability.
```
### Encoding of the Transaction Body
@ -289,304 +292,13 @@ See [annex 2](./adr-050-sign-mode-textual-annex2.md).
## Examples
#### Example 1: Simple `MsgSend`
1. A minimal MsgSend: [see transaction](https://github.com/cosmos/cosmos-sdk/blob/094abcd393379acbbd043996024d66cd65246fb1/tx/textual/internal/testdata/e2e.json#L2-L70).
2. A transaction with a bit of everything: [see transaction](https://github.com/cosmos/cosmos-sdk/blob/094abcd393379acbbd043996024d66cd65246fb1/tx/textual/internal/testdata/e2e.json#L71-L270).
JSON:
```json
{
"body": {
"messages": [
{
"@type": "/cosmos.bank.v1beta1.MsgSend",
"from": "cosmos1...abc",
"to": "cosmos1...def",
"amount": [
{
"denom": "uatom",
"amount": 10000000
}
]
}
]
},
"auth_info": {
"signer_infos": [
{
"public_key": "iQ...==",
"mode_info": { "single": { "mode": "SIGN_MODE_TEXTUAL" } },
"sequence": 2
}
],
"fee": {
"amount": [
{
"denom": "atom",
"amount": 0.002
}
],
"gas_limit": 100000
}
},
// Additional SignerData.
"chain_id": "simapp-1",
"account_number": 10
}
```
SIGN_MODE_TEXTUAL:
```
Chain ID: simapp-1
Account number: 10
*Public Key: iQ...== // Hex pubkey
Sequence: 2
This transaction has 1 message:
Message (1/1): bank v1beta1 send coins
From: cosmos1...abc
To: cosmos1...def
Amount: 10 atom // Conversion from uatom to atom using value renderers
End of transaction messages
Fee: 0.002 atom
*Gas: 100'000
*Hash of raw bytes: <hex_string>
```
#### Example 2: Multi-Msg Transaction with 3 signers
#### Example 3: Legacy Multisig
#### Example 4: Fee Payer with Tips
```json
{
"body": {
"messages": [
{
"@type": "/cosmos.bank.v1beta1.MsgSend",
"from": "cosmos1...tipper",
"to": "cosmos1...abc",
"amount": [
{
"denom": "uatom",
"amount": 10000000
}
]
}
]
},
"auth_info": {
"signer_infos": [
{
"public_key": "iQ...==",
"mode_info": { "single": { "mode": "SIGN_MODE_DIRECT_AUX" } },
"sequence": 42
},
{
"public_key": "iR...==",
"mode_info": { "single": { "mode": "SIGN_MODE_TEXTUAL" } },
"sequence": 2
}
],
"fee": {
"amount": [
{
"denom": "atom",
"amount": 0.002
}
],
"gas_limit": 100000,
"payer": "cosmos1...feepayer"
},
"tip": {
"amount": [
{
"denom": "ibc/CDC4587874B85BEA4FCEC3CEA5A1195139799A1FEE711A07D972537E18FDA39D",
"amount": 200
}
],
"tipper": "cosmos1...tipper"
}
},
// Additional SignerData.
"chain_id": "simapp-1",
"account_number": 10
}
```
SIGN_MODE_TEXTUAL for the feepayer:
```
Chain ID: simapp-1
Account number: 10
*Public Key: iR...==
Sequence: 2
This transaction has 1 message:
Message (1/1): bank v1beta1 send coins
From: cosmos1...abc
To: cosmos1...def
Amount: 10 atom
End of transaction messages
Fee: 0.002 atom
Fee Payer: cosmos1...feepayer
Tipper: cosmos1...tipper
Tip: 200 ibc/CDC4587874B85BEA4FCEC3CEA5A1195139799A1FEE711A07D972537E18FDA39D
*Gas: 100'000
*This transaction has 1 other signer:
*Signer (1/2):
*Public Key: iQ...==
*Sign mode: SIGN_MODE_DIRECT_AUX
*Sequence: 42
*End of other signers
*Hash of raw bytes: <hex_string>
```
#### Example 5: Complex Transaction with Nested Messages
JSON:
```json
{
"body": {
"messages": [
{
"@type": "/cosmos.bank.v1beta1.MsgSend",
"from": "cosmos1...abc",
"to": "cosmos1...def",
"amount": [
{
"denom": "uatom",
"amount": 10000000
}
]
},
{
"@type": "/cosmos.gov.v1.MsgSubmitProposal",
"proposer": "cosmos1...ghi",
"messages": [
{
"@type": "/cosmos.bank.v1beta1.MsgSend",
"from": "cosmos1...jkl",
"to": "cosmos1...mno",
"amount": [
{
"denom": "uatom",
"amount": 20000000
}
]
},
{
"@type": "/cosmos.authz.v1beta1.MsgExec",
"grantee": "cosmos1...pqr",
"msgs": [
{
"@type": "/cosmos.bank.v1beta1.MsgSend",
"from": "cosmos1...stu",
"to": "cosmos1...vwx",
"amount": [
{
"denom": "uatom",
"amount": 30000000
}
]
},
{
"@type": "/cosmos.bank.v1beta1.MsgSend",
"from": "cosmos1...abc",
"to": "cosmos1...def",
"amount": [
{
"denom": "uatom",
"amount": 40000000
}
]
}
]
}
],
"initial_deposit": [
{
"denom": "atom",
"amount": 100.01
}
]
}
]
},
"auth_info": {
"signer_infos": [
{
"public_key": "iQ...==",
"mode_info": { "single": { "mode": "SIGN_MODE_TEXTUAL" } },
"sequence": 2
},
{
"public_key": "iR...==",
"mode_info": { "single": { "mode": "SIGN_MODE_DIRECT" } },
"sequence": 42
}
],
"fee": {
"amount": [
{
"denom": "atom",
"amount": 0.002
}
],
"gas_limit": 100000
}
},
// Additional SignerData.
"chain_id": "simapp-1",
"account_number": 10
}
}
```
SIGN_MODE_TEXTUAL for 1st signer `cosmos1...abc`:
```
Chain ID: simapp-1
Account number: 10
*Public Key: iQ...==
Sequence: 2
This transaction has 2 messages:
Message (1/2): bank v1beta1 send coins
From: cosmos1...abc
To: cosmos1...def
Amount: 10 atom
Message (2/2): gov v1 submit proposal
Messages: 2 Messages
> Message (1/2): bank v1beta1 send coins
> From: cosmos1...jkl
> To: cosmos1...mno
> Amount: 20 atom
> Message (2/2): authz v1beta exec
> Grantee: cosmos1...pqr
> Msgs: 2 Msgs
>> Msg (1/2): bank v1beta1 send coins
>> From: cosmos1...stu
>> To: cosmos1...vwx
>> Amount: 30 atom
>> Msg (2/2): bank v1beta1 send coins
>> From: cosmos1...abc
>> To: cosmos1...def
>> Amount: 40 atom
> End of Msgs
End of transaction messages
Proposer: cosmos1...ghi
Initial Deposit: 100.01 atom
End of transaction messages
Fee: 0.002 atom
*Gas: 100'000
*This transaction has 1 other signer:
*Signer (2/2):
*Public Key: iR...==
*Sign mode: SIGN_MODE_DIRECT
*Sequence: 42
*End of other signers
*Hash of raw bytes: <hex_string>
```
The examples below are stored in a JSON file with the following fields:
- `proto`: the representation of the transaction in ProtoJSON,
- `screens`: the transaction rendered into SIGN_MODE_TEXTUAL screens,
- `cbor`: the sign bytes of the transaction, which is the CBOR encoding of the screens.
## Consequences

View File

@ -0,0 +1,34 @@
package signing
import "google.golang.org/protobuf/types/known/anypb"
// SignerData is the specific information needed to sign a transaction that generally
// isn't included in the transaction body itself
type SignerData struct {
// The address of the signer.
//
// In case of multisigs, this should be the multisig's address.
Address string
// ChainId is the chain that this transaction is targeted
ChainId string
// AccountNumber is the account number of the signer.
//
// In case of multisigs, this should be the multisig account number.
AccountNumber uint64
// Sequence is the account sequence number of the signer that is used
// for replay protection. This field is only useful for Legacy Amino signing,
// since in SIGN_MODE_DIRECT the account sequence is already in the signer
// info.
//
// In case of multisigs, this should be the multisig sequence.
Sequence uint64
// PubKey is the public key of the signer.
//
// In case of multisigs, this should be the pubkey of the member of the
// multisig that is signing the current sign doc.
PubKey *anypb.Any
}

View File

@ -94,36 +94,6 @@
"metadata": {"display": "ucosm", "base":"COSM", "denom_units": [{"denom": "COSM", "exponent": 6}, {"denom": "ucosm", "exponent": 0}]},
"text": "0 ucosm"
},
{
"proto": {"amount": "0.000001", "denom": "COSM"},
"metadata": {"display": "ucosm", "base":"COSM", "denom_units": [{"denom": "COSM", "exponent": 6}, {"denom": "ucosm", "exponent": 0}]},
"text": "1 ucosm"
},
{
"proto": {"amount": "0.00001", "denom": "COSM"},
"metadata": {"display": "ucosm", "base":"COSM", "denom_units": [{"denom": "COSM", "exponent": 6}, {"denom": "ucosm", "exponent": 0}]},
"text": "10 ucosm"
},
{
"proto": {"amount": "0.0001", "denom": "COSM"},
"metadata": {"display": "ucosm", "base":"COSM", "denom_units": [{"denom": "COSM", "exponent": 6}, {"denom": "ucosm", "exponent": 0}]},
"text": "100 ucosm"
},
{
"proto": {"amount": "0.001", "denom": "COSM"},
"metadata": {"display": "ucosm", "base":"COSM", "denom_units": [{"denom": "COSM", "exponent": 6}, {"denom": "ucosm", "exponent": 0}]},
"text": "1'000 ucosm"
},
{
"proto": {"amount": "0.01", "denom": "COSM"},
"metadata": {"display": "ucosm", "base":"COSM", "denom_units": [{"denom": "COSM", "exponent": 6}, {"denom": "ucosm", "exponent": 0}]},
"text": "10'000 ucosm"
},
{
"proto": {"amount": "0.1", "denom": "COSM"},
"metadata": {"display": "ucosm", "base":"COSM", "denom_units": [{"denom": "COSM", "exponent": 6}, {"denom": "ucosm", "exponent": 0}]},
"text": "100'000 ucosm"
},
{
"proto": {"amount": "1", "denom": "COSM"},
"metadata": {"display": "ucosm", "base":"COSM", "denom_units": [{"denom": "COSM", "exponent": 6}, {"denom": "ucosm", "exponent": 0}]},

271
tx/textual/internal/testdata/e2e.json vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -2,8 +2,8 @@
{ "proto": { "ee": 0 }, "text": "One" },
{ "proto": { "ee": 1 }, "text": "Two" },
{ "proto": { "ee": 127 }, "text": "EXTERNAL_ENUM_THREE" },
{ "proto": { "ie": 0 }, "text": "Baz.Four" },
{ "proto": { "ie": 1 }, "text": "Baz.Five" },
{ "proto": { "ie": 0 }, "text": "Four" },
{ "proto": { "ie": 1 }, "text": "Five" },
{ "proto": { "option": 0 }, "text": "BALLOT_OPTION_UNSPECIFIED" },
{ "proto": { "option": 1 }, "text": "BALLOT_OPTION_YES" },
{ "proto": { "option": 4 }, "text": "BALLOT_OPTION_NO_WITH_VETO" }

412
tx/textual/internal/testdata/tx.json vendored Normal file
View File

@ -0,0 +1,412 @@
[
{
"name": "minimal",
"proto": {
"body": {
"messages": [
{
"@type": "/cosmos.bank.v1beta1.MsgSend",
"from_address": "cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs",
"to_address": "cosmos1ejrf4cur2wy6kfurg9f2jppp2h3afe5h6pkh5t",
"amount": [{ "denom": "uatom", "amount": "10000000" }]
}
]
},
"auth_info": {
"signer_infos": [
{
"public_key": {
"@type": "/cosmos.crypto.secp256k1.PubKey",
"key": "Auvdf+T963bciiBe9l15DNMOijdaXCUo6zqSOvH7TXlN"
},
"mode_info": { "single": { "mode": "SIGN_MODE_TEXTUAL" } },
"sequence": 2
}
],
"fee": {
"amount": [{ "denom": "uatom", "amount": "2000" }],
"gas_limit": 100000
}
}
},
"signer_data": {
"account_number": 1,
"address": "cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs",
"chain_id": "my-chain",
"pub_key": {
"@type": "/cosmos.crypto.secp256k1.PubKey",
"key": "Auvdf+T963bciiBe9l15DNMOijdaXCUo6zqSOvH7TXlN"
},
"sequence": 2
},
"metadata": {
"display": "ATOM",
"base": "uatom",
"denom_units": [
{ "denom": "ATOM", "exponent": 6 },
{ "denom": "uatom", "exponent": 0 }
]
},
"screens": [
{ "text": "Chain id: my-chain" },
{ "text": "Account number: 1" },
{ "text": "Sequence: 2" },
{ "text": "Address: cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs" },
{ "text": "Public key: /cosmos.crypto.secp256k1.PubKey", "expert": true },
{ "text": "PubKey object", "indent": 1, "expert": true },
{ "text": "Key: SHA-256=F795 2F7F 4120 C816 DFCF F060 E486 734B 0145 590B 1968 3856 DD00 3074 BD0D 146C", "indent": 2, "expert": true },
{ "text": "This transaction has 1 Message" },
{ "text": "Message (1/1): /cosmos.bank.v1beta1.MsgSend", "indent": 1 },
{ "text": "MsgSend object", "indent": 2 },
{ "text": "From address: cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs", "indent": 3 },
{ "text": "To address: cosmos1ejrf4cur2wy6kfurg9f2jppp2h3afe5h6pkh5t", "indent": 3 },
{ "text": "Amount: 10 ATOM", "indent": 3 },
{ "text": "End of Message" },
{ "text": "Fees: 0.002 ATOM" },
{ "text": "Gas limit: 100'000", "expert": true },
{ "text": "Hash of raw bytes: 785bd306ea8962cdb9600089bdd65f3dc029e1aea112dee69e19546c9adad86e", "expert": true }
]
},
{
"name": "tricky memo (starts with >, utf-8, trailing whitespaces)",
"proto": {
"body": {
"messages": [
{
"@type": "/cosmos.bank.v1beta1.MsgSend",
"from_address": "cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs",
"to_address": "cosmos1ejrf4cur2wy6kfurg9f2jppp2h3afe5h6pkh5t",
"amount": [{ "denom": "uatom", "amount": "10000000" }]
}
],
"memo": "> ⚛️\\u269B⚛ "
},
"auth_info": {
"signer_infos": [
{
"public_key": {
"@type": "/cosmos.crypto.secp256k1.PubKey",
"key": "Auvdf+T963bciiBe9l15DNMOijdaXCUo6zqSOvH7TXlN"
},
"mode_info": { "single": { "mode": "SIGN_MODE_TEXTUAL" } },
"sequence": 2
}
],
"fee": {
"amount": [{ "denom": "uatom", "amount": "2000" }],
"gas_limit": 100000
}
}
},
"signer_data": {
"account_number": 1,
"address": "cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs",
"chain_id": "my-chain",
"pub_key": {
"@type": "/cosmos.crypto.secp256k1.PubKey",
"key": "Auvdf+T963bciiBe9l15DNMOijdaXCUo6zqSOvH7TXlN"
},
"sequence": 2
},
"metadata": {
"display": "ATOM",
"base": "uatom",
"denom_units": [
{ "denom": "ATOM", "exponent": 6 },
{ "denom": "uatom", "exponent": 0 }
]
},
"screens": [
{ "text": "Chain id: my-chain" },
{ "text": "Account number: 1" },
{ "text": "Sequence: 2" },
{ "text": "Address: cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs" },
{ "text": "Public key: /cosmos.crypto.secp256k1.PubKey", "expert": true },
{ "text": "PubKey object", "indent": 1, "expert": true },
{ "text": "Key: SHA-256=F795 2F7F 4120 C816 DFCF F060 E486 734B 0145 590B 1968 3856 DD00 3074 BD0D 146C", "indent": 2, "expert": true },
{ "text": "This transaction has 1 Message" },
{ "text": "Message (1/1): /cosmos.bank.v1beta1.MsgSend", "indent": 1 },
{ "text": "MsgSend object", "indent": 2 },
{ "text": "From address: cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs", "indent": 3 },
{ "text": "To address: cosmos1ejrf4cur2wy6kfurg9f2jppp2h3afe5h6pkh5t", "indent": 3 },
{ "text": "Amount: 10 ATOM", "indent": 3 },
{ "text": "End of Message" },
{ "text": "Memo: > ⚛️\\u269B⚛ " },
{ "text": "Fees: 0.002 ATOM" },
{ "text": "Gas limit: 100'000", "expert": true },
{ "text": "Hash of raw bytes: 9c043290109c270b2ffa9f3c0fa55a090c0125ebef881f7da53978dbf93f7385", "expert": true }
]
},
{
"name": "long text in nested value",
"proto": {
"body": {
"messages": [
{
"@type": "/cosmos.gov.v1.MsgVote",
"proposal_id": 1,
"voter": "cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs",
"option": "VOTE_OPTION_YES",
"metadata": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Also it ends in a single ampersand @"
}
]
},
"auth_info": {
"signer_infos": [
{
"public_key": {
"@type": "/cosmos.crypto.secp256k1.PubKey",
"key": "Auvdf+T963bciiBe9l15DNMOijdaXCUo6zqSOvH7TXlN"
},
"mode_info": { "single": { "mode": "SIGN_MODE_TEXTUAL" } },
"sequence": 2
}
],
"fee": {
"amount": [{ "denom": "uatom", "amount": "2000" }],
"gas_limit": 100000
}
}
},
"signer_data": {
"account_number": 1,
"address": "cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs",
"chain_id": "my-chain",
"pub_key": {
"@type": "/cosmos.crypto.secp256k1.PubKey",
"key": "Auvdf+T963bciiBe9l15DNMOijdaXCUo6zqSOvH7TXlN"
},
"sequence": 2
},
"metadata": {
"display": "ATOM",
"base": "uatom",
"denom_units": [
{ "denom": "ATOM", "exponent": 6 },
{ "denom": "uatom", "exponent": 0 }
]
},
"screens": [
{ "text": "Chain id: my-chain" },
{ "text": "Account number: 1" },
{ "text": "Sequence: 2" },
{ "text": "Address: cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs" },
{ "text": "Public key: /cosmos.crypto.secp256k1.PubKey", "expert": true },
{ "text": "PubKey object", "indent": 1, "expert": true },
{ "text": "Key: SHA-256=F795 2F7F 4120 C816 DFCF F060 E486 734B 0145 590B 1968 3856 DD00 3074 BD0D 146C", "indent": 2, "expert": true },
{ "text": "This transaction has 1 Message" },
{ "text": "Message (1/1): /cosmos.gov.v1.MsgVote", "indent": 1 },
{ "text": "MsgVote object", "indent": 2 },
{ "text": "Proposal id: 1", "indent": 3 },
{ "text": "Voter: cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs", "indent": 3 },
{ "text": "Option: VOTE_OPTION_YES", "indent": 3 },
{
"text": "Metadata: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Also it ends in a single ampersand @",
"indent": 3
},
{ "text": "End of Message" },
{ "text": "Fees: 0.002 ATOM" },
{ "text": "Gas limit: 100'000", "expert": true },
{ "text": "Hash of raw bytes: 0397a88038a9d5f4cc60e3e06bb02bb9f093209fd72057008fddaeab6f039d74", "expert": true }
]
},
{
"name": "a bit of everything",
"proto": {
"body": {
"messages": [
{
"@type": "/cosmos.authz.v1beta1.MsgExec",
"grantee": "cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs",
"msgs": [
{
"@type": "/cosmos.bank.v1beta1.MsgSend",
"from_address": "cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs",
"to_address": "cosmos1ejrf4cur2wy6kfurg9f2jppp2h3afe5h6pkh5t",
"amount": [{ "denom": "uatom", "amount": "10000000" }]
}
]
},
{
"@type": "/cosmos.gov.v1.MsgVote",
"proposal_id": 1,
"voter": "cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs",
"option": "VOTE_OPTION_YES",
"metadata": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Also it ends in a single ampersand @"
}
],
"memo": "> ⚛️\\u269B⚛ ",
"timeout_height": 20,
"extension_options": [
{
"@type": "/cosmos.base.v1beta1.Coin",
"amount": "5000000",
"denom": "uatom"
}
],
"non_critical_extension_options": [
{
"@type": "/cosmos.auth.v1beta1.Params",
"maxMemoCharacters": 10
}
]
},
"auth_info": {
"signer_infos": [
{
"public_key": {
"@type": "/cosmos.crypto.secp256k1.PubKey",
"key": "Auvdf+T963bciiBe9l15DNMOijdaXCUo6zqSOvH7TXlN"
},
"mode_info": { "single": { "mode": "SIGN_MODE_TEXTUAL" } },
"sequence": 2
},
{
"public_key": {
"@type": "/cosmos.crypto.multisig.LegacyAminoPubKey",
"threshold": 2,
"public_keys": [
{
"@type": "/cosmos.crypto.secp256k1.PubKey",
"key": "AldOvgv8dU9ZZzuhGydQD5FYreLhfhoBgrDKi8ZSTbCT"
},
{
"@type": "/cosmos.crypto.ed25519.PubKey",
"key": "AxUMR/GKoycWplR+2otzaQZ9zhHRQWJFt3h1bPg1lnh3"
}
]
},
"mode_info": {
"multi": {
"bitarray": {
"extra_bits_stored": 5,
"elems": "SA=="
},
"mode_infos": [
{
"single": {
"mode": "SIGN_MODE_LEGACY_AMINO_JSON"
}
},
{
"single": {
"mode": "SIGN_MODE_LEGACY_AMINO_JSON"
}
}
]
}
},
"sequence": 5
}
],
"fee": {
"amount": [{ "denom": "uatom", "amount": "2000" }],
"gas_limit": 100000,
"payer": "cosmos1ejrf4cur2wy6kfurg9f2jppp2h3afe5h6pkh5t",
"granter": "cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs"
},
"tip": {
"amount": [
{ "amount": "20000", "denom": "uatom" },
{ "amount": "30000", "denom": "uosmo" }
],
"tipper": "cosmos1ejrf4cur2wy6kfurg9f2jppp2h3afe5h6pkh5t"
}
}
},
"signer_data": {
"account_number": 1,
"address": "cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs",
"chain_id": "my-chain",
"pub_key": {
"@type": "/cosmos.crypto.secp256k1.PubKey",
"key": "Auvdf+T963bciiBe9l15DNMOijdaXCUo6zqSOvH7TXlN"
},
"sequence": 2
},
"metadata": {
"display": "ATOM",
"base": "uatom",
"denom_units": [
{ "denom": "ATOM", "exponent": 6 },
{ "denom": "uatom", "exponent": 0 }
]
},
"screens": [
{ "text": "Chain id: my-chain" },
{ "text": "Account number: 1" },
{ "text": "Sequence: 2" },
{ "text": "Address: cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs" },
{ "text": "Public key: /cosmos.crypto.secp256k1.PubKey", "expert": true },
{ "text": "PubKey object", "indent": 1, "expert": true },
{ "text": "Key: SHA-256=F795 2F7F 4120 C816 DFCF F060 E486 734B 0145 590B 1968 3856 DD00 3074 BD0D 146C", "indent": 2, "expert": true },
{ "text": "This transaction has 2 Messages" },
{ "text": "Message (1/2): /cosmos.authz.v1beta1.MsgExec", "indent": 1 },
{ "text": "MsgExec object", "indent": 2 },
{ "text": "Grantee: cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs", "indent": 3 },
{ "text": "Msgs: 1 Any", "indent": 3 },
{ "text": "Msgs (1/1): /cosmos.bank.v1beta1.MsgSend", "indent": 4 },
{ "text": "MsgSend object", "indent": 5 },
{ "text": "From address: cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs", "indent": 6 },
{ "text": "To address: cosmos1ejrf4cur2wy6kfurg9f2jppp2h3afe5h6pkh5t", "indent": 6 },
{ "text": "Amount: 10 ATOM", "indent": 6 },
{ "text": "End of Msgs", "indent": 3 },
{ "text": "Message (2/2): /cosmos.gov.v1.MsgVote", "indent": 1 },
{ "text": "MsgVote object", "indent": 2 },
{ "text": "Proposal id: 1", "indent": 3 },
{ "text": "Voter: cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs", "indent": 3 },
{ "text": "Option: VOTE_OPTION_YES", "indent": 3 },
{
"text": "Metadata: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Also it ends in a single ampersand @",
"indent": 3
},
{ "text": "End of Message" },
{ "text": "Memo: > ⚛️\\u269B⚛ " },
{ "text": "Fees: 0.002 ATOM" },
{ "text": "Fee payer: cosmos1ejrf4cur2wy6kfurg9f2jppp2h3afe5h6pkh5t", "expert": true },
{ "text": "Fee granter: cosmos1ulav3hsenupswqfkw2y3sup5kgtqwnvqa8eyhs", "expert": true },
{ "text": "Tip: 0.02 ATOM, 30'000 uosmo" },
{ "text": "Tipper: cosmos1ejrf4cur2wy6kfurg9f2jppp2h3afe5h6pkh5t" },
{ "text": "Gas limit: 100'000", "expert": true },
{ "text": "Timeout height: 20", "expert": true },
{ "text": "Other signer: 1 SignerInfo", "expert": true },
{ "text": "Other signer (1/1): SignerInfo object", "indent": 1, "expert": true },
{ "text": "Public key: /cosmos.crypto.multisig.LegacyAminoPubKey", "indent": 2, "expert": true },
{ "text": "LegacyAminoPubKey object", "indent": 3, "expert": true },
{ "text": "Threshold: 2", "indent": 4, "expert": true },
{ "text": "Public keys: 2 Any", "indent": 4, "expert": true },
{ "text": "Public keys (1/2): /cosmos.crypto.secp256k1.PubKey", "indent": 5, "expert": true },
{ "text": "PubKey object", "indent": 6, "expert": true },
{ "text": "Key: SHA-256=8560 3B16 9613 06E3 9D23 BEE0 3156 7D77 F9BB 5977 A249 529F 8D9C AE2D 71CA 0ADE", "indent": 7, "expert": true },
{ "text": "Public keys (2/2): /cosmos.crypto.ed25519.PubKey", "indent": 5, "expert": true },
{ "text": "PubKey object", "indent": 6, "expert": true },
{ "text": "Key: SHA-256=2DEF 8D73 17D8 7F1F A337 CA21 9CDE 80EC C602 6605 1328 B964 E27D 056A 94DD AC64", "indent": 7, "expert": true },
{ "text": "End of Public keys", "indent": 4, "expert": true },
{ "text": "Mode info: ModeInfo object", "indent": 2, "expert": true },
{ "text": "Multi: Multi object", "indent": 3, "expert": true },
{ "text": "Bitarray: CompactBitArray object", "indent": 4, "expert": true },
{ "text": "Extra bits stored: 5", "indent": 5, "expert": true },
{ "text": "Elems: 48", "indent": 5, "expert": true },
{ "text": "Mode infos: 2 ModeInfo", "indent": 4, "expert": true },
{ "text": "Mode infos (1/2): ModeInfo object", "indent": 5, "expert": true },
{ "text": "Single: Single object", "indent": 6, "expert": true },
{ "text": "Mode: SIGN_MODE_LEGACY_AMINO_JSON", "indent": 7, "expert": true },
{ "text": "Mode infos (2/2): ModeInfo object", "indent": 5, "expert": true },
{ "text": "Single: Single object", "indent": 6, "expert": true },
{ "text": "Mode: SIGN_MODE_LEGACY_AMINO_JSON", "indent": 7, "expert": true },
{ "text": "End of Mode infos", "indent": 4, "expert": true },
{ "text": "Sequence: 5", "indent": 2, "expert": true },
{ "text": "End of Other signer", "expert": true },
{ "text": "Extension options: 1 Any", "expert": true },
{ "text": "Extension options (1/1): /cosmos.base.v1beta1.Coin", "indent": 1, "expert": true },
{ "text": "5 ATOM", "indent": 2, "expert": true },
{ "text": "End of Extension options", "expert": true },
{ "text": "Non critical extension options: 1 Any", "expert": true },
{ "text": "Non critical extension options (1/1): /cosmos.auth.v1beta1.Params", "indent": 1, "expert": true },
{ "text": "Params object", "indent": 2, "expert": true },
{ "text": "Max memo characters: 10", "indent": 3, "expert": true },
{ "text": "End of Non critical extension options", "expert": true },
{ "text": "Hash of raw bytes: 7ea02e8f0baed2db969e2d9ae4dc51fa31116259bd42897588072faf0ebb4d2e", "expert": true }
]
}
]

View File

@ -0,0 +1,2 @@
codegen:
@(buf generate)

View File

@ -0,0 +1,15 @@
version: v1
managed:
enabled: true
go_package_prefix:
default: cosmossdk.io/tx/textual/internal/textualpb
except:
- buf.build/googleapis/googleapis
- buf.build/cosmos/gogo-proto
- buf.build/cosmos/cosmos-proto
override:
buf.build/cosmos/cosmos-sdk: cosmossdk.io/api
plugins:
- name: go-pulsar
out: .
opt: paths=source_relative

View File

@ -0,0 +1,11 @@
# Generated by buf. DO NOT EDIT.
version: v1
deps:
- remote: buf.build
owner: cosmos
repository: cosmos-proto
commit: 1935555c206d4afb9e94615dfd0fad31
- remote: buf.build
owner: cosmos
repository: gogo-proto
commit: 6652e3443c3b4504bb3bf82e73a7e409

View File

@ -0,0 +1,12 @@
version: v1
deps:
- buf.build/cosmos/cosmos-proto
- buf.build/cosmos/gogo-proto
lint:
use:
- DEFAULT
except:
- PACKAGE_VERSION_SUFFIX
breaking:
ignore:
- testpb

View File

@ -0,0 +1,3 @@
// Package textualpb contains all protobuf definitions and generated codes
// used internally by Textual.
package textualpb

View File

@ -0,0 +1,89 @@
syntax = "proto3";
import "cosmos/base/v1beta1/coin.proto";
import "cosmos/tx/v1beta1/tx.proto";
import "cosmos_proto/cosmos.proto";
import "google/protobuf/any.proto";
// TextualData represents all the information needed to generate
// the textual SignDoc (which is []Screen encoded to CBOR). It is meant to be
// used as an internal type in Textual's implementations.
message TextualData {
// body_bytes is a protobuf serialization of a TxBody that matches the
// representation in SignDoc.
bytes body_bytes = 1;
// auth_info_bytes is a protobuf serialization of an AuthInfo that matches the
// representation in SignDoc.
bytes auth_info_bytes = 2;
// signer_data represents all data in Textual's SignDoc that are not
// inside the Tx body and auth_info.
SignerData signer_data = 3;
}
// SignerData is the specific information needed to sign a transaction that generally
// isn't included in the transaction body itself.
//
// It is the same struct as signing.SignerData, but only used internally
// in Textual because we need it as a proto.Message. If that struct is updated,
// then this proto SignerData also needs to be modified.
message SignerData {
// address is the address of the signer.
//
// In case of multisigs, this should be the multisig's address.
string address = 1 [(cosmos_proto.scalar) = "AddressString"];
// chain_id is the chain that this transaction is targeting.
string chain_id = 2;
// account_number is the account number of the signer.
//
// In case of multisigs, this should be the multisig account number.
uint64 account_number = 3;
// sequence is the account sequence number of the signer that is used
// for replay protection. This field is only useful for Legacy Amino signing,
// since in SIGN_MODE_DIRECT the account sequence is already in the signer
// info.
//
// In case of multisigs, this should be the multisig sequence.
uint64 sequence = 4;
// pub_key is the public key of the signer.
//
// In case of multisigs, this should be the pubkey of the member of the
// multisig that is signing the current sign doc.
google.protobuf.Any pub_key = 5;
}
// Envelope is an internal data structure used to generate the tx envelope
// screens. It is derived from the TextualData struct (also internal) which
// contains the three following fields:
// - body_bytes (from the original tx),
// - auth_info_bytes (from the original tx),
// - signer_data (passed in by the sign mode handler)
//
// If any of the three structs above is modified, then this Envelope message
// also needs to be updated.
message Envelope {
string chain_id = 1;
uint64 account_number = 2;
uint64 sequence = 3;
string address = 4;
google.protobuf.Any public_key = 5;
repeated google.protobuf.Any message = 6;
string memo = 7;
repeated cosmos.base.v1beta1.Coin fees = 8;
string fee_payer = 9;
string fee_granter = 10;
repeated cosmos.base.v1beta1.Coin tip = 11;
string tipper = 12;
uint64 gas_limit = 13;
uint64 timeout_height = 14;
repeated cosmos.tx.v1beta1.SignerInfo other_signer = 15;
repeated google.protobuf.Any extension_options = 16;
repeated google.protobuf.Any non_critical_extension_options = 17;
string hash_of_raw_bytes = 18;
}

File diff suppressed because it is too large Load Diff

View File

@ -53,9 +53,9 @@ func (vr bytesValueRenderer) Parse(_ context.Context, screens []Screen) (protore
formatted := screens[0].Text
// If the formatted string starts with `SHA-256=`, then we can't actually
// invert to get the original bytes. In this case, we error.
// invert to get the original bytes. In this case, we return empty bytes.
if strings.HasPrefix(formatted, hashPrefix) {
return nilValue, fmt.Errorf("cannot parse bytes hash")
return protoreflect.ValueOfBytes([]byte{}), nil
}
// Remove all spaces between every 4th char, then we can decode hex.

View File

@ -2,7 +2,6 @@ package valuerenderer_test
import (
"context"
"encoding/base64"
"encoding/json"
"os"
"testing"
@ -21,35 +20,30 @@ func TestBytesJsonTestCases(t *testing.T) {
err = json.Unmarshal(raw, &testcases)
require.NoError(t, err)
textual := valuerenderer.NewTextual(mockCoinMetadataQuerier)
textual := valuerenderer.NewTextual(nil)
for _, tc := range testcases {
data, err := base64.StdEncoding.DecodeString(tc.base64)
require.NoError(t, err)
valrend, err := textual.GetFieldValueRenderer(fieldDescriptorFromName("BYTES"))
require.NoError(t, err)
screens, err := valrend.Format(context.Background(), protoreflect.ValueOfBytes(data))
screens, err := valrend.Format(context.Background(), protoreflect.ValueOfBytes(tc.base64))
require.NoError(t, err)
require.Equal(t, 1, len(screens))
require.Equal(t, tc.hex, screens[0].Text)
// Round trip
val, err := valrend.Parse(context.Background(), screens)
if err != nil {
// Make sure Parse() only errors because of hashed bytes.
require.Equal(t, "cannot parse bytes hash", err.Error())
require.Greater(t, len(tc.base64), 32)
continue
}
require.NoError(t, err)
require.Equal(t, tc.base64, base64.StdEncoding.EncodeToString(val.Bytes()))
if len(tc.base64) > 32 {
require.Equal(t, 0, len(val.Bytes()))
} else {
require.Equal(t, tc.base64, val.Bytes())
}
}
}
type bytesTest struct {
base64 string
base64 []byte
hex string
}

View File

@ -31,6 +31,20 @@ func mockCoinMetadataQuerier(ctx context.Context, denom string) (*bankv1beta1.Me
return v.(*bankv1beta1.Metadata), nil
}
// addMetadataToContext appends relevant coin metadata to the mock context
// used in tests.
func addMetadataToContext(ctx context.Context, metadata *bankv1beta1.Metadata) context.Context {
if metadata == nil {
return ctx
}
for _, m := range metadata.DenomUnits {
ctx = context.WithValue(ctx, mockCoinMetadataKey(m.Denom), metadata)
}
return ctx
}
func TestMetadataQuerier(t *testing.T) {
// Errors on nil metadata querier
textual := valuerenderer.NewTextual(nil)
@ -66,10 +80,7 @@ func TestCoinJsonTestcases(t *testing.T) {
for _, tc := range testcases {
t.Run(tc.Text, func(t *testing.T) {
if tc.Proto != nil {
ctx := context.WithValue(context.Background(), mockCoinMetadataKey(tc.Proto.Denom), tc.Metadata)
if tc.Metadata != nil {
ctx = context.WithValue(ctx, mockCoinMetadataKey(tc.Metadata.Display), tc.Metadata)
}
ctx := addMetadataToContext(context.Background(), tc.Metadata)
screens, err := vr.Format(ctx, protoreflect.ValueOf(tc.Proto.ProtoReflect()))

View File

@ -153,17 +153,21 @@ func (vr coinsValueRenderer) parseCoins(ctx context.Context, coinsStr string) ([
// a core Parse function for coins.
func parseCoin(coinStr string, metadata *bankv1beta1.Metadata) (*basev1beta1.Coin, error) {
coinArr := strings.Split(coinStr, " ")
amt1 := coinArr[0]
amt1 := coinArr[0] // Contains potentially some thousandSeparators
coinDenom := coinArr[1]
if metadata == nil || metadata.Base == "" || coinArr[1] == metadata.Base {
dec, err := parseDec(amt1)
if err != nil {
return nil, err
}
amtDecStr, err := parseDec(amt1)
if err != nil {
return nil, err
}
amtDec, err := math.LegacyNewDecFromStr(amtDecStr)
if err != nil {
return nil, err
}
if metadata == nil || metadata.Base == "" || coinArr[1] == metadata.Base {
return &basev1beta1.Coin{
Amount: dec,
Amount: amtDecStr,
Denom: coinDenom,
}, nil
}
@ -185,37 +189,24 @@ func parseCoin(coinStr string, metadata *bankv1beta1.Metadata) (*basev1beta1.Coi
// If we didn't find either exponent, then we return early.
if !foundCoinExp || !foundBaseExp {
amt, err := parseDec(amt1)
if err != nil {
return nil, err
}
return &basev1beta1.Coin{
Amount: amt,
Amount: amtDecStr,
Denom: baseDenom,
}, nil
}
// remove 1000 separators, (ex: 1'000'000 -> 1000000)
amt1 = strings.ReplaceAll(amt1, "'", "")
amt, err := math.LegacyNewDecFromStr(amt1)
if err != nil {
return nil, err
}
if coinExp > baseExp {
amt = amt.Mul(math.LegacyNewDec(10).Power(uint64(coinExp - baseExp)))
amtDec = amtDec.Mul(math.LegacyNewDec(10).Power(uint64(coinExp - baseExp)))
} else {
amt = amt.Quo(math.LegacyNewDec(10).Power(uint64(baseExp - coinExp)))
amtDec = amtDec.Quo(math.LegacyNewDec(10).Power(uint64(baseExp - coinExp)))
}
amtStr, err := parseDec(amt.String())
if err != nil {
return nil, err
if !amtDec.TruncateDec().Equal(amtDec) {
return nil, fmt.Errorf("got non-integer coin amount %s", amtDec)
}
return &basev1beta1.Coin{
Amount: amtStr,
Amount: amtDec.TruncateInt().String(),
Denom: baseDenom,
}, nil
}

View File

@ -32,10 +32,8 @@ func TestCoinsJsonTestcases(t *testing.T) {
// Create a context.Context containing all coins metadata, to simulate
// that they are in state.
ctx := context.Background()
for _, v := range tc.Metadata {
ctx = context.WithValue(ctx, mockCoinMetadataKey(v.Base), v)
ctx = context.WithValue(ctx, mockCoinMetadataKey(v.Display), v)
ctx = addMetadataToContext(ctx, v)
}
listValue := NewGenericList(tc.Proto)
@ -86,10 +84,10 @@ func checkCoinsEqual(t *testing.T, l1, l2 protoreflect.List) {
func checkCoinEqual(t *testing.T, coin, coin1 *basev1beta1.Coin) {
require.Equal(t, coin1.Denom, coin.Denom)
v, err := math.LegacyNewDecFromStr(coin.Amount)
require.NoError(t, err)
v1, err := math.LegacyNewDecFromStr(coin1.Amount)
require.NoError(t, err)
v, ok := math.NewIntFromString(coin.Amount)
require.True(t, ok)
v1, ok := math.NewIntFromString(coin1.Amount)
require.True(t, ok)
require.True(t, v.Equal(v1))
}

View File

@ -0,0 +1,73 @@
package valuerenderer_test
import (
"context"
"encoding/hex"
"encoding/json"
"os"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/reflect/protoreflect"
_ "cosmossdk.io/api/cosmos/auth/v1beta1"
_ "cosmossdk.io/api/cosmos/authz/v1beta1"
_ "cosmossdk.io/api/cosmos/crypto/ed25519"
_ "cosmossdk.io/api/cosmos/crypto/multisig"
_ "cosmossdk.io/api/cosmos/crypto/secp256k1"
_ "cosmossdk.io/api/cosmos/gov/v1"
"cosmossdk.io/tx/textual/internal/textualpb"
"cosmossdk.io/tx/textual/valuerenderer"
)
type e2eJsonTest struct {
txJsonTest
Cbor string
}
func TestE2EJsonTestcases(t *testing.T) {
raw, err := os.ReadFile("../internal/testdata/e2e.json")
require.NoError(t, err)
var testcases []e2eJsonTest
err = json.Unmarshal(raw, &testcases)
require.NoError(t, err)
for _, tc := range testcases {
t.Run(tc.Name, func(t *testing.T) {
_, bodyBz, _, authInfoBz, signerData := createTextualData(t, tc.Proto, tc.SignerData)
tr := valuerenderer.NewTextual(mockCoinMetadataQuerier)
rend := valuerenderer.NewTxValueRenderer(&tr)
ctx := addMetadataToContext(context.Background(), tc.Metadata)
data := &textualpb.TextualData{
BodyBytes: bodyBz,
AuthInfoBytes: authInfoBz,
SignerData: &textualpb.SignerData{
Address: signerData.Address,
ChainId: signerData.ChainId,
AccountNumber: signerData.AccountNumber,
Sequence: signerData.Sequence,
PubKey: signerData.PubKey,
},
}
// Make sure the screens match.
val := protoreflect.ValueOf(data.ProtoReflect())
screens, err := rend.Format(ctx, val)
if tc.Error {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.Screens, screens)
// Make sure CBOR match.
signDoc, err := tr.GetSignBytes(ctx, bodyBz, authInfoBz, signerData)
require.NoError(t, err)
require.Equal(t, tc.Cbor, hex.EncodeToString(signDoc))
})
}
}

View File

@ -30,7 +30,7 @@ func (er enumValueRenderer) Format(_ context.Context, v protoreflect.Value) ([]S
return nil, fmt.Errorf("cannot get enum %s variant of number %d", er.ed.FullName(), v.Enum())
}
return []Screen{{Text: string(evd.FullName())}}, nil
return []Screen{{Text: string(evd.Name())}}, nil
}
@ -46,7 +46,7 @@ func (er enumValueRenderer) Parse(_ context.Context, screens []Screen) (protoref
values := er.ed.Values()
for i := 0; i < values.Len(); i++ {
evd := values.Get(i)
if string(evd.FullName()) == formatted {
if string(evd.Name()) == formatted {
return protoreflect.ValueOfEnum(evd.Number()), nil
}
}

View File

@ -235,14 +235,14 @@ func (mr *messageValueRenderer) Parse(ctx context.Context, screens []Screen) (pr
err = r.ParseRepeated(ctx, subscreens, nf.List())
} else {
err = mr.parseRepeated(ctx, subscreens, nf.List(), vr)
//Skip List Terminator
idx++
}
if err != nil {
return nilValue, err
}
msg.Set(fd, nf)
//Skip List Terminator
idx++
} else {
val, err = vr.Parse(ctx, subscreens)
if err != nil {
@ -252,10 +252,6 @@ func (mr *messageValueRenderer) Parse(ctx context.Context, screens []Screen) (pr
}
}
if idx > len(screens) {
return nilValue, errors.New("leftover screens")
}
return protoreflect.ValueOfMessage(msg), nil
}

View File

@ -0,0 +1,341 @@
package valuerenderer
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"regexp"
"strings"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/anypb"
msg "cosmossdk.io/api/cosmos/msg/v1"
signingv1beta1 "cosmossdk.io/api/cosmos/tx/signing/v1beta1"
txv1beta1 "cosmossdk.io/api/cosmos/tx/v1beta1"
"cosmossdk.io/tx/textual/internal/textualpb"
)
var (
// msgRe is a regex matching the beginning of the TxBody msgs in the envelope.
msgRe = regexp.MustCompile("Message: ([0-9]+) Any")
// inverseMsgRe is a regex matching the textual output of the TxBody msgs
// header.
inverseMsgRe = regexp.MustCompile("This transaction has ([0-9]+) Messages?")
)
type txValueRenderer struct {
tr *Textual
}
// NewTxValueRenderer returns a ValueRenderer for the protobuf
// TextualData type. It follows the specification defined in ADR-050.
// The reason we create a renderer for TextualData (and not directly Tx)
// is that TextualData is a single place that contains all data needed
// to create the `[]Screen` SignDoc.
func NewTxValueRenderer(tr *Textual) ValueRenderer {
return txValueRenderer{
tr: tr,
}
}
// Format implements the ValueRenderer interface.
func (vr txValueRenderer) Format(ctx context.Context, v protoreflect.Value) ([]Screen, error) {
// Reify the reflected message as a proto Tx
msg := v.Message().Interface()
textualData, ok := msg.(*textualpb.TextualData)
if !ok {
return nil, fmt.Errorf("expected Tx, got %T", msg)
}
txBody := &txv1beta1.TxBody{}
txAuthInfo := &txv1beta1.AuthInfo{}
err := proto.Unmarshal(textualData.BodyBytes, txBody)
if err != nil {
return nil, err
}
err = proto.Unmarshal(textualData.AuthInfoBytes, txAuthInfo)
if err != nil {
return nil, err
}
// Create envelope here. We really need to make sure that all the non-Msg
// fields inside both TxBody and AuthInfo are flattened here. For example,
// if we decide to add new fields in either of those 2 structs, then we
// should add a new field here in Envelope.
envelope := &textualpb.Envelope{
ChainId: textualData.SignerData.ChainId,
AccountNumber: textualData.SignerData.AccountNumber,
Sequence: textualData.SignerData.Sequence,
Address: textualData.SignerData.Address,
PublicKey: textualData.SignerData.PubKey,
Message: txBody.Messages,
Memo: txBody.Memo,
Fees: txAuthInfo.Fee.Amount,
FeePayer: txAuthInfo.Fee.Payer,
FeeGranter: txAuthInfo.Fee.Granter,
GasLimit: txAuthInfo.Fee.GasLimit,
TimeoutHeight: txBody.TimeoutHeight,
ExtensionOptions: txBody.ExtensionOptions,
NonCriticalExtensionOptions: txBody.NonCriticalExtensionOptions,
HashOfRawBytes: getHash(textualData.BodyBytes, textualData.AuthInfoBytes),
}
if txAuthInfo.Tip != nil {
envelope.Tip = txAuthInfo.Tip.Amount
envelope.Tipper = txAuthInfo.Tip.Tipper
}
// Find all other tx signers than the current signer. In the case where our
// Textual signer is one key of a multisig, then otherSigners will include
// the multisig pubkey.
otherSigners := []*txv1beta1.SignerInfo{}
for _, si := range txAuthInfo.SignerInfos {
if bytes.Equal(si.PublicKey.Value, textualData.SignerData.PubKey.Value) {
continue
}
otherSigners = append(otherSigners, si)
}
envelope.OtherSigner = otherSigners
mvr, err := vr.tr.GetMessageValueRenderer(envelope.ProtoReflect().Descriptor())
if err != nil {
return nil, err
}
screens, err := mvr.Format(ctx, protoreflect.ValueOf(envelope.ProtoReflect()))
if err != nil {
return nil, err
}
// Since we're value-rendering the (internal) envelope message, we do some
// postprocessing. First, we remove first envelope header screen, and
// unindent 1 level.
// Remove 1st screen
screens = screens[1:]
for i := range screens {
screens[i].Indent--
}
// Expert fields.
expert := map[string]struct{}{
"Public key": {},
"Fee payer": {},
"Fee granter": {},
"Gas limit": {},
"Timeout height": {},
"Other signer": {},
"Extension options": {},
"Non critical extension options": {},
"Hash of raw bytes": {},
}
for i := range screens {
if screens[i].Indent == 0 {
// Do expert fields.
screenKV := strings.Split(screens[i].Text, ": ")
_, ok := expert[screenKV[0]]
if ok {
expertify(screens, i, screenKV[0])
}
// Replace:
// "Message: <N> Any"
// with:
// "This transaction has <N> Message"
matches := msgRe.FindStringSubmatch(screens[i].Text)
if len(matches) > 0 {
screens[i].Text = fmt.Sprintf("This transaction has %s Message", matches[1])
if matches[1] != "1" {
screens[i].Text += "s"
}
}
}
}
return screens, nil
}
// expertify marks all screens starting from `fromIdx` as expert, and stops
// just before it finds the next screen with Indent==0 (unless it's a "End of"
// termination screen). It modifies screens in-place.
func expertify(screens []Screen, fromIdx int, fieldName string) {
for i := fromIdx; i < len(screens); i++ {
if i > fromIdx &&
screens[i].Indent == 0 &&
screens[i].Text != fmt.Sprintf("End of %s", fieldName) {
break
}
screens[i].Expert = true
}
}
// getHash gets the hash of raw bytes to be signed over:
// HEX(sha256(len(body_bytes) ++ body_bytes ++ len(auth_info_bytes) ++ auth_info_bytes))
func getHash(bodyBz, authInfoBz []byte) string {
bodyLen, authInfoLen := make([]byte, 8), make([]byte, 8)
binary.BigEndian.PutUint64(bodyLen, uint64(len(bodyBz)))
binary.BigEndian.PutUint64(authInfoLen, uint64(len(authInfoBz)))
b := make([]byte, 16+len(bodyBz)+len(authInfoBz))
copy(b[:8], bodyLen)
copy(b[8:8+len(bodyBz)], bodyBz)
copy(b[8+len(bodyBz):16+len(bodyBz)], authInfoLen)
copy(b[16+len(bodyBz):], authInfoBz)
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
// Parse implements the ValueRenderer interface.
func (vr txValueRenderer) Parse(ctx context.Context, screens []Screen) (protoreflect.Value, error) {
// Process the screens to be parsable by a envelope message value renderer
parsable := make([]Screen, len(screens)+1)
parsable[0] = Screen{Text: "Envelope object"}
for i := range screens {
parsable[i+1].Indent = screens[i].Indent + 1
// Take same text, except that we weplace:
// "This transaction has <N> Message"
// with:
// "Message: <N> Any"
matches := inverseMsgRe.FindStringSubmatch(screens[i].Text)
if len(matches) > 0 {
parsable[i+1].Text = fmt.Sprintf("Message: %s Any", matches[1])
} else {
parsable[i+1].Text = screens[i].Text
}
}
mvr, err := vr.tr.GetMessageValueRenderer((&textualpb.Envelope{}).ProtoReflect().Descriptor())
if err != nil {
return nilValue, err
}
envelopeV, err := mvr.Parse(ctx, parsable)
if err != nil {
return nilValue, err
}
envelope := envelopeV.Message().Interface().(*textualpb.Envelope)
txBody := &txv1beta1.TxBody{
Messages: envelope.Message,
Memo: envelope.Memo,
TimeoutHeight: envelope.TimeoutHeight,
ExtensionOptions: envelope.ExtensionOptions,
NonCriticalExtensionOptions: envelope.NonCriticalExtensionOptions,
}
authInfo := &txv1beta1.AuthInfo{
Fee: &txv1beta1.Fee{
Amount: envelope.Fees,
GasLimit: envelope.GasLimit,
Payer: envelope.FeePayer,
Granter: envelope.FeeGranter,
},
}
if envelope.Tip != nil {
authInfo.Tip = &txv1beta1.Tip{
Amount: envelope.Tip,
Tipper: envelope.Tipper,
}
}
// Figure out the signers in the correct order.
signers, err := getSigners(txBody, authInfo)
if err != nil {
return nilValue, err
}
signerInfos := make([]*txv1beta1.SignerInfo, len(signers))
for i, s := range signers {
if s == envelope.Address {
signerInfos[i] = &txv1beta1.SignerInfo{
PublicKey: envelope.PublicKey,
ModeInfo: &txv1beta1.ModeInfo{
Sum: &txv1beta1.ModeInfo_Single_{
Single: &txv1beta1.ModeInfo_Single{
Mode: signingv1beta1.SignMode_SIGN_MODE_TEXTUAL,
},
},
},
Sequence: envelope.Sequence,
}
} else {
// We know that signerInfos is well ordered, so just pop from it.
signerInfos[i] = envelope.OtherSigner[0]
envelope.OtherSigner = envelope.OtherSigner[1:]
}
}
authInfo.SignerInfos = signerInfos
// Note that we might not always get back the exact bodyBz and authInfoBz
// that was passed into, because protobuf is not deterministic.
// In tests, we don't check bytes equality, but protobuf object equality.
bodyBz, err := proto.Marshal(txBody)
if err != nil {
return nilValue, err
}
authInfoBz, err := proto.Marshal(authInfo)
if err != nil {
return nilValue, err
}
tx := &textualpb.TextualData{
BodyBytes: bodyBz,
AuthInfoBytes: authInfoBz,
SignerData: &textualpb.SignerData{
Address: envelope.Address,
AccountNumber: envelope.AccountNumber,
ChainId: envelope.ChainId,
Sequence: envelope.Sequence,
PubKey: envelope.PublicKey,
},
}
return protoreflect.ValueOf(tx.ProtoReflect()), nil
}
// getSigners gets the ordered signers of a transaction. It's mostly a
// copy-paste of `types/tx/types.go` GetSigners method, but uses the proto
// annotation `cosmos.msg.v1.signer`, instead of the sdk.Msg#GetSigners method.
func getSigners(body *txv1beta1.TxBody, authInfo *txv1beta1.AuthInfo) ([]string, error) {
var signers []string
seen := map[string]bool{}
for _, msgAny := range body.Messages {
m, err := anypb.UnmarshalNew(msgAny, proto.UnmarshalOptions{})
if err != nil {
return nil, err
}
ext := proto.GetExtension(m.ProtoReflect().Descriptor().Options(), msg.E_Signer)
signerFields, ok := ext.([]string)
if !ok {
return nil, fmt.Errorf("expected []string, got %T", ext)
}
for _, fieldName := range signerFields {
fd := m.ProtoReflect().Descriptor().Fields().ByName(protoreflect.Name(fieldName))
addr := m.ProtoReflect().Get(fd).String()
if !seen[addr] {
signers = append(signers, addr)
seen[addr] = true
}
}
}
// ensure any specified fee payer is included in the required signers (at the end)
feePayer := authInfo.Fee.Payer
if feePayer != "" && !seen[feePayer] {
signers = append(signers, feePayer)
seen[feePayer] = true
}
return signers, nil
}

View File

@ -0,0 +1,182 @@
package valuerenderer_test
import (
"context"
"encoding/json"
"os"
"testing"
"github.com/cosmos/cosmos-proto/any"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/anypb"
_ "cosmossdk.io/api/cosmos/auth/v1beta1"
_ "cosmossdk.io/api/cosmos/authz/v1beta1"
bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1"
_ "cosmossdk.io/api/cosmos/crypto/ed25519"
"cosmossdk.io/api/cosmos/crypto/multisig"
_ "cosmossdk.io/api/cosmos/crypto/secp256k1"
_ "cosmossdk.io/api/cosmos/gov/v1"
txv1beta1 "cosmossdk.io/api/cosmos/tx/v1beta1"
"cosmossdk.io/tx/signing"
"cosmossdk.io/tx/textual/internal/textualpb"
"cosmossdk.io/tx/textual/valuerenderer"
)
// txJsonTestTx represents the type that in the JSON test
// cases `proto` field. The inner contents are protojson
// encoded, so we represent them as []byte here, and decode
// them inside the test.
type txJsonTestTx struct {
Body json.RawMessage
AuthInfo json.RawMessage `json:"auth_info"`
}
type txJsonTest struct {
Name string
Proto txJsonTestTx
SignerData json.RawMessage `json:"signer_data"`
Metadata *bankv1beta1.Metadata
Error bool
Screens []valuerenderer.Screen
}
func TestTxJsonTestcases(t *testing.T) {
raw, err := os.ReadFile("../internal/testdata/tx.json")
require.NoError(t, err)
var testcases []txJsonTest
err = json.Unmarshal(raw, &testcases)
require.NoError(t, err)
for _, tc := range testcases {
t.Run(tc.Name, func(t *testing.T) {
txBody, bodyBz, txAuthInfo, authInfoBz, signerData := createTextualData(t, tc.Proto, tc.SignerData)
tr := valuerenderer.NewTextual(mockCoinMetadataQuerier)
rend := valuerenderer.NewTxValueRenderer(&tr)
ctx := addMetadataToContext(context.Background(), tc.Metadata)
data := &textualpb.TextualData{
BodyBytes: bodyBz,
AuthInfoBytes: authInfoBz,
SignerData: &textualpb.SignerData{
Address: signerData.Address,
ChainId: signerData.ChainId,
AccountNumber: signerData.AccountNumber,
Sequence: signerData.Sequence,
PubKey: signerData.PubKey,
},
}
// Make sure the screens match.
val := protoreflect.ValueOf(data.ProtoReflect())
screens, err := rend.Format(ctx, val)
if tc.Error {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.Screens, screens)
// Round trip.
parsedVal, err := rend.Parse(ctx, screens)
require.NoError(t, err)
// We don't check that bodyBz and authInfoBz are equal, because
// they don't need to be. Instead, we check that the semantic
// proto objects are equal.
parsedTextualData := parsedVal.Message().Interface().(*textualpb.TextualData)
parsedBody := &txv1beta1.TxBody{}
err = proto.Unmarshal(parsedTextualData.BodyBytes, parsedBody)
require.NoError(t, err)
diff := cmp.Diff(txBody, parsedBody, protocmp.Transform())
require.Empty(t, diff)
parsedAuthInfo := &txv1beta1.AuthInfo{}
err = proto.Unmarshal(parsedTextualData.AuthInfoBytes, parsedAuthInfo)
require.NoError(t, err)
// Remove the non-parsable fields, i.e. the hashed bytes
for i, si := range txAuthInfo.SignerInfos {
txAuthInfo.SignerInfos[i].PublicKey = removePkKeys(t, si.PublicKey)
}
diff = cmp.Diff(txAuthInfo, parsedAuthInfo, protocmp.Transform())
require.Empty(t, diff)
// Remove the non-parsable fields, i.e. the hashed public key
removePkKeys(t, signerData.PubKey)
diff = cmp.Diff(
signerData,
signerDataFromProto(parsedTextualData.SignerData),
protocmp.Transform(),
)
require.Empty(t, diff)
})
}
}
// createTextualData creates a Textual data give then JSON
// test case.
func createTextualData(t *testing.T, jsonTx txJsonTestTx, jsonSignerData json.RawMessage) (*txv1beta1.TxBody, []byte, *txv1beta1.AuthInfo, []byte, signing.SignerData) {
body := &txv1beta1.TxBody{}
authInfo := &txv1beta1.AuthInfo{}
protoSignerData := &textualpb.SignerData{}
// We unmarshal from protojson to the protobuf types.
err := protojson.Unmarshal(jsonTx.Body, body)
require.NoError(t, err)
err = protojson.Unmarshal(jsonTx.AuthInfo, authInfo)
require.NoError(t, err)
err = protojson.Unmarshal(jsonSignerData, protoSignerData)
require.NoError(t, err)
// We marshal body and auth_info
bodyBz, err := proto.Marshal(body)
require.NoError(t, err)
authInfoBz, err := proto.Marshal(authInfo)
require.NoError(t, err)
return body, bodyBz, authInfo, authInfoBz, signerDataFromProto(protoSignerData)
}
// signerDataFromProto converts a protobuf SignerData (internal) to a
// signing.SignerData (external).
func signerDataFromProto(d *textualpb.SignerData) signing.SignerData {
return signing.SignerData{
Address: d.Address,
ChainId: d.ChainId,
AccountNumber: d.AccountNumber,
Sequence: d.Sequence,
PubKey: d.PubKey,
}
}
// removePkKeys takes a public key Any, decodes it, and recursively removes all
// the "key" fields (hashed by textual) inside it.
func removePkKeys(t *testing.T, pkAny *anypb.Any) *anypb.Any {
pk, err := anypb.UnmarshalNew(pkAny, proto.UnmarshalOptions{})
require.NoError(t, err)
m := pk.ProtoReflect().Interface()
switch m := m.(type) {
case *multisig.LegacyAminoPubKey:
newAnys := make([]*anypb.Any, len(m.PublicKeys))
for i, any := range m.PublicKeys {
newAnys[i] = removePkKeys(t, any)
}
m.PublicKeys = newAnys
newMultisigAny, err := any.New(m)
require.NoError(t, err)
return newMultisigAny
default:
pkAny.Value = nil
return pkAny
}
}

View File

@ -1,6 +1,7 @@
package valuerenderer
import (
"bytes"
"context"
"fmt"
@ -12,6 +13,8 @@ import (
bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1"
basev1beta1 "cosmossdk.io/api/cosmos/base/v1beta1"
"cosmossdk.io/tx/signing"
"cosmossdk.io/tx/textual/internal/textualpb"
cosmos_proto "github.com/cosmos/cosmos-proto"
)
@ -61,12 +64,11 @@ func (r *Textual) GetFieldValueRenderer(fd protoreflect.FieldDescriptor) (ValueR
}
vr := r.scalars[scalar]
if vr == nil {
return nil, fmt.Errorf("got empty value renderer for scalar %s", scalar)
if vr != nil {
return vr(fd), nil
}
return vr(fd), nil
}
return NewStringValueRenderer(), nil
case fd.Kind() == protoreflect.BytesKind:
@ -125,6 +127,7 @@ func (r *Textual) init() {
r.messages[(&durationpb.Duration{}).ProtoReflect().Descriptor().FullName()] = NewDurationValueRenderer()
r.messages[(&timestamppb.Timestamp{}).ProtoReflect().Descriptor().FullName()] = NewTimestampValueRenderer()
r.messages[(&anypb.Any{}).ProtoReflect().Descriptor().FullName()] = NewAnyValueRenderer(r)
r.messages[(&textualpb.TextualData{}).ProtoReflect().Descriptor().FullName()] = NewTxValueRenderer(r)
}
}
@ -133,3 +136,36 @@ func (r *Textual) DefineScalar(scalar string, vr ValueRendererCreator) {
r.init()
r.scalars[scalar] = vr
}
// GetSignBytes returns the transaction sign bytes.
func (r *Textual) GetSignBytes(ctx context.Context, bodyBz, authInfoBz []byte, signerData signing.SignerData) ([]byte, error) {
data := &textualpb.TextualData{
BodyBytes: bodyBz,
AuthInfoBytes: authInfoBz,
SignerData: &textualpb.SignerData{
Address: signerData.Address,
ChainId: signerData.ChainId,
AccountNumber: signerData.AccountNumber,
Sequence: signerData.Sequence,
PubKey: signerData.PubKey,
},
}
vr, err := r.GetMessageValueRenderer(data.ProtoReflect().Descriptor())
if err != nil {
return nil, err
}
screens, err := vr.Format(ctx, protoreflect.ValueOf(data.ProtoReflect()))
if err != nil {
return nil, err
}
var buf bytes.Buffer
err = encode(screens, &buf)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}