@shuttle.technology/authorization 0.0.0-stage → 0.1.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.
Files changed (56) hide show
  1. package/README.md +708 -2
  2. package/dist/aesGcmSiv.d.ts +14 -0
  3. package/dist/aesGcmSiv.js +160 -0
  4. package/dist/auth.d.ts +68 -0
  5. package/dist/auth.js +215 -0
  6. package/dist/balances.d.ts +31 -0
  7. package/dist/balances.js +44 -0
  8. package/dist/canonicalize.d.ts +19 -0
  9. package/dist/canonicalize.js +107 -0
  10. package/dist/client.d.ts +264 -0
  11. package/dist/client.js +754 -0
  12. package/dist/deviceKey.d.ts +13 -0
  13. package/dist/deviceKey.js +28 -0
  14. package/dist/deviceShare.d.ts +9 -0
  15. package/dist/deviceShare.js +24 -0
  16. package/dist/deviceShareEnvelope.d.ts +13 -0
  17. package/dist/deviceShareEnvelope.js +139 -0
  18. package/dist/linkedChecks.d.ts +17 -0
  19. package/dist/linkedChecks.js +31 -0
  20. package/dist/linkedSendDisplay.d.ts +73 -0
  21. package/dist/linkedSendDisplay.js +84 -0
  22. package/dist/linkedWalletSignals.d.ts +22 -0
  23. package/dist/linkedWalletSignals.js +101 -0
  24. package/dist/linkedWallets.d.ts +373 -0
  25. package/dist/linkedWallets.js +471 -0
  26. package/dist/mediatedOperation.d.ts +27 -0
  27. package/dist/mediatedOperation.js +68 -0
  28. package/dist/modeC.d.ts +131 -0
  29. package/dist/modeC.js +248 -0
  30. package/dist/modeCLinked.d.ts +207 -0
  31. package/dist/modeCLinked.js +270 -0
  32. package/dist/pages.d.ts +22 -0
  33. package/dist/pages.js +45 -0
  34. package/dist/poll.d.ts +15 -0
  35. package/dist/poll.js +76 -0
  36. package/dist/requestGrant.d.ts +169 -0
  37. package/dist/requestGrant.js +243 -0
  38. package/dist/sends.d.ts +89 -0
  39. package/dist/sends.js +127 -0
  40. package/dist/slice4.d.ts +173 -0
  41. package/dist/slice4.js +285 -0
  42. package/dist/streams.d.ts +212 -0
  43. package/dist/streams.js +282 -0
  44. package/dist/trackedWallets.d.ts +53 -0
  45. package/dist/trackedWallets.js +104 -0
  46. package/dist/transferStatus.d.ts +45 -0
  47. package/dist/transferStatus.js +43 -0
  48. package/dist/walletDisplayParts.d.ts +15 -0
  49. package/dist/walletDisplayParts.js +43 -0
  50. package/dist/walletEvents.d.ts +43 -0
  51. package/dist/walletEvents.js +97 -0
  52. package/dist/walletLimits.d.ts +65 -0
  53. package/dist/walletLimits.js +102 -0
  54. package/dist/walletOperations.d.ts +192 -0
  55. package/dist/walletOperations.js +308 -0
  56. package/package.json +64 -4
