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 df1b6594..fd4aefac 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/hive-ops.test.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/hive-ops.test.ts @@ -291,6 +291,115 @@ describe('comment_options beneficiary rules (finding #6)', () => { }) }) +describe('limit_order_create expiration (Graphene time_point_sec)', () => { + // hived's JSON form is a zone-less ISO string, and that is what real dApps + // send. The whole phase-3 suite below uses unix ints, so this shape was never + // exercised until a live hivehub.dev market order hit + // "expiration out of uint32 range: NaN". + const ISO = '2026-08-16T21:44:55' + const UNIX = Math.floor(Date.UTC(2026, 7, 16, 21, 44, 55) / 1000) + const base = { + owner: 'bithighlander22', orderid: 1784583895, + amount_to_sell: '1.000 HIVE', min_to_receive: '0.048 HBD', + fill_or_kill: true, + } + const withExp = (expiration: any) => sign(['limit_order_create', { ...base, expiration }]).serializedTx + + test('the exact payload that failed in the field now serializes', () => { + expect(() => withExp(ISO)).not.toThrow() + }) + + test('ISO and its unix equivalent produce identical bytes', () => { + expect(withExp(ISO).equals(withExp(UNIX))).toBe(true) + }) + + test('a zone-less timestamp is UTC, not host-local', () => { + // The bug this guards: JS parses the zone-less date-time form as LOCAL + // time, so on a non-UTC host the signed expiration would silently drift by + // the UTC offset. TZ is set for the whole run in the package script; assert + // against an explicitly-UTC constant so a local-time regression fails here + // regardless of where the test runs. + // expiration is the op's last field; one trailing varint(0) (tx extensions) + // follows it, so it sits at [-5, -1). + const bytes = withExp(ISO) + expect(bytes.subarray(bytes.length - 5, bytes.length - 1).readUInt32LE()).toBe(UNIX) + }) + + test('an explicit offset is honored, not double-shifted', () => { + expect(withExp('2026-08-16T21:44:55Z').equals(withExp(UNIX))).toBe(true) + // +02:00 means the same instant is two hours EARLIER in UTC. + expect(withExp('2026-08-16T23:44:55+02:00').equals(withExp(UNIX))).toBe(true) + }) + + test('numeric strings are accepted as unix seconds', () => { + expect(withExp(String(UNIX)).equals(withExp(UNIX))).toBe(true) + }) + + test('an unparseable expiration is rejected with a message that says what is wanted', () => { + for (const bad of [undefined, null, '', 'tomorrow', {}, NaN]) { + expect(() => withExp(bad), `accepted ${JSON.stringify(bad)}`) + .toThrow(/expiration must be unix seconds or an ISO 8601 UTC timestamp|out of uint32 range/) + } + }) + + test('a pre-1970 timestamp is rejected rather than wrapping to a huge uint32', () => { + expect(() => withExp('1969-12-31T23:59:59')).toThrow(/out of uint32 range/) + }) + + // Date.parse is permissive in ways that CHANGE the value being signed. A + // timestamp the user never wrote must be rejected, not normalised. + test('impossible calendar dates are rejected, not rolled forward', () => { + // Date.parse('2026-02-30T12:00:00') silently yields 2026-03-02. + expect(() => withExp('2026-02-30T12:00:00')).toThrow(/not a real calendar date/) + expect(() => withExp('2026-04-31T12:00:00')).toThrow(/not a real calendar date/) + expect(() => withExp('2026-13-01T12:00:00')).toThrow(/month out of range/) + expect(() => withExp('2026-00-10T12:00:00')).toThrow(/month out of range/) + expect(() => withExp('2026-01-00T12:00:00')).toThrow(/not a real calendar date/) + }) + + test('leap years are judged correctly, not by a blanket Feb-29 rule', () => { + expect(() => withExp('2028-02-29T12:00:00')).not.toThrow() // divisible by 4 + expect(() => withExp('2000-02-29T12:00:00')).not.toThrow() // 400-year exception + expect(() => withExp('2026-02-29T12:00:00')).toThrow(/not a real calendar date/) + expect(() => withExp('2100-02-29T12:00:00')).toThrow(/not a real calendar date/) // century, not leap + }) + + test('out-of-range times are rejected', () => { + expect(() => withExp('2026-08-16T24:00:00')).toThrow(/time out of range/) + expect(() => withExp('2026-08-16T21:60:00')).toThrow(/time out of range/) + expect(() => withExp('2026-08-16T21:44:60')).toThrow(/time out of range/) + }) + + test('non-ISO date formats are rejected rather than guessed at', () => { + // All of these are accepted by Date.parse despite the promised ISO form — + // and 08/16/2026 vs 16/08/2026 is ambiguous between locales. + for (const bad of [ + 'August 16, 2026', '08/16/2026', '2026/08/16', '16-08-2026', + 'Sun Aug 16 2026', '2026-08-16T21:44:55 GMT+0200', + ]) { + expect(() => withExp(bad), `accepted ${JSON.stringify(bad)}`) + .toThrow(/must be unix seconds or an ISO 8601 UTC timestamp/) + } + }) + + test('a two-digit year is not silently mapped into the 1900s', () => { + // Date.UTC(26, …) means 1926. The 4-digit grammar rejects the short form + // outright rather than signing a century-old expiration. + expect(() => withExp('26-08-16T21:44:55')).toThrow(/must be unix seconds or an ISO/) + }) + + test('seconds are optional and fractional seconds are tolerated', () => { + // hived does not emit either, but neither changes the instant. + expect(withExp('2026-08-16T21:44').equals( + withExp(Math.floor(Date.UTC(2026, 7, 16, 21, 44, 0) / 1000)))).toBe(true) + expect(withExp('2026-08-16T21:44:55.123').equals(withExp(UNIX))).toBe(true) + }) + + test('a non-integer number of seconds is rejected', () => { + expect(() => withExp(1700003600.5)).toThrow(/whole number of seconds/) + }) +}) + describe('phase-3: internal market (limit orders)', () => { // Same vector as the firmware unit test Hive.LimitOrderCreateRetainsEveryDisplayedField const CREATE = { diff --git a/projects/keepkey-vault/src/bun/txbuilder/hive-ops.ts b/projects/keepkey-vault/src/bun/txbuilder/hive-ops.ts index a9c765e4..c9e8298c 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/hive-ops.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/hive-ops.ts @@ -71,6 +71,90 @@ function boolByte(v: any, what: string): Buffer { return Buffer.from([v ? 1 : 0]) } +/** + * Graphene `time_point_sec` → unix seconds. + * + * On the wire it is a uint32, but hived's JSON representation — and therefore + * what every Hive library and dApp sends — is an ISO 8601 string with NO zone + * suffix: "2026-08-16T21:44:55". Number() on that is NaN, which is what a real + * hivehub.dev market order hit. + * + * The 'Z' is appended deliberately. Graphene timestamps are always UTC, but JS + * parses the zone-less date-time form as LOCAL time (ES2015+), so on a host in + * e.g. America/Denver a naive Date.parse would sign an expiration six hours off + * the one the user was shown. Wrong-but-plausible is worse than rejected. + * + * Integers and numeric strings are accepted as unix seconds — the SDK tests and + * some direct REST callers use that form. + * + * The grammar is matched explicitly rather than handed to Date.parse, which is + * permissive in ways that silently change the value being signed: + * "2026-02-30T12:00:00" → Date.parse rolls it to 2026-03-02 + * "August 16, 2026" / "08/16/2026" → accepted despite not being ISO at all + * A timestamp the user never wrote must be rejected, not normalised. + */ +const ISO_TIMESTAMP = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d{1,9})?(Z|[+-]\d{2}:?\d{2})?$/ + +function daysInMonth(year: number, month: number): number { + // month is 1-12. Feb: Gregorian leap rule. + if (month === 2) { + const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 + return leap ? 29 : 28 + } + return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] +} + +function timePointSec(v: any, what: string): number { + if (typeof v === 'number') { + if (Number.isInteger(v)) return v + // NaN/Infinity are "numbers" that say nothing about intent — they fall + // through to the generic message below alongside null/undefined/{}. + if (Number.isFinite(v)) throw new Error(`${what} must be a whole number of seconds (got ${v})`) + } + if (typeof v === 'string') { + const s = v.trim() + if (/^\d+$/.test(s)) return Number(s) + + const m = ISO_TIMESTAMP.exec(s) + if (m) { + const [, yy, mo, dd, hh, mi, ss, zone] = m + const year = Number(yy), month = Number(mo), day = Number(dd) + const hour = Number(hh), minute = Number(mi), second = Number(ss ?? '0') + + // Calendar-component validation. Without this, 2026-02-30 matches the + // grammar and Date would roll it forward into March. + if (month < 1 || month > 12) throw new Error(`${what}: month out of range in "${s}"`) + if (day < 1 || day > daysInMonth(year, month)) { + throw new Error(`${what}: "${s}" is not a real calendar date`) + } + if (hour > 23 || minute > 59 || second > 59) { + throw new Error(`${what}: time out of range in "${s}"`) + } + + // A caller that specified an offset means it and must not be shifted + // again; a zone-less timestamp is UTC, because Graphene timestamps + // always are. JS would parse the zone-less form as LOCAL time, so on a + // host in e.g. America/Denver a naive parse signs an expiration six + // hours off the one the user was shown. + let offsetMinutes = 0 + if (zone && zone !== 'Z') { + const sign = zone[0] === '-' ? -1 : 1 + const digits = zone.slice(1).replace(':', '') + offsetMinutes = sign * (Number(digits.slice(0, 2)) * 60 + Number(digits.slice(2, 4))) + } + + // setUTCFullYear rather than Date.UTC: the latter maps years 0-99 into + // 1900+year, so "0026-08-16T…" would silently become 1926. + const dt = new Date(0) + dt.setUTCFullYear(year, month - 1, day) + dt.setUTCHours(hour, minute, second, 0) + return Math.floor(dt.getTime() / 1000) - offsetMinutes * 60 + } + } + throw new Error(`${what} must be unix seconds or an ISO 8601 UTC timestamp (got ${JSON.stringify(v)})`) +} + // int64 LE amount + u8 precision + 7-byte NUL-padded symbol (append_asset // layout). Precisions are fixed per symbol: HIVE/HBD = 3, VESTS = 6. const ASSET_PRECISION: Record = { HIVE: 3, HBD: 3, VESTS: 6 } @@ -195,7 +279,7 @@ function serializeOp([name, p]: HiveOpTuple): { bytes: Buffer; tier: 'posting' | 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'), + u32(timePointSec(p.expiration, 'limit_order_create expiration'), 'limit_order_create expiration'), ]), tier: 'active', }