@fintekkers/ledger-models 0.4.38 → 0.4.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/node/fintekkers/models/util/decimal_value_pb.d.ts +2 -0
  2. package/node/wrappers/models/position/position.d.ts +1 -1
  3. package/node/wrappers/models/position/position.js.map +1 -1
  4. package/node/wrappers/models/position/position.test.js +2 -2
  5. package/node/wrappers/models/position/position.test.js.map +1 -1
  6. package/node/wrappers/models/position/position.test.ts +2 -2
  7. package/node/wrappers/models/position/position.ts +2 -2
  8. package/node/wrappers/models/price/Price.js +16 -8
  9. package/node/wrappers/models/price/Price.js.map +1 -1
  10. package/node/wrappers/models/price/Price.ts +15 -6
  11. package/node/wrappers/models/price/price_rules.d.ts +2 -2
  12. package/node/wrappers/models/price/price_rules.js +6 -4
  13. package/node/wrappers/models/price/price_rules.js.map +1 -1
  14. package/node/wrappers/models/price/price_rules.ts +5 -3
  15. package/node/wrappers/models/security/BondSecurity.d.ts +1 -1
  16. package/node/wrappers/models/security/BondSecurity.js +5 -5
  17. package/node/wrappers/models/security/BondSecurity.js.map +1 -1
  18. package/node/wrappers/models/security/BondSecurity.ts +5 -4
  19. package/node/wrappers/models/security/FloatingRateNote.js +2 -2
  20. package/node/wrappers/models/security/FloatingRateNote.js.map +1 -1
  21. package/node/wrappers/models/security/FloatingRateNote.ts +2 -1
  22. package/node/wrappers/models/security/Issuance.js +3 -6
  23. package/node/wrappers/models/security/Issuance.js.map +1 -1
  24. package/node/wrappers/models/security/Issuance.ts +3 -2
  25. package/node/wrappers/models/security/MortgageBackedSecurity.js +7 -7
  26. package/node/wrappers/models/security/MortgageBackedSecurity.js.map +1 -1
  27. package/node/wrappers/models/security/MortgageBackedSecurity.ts +7 -6
  28. package/node/wrappers/models/security/TIPSBond.js +2 -2
  29. package/node/wrappers/models/security/TIPSBond.js.map +1 -1
  30. package/node/wrappers/models/security/TIPSBond.ts +2 -1
  31. package/node/wrappers/models/security/security_rules.js +10 -12
  32. package/node/wrappers/models/security/security_rules.js.map +1 -1
  33. package/node/wrappers/models/security/security_rules.ts +9 -9
  34. package/node/wrappers/models/transaction/transaction.js +16 -10
  35. package/node/wrappers/models/transaction/transaction.js.map +1 -1
  36. package/node/wrappers/models/transaction/transaction.ts +16 -7
  37. package/node/wrappers/models/utils/decimal.d.ts +96 -0
  38. package/node/wrappers/models/utils/decimal.js +328 -0
  39. package/node/wrappers/models/utils/decimal.js.map +1 -0
  40. package/node/wrappers/models/utils/decimal.legacy.test.d.ts +1 -0
  41. package/node/wrappers/models/utils/decimal.legacy.test.js +174 -0
  42. package/node/wrappers/models/utils/decimal.legacy.test.js.map +1 -0
  43. package/node/wrappers/models/utils/decimal.legacy.test.ts +158 -0
  44. package/node/wrappers/models/utils/decimal.test.d.ts +1 -0
  45. package/node/wrappers/models/utils/decimal.test.js +258 -0
  46. package/node/wrappers/models/utils/decimal.test.js.map +1 -0
  47. package/node/wrappers/models/utils/decimal.test.ts +272 -0
  48. package/node/wrappers/models/utils/decimal.ts +335 -0
  49. package/node/wrappers/models/utils/decimal.typings.test.d.ts +1 -0
  50. package/node/wrappers/models/utils/decimal.typings.test.js +39 -0
  51. package/node/wrappers/models/utils/decimal.typings.test.js.map +1 -0
  52. package/node/wrappers/models/utils/decimal.typings.test.ts +16 -0
  53. package/node/wrappers/models/utils/serialization.d.ts +2 -1
  54. package/node/wrappers/models/utils/serialization.js +12 -2
  55. package/node/wrappers/models/utils/serialization.js.map +1 -1
  56. package/node/wrappers/models/utils/serialization.ts +14 -3
  57. package/node/wrappers/services/security-service/security.maturityLadder.test.js +1 -1
  58. package/node/wrappers/services/security-service/security.maturityLadder.test.js.map +1 -1
  59. package/node/wrappers/services/security-service/security.maturityLadder.test.ts +3 -2
  60. package/package.json +1 -1
  61. package/tsconfig.json +1 -0