@@ -0,0 +1,13 @@
1
+ export interface DeviceKey {
2
+ /** Non-extractable ECDH P-256 private key handle. */
3
+ readonly privateKey: CryptoKey;
4
+ /** 33-byte compressed public key, 66 lowercase hexadecimal characters. */
5
+ readonly publicKeyHex: string;
6
+ }
7
+ /** generateDeviceKey creates one device key pair with a non-extractable private key. */
8
+ export declare function generateDeviceKey(subtle?: SubtleCrypto): Promise<DeviceKey>;
9
+ /**
10
+ * devicePublicKeyHex returns the compressed SEC1 encoding of a P-256 public
11
+ * key: the parity prefix 02 or 03 followed by the 32-byte X coordinate.
12
+ */
13
+ export declare function devicePublicKeyHex(publicKey: CryptoKey, subtle?: SubtleCrypto): Promise<string>;
@@ -0,0 +1,28 @@
1
+ // Device key (source §1.10): the P-256 key pair the own-wallet system seals a
2
+ // wallet's device share to. The private key is generated non-extractable and
3
+ // never leaves the device; only its compressed public key is sent, as the
4
+ // recipient key of a wallet.create operation. This module performs no
5
+ // persistence and no network request: the application keeps the returned
6
+ // private key handle and the sealed device share it later receives in platform
7
+ // storage that can hold a non-extractable key, such as IndexedDB.
8
+ import { ClientFlowError } from "./auth.js";
9
+ /** generateDeviceKey creates one device key pair with a non-extractable private key. */
10
+ export async function generateDeviceKey(subtle = globalThis.crypto.subtle) {
11
+ const pair = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
12
+ if (pair.privateKey.extractable)
13
+ throw new ClientFlowError("device_key_unavailable");
14
+ return { privateKey: pair.privateKey, publicKeyHex: await devicePublicKeyHex(pair.publicKey, subtle) };
15
+ }
16
+ /**
17
+ * devicePublicKeyHex returns the compressed SEC1 encoding of a P-256 public
18
+ * key: the parity prefix 02 or 03 followed by the 32-byte X coordinate.
19
+ */
20
+ export async function devicePublicKeyHex(publicKey, subtle = globalThis.crypto.subtle) {
21
+ const raw = new Uint8Array(await subtle.exportKey("raw", publicKey));
22
+ if (raw.length !== 65 || raw[0] !== 0x04)
23
+ throw new ClientFlowError("device_key_unavailable");
24
+ const compressed = new Uint8Array(33);
25
+ compressed[0] = 0x02 | (raw[64] & 0x01);
26
+ compressed.set(raw.subarray(1, 33), 1);
27
+ return Array.from(compressed, (byte) => byte.toString(16).padStart(2, "0")).join("");
28
+ }
@@ -0,0 +1,9 @@
1
+ import type { DeviceSharePackage } from "./walletOperations.ts";
2
+ export { DeviceShareAlgorithm } from "./deviceShareEnvelope.ts";
3
+ /**
4
+ * resealDeviceShare opens the device share with the device key and seals it,
5
+ * under its own unchanged context, to the enclave's transport key for one
6
+ * send. The plaintext share exists only inside this call. The transport key
7
+ * is taken as given: this function cannot tell an enclave's key from any other.
8
+ */
9
+ export declare function resealDeviceShare(deviceKey: CryptoKey, sealed: DeviceSharePackage, enclavePublicKeyHex: string, crypto?: Crypto): Promise<DeviceSharePackage>;
@@ -0,0 +1,24 @@
1
+ // Device share (source §1.10): the one of a wallet's three key shares that only
2
+ // the user's device can open. The own-wallet system seals it to the device key
3
+ // when the wallet is created. To sign, the device opens it and seals it again
4
+ // to the enclave's transport key, so the enclave can combine it with its own
5
+ // share. This module is the whole of what an application needs for that, and
6
+ // it never hands the plaintext share to its caller. It performs no persistence
7
+ // and no network request.
8
+ import { openDeviceShare, sealDeviceShare } from "./deviceShareEnvelope.js";
9
+ export { DeviceShareAlgorithm } from "./deviceShareEnvelope.js";
10
+ /**
11
+ * resealDeviceShare opens the device share with the device key and seals it,
12
+ * under its own unchanged context, to the enclave's transport key for one
13
+ * send. The plaintext share exists only inside this call. The transport key
14
+ * is taken as given: this function cannot tell an enclave's key from any other.
15
+ */
16
+ export async function resealDeviceShare(deviceKey, sealed, enclavePublicKeyHex, crypto = globalThis.crypto) {
17
+ const share = await openDeviceShare(deviceKey, sealed, crypto.subtle);
18
+ try {
19
+ return await sealDeviceShare(share, enclavePublicKeyHex, sealed.encryptionContext, crypto);
20
+ }
21
+ finally {
22
+ share.fill(0);
23
+ }
24
+ }
@@ -0,0 +1,13 @@
1
+ import { type Bytes } from "./aesGcmSiv.ts";
2
+ import type { DeviceSharePackage } from "./walletOperations.ts";
3
+ export declare const DeviceShareAlgorithm = "p256-ecdh-hkdf-sha256-aes256-gcm-siv-v1";
4
+ /**
5
+ * uncompressedPoint returns the 65-byte form WebCrypto imports, from the
6
+ * 33-byte compressed or the 65-byte uncompressed SEC1 encoding of a point of
7
+ * P-256. Anything that is not a point of the curve is refused here.
8
+ */
9
+ export declare function uncompressedPoint(encoded: Uint8Array): Bytes;
10
+ /** openDeviceShare returns the plaintext share; the caller clears it after use. */
11
+ export declare function openDeviceShare(privateKey: CryptoKey, sealed: DeviceSharePackage, subtle?: SubtleCrypto): Promise<Bytes>;
12
+ /** sealDeviceShare seals a share to a recipient's P-256 key under the given context. */
13
+ export declare function sealDeviceShare(share: Uint8Array, recipientPublicKeyHex: string, contextHex: string, crypto?: Crypto): Promise<DeviceSharePackage>;
@@ -0,0 +1,139 @@
1
+ // The device share envelope, in the own-wallet system's format byte for byte
2
+ // (enclave device_crypto at Wallet source 9160f84): ECDH over P-256 with an
3
+ // ephemeral key, HKDF-SHA-256 with the encryption context as salt and the info
4
+ // "novasol:device-share:v1", AES-256-GCM-SIV with the same context as
5
+ // associated data. openDeviceShare returns the plaintext share, so this module
6
+ // is not a package entry point: applications use deviceShare.ts, which never
7
+ // lets the plaintext out.
8
+ import { ClientFlowError } from "./auth.js";
9
+ import { aes256GcmSivOpen, aes256GcmSivSeal } from "./aesGcmSiv.js";
10
+ export const DeviceShareAlgorithm = "p256-ecdh-hkdf-sha256-aes256-gcm-siv-v1";
11
+ const INFO = new TextEncoder().encode("novasol:device-share:v1");
12
+ const TAG_BYTES = 16;
13
+ // What an opener accepts, so a sealer never produces what cannot be opened.
14
+ const MAX_CIPHERTEXT_BYTES = 4096;
15
+ const MAX_CONTEXT_BYTES = 512;
16
+ // The P-256 field prime and curve constant b; a is -3.
17
+ const P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
18
+ const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
19
+ function fault() { throw new ClientFlowError("device_share_unavailable"); }
20
+ function fromHex(value, minimum, maximum) {
21
+ if (typeof value !== "string" || value.length % 2 !== 0 || !/^[0-9a-f]*$/.test(value) || value.length < 2 * minimum || value.length > 2 * maximum)
22
+ fault();
23
+ const out = new Uint8Array(value.length / 2);
24
+ for (let index = 0; index < out.length; index++)
25
+ out[index] = Number.parseInt(value.slice(2 * index, 2 * index + 2), 16);
26
+ return out;
27
+ }
28
+ function toHex(bytes) {
29
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
30
+ }
31
+ // power and curveSquare work on public coordinates only, never on a secret.
32
+ function power(base, exponent) {
33
+ let result = 1n;
34
+ for (let value = base % P, rest = exponent; rest > 0n; rest >>= 1n, value = (value * value) % P)
35
+ if (rest & 1n)
36
+ result = (result * value) % P;
37
+ return result;
38
+ }
39
+ function curveSquare(x) { return (power(x, 3n) + P - ((3n * x) % P) + B) % P; }
40
+ function bigEndian(bytes) {
41
+ let value = 0n;
42
+ for (const byte of bytes)
43
+ value = (value << 8n) | BigInt(byte);
44
+ return value;
45
+ }
46
+ /**
47
+ * uncompressedPoint returns the 65-byte form WebCrypto imports, from the
48
+ * 33-byte compressed or the 65-byte uncompressed SEC1 encoding of a point of
49
+ * P-256. Anything that is not a point of the curve is refused here.
50
+ */
51
+ export function uncompressedPoint(encoded) {
52
+ if (encoded.length === 65 && encoded[0] === 0x04) {
53
+ const x = bigEndian(encoded.subarray(1, 33));
54
+ const y = bigEndian(encoded.subarray(33));
55
+ if (x >= P || y >= P || (y * y) % P !== curveSquare(x))
56
+ fault();
57
+ return new Uint8Array(encoded);
58
+ }
59
+ if (encoded.length !== 33 || (encoded[0] !== 0x02 && encoded[0] !== 0x03))
60
+ fault();
61
+ const x = bigEndian(encoded.subarray(1));
62
+ if (x >= P)
63
+ fault();
64
+ const square = curveSquare(x);
65
+ let y = power(square, (P + 1n) / 4n);
66
+ if ((y * y) % P !== square)
67
+ fault();
68
+ if (Number(y & 1n) !== (encoded[0] & 1))
69
+ y = P - y;
70
+ const out = new Uint8Array(65);
71
+ out[0] = 0x04;
72
+ out.set(encoded.subarray(1), 1);
73
+ for (let index = 0; index < 32; index++)
74
+ out[64 - index] = Number((y >> BigInt(8 * index)) & 0xffn);
75
+ return out;
76
+ }
77
+ function compressedPoint(uncompressed) {
78
+ if (uncompressed.length !== 65 || uncompressed[0] !== 0x04)
79
+ fault();
80
+ const out = new Uint8Array(33);
81
+ out[0] = 0x02 | (uncompressed[64] & 0x01);
82
+ out.set(uncompressed.subarray(1, 33), 1);
83
+ return out;
84
+ }
85
+ /** The envelope key: the ECDH shared X coordinate through HKDF-SHA-256, salted with the context. */
86
+ async function envelopeKey(subtle, privateKey, peer, context) {
87
+ const point = uncompressedPoint(peer);
88
+ let shared;
89
+ try {
90
+ const publicKey = await subtle.importKey("raw", point, { name: "ECDH", namedCurve: "P-256" }, false, []);
91
+ shared = new Uint8Array(await subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256));
92
+ const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveBits"]);
93
+ return new Uint8Array(await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: context, info: INFO }, material, 256));
94
+ }
95
+ catch {
96
+ // One answer for every refusal, whatever the platform's own error was.
97
+ return fault();
98
+ }
99
+ finally {
100
+ shared?.fill(0);
101
+ }
102
+ }
103
+ /** openDeviceShare returns the plaintext share; the caller clears it after use. */
104
+ export async function openDeviceShare(privateKey, sealed, subtle = globalThis.crypto.subtle) {
105
+ if (sealed.encryptionAlgorithm !== DeviceShareAlgorithm)
106
+ fault();
107
+ const context = fromHex(sealed.encryptionContext, 1, MAX_CONTEXT_BYTES);
108
+ const ciphertext = fromHex(sealed.ciphertext, TAG_BYTES + 1, MAX_CIPHERTEXT_BYTES);
109
+ const nonce = fromHex(sealed.nonce, 12, 12);
110
+ const key = await envelopeKey(subtle, privateKey, fromHex(sealed.ephemeralPublicKey, 33, 65), context);
111
+ try {
112
+ return await aes256GcmSivOpen(subtle, key, nonce, ciphertext, context);
113
+ }
114
+ catch {
115
+ return fault();
116
+ }
117
+ finally {
118
+ key.fill(0);
119
+ }
120
+ }
121
+ /** sealDeviceShare seals a share to a recipient's P-256 key under the given context. */
122
+ export async function sealDeviceShare(share, recipientPublicKeyHex, contextHex, crypto = globalThis.crypto) {
123
+ if (share.length === 0 || share.length > MAX_CIPHERTEXT_BYTES - TAG_BYTES)
124
+ fault();
125
+ const subtle = crypto.subtle;
126
+ const context = fromHex(contextHex, 1, MAX_CONTEXT_BYTES);
127
+ const recipient = fromHex(recipientPublicKeyHex, 33, 65);
128
+ const ephemeral = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
129
+ const key = await envelopeKey(subtle, ephemeral.privateKey, recipient, context);
130
+ try {
131
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
132
+ const ciphertext = await aes256GcmSivSeal(subtle, key, nonce, share, context);
133
+ const ephemeralPublic = compressedPoint(new Uint8Array(await subtle.exportKey("raw", ephemeral.publicKey)));
134
+ return { ciphertext: toHex(ciphertext), nonce: toHex(nonce), ephemeralPublicKey: toHex(ephemeralPublic), encryptionAlgorithm: DeviceShareAlgorithm, encryptionContext: contextHex };
135
+ }
136
+ finally {
137
+ key.fill(0);
138
+ }
139
+ }
@@ -0,0 +1,17 @@
1
+ import type { ResultRecipient } from './slice4.ts';
2
+ export declare function fault(): never;
3
+ export declare function requestID(v: unknown): void;
4
+ export declare function requestUUID(v: unknown): void;
5
+ export declare function token(v: unknown): void;
6
+ export declare function uuid(v: unknown): v is string;
7
+ export declare function safeRevision(v: unknown): v is string;
8
+ export declare function text(v: unknown, max: number): v is string;
9
+ export declare function hex(v: unknown): v is string;
10
+ export declare function b64(v: unknown): v is string;
11
+ export declare function digest(v: unknown): v is string;
12
+ export declare function publicKey(a: unknown, v: unknown): boolean;
13
+ export declare function network(v: unknown): v is string;
14
+ export declare function assets(v: unknown): v is string[];
15
+ export declare function activityTypes(v: unknown): v is string[];
16
+ export declare function recipient(v: unknown): ResultRecipient;
17
+ export declare function https(v: unknown): boolean;
@@ -0,0 +1,31 @@
1
+ // The small checks the linked-wallet modules share: identifiers, revisions,
2
+ // networks, assets and the error a request or an answer fails with. They
3
+ // moved out of linkedWallets.ts when that file reached the repository line
4
+ // target.
5
+ import { ClientFlowError, canonicalDecimal, opaqueId, record } from './auth.js';
6
+ import { MaxStreamsActivityTypes } from './streams.js';
7
+ export function fault() { throw new ClientFlowError('invalid_server_response'); }
8
+ export function requestID(v) { if (!opaqueId(v))
9
+ throw new ClientFlowError('invalid_linked_wallet_request'); }
10
+ export function requestUUID(v) { if (!uuid(v))
11
+ throw new ClientFlowError('invalid_linked_wallet_request'); }
12
+ export function token(v) { if (!text(v, 8192))
13
+ throw new ClientFlowError('invalid_linked_wallet_request'); }
14
+ export function uuid(v) { return typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(v); }
15
+ export function safeRevision(v) { return canonicalDecimal(v) && v.length <= 16 && v !== '0' && BigInt(v) <= BigInt(Number.MAX_SAFE_INTEGER); }
16
+ export function text(v, max) { return typeof v === 'string' && v.length > 0 && new TextEncoder().encode(v).length <= max && v.trim() === v && !/[\x00-\x1f\x7f]/.test(v); }
17
+ export function hex(v) { return typeof v === 'string' && v.length > 0 && v.length <= 2048 && /^([0-9a-f]{2})+$/.test(v); }
18
+ export function b64(v) { return typeof v === 'string' && /^[A-Za-z0-9_-]+$/.test(v); }
19
+ export function digest(v) { return typeof v === 'string' && /^[0-9a-f]{64}$/.test(v); }
20
+ export function publicKey(a, v) { return typeof v === 'string' && (a === 'ed25519' ? /^[a-f0-9]{64}$/.test(v) : a === 'secp256k1' && /^(02|03)[a-f0-9]{64}$|^04[a-f0-9]{128}$/.test(v)); }
21
+ export function network(v) { return typeof v === 'string' && /^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}$/.test(v); }
22
+ export function assets(v) { return Array.isArray(v) && v.length <= 32 && new Set(v).size === v.length && v.every(a => typeof a === 'string' && /^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}\/[-a-z0-9]{3,8}:[-.%a-zA-Z0-9]{1,128}(\/[-.%a-zA-Z0-9]{1,78})?$/.test(a)); }
23
+ export function activityTypes(v) { return Array.isArray(v) && v.length <= MaxStreamsActivityTypes && new Set(v).size === v.length && v.every(t => typeof t === 'string' && t.length <= 64 && /^[a-z][a-z0-9]*(?:[._][a-z0-9]+)*$/.test(t)); }
24
+ export function recipient(v) { const r = record(v, ['type', 'id']); if (!['account', 'service'].includes(r.type) || !opaqueId(r.id))
25
+ fault(); return { type: r.type, id: r.id }; }
26
+ export function https(v) { try {
27
+ return typeof v === 'string' && new URL(v).protocol === 'https:';
28
+ }
29
+ catch {
30
+ return false;
31
+ } }
@@ -0,0 +1,73 @@
1
+ import { type DisplayCommon } from "./walletDisplayParts.ts";
2
+ /**
3
+ * LinkedWalletIdentity names a wallet created through the provider in the
4
+ * own-wallet system. It has no walletId: the operation names it by this
5
+ * identity instead.
6
+ */
7
+ export interface LinkedWalletIdentity {
8
+ connectionId: string;
9
+ linkedWalletId: string;
10
+ bindingRevision: string;
11
+ }
12
+ /** What a send from a linked wallet may move: the network's own coin, or a token the own-wallet system sends there. */
13
+ export declare const LinkedSendAssets: readonly ["native", "usdt", "usdc"];
14
+ export type LinkedSendAsset = (typeof LinkedSendAssets)[number];
15
+ export declare function linkedSendAsset(value: unknown): value is LinkedSendAsset;
16
+ /**
17
+ * What replaces a send the network has not included, on an EVM network:
18
+ * "speed_up" sends the same transfer again with a higher fee, "cancel" sends
19
+ * nothing to the wallet's own address in its place. Either way at most one of
20
+ * the two transactions is ever included.
21
+ */
22
+ export declare const ReplaceActions: readonly ["speed_up", "cancel"];
23
+ export type ReplaceAction = (typeof ReplaceActions)[number];
24
+ export declare function replaceAction(value: unknown): value is ReplaceAction;
25
+ /** The longest comment a transfer carries, in bytes of UTF-8: what one TON comment holds. */
26
+ export declare const MaxTransferCommentBytes = 120;
27
+ /**
28
+ * transferComment says whether a value is a text a send may carry to its
29
+ * recipient: 1 to 120 bytes of UTF-8 with no control character, line
30
+ * separator or character that reorders text. The provider applies the same
31
+ * rule, and the owner confirms the comment exactly as it is shown.
32
+ */
33
+ export declare function transferComment(value: unknown): value is string;
34
+ /**
35
+ * LinkedSendDisplay is what the owner confirms for a send from a linked
36
+ * wallet. address, preparedSummary and feeEstimate appear once the own-wallet
37
+ * system prepared the transfer (LinkedWallets.prepare()). feeEstimate is "estimated"
38
+ * with estimatedFeeRaw, the network fee in minimal units of the network's own
39
+ * coin, paid on top of the amount; or "unavailable" when that system could
40
+ * estimate none, which is never a fee of zero. It is an estimate: the
41
+ * confirmation does not bound the fee.
42
+ *
43
+ * A replacement carries replacesOperationId, the send whose transfer it
44
+ * replaces, and replaceAction. network, asset, recipient and amountRaw are
45
+ * then what the replacing transaction itself moves: for a cancel nothing
46
+ * ("0") of the network's own coin to the wallet's own address.
47
+ *
48
+ * comment is the text the transfer carries to its recipient, on TON; it is
49
+ * absent for a send without one.
50
+ */
51
+ export type LinkedSendDisplay = DisplayCommon & LinkedWalletIdentity & {
52
+ kind: "wallet.send";
53
+ network: string;
54
+ asset: string;
55
+ recipient: string;
56
+ amountRaw: string;
57
+ address?: string;
58
+ preparedSummary?: string;
59
+ feeEstimate?: "estimated" | "unavailable";
60
+ estimatedFeeRaw?: string;
61
+ replacesOperationId?: string;
62
+ replaceAction?: ReplaceAction;
63
+ comment?: string;
64
+ };
65
+ export declare function parseLinkedSendDisplay(value: unknown): LinkedSendDisplay;
66
+ /**
67
+ * MediatedLinkedSendDisplay is the safe display of a Mode C transfer from a
68
+ * linked wallet that a service forwarded on the person's signed request. It
69
+ * freezes no result recipient and is never a replacement.
70
+ */
71
+ export type MediatedLinkedSendDisplay = Omit<LinkedSendDisplay, "resultRecipient" | "replacesOperationId" | "replaceAction">;
72
+ /** parseMediatedLinkedSendDisplay reads exactly the display of a forwarded linked transfer. */
73
+ export declare function parseMediatedLinkedSendDisplay(value: unknown): MediatedLinkedSendDisplay;
@@ -0,0 +1,84 @@
1
+ // The safe display of a send from a linked wallet: a wallet created through
2
+ // the provider that lives in the own-wallet system. The same display serves
3
+ // the replacement of such a send that the network has not included.
4
+ import { ClientFlowError, opaqueId, record } from "./auth.js";
5
+ import { bindingRevision, displayCommon, lowerUUID, rawQuantity, summaryOf, walletRecipient } from "./walletDisplayParts.js";
6
+ const linkedNetwork = /^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}$/;
7
+ /** What a send from a linked wallet may move: the network's own coin, or a token the own-wallet system sends there. */
8
+ export const LinkedSendAssets = ["native", "usdt", "usdc"];
9
+ export function linkedSendAsset(value) {
10
+ return typeof value === "string" && LinkedSendAssets.includes(value);
11
+ }
12
+ /**
13
+ * What replaces a send the network has not included, on an EVM network:
14
+ * "speed_up" sends the same transfer again with a higher fee, "cancel" sends
15
+ * nothing to the wallet's own address in its place. Either way at most one of
16
+ * the two transactions is ever included.
17
+ */
18
+ export const ReplaceActions = ["speed_up", "cancel"];
19
+ export function replaceAction(value) {
20
+ return typeof value === "string" && ReplaceActions.includes(value);
21
+ }
22
+ /** The longest comment a transfer carries, in bytes of UTF-8: what one TON comment holds. */
23
+ export const MaxTransferCommentBytes = 120;
24
+ // Control characters, line and paragraph separators, characters that reorder
25
+ // the text around them, and halves of a character.
26
+ const notInComment = /[\p{Cc}\p{Zl}\p{Zp}\p{Bidi_Control}\p{Cs}]/u;
27
+ const utf8 = new TextEncoder();
28
+ /**
29
+ * transferComment says whether a value is a text a send may carry to its
30
+ * recipient: 1 to 120 bytes of UTF-8 with no control character, line
31
+ * separator or character that reorders text. The provider applies the same
32
+ * rule, and the owner confirms the comment exactly as it is shown.
33
+ */
34
+ export function transferComment(value) {
35
+ return typeof value === "string" && value.length > 0 && value.length <= MaxTransferCommentBytes && !notInComment.test(value) && utf8.encode(value).length <= MaxTransferCommentBytes;
36
+ }
37
+ export function parseLinkedSendDisplay(value) {
38
+ const display = record(value, [...linkedSendMembers, "result_recipient"], [...linkedSendOptional, "replaces_operation_id", "replace_action"]);
39
+ const replaces = display.replaces_operation_id !== undefined;
40
+ if (!linkedSendValid(display, replaces) || replaces !== (display.replace_action !== undefined) || (replaces && (!opaqueId(display.replaces_operation_id) || !replaceAction(display.replace_action))) ||
41
+ // A replacement carries no comment.
42
+ (replaces && display.comment !== undefined)) {
43
+ throw new ClientFlowError("invalid_server_response");
44
+ }
45
+ const parsed = { ...displayCommon(display), ...linkedSendParts(display) };
46
+ if (replaces) {
47
+ parsed.replacesOperationId = display.replaces_operation_id;
48
+ parsed.replaceAction = display.replace_action;
49
+ }
50
+ return parsed;
51
+ }
52
+ /** parseMediatedLinkedSendDisplay reads exactly the display of a forwarded linked transfer. */
53
+ export function parseMediatedLinkedSendDisplay(value) {
54
+ const display = record(value, linkedSendMembers, linkedSendOptional);
55
+ if (!linkedSendValid(display, false) || !opaqueId(display.owner_id) || !opaqueId(display.requester_id))
56
+ throw new ClientFlowError("invalid_server_response");
57
+ return { ownerId: display.owner_id, requesterId: display.requester_id, ...linkedSendParts(display) };
58
+ }
59
+ const linkedSendMembers = ["kind", "owner_id", "requester_id", "binding_source", "connection_id", "linked_wallet_id", "binding_revision", "network", "asset", "recipient", "amount_raw"];
60
+ const linkedSendOptional = ["address", "prepared_summary", "fee_estimate", "estimated_fee_raw", "comment"];
61
+ function linkedSendValid(display, replaces) {
62
+ return display.kind === "wallet.send" && display.binding_source === "linked_wallet" && lowerUUID(display.connection_id) && lowerUUID(display.linked_wallet_id) && bindingRevision(display.binding_revision) &&
63
+ typeof display.network === "string" && linkedNetwork.test(display.network) && linkedSendAsset(display.asset) && typeof display.recipient === "string" && walletRecipient.test(display.recipient) &&
64
+ // Only a replacement moves nothing: a cancel, or a cancel sent again.
65
+ rawQuantity(display.amount_raw, !replaces) && (display.address === undefined || (typeof display.address === "string" && walletRecipient.test(display.address))) &&
66
+ (display.fee_estimate === undefined || display.fee_estimate === "estimated" || display.fee_estimate === "unavailable") &&
67
+ (display.fee_estimate === "estimated") === (display.estimated_fee_raw !== undefined) && (display.estimated_fee_raw === undefined || rawQuantity(display.estimated_fee_raw, false)) &&
68
+ (display.comment === undefined || transferComment(display.comment));
69
+ }
70
+ function linkedSendParts(display) {
71
+ const parts = {
72
+ kind: "wallet.send", connectionId: display.connection_id, linkedWalletId: display.linked_wallet_id, bindingRevision: display.binding_revision,
73
+ network: display.network, asset: display.asset, recipient: display.recipient, amountRaw: display.amount_raw, ...summaryOf(display.prepared_summary),
74
+ };
75
+ if (display.address !== undefined)
76
+ parts.address = display.address;
77
+ if (display.fee_estimate !== undefined)
78
+ parts.feeEstimate = display.fee_estimate;
79
+ if (display.estimated_fee_raw !== undefined)
80
+ parts.estimatedFeeRaw = display.estimated_fee_raw;
81
+ if (display.comment !== undefined)
82
+ parts.comment = display.comment;
83
+ return parts;
84
+ }
@@ -0,0 +1,22 @@
1
+ /** The most linked wallets one stream names. */
2
+ export declare const MaxSignalWallets = 32;
3
+ /** Why a stream ended: its time ran out, the server is restarting, or the connection closed. */
4
+ export type LinkedWalletSignalEnd = 'expired' | 'server_draining' | 'connection_closed';
5
+ export interface LinkedWalletSignalHandlers {
6
+ /**
7
+ * The stream is live. Read every wallet once now: whatever happened before
8
+ * this moment is in that read, whatever happens after it is signalled.
9
+ * expiresAt is when the server ends the stream, in epoch milliseconds.
10
+ */
11
+ onOpen?: (expiresAt: number) => void;
12
+ /** These linked wallets have new events. Read them now. */
13
+ onWallets: (linkedWalletIds: readonly string[]) => void;
14
+ signal?: AbortSignal;
15
+ }
16
+ export declare function linkedSignalsPath(linkedWalletIds: readonly string[]): string;
17
+ /**
18
+ * Reads one stream to its end and reports what it says. It resolves with the
19
+ * reason the stream ended and rejects when the answer is not this protocol or
20
+ * the connection fails. Aborting the signal rejects with the abort reason.
21
+ */
22
+ export declare function readLinkedWalletSignals(body: ReadableStream<Uint8Array>, linkedWalletIds: readonly string[], handlers: LinkedWalletSignalHandlers): Promise<LinkedWalletSignalEnd>;
@@ -0,0 +1,101 @@
1
+ // Live signals for linked wallets. Authorization holds one long-lived answer
2
+ // open and names on it the linked wallets that have new events. A signal
3
+ // carries no event: the wallets it names are read through events(), which
4
+ // authorizes every read. A stream lives no longer than an access token does;
5
+ // when it ends, a new one is opened with the session's current token.
6
+ import { ClientFlowError, record, rfc3339UTC } from './auth.js';
7
+ /** The most linked wallets one stream names. */
8
+ export const MaxSignalWallets = 32;
9
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
10
+ // One frame is a short line or two; anything longer is not this protocol.
11
+ const maxFrameLength = 4096;
12
+ export function linkedSignalsPath(linkedWalletIds) {
13
+ if (!Array.isArray(linkedWalletIds) || linkedWalletIds.length < 1 || linkedWalletIds.length > MaxSignalWallets ||
14
+ new Set(linkedWalletIds).size !== linkedWalletIds.length || linkedWalletIds.some(id => typeof id !== 'string' || !uuidPattern.test(id))) {
15
+ throw new ClientFlowError('invalid_linked_wallet_request');
16
+ }
17
+ const query = new URLSearchParams();
18
+ for (const id of linkedWalletIds)
19
+ query.append('linked_wallet_id', id);
20
+ return `/v1/linked-wallets/signals?${query.toString()}`;
21
+ }
22
+ /**
23
+ * Reads one stream to its end and reports what it says. It resolves with the
24
+ * reason the stream ended and rejects when the answer is not this protocol or
25
+ * the connection fails. Aborting the signal rejects with the abort reason.
26
+ */
27
+ export async function readLinkedWalletSignals(body, linkedWalletIds, handlers) {
28
+ const named = new Set(linkedWalletIds);
29
+ const reader = body.getReader();
30
+ const decoder = new TextDecoder('utf-8', { fatal: true });
31
+ const signal = handlers.signal;
32
+ // Aborting ends the read whoever opened the stream: a read in flight then
33
+ // comes back as done, and the abort reason is what the caller gets.
34
+ const abort = () => { reader.cancel(signal?.reason).catch(() => undefined); };
35
+ signal?.addEventListener('abort', abort, { once: true });
36
+ let pending = '';
37
+ let open = false;
38
+ try {
39
+ for (;;) {
40
+ if (signal?.aborted)
41
+ throw signal.reason;
42
+ const { value, done } = await reader.read();
43
+ if (signal?.aborted)
44
+ throw signal.reason;
45
+ if (done) {
46
+ if (pending.length > 0)
47
+ fault(); // cut inside a frame
48
+ return 'connection_closed';
49
+ }
50
+ try {
51
+ pending += decoder.decode(value, { stream: true });
52
+ }
53
+ catch {
54
+ fault();
55
+ }
56
+ for (let end = pending.indexOf('\n\n'); end >= 0; end = pending.indexOf('\n\n')) {
57
+ const frame = pending.slice(0, end);
58
+ pending = pending.slice(end + 2);
59
+ if (frame.startsWith(':'))
60
+ continue; // a comment keeps the connection alive
61
+ const parsed = /^event: (open|wallets|close)\ndata: (\{[^\n]*\})$/.exec(frame);
62
+ if (!parsed)
63
+ fault();
64
+ let data;
65
+ try {
66
+ data = JSON.parse(parsed[2]);
67
+ }
68
+ catch {
69
+ fault();
70
+ }
71
+ if (parsed[1] === 'open') {
72
+ const root = record(data, ['expires_at']);
73
+ if (open || !rfc3339UTC(root.expires_at))
74
+ fault();
75
+ open = true;
76
+ handlers.onOpen?.(Date.parse(root.expires_at));
77
+ }
78
+ else if (parsed[1] === 'wallets') {
79
+ const root = record(data, ['linked_wallet_ids']);
80
+ const ids = root.linked_wallet_ids;
81
+ if (!open || !Array.isArray(ids) || ids.length < 1 || new Set(ids).size !== ids.length || ids.some(id => typeof id !== 'string' || !named.has(id)))
82
+ fault();
83
+ handlers.onWallets(Object.freeze([...ids]));
84
+ }
85
+ else {
86
+ const root = record(data, ['reason']);
87
+ if (root.reason !== 'expired' && root.reason !== 'server_draining')
88
+ fault();
89
+ return root.reason;
90
+ }
91
+ }
92
+ if (pending.length > maxFrameLength)
93
+ fault();
94
+ }
95
+ }
96
+ finally {
97
+ signal?.removeEventListener('abort', abort);
98
+ reader.cancel().catch(() => undefined);
99
+ }
100
+ }
101
+ function fault() { throw new ClientFlowError('invalid_server_response'); }