diff --git a/projects/keepkey-vault/src/bun/txbuilder/hive-ops.test.ts b/projects/keepkey-vault/src/bun/txbuilder/hive-ops.test.ts index 0fa49323..df1b6594 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/hive-ops.test.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/hive-ops.test.ts @@ -290,3 +290,64 @@ describe('comment_options beneficiary rules (finding #6)', () => { .not.toThrow() }) }) + +describe('phase-3: internal market (limit orders)', () => { + // Same vector as the firmware unit test Hive.LimitOrderCreateRetainsEveryDisplayedField + const CREATE = { + owner: 'alice', orderid: 42, + amount_to_sell: '1.500 HIVE', min_to_receive: '0.400 HBD', + fill_or_kill: true, expiration: 1700003600, + } + const u32le = (n: number) => { const b = Buffer.alloc(4); b.writeUInt32LE(n); return b } + + test('limit_order_create byte layout', () => { + const { serializedTx, tier } = sign(['limit_order_create', CREATE]) + expect(tier).toBe('active') + expect(serializedTx.equals(txBytes(1, + Buffer.from([5]), vstr('alice'), u32le(42), + vasset(1500n, 3, 'HIVE'), vasset(400n, 3, 'HBD'), + Buffer.from([1]), u32le(1700003600), + ))).toBe(true) + }) + + test('limit_order_cancel byte layout', () => { + const { serializedTx, tier } = sign(['limit_order_cancel', { owner: 'alice', orderid: 42 }]) + expect(tier).toBe('active') + expect(serializedTx.equals(txBytes(1, + Buffer.from([6]), vstr('alice'), u32le(42), + ))).toBe(true) + }) + + test('fill_or_kill false serializes as 0', () => { + const { serializedTx } = sign(['limit_order_create', { ...CREATE, fill_or_kill: false }]) + expect(serializedTx[serializedTx.length - 6]).toBe(0) + }) + + // Degenerate orders the firmware rejects — mirrored host-side for a clearer error + test('same symbol on both sides is rejected', () => { + expect(() => sign(['limit_order_create', { ...CREATE, min_to_receive: '0.400 HIVE' }])) + .toThrow(/symbols must differ/) + }) + test('zero amount_to_sell is rejected', () => { + expect(() => sign(['limit_order_create', { ...CREATE, amount_to_sell: '0.000 HIVE' }])) + .toThrow(/greater than zero/) + }) + test('zero min_to_receive is rejected', () => { + expect(() => sign(['limit_order_create', { ...CREATE, min_to_receive: '0.000 HBD' }])) + .toThrow(/greater than zero/) + }) + test('VESTS never trades on the internal market', () => { + expect(() => sign(['limit_order_create', { ...CREATE, amount_to_sell: '1.500000 VESTS' }])) + .toThrow(/must be HIVE or HBD/) + }) + test('non-boolean fill_or_kill is rejected', () => { + expect(() => sign(['limit_order_create', { ...CREATE, fill_or_kill: 1 }])) + .toThrow(/must be a boolean/) + }) + test('cannot share a tx with posting-tier ops', () => { + expect(() => sign( + ['limit_order_cancel', { owner: 'alice', orderid: 1 }], + ['vote', { voter: 'alice', author: 'bob', permlink: 'p', weight: 10000 }], + )).toThrow(/Cannot mix posting-tier and active-tier/) + }) +}) diff --git a/projects/keepkey-vault/src/bun/txbuilder/hive-ops.ts b/projects/keepkey-vault/src/bun/txbuilder/hive-ops.ts index f307ea20..a9c765e4 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/hive-ops.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/hive-ops.ts @@ -6,6 +6,8 @@ * (3), withdraw_vesting (4), convert (8), comment_options (19), * transfer_to/from_savings (32/33), claim_reward_balance (39), * delegate_vesting_shares (40), account_update2 (43, metadata-only). + * Phase-3 ops: limit_order_create (5), limit_order_cancel (6) — the internal + * market, required for in-wallet HIVE/HBD swaps. * * Byte-exact mirror of hived's condenser serialization and the independent * python-keepkey test serializer (test_msg_hive.py _op_* helpers). The @@ -21,6 +23,8 @@ const OP_VOTE = 0 const OP_COMMENT = 1 const OP_TRANSFER_TO_VESTING = 3 const OP_WITHDRAW_VESTING = 4 +const OP_LIMIT_ORDER_CREATE = 5 +const OP_LIMIT_ORDER_CANCEL = 6 const OP_CONVERT = 8 const OP_CUSTOM_JSON = 18 const OP_COMMENT_OPTIONS = 19 @@ -174,6 +178,37 @@ function serializeOp([name, p]: HiveOpTuple): { bytes: Buffer; tier: 'posting' | ]), tier: 'active', } + case 'limit_order_create': { + const sell = positiveAsset(p.amount_to_sell, ['HIVE', 'HBD'], 'limit_order_create amount_to_sell') + const recv = positiveAsset(p.min_to_receive, ['HIVE', 'HBD'], 'limit_order_create min_to_receive') + // A same-symbol order is a no-op trade on screen but still burns the + // fill; firmware refuses it, reject here for a clearer message. + if (sell.subarray(9, 16).equals(recv.subarray(9, 16))) { + throw new Error('limit_order_create: amount_to_sell and min_to_receive symbols must differ') + } + // expiration is unbounded here: the device has no RTC and the only cost + // of a stale/far-future value is an on-chain rejection, not a bad sign. + return { + bytes: Buffer.concat([ + varint(OP_LIMIT_ORDER_CREATE), + str(p.owner), + u32(Number(p.orderid), 'limit_order_create orderid'), + sell, recv, + boolByte(p.fill_or_kill, 'limit_order_create fill_or_kill'), + u32(Number(p.expiration), 'limit_order_create expiration'), + ]), + tier: 'active', + } + } + case 'limit_order_cancel': + return { + bytes: Buffer.concat([ + varint(OP_LIMIT_ORDER_CANCEL), + str(p.owner), + u32(Number(p.orderid), 'limit_order_cancel orderid'), + ]), + tier: 'active', + } case 'convert': return { bytes: Buffer.concat([