diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index 7d61dcf71..a66f3b85d 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -145,6 +145,13 @@ void fsm_msgZcashTransparentOutput(const ZcashTransparentOutput* msg); void fsm_msgZcashTransparentInput(const ZcashTransparentInput* msg); void fsm_msgZcashDisplayAddress(const ZcashDisplayAddress* msg); #endif +void fsm_msgHiveGetPublicKey(const HiveGetPublicKey* msg); +void fsm_msgHiveGetPublicKeys(const HiveGetPublicKeys* msg); +void fsm_msgHiveSignTx(const HiveSignTx* msg); +void fsm_msgHiveSignAccountCreate(const HiveSignAccountCreate* msg); +void fsm_msgHiveSignAccountUpdate(const HiveSignAccountUpdate* msg); +void fsm_msgHiveSignMessage(const HiveSignMessage* msg); +void fsm_msgHiveSignOperations(const HiveSignOperations* msg); #if DEBUG_LINK // void fsm_msgDebugLinkDecision(DebugLinkDecision *msg); diff --git a/include/keepkey/firmware/hive.h b/include/keepkey/firmware/hive.h new file mode 100644 index 000000000..ad98b4731 --- /dev/null +++ b/include/keepkey/firmware/hive.h @@ -0,0 +1,261 @@ +#ifndef KEEPKEY_FIRMWARE_HIVE_H +#define KEEPKEY_FIRMWARE_HIVE_H + +#include "trezor/crypto/bip32.h" +#include "messages-hive.pb.h" + +// ── Hive mainnet chain ID ───────────────────────────────────────────────── +#define HIVE_CHAIN_ID \ + "\xbe\xea\xb0\xde\x00\x00\x00\x00\x00\x00\x00\x00" \ + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ + "\x00\x00\x00\x00\x00\x00\x00\x00" + +#define HIVE_CHAIN_ID_LEN 32 + +// ── STM public key prefix (Hive inherited from Steem / Graphene) ────────── +#define HIVE_PUBKEY_PREFIX "STM" + +// ── SLIP-0048 derivation constants (all hardened) ───────────────────────── +// Path: m/48'/13'/role'/account_index'/0' +// 13' is the de-facto Hive network index shipped by Ledger (LedgerHQ/app-hive) +// and hive-ledger-cli, NOT the slip-0048.md registry entry (0xbee = 3054', +// which no wallet implements). Chosen deliberately for seed-level key +// compatibility with the existing hardware-wallet ecosystem. +#define HIVE_SLIP48_PURPOSE (0x80000030u) // 48' +#define HIVE_SLIP48_NETWORK (0x8000000Du) // 13' +#define HIVE_ROLE_OWNER \ + (0x80000000u) // 0' — account recovery, authority changes +#define HIVE_ROLE_ACTIVE (0x80000001u) // 1' — transfers, staking +#define HIVE_ROLE_MEMO (0x80000003u) // 3' — memo field encryption +#define HIVE_ROLE_POSTING (0x80000004u) // 4' — votes, posts, follows + +/** + * Validate a complete Hive SLIP-0048 path: + * m/48'/13'/role'/account'/0'. The role must be one of owner, active, memo, + * or posting; every component is hardened. + */ +bool hive_slip48_path_valid(const uint32_t* address_n, size_t count); + +/** Validate a complete Hive SLIP-0048 path for one required role. */ +bool hive_slip48_path_valid_for_role(const uint32_t* address_n, size_t count, + uint32_t required_role); + +// ── Graphene operation type IDs ─────────────────────────────────────────── +#define HIVE_OP_VOTE 0 +#define HIVE_OP_COMMENT 1 +#define HIVE_OP_TRANSFER 2 +#define HIVE_OP_TRANSFER_TO_VESTING 3 +#define HIVE_OP_WITHDRAW_VESTING 4 +#define HIVE_OP_LIMIT_ORDER_CREATE 5 +#define HIVE_OP_LIMIT_ORDER_CANCEL 6 +#define HIVE_OP_CONVERT 8 +#define HIVE_OP_ACCOUNT_CREATE 9 +#define HIVE_OP_ACCOUNT_UPDATE 10 +#define HIVE_OP_CUSTOM_JSON 18 +#define HIVE_OP_COMMENT_OPTIONS 19 +#define HIVE_OP_TRANSFER_TO_SAVINGS 32 +#define HIVE_OP_TRANSFER_FROM_SAVINGS 33 +#define HIVE_OP_CLAIM_REWARD_BALANCE 39 +#define HIVE_OP_DELEGATE_VESTING_SHARES 40 +#define HIVE_OP_ACCOUNT_UPDATE2 43 + +// ── Protocol limits ─────────────────────────────────────────────────────── +#define HIVE_DECIMALS 3 // HIVE and HBD both use 3 decimal places +// Maximum memo length that fits safely in the signer's tx_buf[512] with all +// other fields. Non-memo overhead: header(12) + from(17) + to(17) + asset(16) +// + footer(1) = ~63 bytes. 512 - 63 - 3 (varint) = 446; 440 is conservative. +#define HIVE_MAX_MEMO_LEN 440 +// Maximum signable message length. MUST match HiveSignMessage.message +// max_size in messages-hive.options (proto cap and code cap kept in sync). +#define HIVE_MAX_MESSAGE_LEN 1024 +// Maximum host-serialized transaction length for HiveSignOperations. MUST +// match HiveSignOperations.serialized_tx max_size in messages-hive.options. +#define HIVE_MAX_OPS_TX_LEN 2048 +// Maximum operations per HiveSignOperations transaction. +#define HIVE_MAX_TX_OPS 4 +// Graphene asset: int64 LE amount + uint8 precision + 7-byte NUL-padded +// symbol (append_asset layout). +#define HIVE_ASSET_LEN 16 +// Most assets carried by a single op in the table (claim_reward_balance +// carries three: HIVE, HBD, VESTS). +#define HIVE_MAX_OP_ASSETS 3 +// Most comment_payout_beneficiaries entries accepted on a comment_options op. +// Matches the host serializer's cap; hived itself allows more, but eight is +// all that can be reviewed on the OLED before approval fatigue sets in. +#define HIVE_MAX_BENEFICIARIES 8 +// Maximum custom_json authorization accounts accepted per operation. Every +// account is confirmed individually; bounding the set prevents an unreviewable +// approval loop and keeps the parsed transaction's static RAM use predictable. +#define HIVE_MAX_CUSTOM_JSON_AUTHS 4 + +// Symbol whitelist bits for the asset parser. Every asset field in the op +// table pins an explicit set — an op that accepts HIVE must never silently +// accept VESTS, since the two differ by 1000x in displayed magnitude. +#define HIVE_SYM_HIVE (1u << 0) +#define HIVE_SYM_HBD (1u << 1) +#define HIVE_SYM_VESTS (1u << 2) + +// ── Public API ──────────────────────────────────────────────────────────── +/** + * Encode a 33-byte compressed public key in Hive/Steem STM-prefix base58 + * format. Uses RIPEMD checksum (Graphene convention, not SHA256d). + */ +bool hive_getPublicKey(const uint8_t public_key[33], char* out, size_t out_len); + +/** + * Derive one SLIP-0048 role key for a given account index to raw 33 bytes. + * role_hardened: HIVE_ROLE_OWNER | HIVE_ROLE_ACTIVE | HIVE_ROLE_MEMO | + * HIVE_ROLE_POSTING account_index_hardened: account_index | 0x80000000u Returns + * false if derivation fails. + */ +bool hive_deriveRawKey(const HDNode* root, uint32_t role_hardened, + uint32_t account_index_hardened, uint8_t out[33]); + +/** + * Derive all four SLIP-0048 role keys for a given account index and encode + * each as an STM-prefixed string. All output buffers must be >= 64 bytes. + * Returns false if any derivation or encoding step fails. + */ +bool hive_getPublicKeys(const HDNode* root, uint32_t account_index, + char* owner_out, size_t owner_len, char* active_out, + size_t active_len, char* memo_out, size_t memo_len, + char* posting_out, size_t posting_len); + +/** + * Sign a Hive transfer transaction (op type 2). + * Rejects memos longer than HIVE_MAX_MEMO_LEN (440 bytes). + */ +void hive_signTx(const HDNode* node, const HiveSignTx* msg, HiveSignedTx* resp); + +// ── Parsed operations (HiveSignOperations) ──────────────────────────────── + +typedef struct { + uint32_t op_type; + bool needs_active; // custom_json with required_auths; false = posting tier + // Borrowed slices into the request's serialized_tx (NOT NUL-terminated): + const uint8_t* acct; // vote: voter / comment: author / cj: first auth name + uint16_t acct_len; + const uint8_t* target; // vote: author / comment: title / cj: id + uint16_t target_len; + const uint8_t* detail; // vote: permlink / comment: body / cj: json + uint16_t detail_len; + const uint8_t* parent_author; // comment only + uint16_t parent_author_len; + const uint8_t* + parent_permlink; // comment only (category for a top-level post) + uint16_t parent_permlink_len; + const uint8_t* permlink; // comment only: this post/reply's permlink + uint16_t permlink_len; + const uint8_t* json_metadata; // comment only + uint16_t json_metadata_len; + int16_t weight; // vote (-10000..10000), or a 0..10000 basis-point + // percent (comment_options percent_hbd, + // set_withdraw_vesting_route percent) + bool is_top_level; // comment only: parent_author empty + uint8_t n_auths; // custom_json only: total auth account names + const uint8_t* auth_acct[HIVE_MAX_CUSTOM_JSON_AUTHS]; + uint16_t auth_acct_len[HIVE_MAX_CUSTOM_JSON_AUTHS]; + + // ── Phase-3 op fields ─────────────────────────────────────────────────── + // Borrowed HIVE_ASSET_LEN-byte asset slices in the op's own field order: + // transfer_to_vesting/convert/claim_account/savings: [0] = amount + // withdraw_vesting/delegate_vesting_shares: [0] = vesting_shares + // limit_order_create: [0] = amount_to_sell, [1] = min_to_receive + // claim_reward_balance: [0] = HIVE, [1] = HBD, [2] = VESTS + // comment_options: [0] = max_accepted_payout + const uint8_t* assets[HIVE_MAX_OP_ASSETS]; + uint8_t n_assets; + uint32_t req_id; // convert requestid / savings request_id / order id + uint32_t expiration; // limit_order_create only + bool flag; // fill_or_kill / approve / auto_vest / allow_votes + bool flag2; // comment_options: allow_curation_rewards + uint8_t n_benef; // comment_options: beneficiary count (0 = none) + const uint8_t* benef_acct[HIVE_MAX_BENEFICIARIES]; + uint16_t benef_acct_len[HIVE_MAX_BENEFICIARIES]; + uint16_t benef_weight[HIVE_MAX_BENEFICIARIES]; // basis points +} HiveTxOp; + +typedef struct { + uint8_t num_ops; + bool needs_active; // tx tier: active' path required, else posting' + HiveTxOp ops[HIVE_MAX_TX_OPS]; +} HiveParsedTx; + +/** + * Parse and validate a host-serialized Graphene transaction against the + * device clear-sign op table. Returns NULL on success or a static error + * message. Slices in `out` borrow from `tx` — keep it alive. + * + * Ops 2 (transfer), 9 (account_create) and 10 (account_update) are + * permanently excluded; everything not in the table is refused outright — + * there is no blind-sign fallback. + */ +const char* hive_parseOperations(const uint8_t* tx, size_t len, + HiveParsedTx* out); + +/** + * Accessors for a HIVE_ASSET_LEN-byte asset slice stored in HiveTxOp.assets. + * The parser has already validated the symbol/precision pair, so the symbol + * is always a NUL-terminated "HIVE" / "HBD" / "VESTS" and the amount is + * non-negative. + */ +uint64_t hive_assetAmount(const uint8_t* asset); +uint8_t hive_assetPrecision(const uint8_t* asset); +const char* hive_assetSymbol(const uint8_t* asset); + +/** + * Sign a parsed HiveSignOperations transaction: digest is + * SHA256(chain_id || serialized_tx), identical to HiveSignTx. The caller + * (FSM handler) is responsible for parsing, display, and role checks. + */ +void hive_signOperations(const HDNode* node, const HiveSignOperations* msg, + HiveSignedOperations* resp); + +/** + * Sign an arbitrary message per the Hive Keychain signBuffer contract: + * signature over SHA256(message bytes) only — no chain_id prepend, no + * message prefix. Emits the 65-byte compact recoverable signature plus the + * signing key's 33-byte compressed public key. + */ +void hive_signMessage(const HDNode* node, const HiveSignMessage* msg, + HiveSignedMessage* resp); + +/** + * True iff every byte is printable ASCII (0x20-0x7e). Hive message signing + * requires this: a transaction digest is SHA256(chain_id || serialized_tx) + * whose chain_id and serialized fields are binary, so a printable-only message + * domain can never collide with a transaction preimage on ANY chain id. This + * closes the cross-chain message→transaction signature oracle that a + * mainnet-only prefix reject cannot. Empty (len == 0) returns true. + */ +bool hive_message_is_printable(const uint8_t* message, size_t len); + +/** + * Sign a Hive account_create transaction (op type 9). + * owner/active/posting/memo_raw must be device-derived 33-byte compressed keys. + * The firmware uses these directly; host-supplied key strings in msg are + * ignored. + */ +void hive_signAccountCreate(const HDNode* signing_node, + const HiveSignAccountCreate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountCreate* resp); + +/** + * Sign a Hive account_update transaction (op type 10). + * owner/active/posting/memo_raw must be device-derived 33-byte compressed keys. + * The firmware uses these directly; host-supplied new_*_key strings in msg are + * ignored. + */ +void hive_signAccountUpdate(const HDNode* signing_node, + const HiveSignAccountUpdate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountUpdate* resp); + +#endif // KEEPKEY_FIRMWARE_HIVE_H diff --git a/include/keepkey/transport/interface.h b/include/keepkey/transport/interface.h index ab435baf8..6ec14d24e 100644 --- a/include/keepkey/transport/interface.h +++ b/include/keepkey/transport/interface.h @@ -39,6 +39,7 @@ #include "messages-ton.pb.h" #include "messages-solana.pb.h" #include "messages-zcash.pb.h" +#include "messages-hive.pb.h" #include "types.pb.h" #include "trezor_transport.h" diff --git a/include/keepkey/transport/messages-hive.options b/include/keepkey/transport/messages-hive.options new file mode 100644 index 000000000..d39d59215 --- /dev/null +++ b/include/keepkey/transport/messages-hive.options @@ -0,0 +1,58 @@ +HiveGetPublicKey.address_n max_count:8 + +HivePublicKey.public_key max_size:64 +HivePublicKey.raw_public_key max_size:33 + +HiveGetPublicKeys.account_index int_size:IS_32 + +HivePublicKeys.owner_key max_size:64 +HivePublicKeys.active_key max_size:64 +HivePublicKeys.memo_key max_size:64 +HivePublicKeys.posting_key max_size:64 + +HiveSignTx.address_n max_count:8 +HiveSignTx.chain_id max_size:32 +HiveSignTx.from max_size:16 +HiveSignTx.to max_size:16 +HiveSignTx.amount int_size:IS_64 +HiveSignTx.asset_symbol max_size:10 +HiveSignTx.memo max_size:2048 + +HiveSignedTx.signature max_size:65 +HiveSignedTx.serialized_tx max_size:512 + +HiveSignAccountCreate.address_n max_count:8 +HiveSignAccountCreate.chain_id max_size:32 +HiveSignAccountCreate.creator max_size:16 +HiveSignAccountCreate.new_account_name max_size:16 +HiveSignAccountCreate.owner_key max_size:64 +HiveSignAccountCreate.active_key max_size:64 +HiveSignAccountCreate.posting_key max_size:64 +HiveSignAccountCreate.memo_key max_size:64 +HiveSignAccountCreate.fee_amount int_size:IS_64 + +HiveSignedAccountCreate.signature max_size:65 +HiveSignedAccountCreate.serialized_tx max_size:512 + +HiveSignAccountUpdate.address_n max_count:8 +HiveSignAccountUpdate.chain_id max_size:32 +HiveSignAccountUpdate.account max_size:16 +HiveSignAccountUpdate.new_owner_key max_size:64 +HiveSignAccountUpdate.new_active_key max_size:64 +HiveSignAccountUpdate.new_posting_key max_size:64 +HiveSignAccountUpdate.new_memo_key max_size:64 + +HiveSignedAccountUpdate.signature max_size:65 +HiveSignedAccountUpdate.serialized_tx max_size:512 + +HiveSignMessage.address_n max_count:8 +HiveSignMessage.message max_size:1024 + +HiveSignedMessage.signature max_size:65 +HiveSignedMessage.public_key max_size:33 + +HiveSignOperations.address_n max_count:8 +HiveSignOperations.chain_id max_size:32 +HiveSignOperations.serialized_tx max_size:2048 + +HiveSignedOperations.signature max_size:65 diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index fecbf5835..1247b246b 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -21,6 +21,7 @@ set(sources ethereum_contracts/zxswap.c ethereum_tokens.c fsm.c + hive.c home_sm.c mayachain.c nano.c diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c index 3653ea7b0..93d639564 100644 --- a/lib/firmware/fsm.c +++ b/lib/firmware/fsm.c @@ -59,6 +59,7 @@ #include "keepkey/firmware/signed_metadata.h" #include "keepkey/firmware/solana.h" #include "keepkey/firmware/zcash.h" +#include "keepkey/firmware/hive.h" #include "keepkey/firmware/storage.h" #include "keepkey/firmware/tendermint.h" #include "keepkey/firmware/thorchain.h" @@ -94,6 +95,7 @@ #include "messages-ton.pb.h" #include "messages-solana.pb.h" #include "messages-zcash.pb.h" +#include "messages-hive.pb.h" #include @@ -299,6 +301,7 @@ void fsm_msgClearSession(ClearSession* msg) { #include "fsm_msg_tron.h" #include "fsm_msg_ton.h" #include "fsm_msg_solana.h" +#include "fsm_msg_hive.h" /* After fsm_msg_solana.h: reuses its base58 helper and the KKSOLSC1 parser. */ #include "fsm_msg_clearsign_attestor.h" #if ZCASH_PRIVACY diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h new file mode 100644 index 000000000..d87188a14 --- /dev/null +++ b/lib/firmware/fsm_msg_hive.h @@ -0,0 +1,1024 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +// ── HiveGetPublicKey ────────────────────────────────────────────────────── +// Returns a single STM-prefixed public key for the given SLIP-0048 path. +// Path format: m/48'/13'/role'/account'/0' (all 5 components hardened). + +void fsm_msgHiveGetPublicKey(const HiveGetPublicKey* msg) { + RESP_INIT(HivePublicKey); + + CHECK_INITIALIZED + CHECK_PIN + + if (!hive_slip48_path_valid(msg->address_n, msg->address_n_count)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + resp->has_raw_public_key = true; + resp->raw_public_key.size = 33; + memcpy(resp->raw_public_key.bytes, node->public_key, 33); + + resp->has_public_key = true; + if (!hive_getPublicKey(node->public_key, resp->public_key, + sizeof(resp->public_key))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to encode Hive public key")); + layoutHome(); + return; + } + + if (msg->has_show_display && msg->show_display) { + // Label the key by the role in the ACTUAL derivation path + // (m/48'/13'/role'/account'/0'), never the host-supplied msg->role, + // which could mislabel the exported key. + const char* role_label = "Hive Public Key"; + if (msg->address_n_count >= 3) { + switch (msg->address_n[2] & 0x7FFFFFFFu) { + case 0: + role_label = "Hive Owner Key"; + break; + case 1: + role_label = "Hive Active Key"; + break; + case 3: + role_label = "Hive Memo Key"; + break; + case 4: + role_label = "Hive Posting Key"; + break; + default: + break; + } + } + if (!confirm_ethereum_address(role_label, resp->public_key)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Cancelled")); + layoutHome(); + return; + } + } + + memzero(node, sizeof(*node)); + msg_write(MessageType_MessageType_HivePublicKey, resp); + layoutHome(); +} + +// ── HiveGetPublicKeys ───────────────────────────────────────────────────── +// Returns all four SLIP-0048 role keys (owner/active/memo/posting) for a +// given account index in a single device interaction. + +void fsm_msgHiveGetPublicKeys(const HiveGetPublicKeys* msg) { + RESP_INIT(HivePublicKeys); + + CHECK_INITIALIZED + CHECK_PIN + + uint32_t account_index = msg->has_account_index ? msg->account_index : 0; + + HDNode* root = fsm_getDerivedNode(SECP256K1_NAME, NULL, 0, NULL); + if (!root) return; + + resp->has_owner_key = true; + resp->has_active_key = true; + resp->has_memo_key = true; + resp->has_posting_key = true; + + if (!hive_getPublicKeys(root, account_index, resp->owner_key, + sizeof(resp->owner_key), resp->active_key, + sizeof(resp->active_key), resp->memo_key, + sizeof(resp->memo_key), resp->posting_key, + sizeof(resp->posting_key))) { + memzero(root, sizeof(*root)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to derive Hive keys")); + layoutHome(); + return; + } + + if (msg->has_show_display && msg->show_display) { + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Hive Keys", + "Export all Hive keys for account %u?", + (unsigned int)account_index)) { + memzero(root, sizeof(*root)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Cancelled")); + layoutHome(); + return; + } + } + + memzero(root, sizeof(*root)); + msg_write(MessageType_MessageType_HivePublicKeys, resp); + layoutHome(); +} + +// ── SLIP-0048 path validation ───────────────────────────────────────────── +// All three sign handlers enforce the full path shape before anything is +// derived or signed: m/48'/13'/role'/account'/0' (all 5 components hardened), +// with the role pinned to the one the operation needs on-chain: +// transfer -> active' (post-HF28 hived no longer accepts higher-role +// substitution, and the cold owner key must not be spent) +// create/update -> owner' (the attestation contract: the sponsor verifies +// the signature recovers to the device OWNER key, and +// account_update replaces the owner authority itself) +// Rejecting arbitrary host paths means a compromised host can never make the +// device produce a Hive signature with a key from another coin's derivation +// tree, nor with the wrong role's key. + +static bool hive_slip48_path_ok(const uint32_t* address_n, uint32_t count, + uint32_t required_role) { + return hive_slip48_path_valid_for_role(address_n, count, required_role); +} + +static bool hive_confirm_slice(ButtonRequestType type, const char* title, + const uint8_t* s, uint16_t len); + +// ── HiveSignTx (transfer) ───────────────────────────────────────────────── + +void fsm_msgHiveSignTx(const HiveSignTx* msg) { + RESP_INIT(HiveSignedTx); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_from || !msg->has_to || !msg->has_amount || + !msg->has_ref_block_num || !msg->has_ref_block_prefix || + !msg->has_expiration) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing required Hive transaction fields")); + layoutHome(); + return; + } + + if (!hive_slip48_path_ok(msg->address_n, msg->address_n_count, + HIVE_ROLE_ACTIVE)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path (transfer needs active')")); + layoutHome(); + return; + } + + // Reject over-long memos up front with a specific error; the serializer's + // own bounds check would otherwise surface as a generic signing failure. + if (msg->has_memo && strlen(msg->memo) > HIVE_MAX_MEMO_LEN) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Hive memo too long (max 440 bytes)")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Display precision MUST match the precision the serializer signs + // (append_asset uses msg->decimals), otherwise the user approves an + // amount that differs from what is signed. Reject implausible precision. + uint8_t prec = msg->has_decimals ? (uint8_t)msg->decimals : HIVE_DECIMALS; + if (prec > 18) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive asset precision")); + layoutHome(); + return; + } + const char* symbol = msg->has_asset_symbol ? msg->asset_symbol : "HIVE"; + char suffix[sizeof(msg->asset_symbol) + 2]; // leading space + symbol + NUL + snprintf(suffix, sizeof(suffix), " %s", symbol); + char amount_str[32]; + bn_format_uint64(msg->amount, NULL, suffix, prec, 0, false, amount_str, + sizeof(amount_str)); + + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send Hive", + "Send %s to @%s?", amount_str, msg->to)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + if (msg->has_memo && strlen(msg->memo) > 0) { + if (!hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmMemo, "Memo", + (const uint8_t*)msg->memo, + (uint16_t)strlen(msg->memo))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + } + + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Transaction", + "Sign Hive transaction from @%s?", msg->from)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signTx(node, msg, resp); + memzero(node, sizeof(*node)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedTx, resp); + layoutHome(); +} + +typedef struct { + uint8_t owner[33]; + uint8_t active[33]; + uint8_t posting[33]; + uint8_t memo[33]; +} HiveRoleKeys; + +static bool hive_prepare_account_sign(const uint32_t* address_n, + uint32_t address_n_count, + HiveRoleKeys* keys, HDNode** node_out, + char* owner_stm, size_t owner_stm_len) { + if (!hive_slip48_path_ok(address_n, address_n_count, HIVE_ROLE_OWNER)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path (needs owner')")); + layoutHome(); + return false; + } + uint32_t account_index = address_n[3] & 0x7FFFFFFFu; + + // Derive all four role keys from the device root. + // Do this BEFORE fetching the signing node so the root static buffer + // is not clobbered by the second fsm_getDerivedNode call. + const HDNode* root = fsm_getDerivedNode(SECP256K1_NAME, NULL, 0, NULL); + if (!root) return false; + + uint32_t acc_hardened = account_index | 0x80000000u; + bool keys_ok = + hive_deriveRawKey(root, HIVE_ROLE_OWNER, acc_hardened, keys->owner) && + hive_deriveRawKey(root, HIVE_ROLE_ACTIVE, acc_hardened, keys->active) && + hive_deriveRawKey(root, HIVE_ROLE_POSTING, acc_hardened, keys->posting) && + hive_deriveRawKey(root, HIVE_ROLE_MEMO, acc_hardened, keys->memo); + // root static buffer is done with; signing node derivation may overwrite it. + + if (!keys_ok) { + memzero(keys, sizeof(*keys)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to derive Hive keys")); + layoutHome(); + return false; + } + + // Now get the signing node (owner key, overwrites root static buffer). + HDNode* node = + fsm_getDerivedNode(SECP256K1_NAME, address_n, address_n_count, NULL); + if (!node) { + memzero(keys, sizeof(*keys)); + return false; + } + hdnode_fill_public_key(node); + + // Encode the device-derived owner key for display confirmation. + if (!hive_getPublicKey(keys->owner, owner_stm, owner_stm_len)) { + memzero(node, sizeof(*node)); + memzero(keys, sizeof(*keys)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to encode Hive owner key")); + layoutHome(); + return false; + } + + *node_out = node; + return true; +} + +// ── HiveSignAccountCreate ───────────────────────────────────────────────── +// Signs a Graphene account_create operation. +// Device derives all four role keys internally; host-supplied key strings +// are informational only (displayed for confirmation) and never used for +// the actual transaction. KeepKey is the sole root of trust from genesis. + +void fsm_msgHiveSignAccountCreate(const HiveSignAccountCreate* msg) { + RESP_INIT(HiveSignedAccountCreate); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_new_account_name || !msg->has_creator || + !msg->has_ref_block_num || !msg->has_ref_block_prefix || + !msg->has_expiration) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing required account_create fields")); + layoutHome(); + return; + } + + HiveRoleKeys keys; + HDNode* node = NULL; + char owner_stm[64]; + if (!hive_prepare_account_sign(msg->address_n, msg->address_n_count, &keys, + &node, owner_stm, sizeof(owner_stm))) { + return; + } + + // Primary confirmation: show the new username prominently. + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Create Hive Account", + "Create @%s secured by KeepKey?\n\nAll keys from your device.", + msg->new_account_name)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Secondary confirmation: show device-derived owner key so user can verify. + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Owner Key", "%s", + owner_stm)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Tertiary confirmation: show sponsor + fee. + char fee_str[32]; + uint64_t fee = msg->has_fee_amount ? msg->fee_amount : 3000; + snprintf(fee_str, sizeof(fee_str), "%" PRIu64 ".%03" PRIu64 " HIVE", + fee / 1000, fee % 1000); + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Creation Fee", + "Fee: %s paid by @%s", fee_str, msg->creator)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signAccountCreate(node, msg, keys.owner, keys.active, keys.posting, + keys.memo, resp); + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive account_create signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedAccountCreate, resp); + layoutHome(); +} + +// ── HiveSignAccountUpdate ───────────────────────────────────────────────── +// Signs a Graphene account_update operation. +// Device derives all four new role keys internally; host-supplied new_*_key +// strings are not used for signing. The device-derived owner key is shown +// so the user can verify it matches their device before replacing all keys. + +void fsm_msgHiveSignAccountUpdate(const HiveSignAccountUpdate* msg) { + RESP_INIT(HiveSignedAccountUpdate); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_account || !msg->has_ref_block_num || + !msg->has_ref_block_prefix || !msg->has_expiration) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing required account_update fields")); + layoutHome(); + return; + } + + HiveRoleKeys keys; + HDNode* node = NULL; + char owner_stm[64]; + if (!hive_prepare_account_sign(msg->address_n, msg->address_n_count, &keys, + &node, owner_stm, sizeof(owner_stm))) { + return; + } + + // Warning: this replaces all existing keys. + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, + "Secure Hive Account", + "Replace ALL keys for @%s with KeepKey keys?\n\nOld keys will " + "be retired.", + msg->account)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Show device-derived owner key so user can verify it's their device. + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "New Owner Key", "%s", + owner_stm)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signAccountUpdate(node, msg, keys.owner, keys.active, keys.posting, + keys.memo, resp); + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive account_update signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedAccountUpdate, resp); + layoutHome(); +} + +// ── HiveSignMessage (Keychain signBuffer) ───────────────────────────────── +// The Hive dApp login primitive: Aioha / Keychain-SDK dApps authenticate by +// having the account sign a challenge string, then recover the pubkey and +// check it against the account's authority on-chain. Contract (hive-js +// Signature.signBuffer): sig over SHA256(raw message bytes) — no chain_id, +// no prefix. Roles: posting/active/memo, Keychain's requestSignBuffer +// surface. owner' is deliberately rejected — no consumer offers it, and the +// cold owner key must not be normalized into dApp flows. The full path +// shape is still enforced like the tx handlers. + +static bool hive_slip48_message_path_ok(const uint32_t* address_n, + uint32_t count, + const char** role_label) { + if (!hive_slip48_path_valid(address_n, count)) return false; + switch (address_n[2]) { + case HIVE_ROLE_ACTIVE: + *role_label = "active"; + return true; + case HIVE_ROLE_MEMO: + *role_label = "memo"; + return true; + case HIVE_ROLE_POSTING: + *role_label = "posting"; + return true; + default: + return false; + } +} + +void fsm_msgHiveSignMessage(const HiveSignMessage* msg) { + RESP_INIT(HiveSignedMessage); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_message || msg->message.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("Missing message")); + layoutHome(); + return; + } + + // Mirrors the proto max_size cap so proto and code can never disagree + // (the memo-length lesson from the transfer handler). + if (msg->message.size > HIVE_MAX_MESSAGE_LEN) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Hive message too long (max 1024 bytes)")); + layoutHome(); + return; + } + + // A Hive TRANSACTION digest is SHA256(chain_id || tx), and this message + // digest is SHA256(message) — so a "message" that begins with the mainnet + // chain-id bytes would hash to a broadcastable transaction's digest. No + // legitimate challenge starts with the chain id; refuse the collision. + const uint8_t hive_chain_id[HIVE_CHAIN_ID_LEN] = HIVE_CHAIN_ID; + if (msg->message.size >= HIVE_CHAIN_ID_LEN && + memcmp(msg->message.bytes, hive_chain_id, HIVE_CHAIN_ID_LEN) == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Message must not start with the Hive chain ID")); + layoutHome(); + return; + } + + const char* role_label = NULL; + if (!hive_slip48_message_path_ok(msg->address_n, msg->address_n_count, + &role_label)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Domain-separate messages from transactions. A Hive TRANSACTION digest is + // SHA256(chain_id || serialized_tx), where the 32-byte chain_id and the + // serialized Graphene fields (ref_block_prefix, expiration, ...) are BINARY. + // Constraining signable messages to printable ASCII puts them in a domain + // disjoint from every transaction preimage — for ANY chain id, not just + // mainnet — so a binary "message" equal to C || serialized_tx can no longer + // be signed into a valid transaction signature on a fork chain C. This is the + // real fix; the mainnet-only prefix reject above is a belt-and-suspenders + // subset of it. hive-js signBuffer signs printable challenges, so nothing + // legitimate is lost. (A prefix blacklist could never be complete because the + // host chooses the chain id; a printable-only whitelist is complete by + // construction against binary preimages.) + if (!hive_message_is_printable(msg->message.bytes, msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Hive messages must be printable text")); + layoutHome(); + return; + } + + // Page the FULL message (72-char ASCII pages) so no trailing content is ever + // truncated behind a benign-looking prefix, and name the signing key. + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, "Sign Hive Message", + "Signing with %s key", role_label) || + !hive_confirm_slice(ButtonRequestType_ButtonRequest_ProtectCall, + "Hive Message", msg->message.bytes, + (uint16_t)msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signMessage(node, msg, resp); + memzero(node, sizeof(*node)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive message signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedMessage, resp); + layoutHome(); +} + +// ── HiveSignOperations (parsed generic op signing) ──────────────────────── +// The host serializes the transaction; firmware parses the Graphene bytes, +// clear-signs the ops it recognizes (vote, comment, custom_json), and +// refuses everything else — no blind-sign fallback. Everything shown on the +// OLED is re-derived from the bytes being signed, so a host serializer bug +// can only produce a node rejection, never a silent wrong-sign. + +// Dedicated path validator: {posting', active'} ONLY, pinned to the tx tier. +// Do NOT fold into hive_slip48_message_path_ok — that one deliberately +// accepts memo' (a legitimate signBuffer target), but no Graphene operation +// uses memo authority; a memo-path vote must be refused here, not +// discovered at the chain. owner' is likewise excluded. +static bool hive_slip48_ops_path_ok(const uint32_t* address_n, uint32_t count, + bool needs_active) { + return hive_slip48_path_valid_for_role( + address_n, count, needs_active ? HIVE_ROLE_ACTIVE : HIVE_ROLE_POSTING); +} + +// User-controlled string fields are paged in full. Printable fields are shown +// as text; fields containing non-ASCII bytes are shown as complete hex rather +// than a short preview. Page boundaries are selected with the same font and +// word-wrapping calculation used by draw_string(), so no signed suffix can be +// pushed below the OLED's three visible body rows. + +static bool hive_slice_is_ascii(const uint8_t* s, uint16_t len) { + bool ascii = true; + for (uint16_t i = 0; i < len; i++) { + if (s[i] < 0x20 || s[i] > 0x7e) { + ascii = false; + break; + } + } + return ascii; +} + +static uint16_t hive_rendered_page_len(const uint8_t* s, uint16_t len, + bool ascii) { + if (len == 0) return 0; + + if (ascii) { + size_t candidate = len; + if (candidate >= BODY_CHAR_MAX) candidate = BODY_CHAR_MAX - 1; + return (uint16_t)calc_str_page(get_body_font(), (const char*)s, candidate, + BODY_WIDTH, BODY_ROWS); + } + + uint16_t candidate = len; + if (candidate > (BODY_CHAR_MAX - 1) / 2) candidate = (BODY_CHAR_MAX - 1) / 2; + char rendered[BODY_CHAR_MAX]; + for (uint16_t i = 0; i < candidate; i++) { + snprintf(rendered + 2 * i, 3, "%02x", s[i]); + } + size_t chars = calc_str_page(get_body_font(), rendered, 2 * candidate, + BODY_WIDTH, BODY_ROWS); + return (uint16_t)(chars / 2); +} + +static bool hive_confirm_slice(ButtonRequestType type, const char* title, + const uint8_t* s, uint16_t len) { + if (len == 0) return confirm(type, title, "(empty)"); + + bool ascii = hive_slice_is_ascii(s, len); + uint16_t pages = 0; + uint16_t offset = 0; + while (offset < len) { + uint16_t take = hive_rendered_page_len(s + offset, len - offset, ascii); + if (take == 0) return false; + offset = (uint16_t)(offset + take); + pages++; + } + + offset = 0; + for (uint16_t page = 0; page < pages; page++) { + uint16_t take = hive_rendered_page_len(s + offset, len - offset, ascii); + if (take == 0) return false; + + char page_title[TITLE_CHAR_MAX]; + if (pages > 1 || !ascii) { + snprintf(page_title, sizeof(page_title), + ascii ? "%s %u/%u" : "%s Hex %u/%u", title, (unsigned)(page + 1), + (unsigned)pages); + } else { + strlcpy(page_title, title, sizeof(page_title)); + } + + if (ascii) { + char rendered[BODY_CHAR_MAX]; + memcpy(rendered, s + offset, take); + rendered[take] = '\0'; + if (!confirm(type, page_title, "%s", rendered)) return false; + } else { + char rendered[BODY_CHAR_MAX]; + for (uint16_t i = 0; i < take; i++) { + snprintf(rendered + 2 * i, 3, "%02x", s[offset + i]); + } + if (!confirm(type, page_title, "%s", rendered)) return false; + } + offset = (uint16_t)(offset + take); + } + return true; +} + +// "1.234 HIVE" — precision comes from the asset bytes being signed, which +// the parser has already pinned to the symbol's protocol-fixed value. +static void hive_format_asset(const uint8_t* a, char* out, size_t out_len) { + char suffix[9]; // space + longest symbol ("VESTS") + NUL + snprintf(suffix, sizeof(suffix), " %s", hive_assetSymbol(a)); + bn_format_uint64(hive_assetAmount(a), NULL, suffix, hive_assetPrecision(a), 0, + false, out, out_len); +} + +// Basis points (0..10000) as "12.34%". +static void hive_format_percent(int16_t bp, char* out, size_t out_len) { + snprintf(out, out_len, "%d.%02d%%", bp / 100, bp % 100); +} + +static void hive_copy_slice(char* out, size_t out_len, const uint8_t* s, + uint16_t len) { + if (out_len == 0) return; + size_t take = len; + if (take >= out_len) take = out_len - 1; + memcpy(out, s, take); + out[take] = '\0'; +} + +void fsm_msgHiveSignOperations(const HiveSignOperations* msg) { + RESP_INIT(HiveSignedOperations); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_serialized_tx || msg->serialized_tx.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing serialized transaction")); + layoutHome(); + return; + } + + static HiveParsedTx parsed; // slices borrow from the static msg buffer + const char* parse_err = hive_parseOperations( + msg->serialized_tx.bytes, msg->serialized_tx.size, &parsed); + if (parse_err) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _(parse_err)); + layoutHome(); + return; + } + + if (!hive_slip48_ops_path_ok(msg->address_n, msg->address_n_count, + parsed.needs_active)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + parsed.needs_active + ? _("Invalid Hive SLIP-0048 path (needs active')") + : _("Invalid Hive SLIP-0048 path (needs posting')")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Confirm operation summaries and payloads, then show a final sign prompt. + for (uint8_t i = 0; i < parsed.num_ops; i++) { + const HiveTxOp* op = &parsed.ops[i]; + char name[17]; // hive account names are <= 16 chars, length-validated + hive_copy_slice(name, sizeof(name), op->acct, op->acct_len); + + bool approved = false; + switch (op->op_type) { + case HIVE_OP_VOTE: { + char target[17]; + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + int w = op->weight < 0 ? -op->weight : op->weight; + approved = + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + op->weight < 0 ? "Downvote" : "Vote", + "@%s -> @%s at %d.%02d%%", name, target, w / 100, w % 100); + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Vote Target", op->detail, op->detail_len); + } + break; + } + case HIVE_OP_COMMENT: { + char parent[17]; + hive_copy_slice(parent, sizeof(parent), op->parent_author, + op->parent_author_len); + approved = + op->is_top_level + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Post", + "Create post by @%s?", name) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Comment", "Reply by @%s to @%s?", name, parent); + if (approved) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, + op->is_top_level ? "Post Category" : "Reply Target", + op->parent_permlink, op->parent_permlink_len); + } + if (approved) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Post Permlink", + op->permlink, op->permlink_len); + } + if (approved && op->target_len > 0) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Post Title", op->target, op->target_len); + } + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Post Body", op->detail, op->detail_len); + } + if (approved && op->json_metadata_len > 0) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Post Metadata", + op->json_metadata, op->json_metadata_len); + } + break; + } + case HIVE_OP_CUSTOM_JSON: { + approved = true; + for (uint8_t a = 0; approved && a < op->n_auths; a++) { + char auth_name[17]; + hive_copy_slice(auth_name, sizeof(auth_name), op->auth_acct[a], + op->auth_acct_len[a]); + approved = confirm( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Custom JSON Auth", + "%u/%u: @%s\n%s key", (unsigned)(a + 1), (unsigned)op->n_auths, + auth_name, op->needs_active ? "Active" : "Posting"); + } + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Custom JSON ID", op->target, op->target_len); + } + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Custom JSON", op->detail, op->detail_len); + } + break; + } + case HIVE_OP_TRANSFER_TO_VESTING: { + char amount[40], target[17]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + approved = + op->target_len == 0 + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Power Up", "Power up\n%s\nto @%s", amount, name) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Power Up", "%s\nfrom @%s\nto @%s", amount, name, + target); + break; + } + case HIVE_OP_WITHDRAW_VESTING: { + char amount[40]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + approved = + hive_assetAmount(op->assets[0]) == 0 + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Stop Power Down", "Cancel power down\nfor @%s", name) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Power Down", "Power down\n%s\nfrom @%s", amount, + name); + break; + } + case HIVE_OP_LIMIT_ORDER_CREATE: { + char sell[40], receive[40]; + hive_format_asset(op->assets[0], sell, sizeof(sell)); + hive_format_asset(op->assets[1], receive, sizeof(receive)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Market Order", "@%s sells\n%s\nfor >= %s", name, + sell, receive); + if (approved) { + // Order id and fill_or_kill decide whether an unfilled order rests + // on the book or is discarded, so they get their own screen rather + // than being crowded off the first one. + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Order Terms", "Order #%u\n%s\nExpires %u", + (unsigned)op->req_id, + op->flag ? "Fill or kill" : "Rests on book", + (unsigned)op->expiration); + } + break; + } + case HIVE_OP_LIMIT_ORDER_CANCEL: + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Cancel Order", "Cancel order #%u\nfor @%s?", + (unsigned)op->req_id, name); + break; + case HIVE_OP_CONVERT: { + char amount[40]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Convert", "Convert %s\nto HIVE for @%s\n(#%u)", + amount, name, (unsigned)op->req_id); + break; + } + case HIVE_OP_COMMENT_OPTIONS: { + char max_payout[40], percent[16]; + hive_format_asset(op->assets[0], max_payout, sizeof(max_payout)); + hive_format_percent(op->weight, percent, sizeof(percent)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Payout Options", "@%s\nMax %s\nHBD split %s", name, + max_payout, percent); + if (approved) { + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Payout Options", "Votes: %s\nCuration: %s", + op->flag ? "allowed" : "disabled", + op->flag2 ? "allowed" : "disabled"); + } + // Beneficiaries divert payout to other accounts — each one is + // confirmed individually rather than summarized as a count. + for (uint8_t b = 0; approved && b < op->n_benef; b++) { + char benef[17], benef_pct[16]; + hive_copy_slice(benef, sizeof(benef), op->benef_acct[b], + op->benef_acct_len[b]); + hive_format_percent((int16_t)op->benef_weight[b], benef_pct, + sizeof(benef_pct)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Payout Beneficiary", "%u/%u: @%s\ngets %s", + (unsigned)(b + 1), (unsigned)op->n_benef, benef, + benef_pct); + } + if (approved) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Payout Permlink", + op->permlink, op->permlink_len); + } + break; + } + case HIVE_OP_TRANSFER_TO_SAVINGS: + case HIVE_OP_TRANSFER_FROM_SAVINGS: { + char amount[40], target[17]; + bool deposit = (op->op_type == HIVE_OP_TRANSFER_TO_SAVINGS); + hive_format_asset(op->assets[0], amount, sizeof(amount)); + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + // One variable per row. A 16-character account name sharing a row + // with a label can wrap into a fourth row, which the display drops + // silently — and here that row carries the destination account. + // req_id is deliberately not shown: it is a cancellation handle, not + // a fund-routing field, and crowding it in costs the destination row. + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + deposit ? "Savings Deposit" : "Savings Withdraw", + "%s\nfrom @%s\nto @%s", amount, name, target); + if (approved && op->detail_len > 0) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Savings Memo", op->detail, op->detail_len); + } + break; + } + case HIVE_OP_CLAIM_REWARD_BALANCE: { + char hive_amt[40], hbd_amt[40], vests_amt[40]; + hive_format_asset(op->assets[0], hive_amt, sizeof(hive_amt)); + hive_format_asset(op->assets[1], hbd_amt, sizeof(hbd_amt)); + hive_format_asset(op->assets[2], vests_amt, sizeof(vests_amt)); + // Three assets plus the account name cannot share one screen: the + // OLED body fits exactly three rows (layout.c places rows at y = + // 24/38/52 and draw_char_with_shift silently drops any glyph past + // y+height > 64), so a fourth row would be signed but never shown. + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Claim Rewards", "@%s claims\n%s\n%s", name, + hive_amt, hbd_amt); + if (approved) { + approved = + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Claim Rewards", "@%s claims\n%s", name, vests_amt); + } + break; + } + case HIVE_OP_DELEGATE_VESTING_SHARES: { + char amount[40], target[17]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + approved = + hive_assetAmount(op->assets[0]) == 0 + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Remove Delegation", + "@%s removes its\ndelegation to @%s?", name, target) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Delegate", "@%s delegates\n%s\nto @%s", name, amount, + target); + break; + } + case HIVE_OP_ACCOUNT_UPDATE2: + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Profile Update", "Update profile\nof @%s?", name); + if (approved && op->detail_len > 0) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Account Metadata", + op->detail, op->detail_len); + } + if (approved && op->json_metadata_len > 0) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Profile Metadata", + op->json_metadata, op->json_metadata_len); + } + break; + default: + break; // unreachable — parser rejected unknown ops + } + if (!approved) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + } + + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Transaction", + "Sign %u Hive operation%s with the %s key?", + (unsigned)parsed.num_ops, parsed.num_ops == 1 ? "" : "s", + parsed.needs_active ? "active" : "posting")) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signOperations(node, msg, resp); + memzero(node, sizeof(*node)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive operation signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedOperations, resp); + layoutHome(); +} diff --git a/lib/firmware/hive.c b/lib/firmware/hive.c new file mode 100644 index 000000000..304f673ae --- /dev/null +++ b/lib/firmware/hive.c @@ -0,0 +1,1096 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#include "keepkey/firmware/hive.h" + +#include "trezor/crypto/base58.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/sha2.h" + +#include +#include + +// ── STM public key encoding ─────────────────────────────────────────────── + +bool hive_getPublicKey(const uint8_t public_key[33], char* out, + size_t out_len) { + const size_t prefix_len = strlen(HIVE_PUBKEY_PREFIX); + if (out_len < prefix_len + 1) return false; + strlcpy(out, HIVE_PUBKEY_PREFIX, out_len); + // Graphene uses RIPEMD checksum (not SHA256d) for public key encoding + return base58_encode_check(public_key, 33, HASHER_RIPEMD, out + prefix_len, + out_len - prefix_len); +} + +// ── Single-role key derivation to raw 33 bytes ──────────────────────────── +// Path: m/48'/13'/role_hardened/account_index_hardened/0' +// hdnode_private_ckd() returns 1 on success, 0 on failure. + +static bool hive_role_valid(uint32_t role) { + return role == HIVE_ROLE_OWNER || role == HIVE_ROLE_ACTIVE || + role == HIVE_ROLE_MEMO || role == HIVE_ROLE_POSTING; +} + +bool hive_slip48_path_valid(const uint32_t* address_n, size_t count) { + if (!address_n || count != 5) return false; + if (address_n[0] != HIVE_SLIP48_PURPOSE) return false; + if (address_n[1] != HIVE_SLIP48_NETWORK) return false; + if (!hive_role_valid(address_n[2])) return false; + if ((address_n[3] & 0x80000000u) == 0) return false; + if (address_n[4] != 0x80000000u) return false; + return true; +} + +bool hive_slip48_path_valid_for_role(const uint32_t* address_n, size_t count, + uint32_t required_role) { + return hive_role_valid(required_role) && + hive_slip48_path_valid(address_n, count) && + address_n[2] == required_role; +} + +bool hive_deriveRawKey(const HDNode* root, uint32_t role_hardened, + uint32_t account_index_hardened, uint8_t out[33]) { + HDNode node; + memcpy(&node, root, sizeof(HDNode)); + if (!hdnode_private_ckd(&node, HIVE_SLIP48_PURPOSE)) goto fail; + if (!hdnode_private_ckd(&node, HIVE_SLIP48_NETWORK)) goto fail; + if (!hdnode_private_ckd(&node, role_hardened)) goto fail; + if (!hdnode_private_ckd(&node, account_index_hardened)) goto fail; + if (!hdnode_private_ckd(&node, 0x80000000u)) goto fail; + hdnode_fill_public_key(&node); + memcpy(out, node.public_key, 33); + memzero(&node, sizeof(node)); + return true; +fail: + memzero(&node, sizeof(node)); + return false; +} + +// ── SLIP-0048 multi-role key derivation ─────────────────────────────────── + +bool hive_getPublicKeys(const HDNode* root, uint32_t account_index, + char* owner_out, size_t owner_len, char* active_out, + size_t active_len, char* memo_out, size_t memo_len, + char* posting_out, size_t posting_len) { + const uint32_t roles[4] = { + HIVE_ROLE_OWNER, + HIVE_ROLE_ACTIVE, + HIVE_ROLE_MEMO, + HIVE_ROLE_POSTING, + }; + char* outs[4] = {owner_out, active_out, memo_out, posting_out}; + const size_t lens[4] = {owner_len, active_len, memo_len, posting_len}; + + uint32_t account_hardened = account_index | 0x80000000u; + + for (int i = 0; i < 4; i++) { + uint8_t raw[33]; + if (!hive_deriveRawKey(root, roles[i], account_hardened, raw)) return false; + if (!hive_getPublicKey(raw, outs[i], lens[i])) { + memzero(raw, sizeof(raw)); + return false; + } + memzero(raw, sizeof(raw)); + } + return true; +} + +// ── Graphene binary serialization helpers ───────────────────────────────── + +static void append_u8(uint8_t** buf, const uint8_t* end, uint8_t v) { + if (*buf < end) { + **buf = v; + (*buf)++; + } +} + +static void append_u16_le(uint8_t** buf, const uint8_t* end, uint16_t v) { + append_u8(buf, end, v & 0xFF); + append_u8(buf, end, (v >> 8) & 0xFF); +} + +static void append_u32_le(uint8_t** buf, const uint8_t* end, uint32_t v) { + append_u8(buf, end, v & 0xFF); + append_u8(buf, end, (v >> 8) & 0xFF); + append_u8(buf, end, (v >> 16) & 0xFF); + append_u8(buf, end, (v >> 24) & 0xFF); +} + +static void append_u64_le(uint8_t** buf, const uint8_t* end, uint64_t v) { + for (int i = 0; i < 8; i++) { + append_u8(buf, end, v & 0xFF); + v >>= 8; + } +} + +static void append_varint(uint8_t** buf, const uint8_t* end, uint64_t v) { + do { + uint8_t b = v & 0x7F; + v >>= 7; + if (v) b |= 0x80; + append_u8(buf, end, b); + } while (v); +} + +static void append_string(uint8_t** buf, const uint8_t* end, const char* s) { + size_t len = s ? strlen(s) : 0; + append_varint(buf, end, len); + for (size_t i = 0; i < len && *buf < end; i++) + append_u8(buf, end, (uint8_t)s[i]); +} + +/* + * Graphene asset encoding: int64 LE amount + uint8 precision + 7-byte symbol + */ +static void append_asset(uint8_t** buf, const uint8_t* end, uint64_t amount, + uint8_t precision, const char* symbol) { + append_u64_le(buf, end, amount); + append_u8(buf, end, precision); + char sym[7] = {0}; + if (symbol) strncpy(sym, symbol, 6); + for (int i = 0; i < 7 && *buf < end; i++) + append_u8(buf, end, (uint8_t)sym[i]); +} + +/* + * Graphene authority structure (Hive wire format): + * weight_threshold (uint32 LE) = 1 + * num_account_auths (varint) = 0 + * num_key_auths (varint) = 1 + * compressed public key (33 bytes, no type prefix) + * weight (uint16 LE) = 1 + * + * Note: Hive does NOT use a key-type prefix byte before the 33 raw bytes. + */ +static void append_authority(uint8_t** buf, const uint8_t* end, + const uint8_t pubkey[33]) { + append_u32_le(buf, end, 1); // weight_threshold = 1 + append_varint(buf, end, 0); // 0 account auths + append_varint(buf, end, 1); // 1 key auth + for (int i = 0; i < 33 && *buf < end; i++) append_u8(buf, end, pubkey[i]); + append_u16_le(buf, end, 1); // weight = 1 +} + +/* + * Common transaction header: ref_block_num, ref_block_prefix, expiration, + * then a varint op count = 1, then the op type varint. + */ +static void append_tx_header(uint8_t** buf, const uint8_t* end, + uint16_t ref_block_num, uint32_t ref_block_prefix, + uint32_t expiration, uint32_t op_type) { + append_u16_le(buf, end, ref_block_num); + append_u32_le(buf, end, ref_block_prefix); + append_u32_le(buf, end, expiration); + append_varint(buf, end, 1); // 1 operation + append_varint(buf, end, op_type); +} + +static void append_tx_footer(uint8_t** buf, const uint8_t* end) { + append_varint(buf, end, 0); // 0 extensions +} + +/* + * Graphene legacy canonical-signature rule (identical to EOS/Steem): high bit + * of both r and s must be clear — same predicate as eos_is_canonic. Modern + * hived (post-HF28) actually enforces only BIP-0062 low-S (fc is_canonical -> + * is_bip_0062_canonical), which trezor-crypto's low-S normalization already + * guarantees; keeping the stricter legacy rule costs an occasional extra + * RFC6979 iteration and stays compatible with every historical verifier. + */ +static int hive_is_canonic(uint8_t v, uint8_t signature[64]) { + (void)v; + return !(signature[0] & 0x80) && + !(signature[0] == 0 && !(signature[1] & 0x80)) && + !(signature[32] & 0x80) && + !(signature[32] == 0 && !(signature[33] & 0x80)); +} + +/* + * Core sign helper over an already-computed 32-byte digest → 65-byte + * compact recoverable sig: header (27 + recovery_id + 4 compressed-key + * flag), then r(32) ‖ s(32). + */ +static bool hive_sign_raw_digest(const HDNode* node, const uint8_t digest[32], + uint8_t sig[65]) { + uint8_t pby; + if (ecdsa_sign_digest(&secp256k1, node->private_key, digest, sig + 1, &pby, + hive_is_canonic) != 0) { + return false; + } + // Compact signature header: 27 + recovery_id + 4 (compressed key flag) + sig[0] = 27 + pby + 4; + return true; +} + +/* + * Transaction sign helper: SHA256(chain_id || serialized_tx) → compact sig. + * Writes 65 bytes into sig[]. Returns true on success. + */ +static bool hive_sign_digest(const HDNode* node, const uint8_t* chain_id, + const uint8_t* tx_buf, size_t tx_len, + uint8_t sig[65]) { + SHA256_CTX sha; + sha256_Init(&sha); + sha256_Update(&sha, chain_id, HIVE_CHAIN_ID_LEN); + sha256_Update(&sha, tx_buf, tx_len); + uint8_t digest[32]; + sha256_Final(&sha, digest); + + bool ok = hive_sign_raw_digest(node, digest, sig); + memzero(digest, sizeof(digest)); + return ok; +} + +/* + * Chain-id select (host-supplied 32-byte chain_id or mainnet default) + + * hive_sign_digest, writing the 65-byte compact signature into sig[]. + */ +static bool hive_sign_tx_sig(const HDNode* node, bool has_chain_id, + const uint8_t* chain_id_bytes, + size_t chain_id_size, const uint8_t* tx_buf, + size_t tx_len, uint8_t sig[65]) { + const uint8_t default_chain_id[32] = HIVE_CHAIN_ID; + /* Pin to Hive mainnet. A host-supplied chain_id is accepted only if it equals + * mainnet; any other value is refused rather than signed under an undisclosed + * network domain (the confirmations just say "Hive"). This also keeps the tx + * digest domain singular — SHA256(mainnet_chain_id || tx) — so the + * message-signing guard that rejects messages beginning with the mainnet + * chain id fully closes the tx/message signature collision. */ + if (has_chain_id) { + if (chain_id_size != HIVE_CHAIN_ID_LEN || + memcmp(chain_id_bytes, default_chain_id, HIVE_CHAIN_ID_LEN) != 0) { + return false; + } + } + return hive_sign_digest(node, default_chain_id, tx_buf, tx_len, sig); +} + +// ── Parsed operation signing (HiveSignOperations) ───────────────────────── +// +// The host serializes the transaction; firmware re-derives everything it +// displays from the bytes and refuses anything outside the phase-1 op table. +// Digest/signature are identical to HiveSignTx: SHA256(chain_id || tx). + +typedef struct { + const uint8_t* p; + const uint8_t* end; +} HiveCur; + +/* + * Bounded unsigned LEB128: at most 5 bytes, must fit uint32, overlong + * encodings rejected (an unbounded shift is a classic overflow hole). + */ +static bool cur_varint(HiveCur* c, uint32_t* out) { + uint32_t v = 0; + for (int shift = 0; shift <= 28; shift += 7) { + if (c->p >= c->end) return false; + uint8_t b = *c->p++; + if (shift == 28 && (b & 0xF0)) return false; // overflow or 6th byte + v |= (uint32_t)(b & 0x7F) << shift; + if (!(b & 0x80)) { + // A multi-byte LEB128 whose final group is zero has a shorter encoding. + // hived re-serializes values canonically when checking a signature, so + // accepting an overlong form would make the device sign bytes the chain + // interprets and hashes differently. + if (shift > 0 && (b & 0x7F) == 0) return false; + *out = v; + return true; + } + } + return false; +} + +/* varint length + bytes, bounds-checked against the buffer AND field caps. */ +static bool cur_string(HiveCur* c, const uint8_t** s, uint16_t* slen, + uint32_t min_len, uint32_t max_len) { + uint32_t n; + if (!cur_varint(c, &n)) return false; + if (n < min_len || n > max_len) return false; + if ((size_t)(c->end - c->p) < n) return false; + *s = c->p; + *slen = (uint16_t)n; + c->p += n; + return true; +} + +/* + * Hive account names are rendered in compact multi-field confirmation screens, + * so they must be valid protocol names rather than arbitrary byte strings. + * This rejects embedded NUL/newline/control bytes that would truncate or + * reshape the OLED while later bytes remained covered by the signature. + */ +static bool hive_account_name_valid(const uint8_t* s, uint16_t len, + bool allow_empty) { + if (len == 0) return allow_empty; + if (len < 3 || len > 16) return false; + + bool at_segment_start = true; + bool previous_hyphen = false; + for (uint16_t i = 0; i < len; i++) { + uint8_t ch = s[i]; + if (at_segment_start) { + if (ch < 'a' || ch > 'z') return false; + at_segment_start = false; + previous_hyphen = false; + } else if (ch == '.') { + if (previous_hyphen || i + 1 == len) return false; + at_segment_start = true; + } else if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) { + previous_hyphen = false; + } else if (ch == '-') { + previous_hyphen = true; + } else { + return false; + } + } + return !at_segment_start && !previous_hyphen; +} + +static bool cur_account(HiveCur* c, const uint8_t** s, uint16_t* slen, + bool allow_empty) { + if (!cur_string(c, s, slen, allow_empty ? 0 : 1, 16)) return false; + return hive_account_name_valid(*s, *slen, allow_empty); +} + +static int hive_slice_cmp(const uint8_t* a, uint16_t a_len, const uint8_t* b, + uint16_t b_len) { + uint16_t min_len = a_len < b_len ? a_len : b_len; + int cmp = memcmp(a, b, min_len); + if (cmp != 0) return cmp; + return (a_len > b_len) - (a_len < b_len); +} + +/* Fixed-width little-endian readers, bounds-checked against the buffer. */ +static bool cur_u16(HiveCur* c, uint16_t* out) { + if ((size_t)(c->end - c->p) < 2) return false; + *out = (uint16_t)((uint16_t)c->p[0] | ((uint16_t)c->p[1] << 8)); + c->p += 2; + return true; +} + +static bool cur_u32(HiveCur* c, uint32_t* out) { + if ((size_t)(c->end - c->p) < 4) return false; + *out = (uint32_t)c->p[0] | ((uint32_t)c->p[1] << 8) | + ((uint32_t)c->p[2] << 16) | ((uint32_t)c->p[3] << 24); + c->p += 4; + return true; +} + +/* + * Graphene serializes bool as one byte. Anything other than 0/1 is a host + * serializer bug, not a truthy value — reject rather than normalize, so a + * malformed fill_or_kill or allow_votes can never be silently coerced. + */ +static bool cur_bool(HiveCur* c, bool* out) { + if (c->p >= c->end) return false; + uint8_t b = *c->p++; + if (b > 1) return false; + *out = (b == 1); + return true; +} + +uint64_t hive_assetAmount(const uint8_t* asset) { + uint64_t v = 0; + for (int i = 7; i >= 0; i--) v = (v << 8) | asset[i]; + return v; +} + +uint8_t hive_assetPrecision(const uint8_t* asset) { return asset[8]; } + +// Wire symbol → display symbol. The chain serializes the pre-rebrand names; +// the user knows the post-rebrand ones. cur_asset() has already validated the +// symbol and its NUL padding, so the compares below are exact. +const char* hive_assetSymbol(const uint8_t* asset) { + const char* sym = (const char*)(asset + 9); + if (memcmp(sym, "STEEM", 6) == 0) return "HIVE"; + if (memcmp(sym, "SBD", 4) == 0) return "HBD"; + return sym; +} + +/* + * One 16-byte Graphene asset: int64 LE amount, uint8 precision, 7-byte + * NUL-padded symbol. + * + * The symbol must be in `allowed` and carry its protocol-fixed precision. + * Both checks are load-bearing for display integrity: an unexpected symbol + * lets a host swap VESTS for HIVE (a ~2000x difference in real value behind + * an identical-looking number), and a wrong precision moves the decimal + * point on the confirmation screen relative to what the chain applies. + */ +static bool cur_asset(HiveCur* c, const uint8_t** out, uint32_t allowed) { + if ((size_t)(c->end - c->p) < HIVE_ASSET_LEN) return false; + const uint8_t* a = c->p; + const uint8_t* sym = a + 9; + + uint32_t bit; + uint8_t want_precision; + size_t sym_len; + // WIRE symbols, not display symbols: the 2020 rebrand renamed the tokens but + // NOT their on-chain serialization, so hived still encodes HIVE as "STEEM" + // and HBD as "SBD". Accepting the display spellings would let us sign bytes + // hived can never validate — its signature check re-serializes the operation + // and recovers a key from different bytes, surfacing as the misleading + // "missing required active authority". hive_assetSymbol() maps back for the + // OLED so the user still reads HIVE/HBD. + if (memcmp(sym, "STEEM", 5) == 0) { + bit = HIVE_SYM_HIVE; + want_precision = 3; + sym_len = 5; + } else if (memcmp(sym, "SBD", 3) == 0) { + bit = HIVE_SYM_HBD; + want_precision = 3; + sym_len = 3; + } else if (memcmp(sym, "VESTS", 5) == 0) { + bit = HIVE_SYM_VESTS; + want_precision = 6; + sym_len = 5; + } else { + return false; + } + // The prefix compares above would also accept a longer symbol sharing the + // prefix ("HBDX"); the padding check is what makes them exact, and it also + // guarantees hive_assetSymbol() returns a NUL-terminated C string. + for (size_t i = sym_len; i < 7; i++) { + if (sym[i] != 0) return false; + } + if (!(bit & allowed)) return false; + if (a[8] != want_precision) return false; + // Every asset field in this table is a quantity. A negative int64 would + // render as an enormous positive number through the unsigned formatter. + if (a[7] & 0x80) return false; + + *out = a; + c->p += HIVE_ASSET_LEN; + return true; +} + +/* + * Shared rejection reasons. + * + * These are diagnostics, not security surface: the protection is that the + * device REFUSES, and the host already knows which operation it sent. One + * bespoke sentence per failure site cost ~1.8KB of rodata on a part with + * single-digit KB of flash left, so failures are grouped by reason instead. + * The three that carry a distinct security meaning — an authority rotation, + * a detached comment_options, a wrong-tier request — stay separate so they + * are never confused with an ordinary parse failure in a bug report. + */ +static const char E_MALFORMED[] = "Hive tx: malformed operation"; +static const char E_RANGE[] = "Hive tx: value out of range"; +static const char E_AMOUNT[] = "Hive tx: amount must be greater than zero"; +static const char E_NOOP[] = "Hive tx: operation has no effect"; +static const char E_EXTENSIONS[] = "Hive tx: extensions must be empty"; +static const char E_BENEFICIARIES[] = "Hive tx: invalid beneficiaries"; +static const char E_SYMBOLS[] = "Hive tx: order symbols must differ"; +static const char E_AUTHORITY[] = "Hive tx: authority changes not supported"; +static const char E_BINDING[] = + "Hive tx: comment_options must follow its comment"; +static const char E_MIXED_TIER[] = "Hive tx: mixed posting/active ops"; + +const char* hive_parseOperations(const uint8_t* tx, size_t len, + HiveParsedTx* out) { + memzero(out, sizeof(*out)); + // 10-byte header + op_count varint + extensions varint is the structural + // minimum; op bodies are bounds-checked as they parse. + if (len < 12) return "Hive tx too short"; + if (len > HIVE_MAX_OPS_TX_LEN) return "Hive tx too long"; // = proto cap + + // Header (ref_block_num u16, ref_block_prefix u32, expiration u32) is + // covered by the signature but carries nothing to confirm on-device. + HiveCur c = {tx + 10, tx + len}; + + uint32_t op_count; + if (!cur_varint(&c, &op_count)) return E_MALFORMED; + if (op_count < 1 || op_count > HIVE_MAX_TX_OPS) + return "Hive tx: op count must be 1-4"; + out->num_ops = (uint8_t)op_count; + + bool any_posting = false, any_active = false; + + for (uint32_t i = 0; i < op_count; i++) { + HiveTxOp* op = &out->ops[i]; + uint32_t op_type; + if (!cur_varint(&c, &op_type)) return E_MALFORMED; + op->op_type = op_type; + + switch (op_type) { + case HIVE_OP_VOTE: { // posting authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_string(&c, &op->detail, &op->detail_len, 1, 256)) + return E_MALFORMED; + if ((size_t)(c.end - c.p) < 2) return E_MALFORMED; + int16_t w = (int16_t)((uint16_t)c.p[0] | ((uint16_t)c.p[1] << 8)); + c.p += 2; + if (w < -10000 || w > 10000) return E_RANGE; + op->weight = w; + any_posting = true; + break; + } + case HIVE_OP_COMMENT: { // posting authority + const uint8_t *pa, *ppl, *permlink, *jm; + uint16_t pa_len, ppl_len, permlink_len, jm_len; + if (!cur_account(&c, &pa, &pa_len, true) || + !cur_string(&c, &ppl, &ppl_len, 1, 256) || + !cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_string(&c, &permlink, &permlink_len, 1, 256) || + !cur_string(&c, &op->target, &op->target_len, 0, 256) || + !cur_string(&c, &op->detail, &op->detail_len, 1, + HIVE_MAX_OPS_TX_LEN) || + !cur_string(&c, &jm, &jm_len, 0, HIVE_MAX_OPS_TX_LEN)) + return E_MALFORMED; + op->parent_author = pa; + op->parent_author_len = pa_len; + op->parent_permlink = ppl; + op->parent_permlink_len = ppl_len; + op->permlink = permlink; + op->permlink_len = permlink_len; + op->json_metadata = jm; + op->json_metadata_len = jm_len; + op->is_top_level = (pa_len == 0); + any_posting = true; + break; + } + case HIVE_OP_CUSTOM_JSON: { // posting OR active authority + uint32_t n_active, n_posting; + if (!cur_varint(&c, &n_active)) return E_MALFORMED; + if (n_active > HIVE_MAX_CUSTOM_JSON_AUTHS) return E_RANGE; + const uint8_t* previous_auth = NULL; + uint16_t previous_auth_len = 0; + for (uint32_t k = 0; k < n_active; k++) { + const uint8_t* s; + uint16_t sl; + if (!cur_account(&c, &s, &sl, false)) return E_MALFORMED; + if (previous_auth && + hive_slice_cmp(previous_auth, previous_auth_len, s, sl) >= 0) + return E_MALFORMED; + op->auth_acct[op->n_auths] = s; + op->auth_acct_len[op->n_auths++] = sl; + previous_auth = s; + previous_auth_len = sl; + if (!op->acct) { + op->acct = s; + op->acct_len = sl; + } + } + if (!cur_varint(&c, &n_posting)) return E_MALFORMED; + if (n_posting > HIVE_MAX_CUSTOM_JSON_AUTHS - n_active) return E_RANGE; + previous_auth = NULL; + previous_auth_len = 0; + for (uint32_t k = 0; k < n_posting; k++) { + const uint8_t* s; + uint16_t sl; + if (!cur_account(&c, &s, &sl, false)) return E_MALFORMED; + if (previous_auth && + hive_slice_cmp(previous_auth, previous_auth_len, s, sl) >= 0) + return E_MALFORMED; + op->auth_acct[op->n_auths] = s; + op->auth_acct_len[op->n_auths++] = sl; + previous_auth = s; + previous_auth_len = sl; + if (!op->acct) { + op->acct = s; + op->acct_len = sl; + } + } + if (n_active + n_posting == 0) return E_MALFORMED; + // Both tiers on one op can never be satisfied by a single signature + // (post-HF28 hived requires the exact authority) — malformed input. + if (n_active > 0 && n_posting > 0) return E_MIXED_TIER; + if (!cur_string(&c, &op->target, &op->target_len, 1, 32) || + !cur_string(&c, &op->detail, &op->detail_len, 1, + HIVE_MAX_OPS_TX_LEN)) + return E_MALFORMED; + op->needs_active = (n_active > 0); + if (op->needs_active) + any_active = true; + else + any_posting = true; + break; + } + case HIVE_OP_TRANSFER_TO_VESTING: { // active authority + // `to` may be empty — hived reads that as "power up to self". + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, true) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_WITHDRAW_VESTING: { // active authority + // 0.000000 VESTS is meaningful here: it cancels an in-progress + // power-down, so zero must NOT be rejected. + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_VESTS)) + return E_MALFORMED; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_LIMIT_ORDER_CREATE: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_asset(&c, &op->assets[1], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_bool(&c, &op->flag) || !cur_u32(&c, &op->expiration)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0 || + hive_assetAmount(op->assets[1]) == 0) + return E_AMOUNT; + // The internal market only pairs HIVE against HBD. A same-symbol + // order is rejected on-chain anyway, and on the OLED it would read + // as a harmless self-trade while burning the fill. + if (memcmp(op->assets[0] + 9, op->assets[1] + 9, 7) == 0) + return E_SYMBOLS; + op->n_assets = 2; + any_active = true; + break; + } + case HIVE_OP_LIMIT_ORDER_CANCEL: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id)) + return E_MALFORMED; + any_active = true; + break; + } + case HIVE_OP_CONVERT: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HBD)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_COMMENT_OPTIONS: { // posting authority + uint16_t percent_hbd; + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_string(&c, &op->permlink, &op->permlink_len, 1, 256) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HBD) || + !cur_u16(&c, &percent_hbd) || !cur_bool(&c, &op->flag) || + !cur_bool(&c, &op->flag2)) + return E_MALFORMED; + if (percent_hbd > 10000) return E_RANGE; + op->weight = (int16_t)percent_hbd; + op->n_assets = 1; + + // SECURITY: this op redirects a post's payout. It binds to exactly + // one post, so it is accepted ONLY immediately after a comment op + // with the same author and permlink. Standing alone it could attach + // beneficiaries to a post the user published earlier and is not + // reviewing on this screen. + if (i == 0 || out->ops[i - 1].op_type != HIVE_OP_COMMENT) + return E_BINDING; + const HiveTxOp* prev = &out->ops[i - 1]; + if (prev->acct_len != op->acct_len || + memcmp(prev->acct, op->acct, op->acct_len) != 0 || + prev->permlink_len != op->permlink_len || + memcmp(prev->permlink, op->permlink, op->permlink_len) != 0) + return E_BINDING; + + uint32_t ext_n; + if (!cur_varint(&c, &ext_n)) return E_MALFORMED; + // hived permits only one comment_payout_beneficiaries extension; + // two would let a host split 16 beneficiaries past a per-extension + // bound check. + if (ext_n > 1) return E_BENEFICIARIES; + if (ext_n == 1) { + uint32_t tag, n_benef; + if (!cur_varint(&c, &tag) || tag != 0) return E_BENEFICIARIES; + if (!cur_varint(&c, &n_benef) || n_benef < 1 || + n_benef > HIVE_MAX_BENEFICIARIES) + return E_BENEFICIARIES; + uint32_t weight_sum = 0; + const uint8_t* prev_acct = NULL; + uint16_t prev_acct_len = 0; + for (uint32_t k = 0; k < n_benef; k++) { + if (!cur_account(&c, &op->benef_acct[k], &op->benef_acct_len[k], + false) || + !cur_u16(&c, &op->benef_weight[k])) + return E_MALFORMED; + if (op->benef_weight[k] > 10000) return E_RANGE; + // hived requires strictly ascending account names, which also + // enforces uniqueness. An unsorted list is rejected on-chain, so + // signing it would only waste a device confirmation. + if (prev_acct) { + if (hive_slice_cmp(prev_acct, prev_acct_len, op->benef_acct[k], + op->benef_acct_len[k]) >= 0) + return E_BENEFICIARIES; + } + prev_acct = op->benef_acct[k]; + prev_acct_len = op->benef_acct_len[k]; + weight_sum += op->benef_weight[k]; + } + if (weight_sum > 10000) return E_BENEFICIARIES; + op->n_benef = (uint8_t)n_benef; + } + any_posting = true; + break; + } + case HIVE_OP_TRANSFER_TO_SAVINGS: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_string(&c, &op->detail, &op->detail_len, 0, HIVE_MAX_MEMO_LEN)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_TRANSFER_FROM_SAVINGS: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_string(&c, &op->detail, &op->detail_len, 0, HIVE_MAX_MEMO_LEN)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_CLAIM_REWARD_BALANCE: { // posting authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE) || + !cur_asset(&c, &op->assets[1], HIVE_SYM_HBD) || + !cur_asset(&c, &op->assets[2], HIVE_SYM_VESTS)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0 && + hive_assetAmount(op->assets[1]) == 0 && + hive_assetAmount(op->assets[2]) == 0) + return E_NOOP; + op->n_assets = 3; + any_posting = true; + break; + } + case HIVE_OP_DELEGATE_VESTING_SHARES: { // active authority + // 0.000000 VESTS is meaningful: it removes an existing delegation. + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_VESTS)) + return E_MALFORMED; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_ACCOUNT_UPDATE2: { // active or posting authority + uint32_t ext_n; + if (!cur_account(&c, &op->acct, &op->acct_len, false)) + return E_MALFORMED; + // SECURITY: account_update2 can rotate owner/active/posting/memo + // keys. Only the profile-metadata form is in the table — this is the + // op-9/10 device-derived-keys invariant applied field-level. Any + // authority field present is a hard reject; do NOT soften this + // without the authority-management design review. + for (int k = 0; k < 4; k++) { + bool present; + if (!cur_bool(&c, &present)) return E_MALFORMED; + if (present) return E_AUTHORITY; + } + if (!cur_string(&c, &op->detail, &op->detail_len, 0, + HIVE_MAX_OPS_TX_LEN) || + !cur_string(&c, &op->json_metadata, &op->json_metadata_len, 0, + HIVE_MAX_OPS_TX_LEN)) + return E_MALFORMED; + if (op->detail_len == 0 && op->json_metadata_len == 0) return E_NOOP; + if (!cur_varint(&c, &ext_n)) return E_MALFORMED; + if (ext_n != 0) return E_EXTENSIONS; + // json_metadata is an active-key field; a posting_json_metadata-only + // update is a posting-tier profile change. + op->needs_active = (op->detail_len > 0); + if (op->needs_active) + any_active = true; + else + any_posting = true; + break; + } + case HIVE_OP_TRANSFER: + case HIVE_OP_ACCOUNT_CREATE: + case HIVE_OP_ACCOUNT_UPDATE: + // PERMANENTLY excluded from this table: transfer keeps the stronger + // dedicated HiveSignTx display path; the account ops keep the + // device-derived-keys-only invariant (a generic raw-bytes path + // would let a host slip third-party authorities into an + // account_update). Never add these here. + return "Hive tx: op requires its dedicated message type"; + default: + return "Hive tx: unsupported operation type"; + } + } + + uint32_t ext_count; + if (!cur_varint(&c, &ext_count)) return E_MALFORMED; + if (ext_count != 0) return E_EXTENSIONS; + if (c.p != c.end) return "Hive tx: trailing bytes"; + + // One signature cannot satisfy posting- and active-tier ops at once. + if (any_posting && any_active) return E_MIXED_TIER; + out->needs_active = any_active; + return NULL; +} + +void hive_signOperations(const HDNode* node, const HiveSignOperations* msg, + HiveSignedOperations* resp) { + if (!msg->has_serialized_tx || msg->serialized_tx.size == 0 || + msg->serialized_tx.size > HIVE_MAX_OPS_TX_LEN) + return; + + // Hash straight from the decoded message — no stack copy of the 2KB tx. + if (!hive_sign_tx_sig(node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, msg->serialized_tx.bytes, + msg->serialized_tx.size, resp->signature.bytes)) { + return; + } + + resp->has_signature = true; + resp->signature.size = 65; +} + +// ── Message signing (Keychain signBuffer contract) ──────────────────────── +// Digest is SHA256(message bytes) ONLY: no chain_id prepend (unlike +// transactions) and no Bitcoin/Solana-style message prefix. hive-js +// Signature.signBuffer — which every Hive dApp verifies against — hashes +// the raw bytes exactly once; any added prefix silently breaks all dApp +// verification. + +bool hive_message_is_printable(const uint8_t* message, size_t len) { + for (size_t i = 0; i < len; i++) { + if (message[i] < 0x20 || message[i] > 0x7e) return false; + } + return true; +} + +void hive_signMessage(const HDNode* node, const HiveSignMessage* msg, + HiveSignedMessage* resp) { + if (!msg->has_message || msg->message.size > HIVE_MAX_MESSAGE_LEN) return; + + uint8_t digest[32]; + sha256_Raw(msg->message.bytes, msg->message.size, digest); + + uint8_t sig[65]; + if (!hive_sign_raw_digest(node, digest, sig)) { + memzero(digest, sizeof(digest)); + memzero(sig, sizeof(sig)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + memcpy(resp->signature.bytes, sig, 65); + + // Caller must have run hdnode_fill_public_key(node). Returned so the host + // can build Keychain's publicKey response field without a second call. + resp->has_public_key = true; + resp->public_key.size = 33; + memcpy(resp->public_key.bytes, node->public_key, 33); + + memzero(digest, sizeof(digest)); + memzero(sig, sizeof(sig)); +} + +// ── Transfer (op type 2) ────────────────────────────────────────────────── + +static size_t hive_serialize_transfer(const HiveSignTx* msg, uint8_t* buf, + size_t buf_len) { + uint8_t* p = buf; + const uint8_t* end = buf + buf_len; + + append_tx_header(&p, end, (uint16_t)(msg->ref_block_num & 0xFFFF), + msg->ref_block_prefix, msg->expiration, HIVE_OP_TRANSFER); + + append_string(&p, end, msg->has_from ? msg->from : ""); + append_string(&p, end, msg->has_to ? msg->to : ""); + + const char* sym = msg->has_asset_symbol ? msg->asset_symbol : "HIVE"; + uint8_t prec = (uint8_t)(msg->has_decimals ? msg->decimals : HIVE_DECIMALS); + append_asset(&p, end, msg->amount, prec, sym); + + append_string(&p, end, msg->has_memo ? msg->memo : ""); + append_tx_footer(&p, end); + return (size_t)(p - buf); +} + +void hive_signTx(const HDNode* node, const HiveSignTx* msg, + HiveSignedTx* resp) { + // Reject memos that would overflow the fixed-size tx_buf. + if (msg->has_memo && strlen(msg->memo) > HIVE_MAX_MEMO_LEN) return; + + uint8_t tx_buf[512]; + size_t tx_len = hive_serialize_transfer(msg, tx_buf, sizeof(tx_buf)); + + if (!hive_sign_tx_sig(node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, tx_buf, tx_len, + resp->signature.bytes)) { + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(tx_buf, tx_len); +} + +// ── Account create (op type 9) ──────────────────────────────────────────── +// +// All four role keys are device-derived by the caller (FSM handler) and +// passed as raw 33-byte compressed public keys. The firmware never uses +// host-supplied key strings for the actual transaction. + +static size_t hive_serialize_account_create(const HiveSignAccountCreate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + uint8_t* buf, size_t buf_len) { + uint8_t* p = buf; + const uint8_t* end = buf + buf_len; + + append_tx_header(&p, end, (uint16_t)(msg->ref_block_num & 0xFFFF), + msg->ref_block_prefix, msg->expiration, + HIVE_OP_ACCOUNT_CREATE); + + // fee (asset) + uint64_t fee = msg->has_fee_amount ? msg->fee_amount : 3000; + append_asset(&p, end, fee, HIVE_DECIMALS, "HIVE"); + + // creator + append_string(&p, end, msg->has_creator ? msg->creator : ""); + + // new_account_name + append_string(&p, end, + msg->has_new_account_name ? msg->new_account_name : ""); + + // authority fields use device-derived raw bytes (no host trust, no type + // prefix) + append_authority(&p, end, owner_raw); + append_authority(&p, end, active_raw); + append_authority(&p, end, posting_raw); + + // memo_key: 33 raw bytes, no authority wrapper, no type prefix byte + for (int i = 0; i < 33 && p < end; i++) append_u8(&p, end, memo_raw[i]); + + // json_metadata (empty) + append_string(&p, end, ""); + append_tx_footer(&p, end); + + return (size_t)(p - buf); +} + +void hive_signAccountCreate(const HDNode* signing_node, + const HiveSignAccountCreate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountCreate* resp) { + uint8_t tx_buf[512]; + size_t tx_len = + hive_serialize_account_create(msg, owner_raw, active_raw, posting_raw, + memo_raw, tx_buf, sizeof(tx_buf)); + + if (!hive_sign_tx_sig(signing_node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, tx_buf, tx_len, + resp->signature.bytes)) { + memzero(tx_buf, sizeof(tx_buf)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(tx_buf, tx_len); +} + +// ── Account update (op type 10) ─────────────────────────────────────────── +// +// All four new role keys are device-derived by the caller (FSM handler). +// The host-supplied new_*_key fields in the message are not used for signing. + +static size_t hive_serialize_account_update(const HiveSignAccountUpdate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + uint8_t* buf, size_t buf_len) { + uint8_t* p = buf; + const uint8_t* end = buf + buf_len; + + append_tx_header(&p, end, (uint16_t)(msg->ref_block_num & 0xFFFF), + msg->ref_block_prefix, msg->expiration, + HIVE_OP_ACCOUNT_UPDATE); + + // account name + append_string(&p, end, msg->has_account ? msg->account : ""); + + /* + * account_update optional authority fields use a Graphene "optional" wrapper: + * present: 0x01 + authority bytes + * absent: 0x00 + * We always include all four — this replaces all authorities. + */ + append_u8(&p, end, 0x01); // owner present + append_authority(&p, end, owner_raw); + append_u8(&p, end, 0x01); // active present + append_authority(&p, end, active_raw); + append_u8(&p, end, 0x01); // posting present + append_authority(&p, end, posting_raw); + + // memo_key: 33 raw bytes, always present, no type prefix byte + for (int i = 0; i < 33 && p < end; i++) append_u8(&p, end, memo_raw[i]); + + // json_metadata (empty) + append_string(&p, end, ""); + append_tx_footer(&p, end); + + return (size_t)(p - buf); +} + +void hive_signAccountUpdate(const HDNode* signing_node, + const HiveSignAccountUpdate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountUpdate* resp) { + uint8_t tx_buf[512]; + size_t tx_len = + hive_serialize_account_update(msg, owner_raw, active_raw, posting_raw, + memo_raw, tx_buf, sizeof(tx_buf)); + + if (!hive_sign_tx_sig(signing_node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, tx_buf, tx_len, + resp->signature.bytes)) { + memzero(tx_buf, sizeof(tx_buf)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(tx_buf, tx_len); +} diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 1d057579b..3fe884809 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -184,6 +184,23 @@ MSG_OUT(MessageType_MessageType_ZcashAddress, ZcashAddress, NO_PROCESS_FUNC) #endif + /* Hive */ + MSG_IN(MessageType_MessageType_HiveGetPublicKey, HiveGetPublicKey, fsm_msgHiveGetPublicKey) + MSG_IN(MessageType_MessageType_HiveGetPublicKeys, HiveGetPublicKeys, fsm_msgHiveGetPublicKeys) + MSG_IN(MessageType_MessageType_HiveSignTx, HiveSignTx, fsm_msgHiveSignTx) + MSG_IN(MessageType_MessageType_HiveSignAccountCreate, HiveSignAccountCreate, fsm_msgHiveSignAccountCreate) + MSG_IN(MessageType_MessageType_HiveSignAccountUpdate, HiveSignAccountUpdate, fsm_msgHiveSignAccountUpdate) + MSG_IN(MessageType_MessageType_HiveSignMessage, HiveSignMessage, fsm_msgHiveSignMessage) + MSG_IN(MessageType_MessageType_HiveSignOperations, HiveSignOperations, fsm_msgHiveSignOperations) + + MSG_OUT(MessageType_MessageType_HivePublicKey, HivePublicKey, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HivePublicKeys, HivePublicKeys, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedTx, HiveSignedTx, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedAccountCreate, HiveSignedAccountCreate, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedAccountUpdate, HiveSignedAccountUpdate, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedMessage, HiveSignedMessage, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedOperations, HiveSignedOperations, NO_PROCESS_FUNC) + #if DEBUG_LINK /* Debug Messages */ DEBUG_IN(MessageType_MessageType_DebugLinkDecision, DebugLinkDecision, NO_PROCESS_FUNC) diff --git a/lib/transport/CMakeLists.txt b/lib/transport/CMakeLists.txt index 805b27762..c8dd5c0ac 100644 --- a/lib/transport/CMakeLists.txt +++ b/lib/transport/CMakeLists.txt @@ -19,6 +19,7 @@ set(protoc_pb_sources ${DEVICE_PROTOCOL}/messages-tron.proto ${DEVICE_PROTOCOL}/messages-ton.proto ${DEVICE_PROTOCOL}/messages-zcash.proto + ${DEVICE_PROTOCOL}/messages-hive.proto ${DEVICE_PROTOCOL}/messages.proto) set(protoc_pb_options @@ -37,6 +38,7 @@ set(protoc_pb_options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-tron.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-ton.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-zcash.options + ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-hive.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages.options) set(protoc_c_sources @@ -55,6 +57,7 @@ set(protoc_c_sources ${CMAKE_BINARY_DIR}/lib/transport/messages-tron.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages-ton.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages-zcash.pb.c + ${CMAKE_BINARY_DIR}/lib/transport/messages-hive.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages.pb.c) set(protoc_c_headers @@ -73,6 +76,7 @@ set(protoc_c_headers ${CMAKE_BINARY_DIR}/include/messages-tron.pb.h ${CMAKE_BINARY_DIR}/include/messages-ton.pb.h ${CMAKE_BINARY_DIR}/include/messages-zcash.pb.h + ${CMAKE_BINARY_DIR}/include/messages-hive.pb.h ${CMAKE_BINARY_DIR}/include/messages.pb.h) set(protoc_pb_sources_moved @@ -91,6 +95,7 @@ set(protoc_pb_sources_moved ${CMAKE_BINARY_DIR}/lib/transport/messages-tron.proto ${CMAKE_BINARY_DIR}/lib/transport/messages-ton.proto ${CMAKE_BINARY_DIR}/lib/transport/messages-zcash.proto + ${CMAKE_BINARY_DIR}/lib/transport/messages-hive.proto ${CMAKE_BINARY_DIR}/lib/transport/messages.proto) add_custom_command( @@ -172,6 +177,10 @@ add_custom_command( ${PROTOC_BINARY} -I. -I/usr/include --plugin=nanopb=${NANOPB_DIR}/generator/protoc-gen-nanopb "--nanopb_out=-f messages-zcash.options:." messages-zcash.proto + COMMAND + ${PROTOC_BINARY} -I. -I/usr/include + --plugin=nanopb=${NANOPB_DIR}/generator/protoc-gen-nanopb + "--nanopb_out=-f messages-hive.options:." messages-hive.proto COMMAND ${PROTOC_BINARY} -I. -I/usr/include --plugin=nanopb=${NANOPB_DIR}/generator/protoc-gen-nanopb diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index f8df17d09..597b2bd23 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -7,6 +7,7 @@ set(sources eos.cpp eip712.cpp ethereum.cpp + hive.cpp mayachain.cpp nano.cpp osmosis.cpp diff --git a/unittests/firmware/hive.cpp b/unittests/firmware/hive.cpp new file mode 100644 index 000000000..4a90cdba0 --- /dev/null +++ b/unittests/firmware/hive.cpp @@ -0,0 +1,983 @@ +extern "C" { +#include "keepkey/board/font.h" +#include "keepkey/board/layout.h" +#include "keepkey/firmware/hive.h" +} + +#include "gtest/gtest.h" + +#include +#include +#include +#include + +namespace { + +void append_varint(std::vector& out, uint32_t value) { + do { + uint8_t byte = static_cast(value & 0x7f); + value >>= 7; + if (value != 0) byte |= 0x80; + out.push_back(byte); + } while (value != 0); +} + +void append_u16_le(std::vector& out, uint16_t value) { + out.push_back(static_cast(value)); + out.push_back(static_cast(value >> 8)); +} + +void append_u32_le(std::vector& out, uint32_t value) { + for (int i = 0; i < 4; i++) { + out.push_back(static_cast(value >> (8 * i))); + } +} + +void append_string(std::vector& out, const std::string& value) { + append_varint(out, static_cast(value.size())); + out.insert(out.end(), value.begin(), value.end()); +} + +std::string slice(const uint8_t* value, uint16_t len) { + return std::string(reinterpret_cast(value), len); +} + +std::vector comment_tx(const std::string& parent_author, + const std::string& parent_permlink, + const std::string& author, + const std::string& permlink, + const std::string& title, + const std::string& body, + const std::string& json_metadata) { + std::vector tx; + append_u16_le(tx, 12345); + append_u32_le(tx, 67890); + append_u32_le(tx, 1700000000); + append_varint(tx, 1); + append_varint(tx, HIVE_OP_COMMENT); + append_string(tx, parent_author); + append_string(tx, parent_permlink); + append_string(tx, author); + append_string(tx, permlink); + append_string(tx, title); + append_string(tx, body); + append_string(tx, json_metadata); + append_varint(tx, 0); + return tx; +} + +// Call sites pass DISPLAY symbols ("HIVE"/"HBD") because that is what the test +// is about; this helper writes what the chain actually serializes. Verified +// against hived itself via condenser_api.get_transaction_hex — see +// Hive.SerializationMatchesHived. +std::string wire_symbol(const std::string& display) { + if (display == "HIVE") return "STEEM"; + if (display == "HBD") return "SBD"; + return display; +} + +void append_asset(std::vector& out, int64_t amount, uint8_t precision, + const std::string& symbol) { + const std::string wire = wire_symbol(symbol); + uint64_t raw = static_cast(amount); + for (int i = 0; i < 8; i++) { + out.push_back(static_cast(raw >> (8 * i))); + } + out.push_back(precision); + for (size_t i = 0; i < 7; i++) { + out.push_back(i < wire.size() ? static_cast(wire[i]) : 0); + } +} + +// Wrap already-serialized ops in the 10-byte TaPoS header, op count and the +// empty extensions varint that hive_parseOperations expects. +std::vector wrap_ops(const std::vector>& ops) { + std::vector tx; + append_u16_le(tx, 12345); + append_u32_le(tx, 67890); + append_u32_le(tx, 1700000000); + append_varint(tx, static_cast(ops.size())); + for (const std::vector& op : ops) { + tx.insert(tx.end(), op.begin(), op.end()); + } + append_varint(tx, 0); + return tx; +} + +std::vector limit_order_create_op( + const std::string& owner, uint32_t orderid, int64_t sell, + const std::string& sell_symbol, int64_t receive, + const std::string& receive_symbol, bool fill_or_kill, uint32_t expiration) { + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CREATE); + append_string(op, owner); + append_u32_le(op, orderid); + append_asset(op, sell, 3, sell_symbol); + append_asset(op, receive, 3, receive_symbol); + op.push_back(fill_or_kill ? 1 : 0); + append_u32_le(op, expiration); + return op; +} + +// A limit order priced in VESTS at its CORRECT precision (6), so the +// rejection comes from the symbol whitelist rather than the precision check. +std::vector limit_order_vests_op() { + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CREATE); + append_string(op, "alice"); + append_u32_le(op, 1); + append_asset(op, 100, 6, "VESTS"); + append_asset(op, 100, 3, "HBD"); + op.push_back(0); + append_u32_le(op, 1); + return op; +} + +// transfer_to_vesting with a caller-chosen symbol/precision, so the asset +// validator can be probed with values a correct host would never send. +std::vector power_up_op(int64_t amount, uint8_t precision, + const std::string& symbol) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_VESTING); + append_string(op, "alice"); + append_string(op, "bob"); + append_asset(op, amount, precision, symbol); + return op; +} + +std::vector comment_op(const std::string& author, + const std::string& permlink) { + std::vector op; + append_varint(op, HIVE_OP_COMMENT); + append_string(op, ""); + append_string(op, "hive-100"); + append_string(op, author); + append_string(op, permlink); + append_string(op, "Title"); + append_string(op, "Body"); + append_string(op, "{}"); + return op; +} + +// beneficiaries: (account, basis-point weight) pairs; empty = no extension. +std::vector comment_options_op( + const std::string& author, const std::string& permlink, + const std::vector>& beneficiaries) { + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, author); + append_string(op, permlink); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + if (beneficiaries.empty()) { + append_varint(op, 0); + } else { + append_varint(op, 1); + append_varint(op, 0); + append_varint(op, static_cast(beneficiaries.size())); + for (const auto& b : beneficiaries) { + append_string(op, b.first); + append_u16_le(op, b.second); + } + } + return op; +} + +std::vector account_update2_op(const std::string& json_metadata, + const std::string& posting_metadata, + bool authority_present) { + std::vector op; + append_varint(op, HIVE_OP_ACCOUNT_UPDATE2); + append_string(op, "alice"); + op.push_back(authority_present ? 1 : 0); + op.push_back(0); + op.push_back(0); + op.push_back(0); + append_string(op, json_metadata); + append_string(op, posting_metadata); + append_varint(op, 0); + return op; +} + +std::vector custom_json_op( + const std::vector& active_auths, + const std::vector& posting_auths, const std::string& id, + const std::string& json) { + std::vector op; + append_varint(op, HIVE_OP_CUSTOM_JSON); + append_varint(op, static_cast(active_auths.size())); + for (const std::string& auth : active_auths) append_string(op, auth); + append_varint(op, static_cast(posting_auths.size())); + for (const std::string& auth : posting_auths) append_string(op, auth); + append_string(op, id); + append_string(op, json); + return op; +} + +} // namespace + +TEST(Hive, Slip48PathValidation) { + uint32_t path[5] = {HIVE_SLIP48_PURPOSE, HIVE_SLIP48_NETWORK, + HIVE_ROLE_ACTIVE, 0x80000007u, 0x80000000u}; + + EXPECT_TRUE(hive_slip48_path_valid(path, 5)); + EXPECT_TRUE(hive_slip48_path_valid_for_role(path, 5, HIVE_ROLE_ACTIVE)); + EXPECT_FALSE(hive_slip48_path_valid_for_role(path, 5, HIVE_ROLE_OWNER)); + EXPECT_FALSE(hive_slip48_path_valid(path, 4)); + + path[0] = 0x8000002cu; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[0] = HIVE_SLIP48_PURPOSE; + path[1] = 0x8000003cu; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[1] = HIVE_SLIP48_NETWORK; + path[2] = 0x80000002u; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[2] = HIVE_ROLE_ACTIVE; + path[3] = 7; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[3] = 0x80000007u; + path[4] = 0; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); +} + +TEST(Hive, CommentParserRetainsEveryDisplayedField) { + std::vector tx = comment_tx( + "parent-author", "parent-permlink", "reply-author", "reply-permlink", + "Reply title", "Complete reply body", "{\"tags\":[\"keepkey\"]}"); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + ASSERT_EQ(1, parsed.num_ops); + const HiveTxOp& op = parsed.ops[0]; + EXPECT_FALSE(op.is_top_level); + EXPECT_EQ("reply-author", slice(op.acct, op.acct_len)); + EXPECT_EQ("parent-author", slice(op.parent_author, op.parent_author_len)); + EXPECT_EQ("parent-permlink", + slice(op.parent_permlink, op.parent_permlink_len)); + EXPECT_EQ("reply-permlink", slice(op.permlink, op.permlink_len)); + EXPECT_EQ("Reply title", slice(op.target, op.target_len)); + EXPECT_EQ("Complete reply body", slice(op.detail, op.detail_len)); + EXPECT_EQ("{\"tags\":[\"keepkey\"]}", + slice(op.json_metadata, op.json_metadata_len)); +} + +// Message signing is restricted to printable ASCII so a message can never be a +// binary transaction preimage (chain_id || serialized_tx) on any chain id. +TEST(Hive, MessagePrintableAcceptsAsciiRejectsBinary) { + const char* login = "keepkey-login-challenge:1700000000"; + EXPECT_TRUE(hive_message_is_printable(reinterpret_cast(login), + strlen(login))); + + // Empty message is trivially printable. + EXPECT_TRUE( + hive_message_is_printable(reinterpret_cast(""), 0)); + + // Any non-printable byte (control char / high bit) is refused. + const uint8_t withNul[] = {'h', 'i', 0x00, 'x'}; + EXPECT_FALSE(hive_message_is_printable(withNul, sizeof(withNul))); + const uint8_t highBit[] = {'o', 'k', 0x80}; + EXPECT_FALSE(hive_message_is_printable(highBit, sizeof(highBit))); + + // The oracle vector: a "message" that begins with the binary mainnet chain id + // (beeab0de00...) followed by a serialized tx. The leading 0xbe/0xea/0x00 + // bytes are non-printable, so this can never be signed as a message. + const uint8_t chainIdPrefixed[] = {0xbe, 0xea, 0xb0, 0xde, 0x00, + 0x00, 0x00, 't', 'x'}; + EXPECT_FALSE( + hive_message_is_printable(chainIdPrefixed, sizeof(chainIdPrefixed))); +} + +TEST(Hive, TopLevelCommentRetainsCategoryAndEmptyTitle) { + std::vector tx = comment_tx("", "hive-123456", "post-author", + "post-permlink", "", "Post body", "{}"); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + const HiveTxOp& op = parsed.ops[0]; + EXPECT_TRUE(op.is_top_level); + EXPECT_EQ(0, op.parent_author_len); + EXPECT_EQ("hive-123456", slice(op.parent_permlink, op.parent_permlink_len)); + EXPECT_EQ("post-permlink", slice(op.permlink, op.permlink_len)); + EXPECT_EQ(0, op.target_len); + EXPECT_EQ("{}", slice(op.json_metadata, op.json_metadata_len)); +} + +TEST(Hive, RejectsNonCanonicalVarints) { + std::vector op; + append_varint(op, HIVE_OP_VOTE); + append_string(op, "alice"); + append_string(op, "bob"); + append_string(op, "post"); + append_u16_le(op, 10000); + + HiveParsedTx parsed; + + // Operation count 1 encoded as 0x81 0x00 instead of canonical 0x01. + std::vector overlong_count = wrap_ops({op}); + overlong_count[10] = 0x81; + overlong_count.insert(overlong_count.begin() + 11, 0x00); + EXPECT_NE(nullptr, hive_parseOperations(overlong_count.data(), + overlong_count.size(), &parsed)); + + // The voter string length 5 encoded as 0x85 0x00. + std::vector overlong_string = wrap_ops({op}); + overlong_string[12] = 0x85; + overlong_string.insert(overlong_string.begin() + 13, 0x00); + EXPECT_NE(nullptr, hive_parseOperations(overlong_string.data(), + overlong_string.size(), &parsed)); +} + +TEST(Hive, RejectsAccountNamesThatCanSpoofTheDisplay) { + HiveParsedTx parsed; + const std::vector invalid = { + "al\nice", std::string("ali\0ce", 6), "Alice", "alice-", ".alice", "a"}; + for (const std::string& account : invalid) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_SAVINGS); + append_string(op, account); + append_string(op, "bob"); + append_asset(op, 1000, 3, "HIVE"); + append_string(op, ""); + std::vector tx = wrap_ops({op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } +} + +TEST(Hive, CustomJsonRetainsAndBoundsEveryAuthorization) { + HiveParsedTx parsed; + std::vector tx = + wrap_ops({custom_json_op({}, {"alice", "bob", "carol"}, "follow", "[]")}); + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + const HiveTxOp& op = parsed.ops[0]; + ASSERT_EQ(3, op.n_auths); + EXPECT_EQ("alice", slice(op.auth_acct[0], op.auth_acct_len[0])); + EXPECT_EQ("bob", slice(op.auth_acct[1], op.auth_acct_len[1])); + EXPECT_EQ("carol", slice(op.auth_acct[2], op.auth_acct_len[2])); + EXPECT_FALSE(parsed.needs_active); + + std::vector too_many = wrap_ops({custom_json_op( + {}, {"alice", "bob", "carol", "dave", "erin"}, "follow", "[]")}); + EXPECT_NE(nullptr, + hive_parseOperations(too_many.data(), too_many.size(), &parsed)); + + std::vector unsorted = + wrap_ops({custom_json_op({}, {"bob", "alice"}, "follow", "[]")}); + EXPECT_NE(nullptr, + hive_parseOperations(unsorted.data(), unsorted.size(), &parsed)); + + std::vector duplicate = + wrap_ops({custom_json_op({}, {"alice", "alice"}, "follow", "[]")}); + EXPECT_NE(nullptr, + hive_parseOperations(duplicate.data(), duplicate.size(), &parsed)); +} + +TEST(Hive, DisplayPaginationUsesRenderedBodyRows) { + const std::string payload = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_GT(calc_str_line(get_body_font(), payload.c_str(), BODY_WIDTH), + BODY_ROWS); + + std::string reconstructed; + size_t offset = 0; + unsigned pages = 0; + while (offset < payload.size()) { + size_t take = calc_str_page(get_body_font(), payload.data() + offset, + payload.size() - offset, BODY_WIDTH, BODY_ROWS); + ASSERT_GT(take, 0u); + const std::string page = payload.substr(offset, take); + EXPECT_LE(calc_str_line(get_body_font(), page.c_str(), BODY_WIDTH), + BODY_ROWS); + reconstructed += page; + offset += take; + pages++; + } + + EXPECT_GT(pages, 1u); + EXPECT_EQ(payload, reconstructed); +} + +// ── Phase-3 op table ──────────────────────────────────────────────────────── + +// The op that started this: a HIVE->HBD internal-market swap. Every field the +// approval screen shows must survive the parse. +TEST(Hive, LimitOrderCreateRetainsEveryDisplayedField) { + std::vector tx = wrap_ops({limit_order_create_op( + "alice", 42, 1500, "HIVE", 400, "HBD", true, 1700003600)}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + ASSERT_EQ(1, parsed.num_ops); + const HiveTxOp& op = parsed.ops[0]; + EXPECT_EQ(HIVE_OP_LIMIT_ORDER_CREATE, op.op_type); + EXPECT_EQ("alice", slice(op.acct, op.acct_len)); + EXPECT_EQ(42u, op.req_id); + EXPECT_EQ(1700003600u, op.expiration); + EXPECT_TRUE(op.flag); // fill_or_kill + ASSERT_EQ(2, op.n_assets); + EXPECT_EQ(1500u, hive_assetAmount(op.assets[0])); + EXPECT_STREQ("HIVE", hive_assetSymbol(op.assets[0])); + EXPECT_EQ(3, hive_assetPrecision(op.assets[0])); + EXPECT_EQ(400u, hive_assetAmount(op.assets[1])); + EXPECT_STREQ("HBD", hive_assetSymbol(op.assets[1])); + // Trading needs the active key. + EXPECT_TRUE(parsed.needs_active); +} + +TEST(Hive, LimitOrderRejectsDegenerateOrders) { + HiveParsedTx parsed; + + // A same-symbol pair is a no-op trade on screen but still burns the fill. + std::vector same = wrap_ops( + {limit_order_create_op("alice", 1, 100, "HIVE", 100, "HIVE", false, 1)}); + EXPECT_NE(nullptr, hive_parseOperations(same.data(), same.size(), &parsed)); + + std::vector zero_sell = wrap_ops( + {limit_order_create_op("alice", 1, 0, "HIVE", 100, "HBD", false, 1)}); + EXPECT_NE(nullptr, + hive_parseOperations(zero_sell.data(), zero_sell.size(), &parsed)); + + std::vector zero_recv = wrap_ops( + {limit_order_create_op("alice", 1, 100, "HIVE", 0, "HBD", false, 1)}); + EXPECT_NE(nullptr, + hive_parseOperations(zero_recv.data(), zero_recv.size(), &parsed)); + + // VESTS never trades on the internal market. + std::vector vests = wrap_ops({limit_order_vests_op()}); + EXPECT_NE(nullptr, hive_parseOperations(vests.data(), vests.size(), &parsed)); +} + +TEST(Hive, LimitOrderCancelParses) { + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CANCEL); + append_string(op, "alice"); + append_u32_le(op, 42); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_EQ("alice", slice(parsed.ops[0].acct, parsed.ops[0].acct_len)); + EXPECT_EQ(42u, parsed.ops[0].req_id); + EXPECT_TRUE(parsed.needs_active); +} + +// The asset validator is what stops a host from moving the decimal point or +// swapping a ~2000x-more-valuable symbol behind an identical-looking number. +TEST(Hive, AssetValidatorPinsSymbolAndPrecision) { + HiveParsedTx parsed; + + std::vector ok = wrap_ops({power_up_op(1000, 3, "HIVE")}); + EXPECT_EQ(nullptr, hive_parseOperations(ok.data(), ok.size(), &parsed)); + + // transfer_to_vesting is HIVE-only; HBD and VESTS are out of the whitelist. + std::vector hbd = wrap_ops({power_up_op(1000, 3, "HBD")}); + EXPECT_NE(nullptr, hive_parseOperations(hbd.data(), hbd.size(), &parsed)); + std::vector vests = wrap_ops({power_up_op(1000, 6, "VESTS")}); + EXPECT_NE(nullptr, hive_parseOperations(vests.data(), vests.size(), &parsed)); + + // Right symbol, wrong precision: 1000 would render as 0.001 vs 1.000. + std::vector prec = wrap_ops({power_up_op(1000, 6, "HIVE")}); + EXPECT_NE(nullptr, hive_parseOperations(prec.data(), prec.size(), &parsed)); + + // A negative int64 would print as an enormous positive number. + std::vector negative = wrap_ops({power_up_op(-1000, 3, "HIVE")}); + EXPECT_NE(nullptr, + hive_parseOperations(negative.data(), negative.size(), &parsed)); + + // Unknown symbol, and a longer symbol sharing an accepted prefix. + std::vector unknown = wrap_ops({power_up_op(1000, 3, "SBD")}); + EXPECT_NE(nullptr, + hive_parseOperations(unknown.data(), unknown.size(), &parsed)); + std::vector prefixed = wrap_ops({power_up_op(1000, 3, "HIVEX")}); + EXPECT_NE(nullptr, + hive_parseOperations(prefixed.data(), prefixed.size(), &parsed)); +} + +// Zero is a real instruction for some ops and nonsense for others; the parser +// must not apply one blanket rule. +TEST(Hive, ZeroAmountSemanticsDifferPerOp) { + HiveParsedTx parsed; + + // Zero HIVE power-up: nothing to do, reject. + std::vector power_up = wrap_ops({power_up_op(0, 3, "HIVE")}); + EXPECT_NE(nullptr, + hive_parseOperations(power_up.data(), power_up.size(), &parsed)); + + // Zero VESTS withdraw_vesting: cancels an in-progress power-down, accept. + std::vector stop_pd; + { + std::vector op; + append_varint(op, HIVE_OP_WITHDRAW_VESTING); + append_string(op, "alice"); + append_asset(op, 0, 6, "VESTS"); + stop_pd = wrap_ops({op}); + } + EXPECT_EQ(nullptr, + hive_parseOperations(stop_pd.data(), stop_pd.size(), &parsed)); + + // Zero VESTS delegation: removes an existing delegation, accept. + std::vector undelegate; + { + std::vector op; + append_varint(op, HIVE_OP_DELEGATE_VESTING_SHARES); + append_string(op, "alice"); + append_string(op, "bob"); + append_asset(op, 0, 6, "VESTS"); + undelegate = wrap_ops({op}); + } + EXPECT_EQ(nullptr, hive_parseOperations(undelegate.data(), undelegate.size(), + &parsed)); + + // claim_reward_balance with all three at zero: nothing to claim, reject. + std::vector empty_claim; + { + std::vector op; + append_varint(op, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(op, "alice"); + append_asset(op, 0, 3, "HIVE"); + append_asset(op, 0, 3, "HBD"); + append_asset(op, 0, 6, "VESTS"); + empty_claim = wrap_ops({op}); + } + EXPECT_NE(nullptr, hive_parseOperations(empty_claim.data(), + empty_claim.size(), &parsed)); +} + +TEST(Hive, ClaimRewardBalanceKeepsAssetOrder) { + std::vector op; + append_varint(op, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(op, "alice"); + append_asset(op, 1234, 3, "HIVE"); + append_asset(op, 5678, 3, "HBD"); + append_asset(op, 90123456, 6, "VESTS"); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + ASSERT_EQ(3, parsed.ops[0].n_assets); + EXPECT_EQ(1234u, hive_assetAmount(parsed.ops[0].assets[0])); + EXPECT_STREQ("HIVE", hive_assetSymbol(parsed.ops[0].assets[0])); + EXPECT_EQ(5678u, hive_assetAmount(parsed.ops[0].assets[1])); + EXPECT_STREQ("HBD", hive_assetSymbol(parsed.ops[0].assets[1])); + EXPECT_EQ(90123456u, hive_assetAmount(parsed.ops[0].assets[2])); + EXPECT_STREQ("VESTS", hive_assetSymbol(parsed.ops[0].assets[2])); + // Claiming rewards is a posting-tier action. + EXPECT_FALSE(parsed.needs_active); +} + +// SECURITY: comment_options redirects a post's payout. Detached from its +// comment it could retarget a post the user published earlier and is not +// reviewing on screen. +TEST(Hive, CommentOptionsMustBindToItsComment) { + HiveParsedTx parsed; + + std::vector alone = + wrap_ops({comment_options_op("alice", "my-post", {})}); + EXPECT_NE(nullptr, hive_parseOperations(alone.data(), alone.size(), &parsed)); + + std::vector wrong_permlink = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "other-post", {})}); + EXPECT_NE(nullptr, hive_parseOperations(wrong_permlink.data(), + wrong_permlink.size(), &parsed)); + + std::vector wrong_author = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("mallory", "my-post", {})}); + EXPECT_NE(nullptr, hive_parseOperations(wrong_author.data(), + wrong_author.size(), &parsed)); + + std::vector ok = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", {})}); + ASSERT_EQ(nullptr, hive_parseOperations(ok.data(), ok.size(), &parsed)); + EXPECT_EQ(2, parsed.num_ops); + EXPECT_EQ(10000, parsed.ops[1].weight); // percent_hbd + EXPECT_FALSE(parsed.needs_active); +} + +TEST(Hive, CommentOptionsBeneficiaryRules) { + HiveParsedTx parsed; + + std::vector ok = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"aaron", 1000}, {"zoe", 500}})}); + ASSERT_EQ(nullptr, hive_parseOperations(ok.data(), ok.size(), &parsed)); + ASSERT_EQ(2, parsed.ops[1].n_benef); + EXPECT_EQ("aaron", slice(parsed.ops[1].benef_acct[0], + parsed.ops[1].benef_acct_len[0])); + EXPECT_EQ(1000, parsed.ops[1].benef_weight[0]); + EXPECT_EQ("zoe", slice(parsed.ops[1].benef_acct[1], + parsed.ops[1].benef_acct_len[1])); + + // hived requires strictly ascending names; unsorted is rejected on-chain. + std::vector unsorted = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"zoe", 500}, {"aaron", 1000}})}); + EXPECT_NE(nullptr, + hive_parseOperations(unsorted.data(), unsorted.size(), &parsed)); + + // Duplicates are the same violation. + std::vector duped = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"aaron", 500}, {"aaron", 500}})}); + EXPECT_NE(nullptr, hive_parseOperations(duped.data(), duped.size(), &parsed)); + + // Weights may not add up to more than 100%. + std::vector overweight = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"aaron", 6000}, {"zoe", 5000}})}); + EXPECT_NE(nullptr, hive_parseOperations(overweight.data(), overweight.size(), + &parsed)); +} + +// SECURITY: account_update2 can rotate account keys. Only the profile-metadata +// form is in the table — the op-9/10 exclusion applied field-level. +TEST(Hive, AccountUpdate2RejectsAuthorityChanges) { + HiveParsedTx parsed; + + std::vector authority = + wrap_ops({account_update2_op("{\"profile\":{}}", "", true)}); + EXPECT_NE(nullptr, + hive_parseOperations(authority.data(), authority.size(), &parsed)); + + std::vector empty = wrap_ops({account_update2_op("", "", false)}); + EXPECT_NE(nullptr, hive_parseOperations(empty.data(), empty.size(), &parsed)); + + // json_metadata is an active-key field. + std::vector active = + wrap_ops({account_update2_op("{\"profile\":{}}", "", false)}); + ASSERT_EQ(nullptr, + hive_parseOperations(active.data(), active.size(), &parsed)); + EXPECT_TRUE(parsed.needs_active); + + // posting_json_metadata alone stays on the posting tier. + std::vector posting = + wrap_ops({account_update2_op("", "{\"profile\":{}}", false)}); + ASSERT_EQ(nullptr, + hive_parseOperations(posting.data(), posting.size(), &parsed)); + EXPECT_FALSE(parsed.needs_active); +} + +TEST(Hive, SavingsWithdrawRetainsDisplayedFields) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_FROM_SAVINGS); + append_string(op, "alice"); + append_u32_le(op, 7); + append_string(op, "bob"); + append_asset(op, 2500, 3, "HBD"); + append_string(op, "rent"); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + // request_id sits BETWEEN from and to on the wire — the easiest field-order + // bug to make in this op, and the one that would swap displayed accounts. + EXPECT_EQ("alice", slice(parsed.ops[0].acct, parsed.ops[0].acct_len)); + EXPECT_EQ(7u, parsed.ops[0].req_id); + EXPECT_EQ("bob", slice(parsed.ops[0].target, parsed.ops[0].target_len)); + EXPECT_EQ("rent", slice(parsed.ops[0].detail, parsed.ops[0].detail_len)); + EXPECT_EQ(2500u, hive_assetAmount(parsed.ops[0].assets[0])); + EXPECT_TRUE(parsed.needs_active); +} + +TEST(Hive, SavingsDepositRetainsDisplayedFields) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_SAVINGS); + append_string(op, "alice"); + append_string(op, "bob"); + append_asset(op, 1500, 3, "HIVE"); + append_string(op, ""); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_EQ("alice", slice(parsed.ops[0].acct, parsed.ops[0].acct_len)); + EXPECT_EQ("bob", slice(parsed.ops[0].target, parsed.ops[0].target_len)); + EXPECT_EQ(0, parsed.ops[0].detail_len); // empty memo is legal + EXPECT_EQ(1500u, hive_assetAmount(parsed.ops[0].assets[0])); +} + +// An empty `to` means "power up to self" on Hive, not a malformed field. +TEST(Hive, PowerUpAcceptsEmptyDestination) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_VESTING); + append_string(op, "alice"); + append_string(op, ""); + append_asset(op, 1000, 3, "HIVE"); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_EQ(0, parsed.ops[0].target_len); +} + +// Truncating any op body by one byte must be refused, never partially parsed: +// the signature covers the whole buffer, so a short read would mean signing +// bytes the device never looked at. +TEST(Hive, TruncatedOpBodiesRejected) { + std::vector> bodies; + bodies.push_back( + limit_order_create_op("alice", 1, 100, "HIVE", 50, "HBD", false, 9)); + bodies.push_back(power_up_op(1000, 3, "HIVE")); + { + std::vector op; + append_varint(op, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(op, "alice"); + append_asset(op, 1, 3, "HIVE"); + append_asset(op, 1, 3, "HBD"); + append_asset(op, 1, 6, "VESTS"); + bodies.push_back(op); + } + { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_FROM_SAVINGS); + append_string(op, "alice"); + append_u32_le(op, 7); + append_string(op, "bob"); + append_asset(op, 2500, 3, "HBD"); + append_string(op, "memo"); + bodies.push_back(op); + } + + HiveParsedTx parsed; + for (const std::vector& body : bodies) { + ASSERT_EQ(nullptr, hive_parseOperations(wrap_ops({body}).data(), + wrap_ops({body}).size(), &parsed)); + for (size_t cut = 1; cut < body.size(); cut++) { + std::vector truncated(body.begin(), body.end() - cut); + std::vector tx = wrap_ops({truncated}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)) + << "op type " << (unsigned)body[0] << " truncated by " << cut; + } + } +} + +TEST(Hive, CommentOptionsExtensionShapeRejected) { + HiveParsedTx parsed; + const std::vector comment = comment_op("alice", "my-post"); + + // More than one extension could split beneficiaries past a per-extension cap. + { + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, "alice"); + append_string(op, "my-post"); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + append_varint(op, 2); + std::vector tx = wrap_ops({comment, op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } + // Only comment_payout_beneficiaries (tag 0) is in the table. + { + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, "alice"); + append_string(op, "my-post"); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + append_varint(op, 1); + append_varint(op, 1); // tag != 0 + std::vector tx = wrap_ops({comment, op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } + // Zero beneficiaries in a present extension is malformed, not "none". + { + std::vector tx = + wrap_ops({comment, comment_options_op("alice", "my-post", {})}); + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, "alice"); + append_string(op, "my-post"); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + append_varint(op, 1); + append_varint(op, 0); + append_varint(op, 0); // n_benef = 0 + std::vector bad = wrap_ops({comment, op}); + EXPECT_NE(nullptr, hive_parseOperations(bad.data(), bad.size(), &parsed)); + } +} + +TEST(Hive, AccountUpdate2RejectsNonEmptyExtensions) { + std::vector op; + append_varint(op, HIVE_OP_ACCOUNT_UPDATE2); + append_string(op, "alice"); + for (int i = 0; i < 4; i++) op.push_back(0); + append_string(op, "{\"profile\":{}}"); + append_string(op, ""); + append_varint(op, 1); // extensions must be empty + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +// Graphene bools are one byte; anything but 0/1 is a host serializer bug. +TEST(Hive, RejectsNonCanonicalBool) { + // limit_order_create's fill_or_kill byte, set to 2. + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CREATE); + append_string(op, "alice"); + append_u32_le(op, 1); + append_asset(op, 100, 3, "HIVE"); + append_asset(op, 50, 3, "HBD"); + op.push_back(2); + append_u32_le(op, 9); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +TEST(Hive, MixedTierOpsRejected) { + // vote is posting-tier, convert is active-tier; one signature cannot + // satisfy both post-HF28. + std::vector vote; + append_varint(vote, HIVE_OP_VOTE); + append_string(vote, "alice"); + append_string(vote, "bob"); + append_string(vote, "a-post"); + append_u16_le(vote, 10000); + + std::vector convert; + append_varint(convert, HIVE_OP_CONVERT); + append_string(convert, "alice"); + append_u32_le(convert, 1); + append_asset(convert, 1000, 3, "HBD"); + + std::vector tx = wrap_ops({vote, convert}); + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +TEST(Hive, ExcludedAndUnknownOpsStillRejected) { + HiveParsedTx parsed; + + // Ops 2/9/10 keep their dedicated message types — never fold them in. + for (uint32_t excluded : {static_cast(HIVE_OP_TRANSFER), + static_cast(HIVE_OP_ACCOUNT_CREATE), + static_cast(HIVE_OP_ACCOUNT_UPDATE)}) { + std::vector op; + append_varint(op, excluded); + append_string(op, "alice"); + std::vector tx = wrap_ops({op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } + + // Anything outside the table is refused; there is no blind-sign fallback. + // 49 = recurrent_transfer, a real op deliberately not in the table. + std::vector unknown; + append_varint(unknown, 49); + append_string(unknown, "alice"); + std::vector tx = wrap_ops({unknown}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +// Trailing bytes after a well-formed op must not be silently accepted: the +// signature covers them, so what the device displays would be a subset of +// what it signs. +TEST(Hive, TrailingBytesRejected) { + std::vector tx = wrap_ops( + {limit_order_create_op("alice", 1, 100, "HIVE", 50, "HBD", false, 1)}); + tx.push_back(0xff); + + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +// --------------------------------------------------------------------------- +// Golden vectors produced by hived itself: +// +// curl -X POST https://api.hive.blog -H 'Content-Type: application/json' \ +// -d '{"jsonrpc":"2.0","method":"condenser_api.get_transaction_hex", +// "params":[],"id":1}' +// +// These exist because our serializer and this parser were byte-exact mirrors +// of EACH OTHER while both disagreed with the chain: we wrote "HIVE"/"HBD" +// where hived writes "STEEM"/"SBD". Two wrongs cancelled and every test +// passed, but the device signed bytes hived could not validate — it reported +// "missing required active authority", because signature recovery over +// different bytes yields a key in no authority. A vector the chain generated +// is the only kind that can catch that class of bug. +// --------------------------------------------------------------------------- + +std::vector from_hex(const std::string& hex) { + std::vector out; + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + out.push_back( + static_cast(std::stoul(hex.substr(i, 2), nullptr, 16))); + } + return out; +} + +// Header shared by both vectors below: ref_block_num 4660 / prefix 0xdeadbeef +// (0/0 for the second) and expiration 2021-01-14T02:19:44. +// +// get_transaction_hex serializes a full transaction, so its output ends with a +// varint count of the `signatures` array. The device is handed the digest +// preimage, which stops after the extensions varint — so the trailing "00" +// from hived's hex is dropped in the goldens below. Everything before it must +// match byte for byte. +TEST(Hive, SerializationMatchesHivedLimitOrderCreate) { + const std::string golden = + "3412efbeadde40aaff5f010505616c6963652a000000dc05000000000000035354" + "45454d00009001000000000000035342440000000001b0f5536500"; + + std::vector tx; + append_u16_le(tx, 4660); + append_u32_le(tx, 0xdeadbeef); + append_u32_le(tx, 0x5fffaa40); + append_varint(tx, 1); + std::vector op = limit_order_create_op("alice", 42, 1500, "HIVE", + 400, "HBD", true, 0x6553f5b0); + tx.insert(tx.end(), op.begin(), op.end()); + append_varint(tx, 0); // extensions + + EXPECT_EQ(from_hex(golden), tx); + + // and the parser accepts what the chain produces + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_STREQ("HIVE", hive_assetSymbol(parsed.ops[0].assets[0])); + EXPECT_STREQ("HBD", hive_assetSymbol(parsed.ops[0].assets[1])); +} + +TEST(Hive, SerializationMatchesHivedClaimRewardBalance) { + const std::string golden = + "00000000000040aaff5f012705616c696365e8030000000000000353544545" + "4d0000d0070000000000000353424400000000c0c62d0000000000065645535453" + "000000"; + + std::vector tx; + append_u16_le(tx, 0); + append_u32_le(tx, 0); + append_u32_le(tx, 0x5fffaa40); + append_varint(tx, 1); + append_varint(tx, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(tx, "alice"); + append_asset(tx, 1000, 3, "HIVE"); + append_asset(tx, 2000, 3, "HBD"); + append_asset(tx, 3000000, 6, "VESTS"); + append_varint(tx, 0); // extensions + + EXPECT_EQ(from_hex(golden), tx); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_STREQ("VESTS", hive_assetSymbol(parsed.ops[0].assets[2])); +}