Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions projects/keepkey-vault/src/bun/txbuilder/hive-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
86 changes: 85 additions & 1 deletion projects/keepkey-vault/src/bun/txbuilder/hive-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = { HIVE: 3, HBD: 3, VESTS: 6 }
Expand Down Expand Up @@ -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',
}
Expand Down