@@ -0,0 +1,272 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { Decimal } from 'decimal.js';
4
+ import { DecimalValueProto } from '../../../fintekkers/models/util/decimal_value_pb';
5
+ import { ModelValidationError } from '../errors';
6
+ import { ProtoSerializationUtil } from './serialization';
7
+ import {
8
+ CompactDecimal,
9
+ MAX_DIGITS,
10
+ MAX_SCALE,
11
+ MAX_UNSCALED_BYTES,
12
+ decodeDecimal,
13
+ encodeDecimal,
14
+ fromDecimalJs,
15
+ parseDecimalString,
16
+ setDecimalLogger,
17
+ toCanonicalString,
18
+ withField,
19
+ } from './decimal';
20
+
21
+ /** Codec tests against the shared fixture (LM-306, JS side of LM-294). */
22
+
23
+ const FIXTURE = JSON.parse(fs.readFileSync(
24
+ path.join(__dirname, '../../../../../ledger-models-protos/fixtures/decimal_compact.json'), 'utf8'));
25
+
26
+ interface Case { name: string; string: string; canonical: string; unscaled_hex: string; scale: number; wire_hex: string }
27
+ const CASES: Case[] = FIXTURE.cases;
28
+
29
+ const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString('hex');
30
+ const unhex = (s: string) => new Uint8Array(Buffer.from(s, 'hex'));
31
+
32
+ /** Two's-complement big-endian, written independently of the codec. */
33
+ function expectedUnscaled(unscaledHex: string): bigint {
34
+ const unsigned = BigInt('0x' + unscaledHex);
35
+ const negative = parseInt(unscaledHex.slice(0, 2), 16) >= 0x80;
36
+ return negative ? unsigned - (BigInt(1) << BigInt(unscaledHex.length * 4)) : unsigned;
37
+ }
38
+
39
+ /** Only fields 11 and 12 set. */
40
+ function compactOnly(c: Case): DecimalValueProto {
41
+ return new DecimalValueProto().setUnscaledValue(unhex(c.unscaled_hex)).setScale(c.scale);
42
+ }
43
+
44
+ function expectInvalid(fn: () => unknown): void {
45
+ expect(fn).toThrow(ModelValidationError);
46
+ }
47
+
48
+ beforeAll(() => {
49
+ // String-only decodes below would warn once; keep the test output quiet.
50
+ setDecimalLogger({ warn: jest.fn() });
51
+ });
52
+
53
+ describe('metric 1: shared fixture', () => {
54
+ test('fixture limits match the codec', () => {
55
+ expect(FIXTURE.limits).toEqual({
56
+ max_digits: MAX_DIGITS, max_scale: MAX_SCALE, max_unscaled_bytes: MAX_UNSCALED_BYTES,
57
+ });
58
+ expect(CASES.length).toBeGreaterThanOrEqual(12);
59
+ });
60
+
61
+ test.each(CASES.map(c => [c.name, c] as [string, Case]))('%s encodes to wire_hex and decodes exactly', (_name, c) => {
62
+ const parsed = parseDecimalString(c.string);
63
+ expect(parsed).toEqual({ unscaled: expectedUnscaled(c.unscaled_hex), scale: c.scale });
64
+ expect(toCanonicalString(parsed)).toBe(c.canonical);
65
+
66
+ const proto = encodeDecimal(parsed);
67
+ expect(hex(proto.getUnscaledValue_asU8())).toBe(c.unscaled_hex);
68
+ expect(proto.getScale()).toBe(c.scale);
69
+ expect(hex(proto.serializeBinary())).toBe(c.wire_hex);
70
+
71
+ // The proto alone is enough to decode (metric 3).
72
+ const decoded = decodeDecimal(DecimalValueProto.deserializeBinary(unhex(c.wire_hex)));
73
+ expect(decoded).toEqual({ unscaled: expectedUnscaled(c.unscaled_hex), scale: c.scale });
74
+ });
75
+
76
+ const stringErrors: [string, string][] = FIXTURE.string_errors.map((c: { name: string; string: string }) => [c.name, c.string]);
77
+ test.each(stringErrors)('string error %s is a ModelValidationError', (_name, s) => {
78
+ expectInvalid(() => parseDecimalString(s));
79
+ expectInvalid(() => decodeDecimal(new DecimalValueProto().setArbitraryPrecisionValue(s)));
80
+ });
81
+
82
+ const decodeErrors: [string, any][] = FIXTURE.decode_errors.map((c: any) => [c.name, c]);
83
+ test.each(decodeErrors)('decode error %s is a ModelValidationError', (_name, c) => {
84
+ const bytes = c.unscaled_hex_repeat
85
+ ? unhex(c.unscaled_hex_repeat.hex.repeat(c.unscaled_hex_repeat.count))
86
+ : unhex(c.unscaled_hex);
87
+ expectInvalid(() => decodeDecimal(new DecimalValueProto().setUnscaledValue(bytes).setScale(c.scale)));
88
+ });
89
+
90
+ test('the 1702-byte case passes the byte check and fails on digits; 1703 fails on bytes', () => {
91
+ const message = (n: number) => {
92
+ try {
93
+ decodeDecimal(new DecimalValueProto().setUnscaledValue(unhex('7f'.repeat(n))));
94
+ } catch (e) {
95
+ return (e as Error).message;
96
+ }
97
+ return '';
98
+ };
99
+ expect(message(1702)).toContain(`more than ${MAX_DIGITS} digits`);
100
+ expect(message(1703)).toContain('1703-byte');
101
+ });
102
+
103
+ test('the committed decimal.js (what npm publishes) encodes and decodes fixture cases', () => {
104
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
105
+ const shipped = require(path.join(__dirname, 'decimal.js'));
106
+ for (const name of ['zero', 'two_pow_127', 'tiny_fraction']) {
107
+ const c = CASES.find(x => x.name === name)!;
108
+ const proto: DecimalValueProto = shipped.encodeDecimal(shipped.parseDecimalString(c.string));
109
+ expect(hex(proto.serializeBinary())).toBe(c.wire_hex);
110
+ expect(shipped.decodeDecimal(DecimalValueProto.deserializeBinary(unhex(c.wire_hex))))
111
+ .toEqual({ unscaled: expectedUnscaled(c.unscaled_hex), scale: c.scale });
112
+ }
113
+ });
114
+
115
+ test('compact form wins over a conflicting string and does not warn', () => {
116
+ jest.isolateModules(() => {
117
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
118
+ const codec = require('./decimal');
119
+ const warn = jest.fn();
120
+ codec.setDecimalLogger({ warn });
121
+ const proto = new DecimalValueProto()
122
+ .setArbitraryPrecisionValue('9')
123
+ .setUnscaledValue(unhex('0096'))
124
+ .setScale(2);
125
+ expect(codec.toCanonicalString(codec.decodeDecimal(proto))).toBe('1.50');
126
+ expect(warn).not.toHaveBeenCalled();
127
+ });
128
+ });
129
+
130
+ test('negative zero from decimal.js encodes to 0x00 at scale 0', () => {
131
+ const v = fromDecimalJs(new Decimal('-0'));
132
+ expect(v).toEqual({ unscaled: BigInt(0), scale: 0 });
133
+ const proto = encodeDecimal(v);
134
+ expect(hex(proto.getUnscaledValue_asU8())).toBe('00');
135
+ expect(proto.getScale()).toBe(0);
136
+ expect(proto.getArbitraryPrecisionValue()).toBe('0');
137
+ });
138
+ });
139
+
140
+ describe('metric 3: decodeDecimal takes the proto only', () => {
141
+ test('a type or field argument does not type-check', () => {
142
+ const p = encodeDecimal(parseDecimalString('1.50'));
143
+ // ts-jest type-checks this file: an unused @ts-expect-error fails it.
144
+ const typeChecked = () => {
145
+ // @ts-expect-error decodeDecimal takes no type argument
146
+ decodeDecimal<number>(p);
147
+ // @ts-expect-error decodeDecimal takes no field argument
148
+ decodeDecimal(p, 'price');
149
+ };
150
+ expect(typeof typeChecked).toBe('function');
151
+ expect(decodeDecimal.length).toBe(1);
152
+ });
153
+
154
+ test('withField names the field on a codec error', () => {
155
+ try {
156
+ withField('price', () => decodeDecimal(new DecimalValueProto()));
157
+ throw new Error('expected a ModelValidationError');
158
+ } catch (e) {
159
+ expect(e).toBeInstanceOf(ModelValidationError);
160
+ expect((e as ModelValidationError).field).toBe('price');
161
+ }
162
+ });
163
+ });
164
+
165
+ describe('metric 4: one deprecation warning per process', () => {
166
+ test('a compact-only decode does not warn', () => {
167
+ jest.isolateModules(() => {
168
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
169
+ const codec = require('./decimal');
170
+ const warn = jest.fn();
171
+ codec.setDecimalLogger({ warn });
172
+ for (const c of CASES) codec.decodeDecimal(compactOnly(c));
173
+ expect(warn).toHaveBeenCalledTimes(0);
174
+ });
175
+ });
176
+
177
+ test('three string-only prices read through the Price wrapper warn exactly once', () => {
178
+ jest.isolateModules(() => {
179
+ // The same isolated registry: Price loads this codec instance.
180
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
181
+ const codec = require('./decimal');
182
+ const warn = jest.fn();
183
+ codec.setDecimalLogger({ warn });
184
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
185
+ const { PriceProto } = require('../../../fintekkers/models/price/price_pb');
186
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
187
+ const Price = require('../price/Price').default;
188
+ const values = ['100.25', '-0.50', '1E+3'];
189
+ const read = values.map(s => new Price(
190
+ new PriceProto().setPrice(new DecimalValueProto().setArbitraryPrecisionValue(s))).getPrice());
191
+ expect(read.map((d: Decimal) => d.toString())).toEqual(['100.25', '-0.5', '1000']);
192
+ expect(warn).toHaveBeenCalledTimes(1);
193
+ expect(warn.mock.calls[0][0]).toContain('arbitrary_precision_value');
194
+ });
195
+ });
196
+ });
197
+
198
+ describe('metric 5: writers set both forms', () => {
199
+ test.each(CASES.map(c => [c.name, c] as [string, Case]))('%s: both forms are set and decode equal', (_name, c) => {
200
+ const proto = encodeDecimal(parseDecimalString(c.string));
201
+ expect(proto.getArbitraryPrecisionValue()).toBe(c.canonical);
202
+ expect(proto.getUnscaledValue_asU8().length).toBeGreaterThan(0);
203
+
204
+ const stringOnly = new DecimalValueProto().setArbitraryPrecisionValue(proto.getArbitraryPrecisionValue());
205
+ const compact = new DecimalValueProto()
206
+ .setUnscaledValue(proto.getUnscaledValue_asU8())
207
+ .setScale(proto.getScale());
208
+ const expected: CompactDecimal = { unscaled: expectedUnscaled(c.unscaled_hex), scale: c.scale };
209
+ expect(decodeDecimal(stringOnly)).toEqual(expected);
210
+ expect(decodeDecimal(compact)).toEqual(expected);
211
+ });
212
+ });
213
+
214
+ describe('guardrail: lossless', () => {
215
+ const NODE = path.join(__dirname, '../../..');
216
+ const SCANNED = [
217
+ 'wrappers/models/utils/decimal.ts',
218
+ 'wrappers/models/utils/serialization.ts',
219
+ 'wrappers/models/price/Price.ts',
220
+ 'wrappers/models/price/price_rules.ts',
221
+ 'wrappers/models/transaction/transaction.ts',
222
+ 'wrappers/models/security/BondSecurity.ts',
223
+ 'wrappers/models/security/Issuance.ts',
224
+ 'wrappers/models/security/MortgageBackedSecurity.ts',
225
+ 'wrappers/models/security/TIPSBond.ts',
226
+ 'wrappers/models/security/FloatingRateNote.ts',
227
+ 'wrappers/models/security/security_rules.ts',
228
+ ];
229
+
230
+ test.each(SCANNED)('%s has no float conversion on the decimal path', (file) => {
231
+ const lines = fs.readFileSync(path.join(NODE, file), 'utf8').split('\n');
232
+ const allowed: number[] = [];
233
+ lines.forEach((line, n) => {
234
+ // Comment lines don't count.
235
+ const code = /^\s*(\*|\/\*)/.test(line) ? '' : line.split('//')[0];
236
+ expect({ file, line: n + 1, code, banned: /parseFloat|parseInt|\.toNumber\(/.test(code) })
237
+ .toEqual({ file, line: n + 1, code, banned: false });
238
+ if (/\bNumber\(/.test(code)) allowed.push(n + 1);
239
+ });
240
+ // The one documented, range-checked conversion in smallInt().
241
+ const expectedNumberCalls = file.endsWith('utils/decimal.ts') ? 1 : 0;
242
+ expect(allowed.length).toBe(expectedNumberCalls);
243
+ if (expectedNumberCalls) expect(lines[allowed[0] - 1]).toContain('lossless-allow');
244
+ });
245
+
246
+ test('serialize(number) accepts only safe integers', () => {
247
+ expectInvalid(() => ProtoSerializationUtil.serialize(0.1));
248
+ expectInvalid(() => ProtoSerializationUtil.serialize(2 ** 53));
249
+ const proto = ProtoSerializationUtil.serialize(42) as DecimalValueProto;
250
+ expect(proto.getArbitraryPrecisionValue()).toBe('42');
251
+ expect(hex(proto.getUnscaledValue_asU8())).toBe('2a');
252
+ expect(proto.getScale()).toBe(0);
253
+ });
254
+
255
+ test('serialize(Decimal) writes both forms; deserialize returns a Decimal', () => {
256
+ const proto = ProtoSerializationUtil.serialize(new Decimal('-123.45')) as DecimalValueProto;
257
+ expect(proto.getArbitraryPrecisionValue()).toBe('-123.45');
258
+ expect(hex(proto.getUnscaledValue_asU8())).toBe('cfc7');
259
+
260
+ const big = CASES.find(c => c.name === 'two_pow_127')!;
261
+ const read = ProtoSerializationUtil.deserialize(compactOnly(big));
262
+ expect(read).toBeInstanceOf(Decimal);
263
+ expect((read as Decimal).toFixed()).toBe(big.canonical);
264
+ });
265
+
266
+ test('a value over the limits is an error, never rounded', () => {
267
+ expectInvalid(() => fromDecimalJs(new Decimal('1e-5000')));
268
+ expectInvalid(() => fromDecimalJs(new Decimal('1e+5000')));
269
+ expectInvalid(() => fromDecimalJs(new Decimal(NaN)));
270
+ expectInvalid(() => encodeDecimal({ unscaled: BigInt(1), scale: 1.5 }));
271
+ });
272
+ });
@@ -0,0 +1,335 @@
1
+ import { Decimal } from 'decimal.js';
2
+ import { DecimalValueProto } from '../../../fintekkers/models/util/decimal_value_pb';
3
+ import { ModelValidationError, violation } from '../errors';
4
+
5
+ /**
6
+ * Codec for `DecimalValueProto` (LM-306, the JS side of LM-294; see
7
+ * docs/adr/compact_decimal.md and the Rust reference in
8
+ * ledger-models-rust/fintekkers/wrappers/models/utils/decimal.rs).
9
+ *
10
+ * value = unscaled x 10^-scale, where `unscaled_value` (field 11) is a minimal
11
+ * two's-complement big-endian integer of any size and `scale` is field 12. A
12
+ * non-empty `unscaled_value` means the compact form is set and it wins.
13
+ * Otherwise the deprecated `arbitrary_precision_value` string (field 10) is
14
+ * read, with a warning logged once per process (LM-307).
15
+ *
16
+ * Lossless: no binary floating point on any path (no JS `number` value, no
17
+ * `parseFloat`). A value that cannot be represented exactly is a
18
+ * `ModelValidationError`, never rounded. The codec does not know which field
19
+ * it is decoding: a caller that wants the field named wraps the call in
20
+ * `withField`.
21
+ *
22
+ * Stage 1 (until LM-295): writers set both forms, so readers on older
23
+ * ledger-models keep working. This module is the only code allowed to touch
24
+ * the deprecated string field.
25
+ */
26
+
27
+ /**
28
+ * Most decimal digits allowed in the unscaled value, and the largest scale.
29
+ * The same in every language (see the ADR).
30
+ */
31
+ export const MAX_DIGITS = 4096;
32
+ export const MAX_SCALE = 4096;
33
+ /**
34
+ * A minimal unscaled_value longer than this always has more than
35
+ * `MAX_DIGITS` digits, so it is rejected before it is parsed.
36
+ */
37
+ export const MAX_UNSCALED_BYTES = 1702;
38
+
39
+ /** The library release that first writes the compact form. */
40
+ const FIRST_COMPACT_RELEASE = 'v0.4.34';
41
+
42
+ // No bigint literals: tsconfig targets es2016.
43
+ const ZERO = BigInt(0);
44
+ const ONE = BigInt(1);
45
+ const EIGHT = BigInt(8);
46
+
47
+ /** An exact decimal: `unscaled x 10^-scale`. `scale` is a count, never a value. */
48
+ export interface CompactDecimal {
49
+ unscaled: bigint;
50
+ scale: number;
51
+ }
52
+
53
+ /** Where the once-per-process deprecation warning goes. Defaults to `console`. */
54
+ export interface DecimalLogger {
55
+ warn(message: string): void;
56
+ }
57
+
58
+ let logger: DecimalLogger = console;
59
+ let warned = false;
60
+
61
+ /** Sends the deprecation warning to `l` from now on. Tests use it to spy. */
62
+ export function setDecimalLogger(l: DecimalLogger): void {
63
+ logger = l;
64
+ }
65
+
66
+ /** An unlabelled ModelValidationError: the field is empty until `withField` adds it. */
67
+ function invalid(message: string): ModelValidationError {
68
+ return new ModelValidationError([violation('', undefined, message)]);
69
+ }
70
+
71
+ /**
72
+ * Runs `fn`, labelling any ModelValidationError it throws with `field` (for
73
+ * example `price`). Violations that already name a field get it as a prefix.
74
+ */
75
+ export function withField<T>(field: string, fn: () => T): T {
76
+ try {
77
+ return fn();
78
+ } catch (e) {
79
+ if (!(e instanceof ModelValidationError)) throw e;
80
+ throw new ModelValidationError(e.violations.map(v => {
81
+ const labelled = v.clone();
82
+ labelled.setField(v.getField() ? `${field}.${v.getField()}` : field);
83
+ return labelled;
84
+ }));
85
+ }
86
+ }
87
+
88
+ /** At most the first 64 characters of `s`, for error messages. */
89
+ function preview(s: string): string {
90
+ return JSON.stringify(s.slice(0, 64));
91
+ }
92
+
93
+ function overCap(s: string): ModelValidationError {
94
+ return invalid(`decimal is over the limit of ${MAX_DIGITS} digits or scale ${MAX_SCALE}: ${preview(s)}`);
95
+ }
96
+
97
+ /**
98
+ * The only conversion from bigint to a JS number: `b` must already be
99
+ * range-checked against MAX_DIGITS / MAX_SCALE, so it is a small integer and
100
+ * the conversion is exact.
101
+ */
102
+ function smallInt(b: bigint): number {
103
+ const limit = BigInt(Math.max(MAX_DIGITS, MAX_SCALE));
104
+ if (b < -limit || b > limit) {
105
+ throw invalid(`decimal count ${b} is outside the limits`);
106
+ }
107
+ return Number(b); // lossless-allow: range-checked above, |b| <= 4096
108
+ }
109
+
110
+ function isDigit(c: string | undefined): boolean {
111
+ return c !== undefined && c >= '0' && c <= '9';
112
+ }
113
+
114
+ /**
115
+ * Parses a decimal string, as written to `arbitrary_precision_value`.
116
+ *
117
+ * Grammar: `[+-]? (digits [. digits?] | . digits) ([eE] [+-]? digits)?`. No
118
+ * whitespace, no `NaN` or `Infinity`. The exponent is folded into the scale;
119
+ * a negative scale becomes scale 0 (`1E+3` is unscaled 1000, scale 0).
120
+ * Trailing zeros are kept: `1.50` is unscaled 150, scale 2. Anything else, or
121
+ * a result over `MAX_DIGITS` / `MAX_SCALE`, is a ModelValidationError.
122
+ */
123
+ export function parseDecimalString(s: string): CompactDecimal {
124
+ const notADecimal = () => invalid(`decimal is not a finite decimal: ${preview(s)}`);
125
+ let i = 0;
126
+ let negative = false;
127
+ if (s[0] === '-' || s[0] === '+') {
128
+ negative = s[0] === '-';
129
+ i = 1;
130
+ }
131
+ const intStart = i;
132
+ while (isDigit(s[i])) i++;
133
+ const intDigits = s.slice(intStart, i);
134
+ let fracDigits = '';
135
+ if (s[i] === '.') {
136
+ i++;
137
+ const fracStart = i;
138
+ while (isDigit(s[i])) i++;
139
+ fracDigits = s.slice(fracStart, i);
140
+ }
141
+ if (intDigits === '' && fracDigits === '') throw notADecimal();
142
+ let exponent = ZERO;
143
+ if (s[i] === 'e' || s[i] === 'E') {
144
+ i++;
145
+ let expNegative = false;
146
+ if (s[i] === '-' || s[i] === '+') {
147
+ expNegative = s[i] === '-';
148
+ i++;
149
+ }
150
+ const expStart = i;
151
+ while (isDigit(s[i])) i++;
152
+ if (i === expStart) throw notADecimal();
153
+ const expDigits = s.slice(expStart, i).replace(/^0+/, '');
154
+ // Any exponent this long is far past the caps below.
155
+ if (expDigits.length > 12) throw overCap(s);
156
+ const magnitude = expDigits === '' ? ZERO : BigInt(expDigits);
157
+ exponent = expNegative ? -magnitude : magnitude;
158
+ }
159
+ if (i !== s.length) throw notADecimal();
160
+
161
+ const significant = (intDigits + fracDigits).replace(/^0+/, '');
162
+ const scale = BigInt(fracDigits.length) - exponent;
163
+ if (significant === '') {
164
+ // Zero has no sign and keeps its scale (`0.00` is scale 2); `0E+3` is scale 0.
165
+ const zeroScale = scale < ZERO ? ZERO : scale;
166
+ if (zeroScale > BigInt(MAX_SCALE)) throw overCap(s);
167
+ return { unscaled: ZERO, scale: smallInt(zeroScale) };
168
+ }
169
+ const zerosToAppend = scale < ZERO ? -scale : ZERO;
170
+ if (scale > BigInt(MAX_SCALE) || BigInt(significant.length) + zerosToAppend > BigInt(MAX_DIGITS)) {
171
+ throw overCap(s);
172
+ }
173
+ const magnitude = BigInt(significant + '0'.repeat(smallInt(zerosToAppend)));
174
+ return {
175
+ unscaled: negative ? -magnitude : magnitude,
176
+ scale: smallInt(scale < ZERO ? ZERO : scale),
177
+ };
178
+ }
179
+
180
+ /** Plain notation, never an exponent, keeping trailing zeros: unscaled 150 scale 2 is `1.50`. */
181
+ export function toCanonicalString(v: CompactDecimal): string {
182
+ const negative = v.unscaled < ZERO;
183
+ const digits = (negative ? -v.unscaled : v.unscaled).toString();
184
+ const sign = negative ? '-' : '';
185
+ if (v.scale === 0) return sign + digits;
186
+ if (digits.length > v.scale) {
187
+ return `${sign}${digits.slice(0, digits.length - v.scale)}.${digits.slice(digits.length - v.scale)}`;
188
+ }
189
+ return `${sign}0.${'0'.repeat(v.scale - digits.length)}${digits}`;
190
+ }
191
+
192
+ /** Minimal two's-complement big-endian bytes. Zero is the single byte 0x00. */
193
+ function bigIntToBytes(n: bigint): Uint8Array {
194
+ let hex: string;
195
+ if (n >= ZERO) {
196
+ hex = n.toString(16);
197
+ if (hex.length % 2 === 1) hex = '0' + hex;
198
+ // A leading byte >= 0x80 would read back as negative.
199
+ if (/^[89a-f]/.test(hex)) hex = '00' + hex;
200
+ } else {
201
+ const magnitude = -n;
202
+ let length = (magnitude.toString(16).length + 1) >> 1;
203
+ // The most negative value in `length` bytes is -(2^(8*length - 1)).
204
+ if (magnitude > ONE << (BigInt(length) * EIGHT - ONE)) length++;
205
+ hex = ((ONE << (BigInt(length) * EIGHT)) + n).toString(16);
206
+ hex = '0'.repeat(length * 2 - hex.length) + hex;
207
+ }
208
+ const out = new Uint8Array(hex.length / 2);
209
+ for (let i = 0; i < out.length; i++) {
210
+ out[i] = (hexValue(hex[2 * i]) << 4) | hexValue(hex[2 * i + 1]);
211
+ }
212
+ return out;
213
+ }
214
+
215
+ function hexValue(c: string): number {
216
+ return '0123456789abcdef'.indexOf(c);
217
+ }
218
+
219
+ /** Reads two's-complement big-endian bytes of any length; non-minimal input is fine. */
220
+ function bytesToBigInt(bytes: Uint8Array): bigint {
221
+ if (bytes.length === 0) return ZERO;
222
+ let hex = '';
223
+ for (const b of bytes) hex += (b < 16 ? '0' : '') + b.toString(16);
224
+ const unsigned = BigInt('0x' + hex);
225
+ return bytes[0] & 0x80 ? unsigned - (ONE << (BigInt(bytes.length) * EIGHT)) : unsigned;
226
+ }
227
+
228
+ /**
229
+ * Decodes the compact form (fields 11 and 12). Rejects a scale over
230
+ * `MAX_SCALE`, then bytes over `MAX_UNSCALED_BYTES`, then more than
231
+ * `MAX_DIGITS` digits, before any large allocation (the same order as Rust).
232
+ */
233
+ function decodeCompact(unscaledValue: Uint8Array, scale: number): CompactDecimal {
234
+ if (scale > MAX_SCALE) {
235
+ throw invalid(`decimal has scale ${scale}, over the limit of ${MAX_SCALE}`);
236
+ }
237
+ if (unscaledValue.length > MAX_UNSCALED_BYTES) {
238
+ throw invalid(`decimal has a ${unscaledValue.length}-byte unscaled_value, over the limit of ${MAX_UNSCALED_BYTES} bytes`);
239
+ }
240
+ const unscaled = bytesToBigInt(unscaledValue);
241
+ if ((unscaled < ZERO ? -unscaled : unscaled).toString().length > MAX_DIGITS) {
242
+ throw invalid(`decimal has more than ${MAX_DIGITS} digits`);
243
+ }
244
+ return { unscaled, scale };
245
+ }
246
+
247
+ /** True when neither form is set: `unscaled_value` and `arbitrary_precision_value` are both empty. */
248
+ export function isUnsetDecimal(p: DecimalValueProto): boolean {
249
+ return p.getUnscaledValue_asU8().length === 0 && p.getArbitraryPrecisionValue() === '';
250
+ }
251
+
252
+ /**
253
+ * Decodes a `DecimalValueProto`. The compact form wins when set. Otherwise
254
+ * falls back to the deprecated string and logs a deprecation warning, once
255
+ * per process. Both forms empty is a ModelValidationError; so is a malformed
256
+ * or over-cap value. Takes the proto only (owner ruling, 12 Oct): label
257
+ * errors with `withField`.
258
+ */
259
+ export function decodeDecimal(p: DecimalValueProto): CompactDecimal {
260
+ const unscaledValue = p.getUnscaledValue_asU8();
261
+ if (unscaledValue.length > 0) {
262
+ return decodeCompact(unscaledValue, p.getScale());
263
+ }
264
+ const legacy = p.getArbitraryPrecisionValue();
265
+ if (legacy === '') {
266
+ throw invalid('decimal is required but unset');
267
+ }
268
+ emitDeprecation();
269
+ return parseDecimalString(legacy);
270
+ }
271
+
272
+ /**
273
+ * Encodes both forms (stage 1): fields 11 and 12, plus the canonical string
274
+ * in field 10 for readers on ledger-models older than v0.4.34.
275
+ */
276
+ export function encodeDecimal(v: CompactDecimal): DecimalValueProto {
277
+ if (typeof v.unscaled !== 'bigint') {
278
+ throw invalid('decimal unscaled value must be a bigint');
279
+ }
280
+ if (!Number.isInteger(v.scale) || v.scale < 0 || v.scale > MAX_SCALE) {
281
+ throw invalid(`decimal scale must be an integer from 0 to ${MAX_SCALE}: ${v.scale}`);
282
+ }
283
+ return new DecimalValueProto()
284
+ .setArbitraryPrecisionValue(toCanonicalString(v))
285
+ .setUnscaledValue(bigIntToBytes(v.unscaled))
286
+ .setScale(v.scale);
287
+ }
288
+
289
+ /**
290
+ * `p` rewritten in both forms (stage 1), keeping its value and scale. Used
291
+ * when re-saving a message loaded from older bytes.
292
+ */
293
+ export function reencodeDecimal(p: DecimalValueProto): DecimalValueProto {
294
+ return encodeDecimal(decodeDecimal(p));
295
+ }
296
+
297
+ /**
298
+ * The value as a decimal.js `Decimal`, built from the canonical string. The
299
+ * `Decimal` constructor is exact and ignores the global `precision`. A
300
+ * `Decimal` does not keep trailing zeros, so `1.50` reads back as `1.5`.
301
+ */
302
+ export function toDecimalJs(v: CompactDecimal): Decimal {
303
+ return new Decimal(toCanonicalString(v));
304
+ }
305
+
306
+ /**
307
+ * The exact value of a decimal.js `Decimal`, from `toFixed()`, which never
308
+ * rounds when given no argument. A `Decimal` does not keep trailing zeros, so
309
+ * `new Decimal('1.50')` becomes scale 1: callers that need scale 2 use
310
+ * `parseDecimalString('1.50')`. Negative zero becomes 0. NaN, Infinity and
311
+ * values over the limits are a ModelValidationError.
312
+ */
313
+ export function fromDecimalJs(d: Decimal): CompactDecimal {
314
+ if (!d.isFinite()) {
315
+ throw invalid(`decimal is not a finite decimal: ${d.toString()}`);
316
+ }
317
+ // Checked before toFixed(), which would write every digit out.
318
+ if (d.e >= MAX_DIGITS || d.decimalPlaces() > MAX_SCALE) {
319
+ throw overCap(d.toString());
320
+ }
321
+ return parseDecimalString(d.toFixed());
322
+ }
323
+
324
+ /**
325
+ * Logs that a decimal was read from the deprecated string, the first time
326
+ * only in this process.
327
+ */
328
+ function emitDeprecation(): void {
329
+ if (warned) return;
330
+ warned = true;
331
+ logger.warn(
332
+ 'DecimalValueProto read from deprecated arbitrary_precision_value; '
333
+ + `writer predates ledger-models ${FIRST_COMPACT_RELEASE}`
334
+ );
335
+ }
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ const fs = __importStar(require("fs"));
27
+ const path = __importStar(require("path"));
28
+ /**
29
+ * Metric 4 (LM-306): the published typings mark the deprecated string
30
+ * accessors. compile.sh adds the tags after protoc-gen-ts runs.
31
+ */
32
+ const TYPINGS = path.join(__dirname, '../../../fintekkers/models/util/decimal_value_pb.d.ts');
33
+ test.each(['getArbitraryPrecisionValue', 'setArbitraryPrecisionValue'])('%s is @deprecated', (accessor) => {
34
+ const lines = fs.readFileSync(TYPINGS, 'utf8').split('\n');
35
+ const at = lines.findIndex(l => l.trim().startsWith(`${accessor}(`));
36
+ expect(at).toBeGreaterThan(0);
37
+ expect(lines[at - 1].trim()).toMatch(/^\/\*\* @deprecated .*\*\/$/);
38
+ });
39
+ //# sourceMappingURL=decimal.typings.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decimal.typings.test.js","sourceRoot":"","sources":["decimal.typings.test.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAAA,uCAAyB;AACzB,2CAA6B;AAE7B;;;GAGG;AAEH,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,uDAAuD,CAAC,CAAC;AAE9F,IAAI,CAAC,IAAI,CAAC,CAAC,4BAA4B,EAAE,4BAA4B,CAAC,CAAC,CAAC,mBAAmB,EAAE,CAAC,QAAQ,EAAE,EAAE;IACtG,MAAM,KAAK,GAAG,EAAE,CAAC,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC3D,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,GAAG,QAAQ,GAAG,CAAC,CAAC,CAAC;IACrE,MAAM,CAAC,EAAE,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;IAC9B,MAAM,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,6BAA6B,CAAC,CAAC;AACxE,CAAC,CAAC,CAAC"}
@@ -0,0 +1,16 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ /**
5
+ * Metric 4 (LM-306): the published typings mark the deprecated string
6
+ * accessors. compile.sh adds the tags after protoc-gen-ts runs.
7
+ */
8
+
9
+ const TYPINGS = path.join(__dirname, '../../../fintekkers/models/util/decimal_value_pb.d.ts');
10
+
11
+ test.each(['getArbitraryPrecisionValue', 'setArbitraryPrecisionValue'])('%s is @deprecated', (accessor) => {
12
+ const lines = fs.readFileSync(TYPINGS, 'utf8').split('\n');
13
+ const at = lines.findIndex(l => l.trim().startsWith(`${accessor}(`));
14
+ expect(at).toBeGreaterThan(0);
15
+ expect(lines[at - 1].trim()).toMatch(/^\/\*\* @deprecated .*\*\/$/);
16
+ });
@@ -9,7 +9,8 @@ import { ProtoEnum } from "./protoEnum";
9
9
  import { UUID } from "./uuid";
10
10
  import { StringValue } from 'google-protobuf/google/protobuf/wrappers_pb';
11
11
  import { Identifier } from "../security/identifier";
12
+ import { Decimal } from "decimal.js";
12
13
  export declare class ProtoSerializationUtil {
13
14
  static serialize(obj: any): UUIDProto | LocalDateProto | LocalTimestampProto | DecimalValueProto | StringValue | IdentifierProto;
14
- static deserialize(obj: any): UUID | Date | ZonedDateTime | Identifier | string | number | ProtoEnum | StrategyAllocationProto;
15
+ static deserialize(obj: any): UUID | Date | ZonedDateTime | Identifier | string | Decimal | ProtoEnum | StrategyAllocationProto;
15
16
  }