@kasufinance/kasu-sdk 2.4.2 → 2.6.0
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.
- package/LICENSE +21 -0
- package/README.md +201 -253
- package/dist/bundle.cjs.js +5472 -1710
- package/dist/bundle.esm.js +5415 -1713
- package/dist/domain/au-minimum.d.ts +135 -0
- package/dist/domain/au-minimum.js +154 -0
- package/dist/domain/au-minimum.js.map +1 -0
- package/dist/domain/au-minimum.test.js +202 -0
- package/dist/domain/au-minimum.test.js.map +1 -0
- package/dist/domain/deposit-bounds.d.ts +62 -0
- package/dist/domain/deposit-bounds.js +84 -0
- package/dist/domain/deposit-bounds.js.map +1 -0
- package/dist/domain/deposit-bounds.test.d.ts +1 -0
- package/dist/domain/deposit-bounds.test.js +135 -0
- package/dist/domain/deposit-bounds.test.js.map +1 -0
- package/dist/domain/index.d.ts +40 -0
- package/dist/domain/index.js +33 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/domain/loan-contract.d.ts +174 -0
- package/dist/domain/loan-contract.js +160 -0
- package/dist/domain/loan-contract.js.map +1 -0
- package/dist/domain/loan-contract.test.d.ts +1 -0
- package/dist/domain/loan-contract.test.js +255 -0
- package/dist/domain/loan-contract.test.js.map +1 -0
- package/dist/domain/partners.d.ts +34 -0
- package/dist/domain/partners.js +43 -0
- package/dist/domain/partners.js.map +1 -0
- package/dist/domain/partners.test.d.ts +1 -0
- package/dist/domain/partners.test.js +53 -0
- package/dist/domain/partners.test.js.map +1 -0
- package/dist/domain/pools.d.ts +70 -0
- package/dist/domain/pools.js +134 -0
- package/dist/domain/pools.js.map +1 -0
- package/dist/domain/pools.test.d.ts +1 -0
- package/dist/domain/pools.test.js +184 -0
- package/dist/domain/pools.test.js.map +1 -0
- package/dist/domain/rates.d.ts +83 -0
- package/dist/domain/rates.js +120 -0
- package/dist/domain/rates.js.map +1 -0
- package/dist/domain/rates.test.d.ts +1 -0
- package/dist/domain/rates.test.js +181 -0
- package/dist/domain/rates.test.js.map +1 -0
- package/dist/domain/requests.d.ts +181 -0
- package/dist/domain/requests.js +202 -0
- package/dist/domain/requests.js.map +1 -0
- package/dist/domain/requests.test.d.ts +1 -0
- package/dist/domain/requests.test.js +470 -0
- package/dist/domain/requests.test.js.map +1 -0
- package/dist/domain/settlement.d.ts +97 -0
- package/dist/domain/settlement.js +117 -0
- package/dist/domain/settlement.js.map +1 -0
- package/dist/domain/settlement.test.d.ts +1 -0
- package/dist/domain/settlement.test.js +152 -0
- package/dist/domain/settlement.test.js.map +1 -0
- package/dist/domain/tranche-display-name.d.ts +17 -0
- package/dist/domain/tranche-display-name.js +30 -0
- package/dist/domain/tranche-display-name.js.map +1 -0
- package/dist/domain/tranche-display-name.test.d.ts +1 -0
- package/dist/domain/tranche-display-name.test.js +58 -0
- package/dist/domain/tranche-display-name.test.js.map +1 -0
- package/dist/domain/tranches.d.ts +89 -0
- package/dist/domain/tranches.js +150 -0
- package/dist/domain/tranches.js.map +1 -0
- package/dist/domain/tranches.test.d.ts +1 -0
- package/dist/domain/tranches.test.js +206 -0
- package/dist/domain/tranches.test.js.map +1 -0
- package/dist/domain/wallet-errors.d.ts +37 -0
- package/dist/domain/wallet-errors.js +56 -0
- package/dist/domain/wallet-errors.js.map +1 -0
- package/dist/domain/wallet-errors.test.d.ts +1 -0
- package/dist/domain/wallet-errors.test.js +71 -0
- package/dist/domain/wallet-errors.test.js.map +1 -0
- package/dist/facade/chain-configs.js +61 -1
- package/dist/facade/chain-configs.js.map +1 -1
- package/dist/facade/config.test.d.ts +1 -0
- package/dist/facade/config.test.js +216 -0
- package/dist/facade/config.test.js.map +1 -0
- package/dist/facade/deposits.d.ts +22 -1
- package/dist/facade/deposits.js +24 -1
- package/dist/facade/deposits.js.map +1 -1
- package/dist/facade/facade.test.d.ts +1 -0
- package/dist/{tests → facade}/facade.test.js +87 -5
- package/dist/facade/facade.test.js.map +1 -0
- package/dist/facade/index.d.ts +2 -1
- package/dist/facade/index.js +2 -0
- package/dist/facade/index.js.map +1 -1
- package/dist/facade/kasu.d.ts +49 -8
- package/dist/facade/kasu.js +117 -13
- package/dist/facade/kasu.js.map +1 -1
- package/dist/facade/strategies.d.ts +25 -0
- package/dist/facade/strategies.js +36 -0
- package/dist/facade/strategies.js.map +1 -1
- package/dist/facade/types.d.ts +57 -2
- package/dist/facade/unused-pool-ids.d.ts +31 -0
- package/dist/facade/unused-pool-ids.js +57 -0
- package/dist/facade/unused-pool-ids.js.map +1 -0
- package/dist/facade/user-portfolio.d.ts +18 -0
- package/dist/facade/user-portfolio.js +23 -0
- package/dist/facade/user-portfolio.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/sdk-config.d.ts +11 -0
- package/dist/sdk-config.js +8 -1
- package/dist/sdk-config.js.map +1 -1
- package/dist/services/DataService/data-service.d.ts +5 -0
- package/dist/services/DataService/data-service.js +10 -12
- package/dist/services/DataService/data-service.js.map +1 -1
- package/dist/services/DataService/directus-client.d.ts +26 -0
- package/dist/services/DataService/directus-client.js +38 -0
- package/dist/services/DataService/directus-client.js.map +1 -0
- package/dist/services/Locking/calculate-apy.test.d.ts +1 -0
- package/dist/services/Locking/calculate-apy.test.js +41 -0
- package/dist/services/Locking/calculate-apy.test.js.map +1 -0
- package/dist/services/Locking/locking.d.ts +14 -0
- package/dist/services/Locking/locking.js +16 -2
- package/dist/services/Locking/locking.js.map +1 -1
- package/dist/services/UserLending/user-lending.js +11 -7
- package/dist/services/UserLending/user-lending.js.map +1 -1
- package/dist/tests/sample.test.js +46 -25
- package/dist/tests/sample.test.js.map +1 -1
- package/package.json +43 -12
- package/src/domain/au-minimum.test.ts +371 -0
- package/src/domain/au-minimum.ts +192 -0
- package/src/domain/deposit-bounds.test.ts +200 -0
- package/src/domain/deposit-bounds.ts +118 -0
- package/src/domain/index.ts +134 -0
- package/src/domain/loan-contract.test.ts +343 -0
- package/src/domain/loan-contract.ts +275 -0
- package/src/domain/partners.test.ts +83 -0
- package/src/domain/partners.ts +66 -0
- package/src/domain/pools.test.ts +260 -0
- package/src/domain/pools.ts +150 -0
- package/src/domain/rates.test.ts +254 -0
- package/src/domain/rates.ts +120 -0
- package/src/domain/requests.test.ts +653 -0
- package/src/domain/requests.ts +414 -0
- package/src/domain/settlement.test.ts +198 -0
- package/src/domain/settlement.ts +161 -0
- package/src/domain/tranche-display-name.test.ts +96 -0
- package/src/domain/tranche-display-name.ts +34 -0
- package/src/domain/tranches.test.ts +300 -0
- package/src/domain/tranches.ts +177 -0
- package/src/domain/wallet-errors.test.ts +100 -0
- package/src/domain/wallet-errors.ts +56 -0
- package/src/facade/chain-configs.ts +62 -1
- package/src/facade/config.test.ts +265 -0
- package/src/facade/deposits.ts +25 -0
- package/src/{tests → facade}/facade.test.ts +134 -5
- package/src/facade/index.ts +4 -0
- package/src/facade/kasu.ts +155 -12
- package/src/facade/strategies.ts +36 -0
- package/src/facade/types.ts +60 -2
- package/src/facade/unused-pool-ids.ts +59 -0
- package/src/facade/user-portfolio.ts +24 -0
- package/src/index.ts +8 -0
- package/src/sdk-config.ts +19 -1
- package/src/services/DataService/data-service.ts +14 -26
- package/src/services/DataService/directus-client.ts +54 -0
- package/src/services/Locking/calculate-apy.test.ts +52 -0
- package/src/services/Locking/locking.ts +16 -2
- package/src/services/UserLending/user-lending.ts +17 -21
- package/src/tests/sample.test.ts +54 -33
- package/.eslintignore +0 -3
- package/.eslintrc.json +0 -90
- package/.github/workflows/ci.yml +0 -39
- package/.github/workflows/gitleaks.yml +0 -22
- package/.github/workflows/main.yml +0 -41
- package/.gitleaksignore +0 -19
- package/.prettierignore +0 -3
- package/.prettierrc +0 -28
- package/CLAUDE.md +0 -410
- package/copy-abi.ps1 +0 -15
- package/dist/tests/facade.test.js.map +0 -1
- package/index.ts +0 -2
- package/jest-config.ts +0 -9
- package/jest.config.js +0 -8
- package/rollup.config.js +0 -45
- package/tsconfig.json +0 -27
- /package/dist/{tests/facade.test.d.ts → domain/au-minimum.test.d.ts} +0 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { ethers } from 'ethers';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
asContractType,
|
|
5
|
+
buildContractVersionType,
|
|
6
|
+
buildFullNameRequestMessage,
|
|
7
|
+
buildLegacyContractRequestMessage,
|
|
8
|
+
buildLoanAgreementSignMessage,
|
|
9
|
+
ContractType,
|
|
10
|
+
encodeDepositData,
|
|
11
|
+
formatSignTimestampUtc,
|
|
12
|
+
parseFormattedMessage,
|
|
13
|
+
} from './loan-contract';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Ported from kasu-ui `src/features/lending/lib/encode-deposit-data.test.ts`
|
|
17
|
+
* and `sign-message.test.ts`.
|
|
18
|
+
*
|
|
19
|
+
* PINNED CROSS-REPO CONTRACT. The expected strings and byte strings here are
|
|
20
|
+
* what kasu-backend reconstructs and verifies against. A diff in this file
|
|
21
|
+
* without a matching kasu-backend diff means signatures stop verifying in
|
|
22
|
+
* production.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
describe('buildContractVersionType', () => {
|
|
26
|
+
it('packs version 1 retail as 0x0100 (256)', () => {
|
|
27
|
+
expect(buildContractVersionType(1, 'retail')).toBe(256);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('packs version 1 exempt as 0x0101 (257)', () => {
|
|
31
|
+
expect(buildContractVersionType(1, 'exempt')).toBe(257);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('packs version 0 retail as 0', () => {
|
|
35
|
+
expect(buildContractVersionType(0, 'retail')).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('packs version 2 exempt as 0x0201 (513)', () => {
|
|
39
|
+
expect(buildContractVersionType(2, 'exempt')).toBe(513);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const SIG_65 =
|
|
44
|
+
'0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' +
|
|
45
|
+
'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef1c';
|
|
46
|
+
const SIG_65_B =
|
|
47
|
+
'0x4a1e2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8' +
|
|
48
|
+
'4a1e2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f81b';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* BYTE FIXTURES — produced by the viem implementation kasu-ui runs
|
|
52
|
+
* (`encodeAbiParameters(parseAbiParameters('bytes, uint256, uint256'), …)`)
|
|
53
|
+
* and pasted here verbatim. This is the whole point of the port: the ethers v5
|
|
54
|
+
* encoder in `loan-contract.ts` must emit exactly these bytes, because kasu-ui
|
|
55
|
+
* and kasu-mobile write the same `depositData` field on the same contract.
|
|
56
|
+
*
|
|
57
|
+
* Covers both contract types, a contract version >= 2, a short signature and
|
|
58
|
+
* the empty-signature/zero-timestamp floor.
|
|
59
|
+
*/
|
|
60
|
+
const VIEM_FIXTURES: {
|
|
61
|
+
name: string;
|
|
62
|
+
signature: string;
|
|
63
|
+
timestamp: number;
|
|
64
|
+
contractVersion: number;
|
|
65
|
+
contractType: ContractType;
|
|
66
|
+
expected: string;
|
|
67
|
+
}[] = [
|
|
68
|
+
{
|
|
69
|
+
name: 'v1 retail, ms timestamp',
|
|
70
|
+
signature: SIG_65,
|
|
71
|
+
timestamp: 1_700_000_000_000,
|
|
72
|
+
contractVersion: 1,
|
|
73
|
+
contractType: 'retail',
|
|
74
|
+
expected:
|
|
75
|
+
'0x00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000018bcfe5680000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000041deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef1c00000000000000000000000000000000000000000000000000000000000000',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'v1 exempt, ms timestamp',
|
|
79
|
+
signature: SIG_65,
|
|
80
|
+
timestamp: 1_700_000_000_000,
|
|
81
|
+
contractVersion: 1,
|
|
82
|
+
contractType: 'exempt',
|
|
83
|
+
expected:
|
|
84
|
+
'0x00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000018bcfe5680000000000000000000000000000000000000000000000000000000000000001010000000000000000000000000000000000000000000000000000000000000041deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef1c00000000000000000000000000000000000000000000000000000000000000',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'v2 retail, deck timestamp',
|
|
88
|
+
signature: SIG_65_B,
|
|
89
|
+
timestamp: 1_785_313_320_000,
|
|
90
|
+
contractVersion: 2,
|
|
91
|
+
contractType: 'retail',
|
|
92
|
+
expected:
|
|
93
|
+
'0x00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000019facf75c40000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000414a1e2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f84a1e2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f81b00000000000000000000000000000000000000000000000000000000000000',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'v2 exempt, deck timestamp',
|
|
97
|
+
signature: SIG_65_B,
|
|
98
|
+
timestamp: 1_785_313_320_000,
|
|
99
|
+
contractVersion: 2,
|
|
100
|
+
contractType: 'exempt',
|
|
101
|
+
expected:
|
|
102
|
+
'0x00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000019facf75c40000000000000000000000000000000000000000000000000000000000000020100000000000000000000000000000000000000000000000000000000000000414a1e2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f84a1e2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f81b00000000000000000000000000000000000000000000000000000000000000',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'short signature, v3 exempt, timestamp 1',
|
|
106
|
+
signature: '0x1234',
|
|
107
|
+
timestamp: 1,
|
|
108
|
+
contractVersion: 3,
|
|
109
|
+
contractType: 'exempt',
|
|
110
|
+
expected:
|
|
111
|
+
'0x00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000030100000000000000000000000000000000000000000000000000000000000000021234000000000000000000000000000000000000000000000000000000000000',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'empty signature, v0 retail, timestamp 0',
|
|
115
|
+
signature: '0x',
|
|
116
|
+
timestamp: 0,
|
|
117
|
+
contractVersion: 0,
|
|
118
|
+
contractType: 'retail',
|
|
119
|
+
expected:
|
|
120
|
+
'0x0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
describe('encodeDepositData — byte-identical to the viem implementation', () => {
|
|
125
|
+
for (const fixture of VIEM_FIXTURES) {
|
|
126
|
+
it(`matches the viem fixture: ${fixture.name}`, () => {
|
|
127
|
+
expect(
|
|
128
|
+
encodeDepositData({
|
|
129
|
+
signature: fixture.signature,
|
|
130
|
+
timestamp: fixture.timestamp,
|
|
131
|
+
contractVersion: fixture.contractVersion,
|
|
132
|
+
contractType: fixture.contractType,
|
|
133
|
+
}),
|
|
134
|
+
).toBe(fixture.expected);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
it('produces a 0x-prefixed hex string', () => {
|
|
139
|
+
const out = encodeDepositData({
|
|
140
|
+
signature: SIG_65,
|
|
141
|
+
timestamp: 1_700_000_000_000,
|
|
142
|
+
contractVersion: 1,
|
|
143
|
+
contractType: 'retail',
|
|
144
|
+
});
|
|
145
|
+
expect(out).toMatch(/^0x[0-9a-f]+$/i);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('round-trips through the same ABI tuple', () => {
|
|
149
|
+
const out = encodeDepositData({
|
|
150
|
+
signature: SIG_65,
|
|
151
|
+
timestamp: 1_700_000_000_000,
|
|
152
|
+
contractVersion: 1,
|
|
153
|
+
contractType: 'exempt',
|
|
154
|
+
});
|
|
155
|
+
const [sigOut, tsOut, vtOut] = ethers.utils.defaultAbiCoder.decode(
|
|
156
|
+
['bytes', 'uint256', 'uint256'],
|
|
157
|
+
out,
|
|
158
|
+
) as [string, ethers.BigNumber, ethers.BigNumber];
|
|
159
|
+
expect(sigOut).toBe(SIG_65);
|
|
160
|
+
expect(tsOut.toString()).toBe('1700000000000');
|
|
161
|
+
expect(vtOut.toString()).toBe('257'); // version 1 exempt
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('encodes retail with low-byte 0 and exempt with low-byte 1', () => {
|
|
165
|
+
const vt = (contractType: ContractType): string => {
|
|
166
|
+
const [, , v] = ethers.utils.defaultAbiCoder.decode(
|
|
167
|
+
['bytes', 'uint256', 'uint256'],
|
|
168
|
+
encodeDepositData({
|
|
169
|
+
signature: SIG_65,
|
|
170
|
+
timestamp: 1,
|
|
171
|
+
contractVersion: 1,
|
|
172
|
+
contractType,
|
|
173
|
+
}),
|
|
174
|
+
) as [string, ethers.BigNumber, ethers.BigNumber];
|
|
175
|
+
return v.toString();
|
|
176
|
+
};
|
|
177
|
+
expect(vt('retail')).toBe('256');
|
|
178
|
+
expect(vt('exempt')).toBe('257');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('formatSignTimestampUtc', () => {
|
|
183
|
+
// 29 July 2026, 08:22:00 UTC.
|
|
184
|
+
const ms = 1785313320000; // 13 digits → milliseconds
|
|
185
|
+
const seconds = 1785313320; // 10 digits → seconds
|
|
186
|
+
|
|
187
|
+
it('formats a millisecond timestamp (>= 13 digits)', () => {
|
|
188
|
+
expect(formatSignTimestampUtc(ms)).toBe('29 July 2026, 08:22');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('formats a second timestamp (< 13 digits) to the same instant', () => {
|
|
192
|
+
expect(formatSignTimestampUtc(seconds)).toBe('29 July 2026, 08:22');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('pads hour/minute but not the day, at a month-boundary midnight', () => {
|
|
196
|
+
// 1 August 2026, 00:05:00 UTC — day non-padded ("1"), minute padded.
|
|
197
|
+
const midnight = Date.UTC(2026, 7, 1, 0, 5, 0);
|
|
198
|
+
expect(formatSignTimestampUtc(midnight)).toBe('1 August 2026, 00:05');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('buildLoanAgreementSignMessage', () => {
|
|
203
|
+
it('builds the exact 4-line message from a millisecond timestamp', () => {
|
|
204
|
+
expect(
|
|
205
|
+
buildLoanAgreementSignMessage({
|
|
206
|
+
strategyName: 'Taxation Funding (Tax Pay)',
|
|
207
|
+
region: 'Australia',
|
|
208
|
+
optionName: 'Upper Mezzanine',
|
|
209
|
+
amountLabel: '500 AUDD',
|
|
210
|
+
timestamp: 1785313320000,
|
|
211
|
+
}),
|
|
212
|
+
).toBe(
|
|
213
|
+
'Generate my Loan Agreement for review:\n' +
|
|
214
|
+
'Taxation Funding (Tax Pay) · Australia · Upper Mezzanine · 500 AUDD.\n' +
|
|
215
|
+
'Request made 29 July 2026, 08:22 UTC.\n' +
|
|
216
|
+
'This request does not commit me to lend.',
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('builds the identical message from the equivalent second timestamp', () => {
|
|
221
|
+
expect(
|
|
222
|
+
buildLoanAgreementSignMessage({
|
|
223
|
+
strategyName: 'Whole Ledger Funding',
|
|
224
|
+
region: 'Australia',
|
|
225
|
+
optionName: 'Mezzanine',
|
|
226
|
+
amountLabel: '1,260.37 USDC',
|
|
227
|
+
timestamp: 1785313320, // seconds form of the same instant
|
|
228
|
+
}),
|
|
229
|
+
).toBe(
|
|
230
|
+
'Generate my Loan Agreement for review:\n' +
|
|
231
|
+
'Whole Ledger Funding · Australia · Mezzanine · 1,260.37 USDC.\n' +
|
|
232
|
+
'Request made 29 July 2026, 08:22 UTC.\n' +
|
|
233
|
+
'This request does not commit me to lend.',
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('formats a month-boundary/midnight request line', () => {
|
|
238
|
+
expect(
|
|
239
|
+
buildLoanAgreementSignMessage({
|
|
240
|
+
strategyName: 'Professional Fee Funding',
|
|
241
|
+
region: 'Australia',
|
|
242
|
+
optionName: 'Junior',
|
|
243
|
+
amountLabel: '10 USDC',
|
|
244
|
+
timestamp: Date.UTC(2026, 7, 1, 0, 5, 0),
|
|
245
|
+
}),
|
|
246
|
+
).toBe(
|
|
247
|
+
'Generate my Loan Agreement for review:\n' +
|
|
248
|
+
'Professional Fee Funding · Australia · Junior · 10 USDC.\n' +
|
|
249
|
+
'Request made 1 August 2026, 00:05 UTC.\n' +
|
|
250
|
+
'This request does not commit me to lend.',
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('separates the line-2 fields with U+00B7 surrounded by single spaces', () => {
|
|
255
|
+
const message = buildLoanAgreementSignMessage({
|
|
256
|
+
strategyName: 'A',
|
|
257
|
+
region: 'B',
|
|
258
|
+
optionName: 'C',
|
|
259
|
+
amountLabel: 'D',
|
|
260
|
+
timestamp: 1785313320000,
|
|
261
|
+
});
|
|
262
|
+
expect(message.split('\n')[1]).toBe('A · B · C · D.');
|
|
263
|
+
expect(message.split('\n')).toHaveLength(4);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('buildLegacyContractRequestMessage', () => {
|
|
268
|
+
const ADDRESS = '0x4c0d92e9c862B58b0FFeAAD031004A049d2c360D';
|
|
269
|
+
|
|
270
|
+
it('builds the exact legacy template with a lowercased address', () => {
|
|
271
|
+
expect(buildLegacyContractRequestMessage(ADDRESS, 1785313320000)).toBe(
|
|
272
|
+
'I request contract content for ' +
|
|
273
|
+
'0x4c0d92e9c862b58b0ffeaad031004a049d2c360d at 1785313320000.',
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('is insensitive to the casing the caller hands over', () => {
|
|
278
|
+
expect(buildLegacyContractRequestMessage(ADDRESS, 1)).toBe(
|
|
279
|
+
buildLegacyContractRequestMessage(ADDRESS.toLowerCase(), 1),
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('prints the timestamp verbatim — no seconds/ms normalisation', () => {
|
|
284
|
+
// The backend echoes the body's `timestamp` into the string it
|
|
285
|
+
// verifies, so whatever is signed must be what is sent.
|
|
286
|
+
expect(buildLegacyContractRequestMessage(ADDRESS, 1785313320)).toBe(
|
|
287
|
+
'I request contract content for ' +
|
|
288
|
+
'0x4c0d92e9c862b58b0ffeaad031004a049d2c360d at 1785313320.',
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('buildFullNameRequestMessage', () => {
|
|
294
|
+
const ADDRESS = '0x26b5b8060A704b0420734d5Ccd657384fb1366C2';
|
|
295
|
+
|
|
296
|
+
it('builds the exact full-name template with a lowercased address', () => {
|
|
297
|
+
expect(buildFullNameRequestMessage(ADDRESS, 1785313320000)).toBe(
|
|
298
|
+
'I request my full name for ' +
|
|
299
|
+
'0x26b5b8060a704b0420734d5ccd657384fb1366c2 at 1785313320000.',
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('is a different message from the contract-content one', () => {
|
|
304
|
+
expect(buildFullNameRequestMessage(ADDRESS, 1)).not.toBe(
|
|
305
|
+
buildLegacyContractRequestMessage(ADDRESS, 1),
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('parseFormattedMessage', () => {
|
|
311
|
+
it('parses the server’s JSON-encoded tree', () => {
|
|
312
|
+
const parsed = parseFormattedMessage(
|
|
313
|
+
JSON.stringify({ intro: 'x', 'subheader-1': { title: 't' } }),
|
|
314
|
+
);
|
|
315
|
+
expect(parsed?.intro).toBe('x');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('returns null on malformed JSON so a renderer can fall back', () => {
|
|
319
|
+
expect(parseFormattedMessage('not json')).toBeNull();
|
|
320
|
+
expect(parseFormattedMessage('')).toBeNull();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('returns null for a JSON scalar, which is not a contract tree', () => {
|
|
324
|
+
expect(parseFormattedMessage('42')).toBeNull();
|
|
325
|
+
expect(parseFormattedMessage('null')).toBeNull();
|
|
326
|
+
expect(parseFormattedMessage('"a string"')).toBeNull();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('asContractType', () => {
|
|
331
|
+
it('narrows the wire string to the two encoded types', () => {
|
|
332
|
+
expect(asContractType('exempt')).toBe('exempt');
|
|
333
|
+
expect(asContractType('retail')).toBe('retail');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('falls back to retail for anything else', () => {
|
|
337
|
+
// Retail is the stricter contract; an unrecognised type must not be
|
|
338
|
+
// silently treated as the exempt one.
|
|
339
|
+
expect(asContractType('')).toBe('retail');
|
|
340
|
+
expect(asContractType('Exempt')).toBe('retail');
|
|
341
|
+
expect(asContractType('whatever')).toBe('retail');
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { ethers } from 'ethers';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loan-contract protocol: the strings and the bytes kasu-backend verifies.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ THE ONE EXCEPTION TO "NUMBERS AND CODES ONLY". Everything this module
|
|
7
|
+
* returns as a string is a PROTOCOL string, not copy. The backend reconstructs
|
|
8
|
+
* each of these messages byte-for-byte and verifies the lender's signature
|
|
9
|
+
* against it; a changed word, separator, line break or date format does not
|
|
10
|
+
* read differently — it stops every signature verifying. They are here, in the
|
|
11
|
+
* shared layer, for exactly the reason `getTrancheDisplayName` is: so the
|
|
12
|
+
* applications cannot drift apart on them.
|
|
13
|
+
*
|
|
14
|
+
* Any change to a builder below is a coordinated multi-repo change that has to
|
|
15
|
+
* land in kasu-backend at the same moment. Do not "tidy" this file.
|
|
16
|
+
*
|
|
17
|
+
* Lifted from kasu-ui's `features/lending/lib/{contract-types, sign-message}.ts`
|
|
18
|
+
* and `encode-deposit-data.ts` — the last rewritten from viem to ethers v5
|
|
19
|
+
* (kasu-mobile already runs that port; the tests pin the two byte-identical).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// The `/contract/generate` and `/contract/resolve` payload
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Recursive list nesting: `list-N-list`, etc. — indexed by string keys. */
|
|
27
|
+
export type ContractListItem = {
|
|
28
|
+
label?: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
} & Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
export type ContractSection = {
|
|
33
|
+
title?: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
} & Record<`list-${number}`, ContractListItem | undefined> &
|
|
36
|
+
Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
export type RetailLoanContract = {
|
|
39
|
+
important?: { title?: string; description?: string };
|
|
40
|
+
intro?: string;
|
|
41
|
+
between?: string;
|
|
42
|
+
parties?: ContractSection;
|
|
43
|
+
background?: ContractSection;
|
|
44
|
+
witnesses?: ContractSection;
|
|
45
|
+
} & Record<`subheader-${number}`, ContractSection | undefined> &
|
|
46
|
+
Record<string, unknown>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The exempt (wholesale) contract renders from the same tree as the retail
|
|
50
|
+
* one; the templates differ, the SHAPE does not. Kept as its own name because
|
|
51
|
+
* `contractType` distinguishes them everywhere else.
|
|
52
|
+
*/
|
|
53
|
+
export type ExemptLoanContract = RetailLoanContract;
|
|
54
|
+
|
|
55
|
+
/** Either contract, parsed. Both are the same tree. */
|
|
56
|
+
export type LoanContractFormatted = RetailLoanContract;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `contractType` arrives over the wire as a free string; these are the two the
|
|
60
|
+
* version byte encodes.
|
|
61
|
+
*/
|
|
62
|
+
export type ContractType = 'retail' | 'exempt';
|
|
63
|
+
|
|
64
|
+
export interface GenerateContractResponse {
|
|
65
|
+
fullName: string;
|
|
66
|
+
/** The plaintext the lender signs (EIP-191). */
|
|
67
|
+
contractMessage: string;
|
|
68
|
+
/** Returned as a JSON-encoded string; parse before rendering. */
|
|
69
|
+
formattedMessage: string;
|
|
70
|
+
contractType: ContractType;
|
|
71
|
+
/** Template version (>= 1). */
|
|
72
|
+
contractVersion: number;
|
|
73
|
+
/** ms-epoch; feeds the on-chain `depositData` and the TTL guard. */
|
|
74
|
+
timestamp: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type ResolvedContractResponse = GenerateContractResponse & {
|
|
78
|
+
isValid: boolean;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Narrow the backend's loose `contractType` string to the encoded union. */
|
|
82
|
+
export function asContractType(raw: string): ContractType {
|
|
83
|
+
return raw === 'exempt' ? 'exempt' : 'retail';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Parse the server's JSON-string `formattedMessage` into a tree. Returns
|
|
88
|
+
* `null` on parse failure so a renderer can fall back to the plaintext.
|
|
89
|
+
*/
|
|
90
|
+
export function parseFormattedMessage(
|
|
91
|
+
raw: string,
|
|
92
|
+
): LoanContractFormatted | null {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
95
|
+
if (parsed && typeof parsed === 'object')
|
|
96
|
+
return parsed as LoanContractFormatted;
|
|
97
|
+
return null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// The on-chain `depositData` blob
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pack the contract version and type into the `versionType` word.
|
|
109
|
+
*
|
|
110
|
+
* ```
|
|
111
|
+
* high byte = contract version (>= 1)
|
|
112
|
+
* low byte = 0 for retail, 1 for exempt
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export function buildContractVersionType(
|
|
116
|
+
contractVersion: number,
|
|
117
|
+
contractType: ContractType,
|
|
118
|
+
): number {
|
|
119
|
+
return (contractVersion << 8) + (contractType === 'retail' ? 0 : 1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build the on-chain `depositData` blob that `requestDepositWithKyc` expects.
|
|
124
|
+
*
|
|
125
|
+
* The KasuController decodes the bytes as
|
|
126
|
+
* `(bytes signature, uint256 timestamp, uint256 versionType)` and uses the
|
|
127
|
+
* embedded acceptance signature to verify — retrospectively, via the
|
|
128
|
+
* agreements service `/contract/resolve` — that the lender signed the
|
|
129
|
+
* loan-contract text. The ABI tuple and the packing are consensus-critical:
|
|
130
|
+
* these bytes go on chain.
|
|
131
|
+
*
|
|
132
|
+
* kasu-ui encodes this with viem, kasu-mobile with ethers v5 (viem is not
|
|
133
|
+
* available on Expo). This is the ethers v5 implementation, and
|
|
134
|
+
* `loan-contract.test.ts` pins its output byte-for-byte against fixtures
|
|
135
|
+
* produced by the viem version, so the two apps can never diverge here.
|
|
136
|
+
*
|
|
137
|
+
* @param args.signature EIP-191 signature from the lender accepting
|
|
138
|
+
* `contractMessage`, as a 0x-prefixed hex string.
|
|
139
|
+
* @param args.timestamp ms-epoch from the contract response.
|
|
140
|
+
*/
|
|
141
|
+
export function encodeDepositData(args: {
|
|
142
|
+
signature: string;
|
|
143
|
+
timestamp: number;
|
|
144
|
+
contractVersion: number;
|
|
145
|
+
contractType: ContractType;
|
|
146
|
+
}): string {
|
|
147
|
+
const versionType = buildContractVersionType(
|
|
148
|
+
args.contractVersion,
|
|
149
|
+
args.contractType,
|
|
150
|
+
);
|
|
151
|
+
return ethers.utils.defaultAbiCoder.encode(
|
|
152
|
+
['bytes', 'uint256', 'uint256'],
|
|
153
|
+
[
|
|
154
|
+
args.signature,
|
|
155
|
+
ethers.BigNumber.from(args.timestamp),
|
|
156
|
+
ethers.BigNumber.from(versionType),
|
|
157
|
+
],
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// The signed messages
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
const MONTH_NAMES = [
|
|
166
|
+
'January',
|
|
167
|
+
'February',
|
|
168
|
+
'March',
|
|
169
|
+
'April',
|
|
170
|
+
'May',
|
|
171
|
+
'June',
|
|
172
|
+
'July',
|
|
173
|
+
'August',
|
|
174
|
+
'September',
|
|
175
|
+
'October',
|
|
176
|
+
'November',
|
|
177
|
+
'December',
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Format a unix timestamp as `{day} {MonthName} {yyyy}, {HH}:{mm}` in UTC.
|
|
182
|
+
* Day is non-padded; hour and minute are zero-padded to two digits (24h). A
|
|
183
|
+
* timestamp with >= 13 digits is treated as milliseconds, otherwise as seconds
|
|
184
|
+
* — the same auto-detection kasu-backend applies.
|
|
185
|
+
*
|
|
186
|
+
* Deliberately a manual formatter with English month names: no locale, no
|
|
187
|
+
* `Intl`, so the output is byte-identical across runtimes and time zones. This
|
|
188
|
+
* is not a display date. It goes inside a signed message.
|
|
189
|
+
*
|
|
190
|
+
* e.g. 1785313320000 → `"29 July 2026, 08:22"`
|
|
191
|
+
*/
|
|
192
|
+
export function formatSignTimestampUtc(timestamp: number): string {
|
|
193
|
+
const ms = timestamp.toString().length >= 13 ? timestamp : timestamp * 1000;
|
|
194
|
+
const date = new Date(ms);
|
|
195
|
+
const day = date.getUTCDate();
|
|
196
|
+
const month = MONTH_NAMES[date.getUTCMonth()];
|
|
197
|
+
const year = date.getUTCFullYear();
|
|
198
|
+
const hours = String(date.getUTCHours()).padStart(2, '0');
|
|
199
|
+
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
|
200
|
+
return `${day} ${month} ${year}, ${hours}:${minutes}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The 4-line human-readable message a lender signs to generate their loan
|
|
205
|
+
* agreement for review — `POST /contract/generate`.
|
|
206
|
+
*
|
|
207
|
+
* ⚠️ BYTE-EXACT PROTOCOL STRING. kasu-backend rebuilds this string from the
|
|
208
|
+
* request body and verifies the signature against it, so the wording,
|
|
209
|
+
* ordering, separators, line breaks and date format are all part of the wire
|
|
210
|
+
* contract. The separator between the line-2 fields is a MIDDLE DOT U+00B7
|
|
211
|
+
* (·) with a single space on each side; the four lines are joined with `\n`.
|
|
212
|
+
*
|
|
213
|
+
* The backend takes this format only when all four display fields are present
|
|
214
|
+
* and non-empty, and it cross-checks `amountLabel`'s leading number against
|
|
215
|
+
* the `depositAmount` it was sent (thousands separators stripped) — a message
|
|
216
|
+
* that states an amount other than the one being executed is refused.
|
|
217
|
+
*/
|
|
218
|
+
export function buildLoanAgreementSignMessage(p: {
|
|
219
|
+
strategyName: string;
|
|
220
|
+
region: string;
|
|
221
|
+
optionName: string;
|
|
222
|
+
amountLabel: string;
|
|
223
|
+
timestamp: number;
|
|
224
|
+
}): string {
|
|
225
|
+
return [
|
|
226
|
+
'Generate my Loan Agreement for review:',
|
|
227
|
+
`${p.strategyName} · ${p.region} · ${p.optionName} · ${p.amountLabel}.`,
|
|
228
|
+
`Request made ${formatSignTimestampUtc(p.timestamp)} UTC.`,
|
|
229
|
+
'This request does not commit me to lend.',
|
|
230
|
+
].join('\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The legacy `/contract/generate` and `/contract/resolve` message.
|
|
235
|
+
*
|
|
236
|
+
* ⚠️ BYTE-EXACT PROTOCOL STRING. kasu-backend rebuilds it as
|
|
237
|
+
* `` `I request contract content for ${address} at ${timestamp}.` `` from the
|
|
238
|
+
* `address` and `timestamp` fields of the request body — so the string signed
|
|
239
|
+
* and the body sent must agree exactly, INCLUDING the address casing. This
|
|
240
|
+
* builder lowercases, and the request body must carry the same lowercased
|
|
241
|
+
* address; that is what both apps signing this format do today.
|
|
242
|
+
*
|
|
243
|
+
* The backend takes this path whenever the four human-readable display fields
|
|
244
|
+
* are absent, and documents it as permanent until the legacy app is
|
|
245
|
+
* decommissioned. `/contract/resolve` has no other format — every consumer
|
|
246
|
+
* signs this one to retrieve an existing agreement.
|
|
247
|
+
*
|
|
248
|
+
* @param timestampMs ms-epoch, and the same value sent as the body's
|
|
249
|
+
* `timestamp`.
|
|
250
|
+
*/
|
|
251
|
+
export function buildLegacyContractRequestMessage(
|
|
252
|
+
address: string,
|
|
253
|
+
timestampMs: number,
|
|
254
|
+
): string {
|
|
255
|
+
return `I request contract content for ${address.toLowerCase()} at ${timestampMs}.`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The `POST /contract/fullname` message.
|
|
260
|
+
*
|
|
261
|
+
* ⚠️ BYTE-EXACT PROTOCOL STRING, on the same terms as
|
|
262
|
+
* `buildLegacyContractRequestMessage`: kasu-backend rebuilds
|
|
263
|
+
* `` `I request my full name for ${address} at ${timestamp}.` `` from the
|
|
264
|
+
* request body and verifies the signature against it, so the body must carry
|
|
265
|
+
* the same lowercased address this builder signs.
|
|
266
|
+
*
|
|
267
|
+
* @param timestampMs ms-epoch, and the same value sent as the body's
|
|
268
|
+
* `timestamp`.
|
|
269
|
+
*/
|
|
270
|
+
export function buildFullNameRequestMessage(
|
|
271
|
+
address: string,
|
|
272
|
+
timestampMs: number,
|
|
273
|
+
): string {
|
|
274
|
+
return `I request my full name for ${address.toLowerCase()} at ${timestampMs}.`;
|
|
275
|
+
}
|