-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmailbox.ts
More file actions
300 lines (263 loc) · 10.4 KB
/
Copy pathmailbox.ts
File metadata and controls
300 lines (263 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
/**
* AlgoChat - Raven Mailbox Protocol (pure layer)
*
* Deterministic, I/O-free implementation of the raven router mailbox
* protocol (RFC 0001; reference contract: CorvidLabs/raven
* `contracts/router/src/router.algo.ts`).
*
* Key derivation (normative):
* msg_key = HMAC-SHA256(key = view_secret, "raven/mailbox/v1" ‖ counter_be32)
* mailbox_id = SHA-256("raven/mailbox/v1/id" ‖ msg_key)
*
* The contract never sees the view secret. Burning a mailbox reveals only
* one counter's msg_key, which is useless afterwards — rotate the counter
* per message. Counter ownership stays with the caller.
*
* This module performs no network or signing operations; see
* `MailboxRouterTransport` in services for the chain-bound layer.
*/
import { hmac } from '@noble/hashes/hmac';
import { sha256 } from '@noble/hashes/sha256';
import { sha512_256 } from '@noble/hashes/sha512';
/** Domain separator HMACed (with the counter) into msg keys. */
export const MAILBOX_MSG_KEY_DOMAIN = 'raven/mailbox/v1';
/** Domain separator hashed in front of msg_key to form mailbox ids. */
export const MAILBOX_ID_DOMAIN = 'raven/mailbox/v1/id';
/** Maximum envelope size accepted by the contract, in bytes. */
export const MAILBOX_MAX_ENVELOPE_SIZE = 2048;
/** Rounds an unclaimed box must age before the depositor may reclaim. */
export const MAILBOX_TTL_ROUNDS = 2_600_000;
/** Consensus flat box MBR, in microALGO. */
export const MAILBOX_BOX_FLAT_MBR = 2500;
/** Consensus per-byte box MBR, in microALGO. */
export const MAILBOX_BOX_BYTE_MBR = 400;
/** Box value header prepended by the contract: 32B depositor + 8B round. */
export const MAILBOX_HEADER_SIZE = 40;
/** Fee charged to the released MBR on every refund inner-transaction. */
export const MAILBOX_REFUND_FEE = 1000;
/** Algorand consensus maximum transactions per atomic group. */
export const MAILBOX_MAX_GROUP_SIZE = 16;
/** Maximum put legs per atomic fan-out group (2 transactions per leg). */
export const MAILBOX_MAX_FANOUT_LEGS = 8;
/** Required view secret length, in bytes. */
export const VIEW_SECRET_SIZE = 32;
/** Msg key length, in bytes. */
export const MSG_KEY_SIZE = 32;
/** Mailbox id length, in bytes. */
export const MAILBOX_ID_SIZE = 32;
/** Largest valid counter value (uint32, big-endian encoded). */
export const MAX_COUNTER = 0xffffffff;
/** Base error for all mailbox protocol and transport failures. */
export class MailboxError extends Error {
constructor(message: string) {
super(message);
this.name = 'MailboxError';
}
}
/** Thrown when a view secret is not exactly 32 bytes. */
export class InvalidViewSecretError extends MailboxError {
actualSize: number;
constructor(actualSize: number) {
super(`Invalid view secret: ${actualSize} bytes (expected ${VIEW_SECRET_SIZE})`);
this.name = 'InvalidViewSecretError';
this.actualSize = actualSize;
}
}
/** Thrown when a msg key is not exactly 32 bytes. */
export class InvalidMsgKeyError extends MailboxError {
actualSize: number;
constructor(actualSize: number) {
super(`Invalid msg key: ${actualSize} bytes (expected ${MSG_KEY_SIZE})`);
this.name = 'InvalidMsgKeyError';
this.actualSize = actualSize;
}
}
/** Thrown when a counter is not an integer in [0, 2^32 - 1]. */
export class InvalidCounterError extends MailboxError {
actualValue: number;
constructor(actualValue: number) {
super(`Invalid counter: ${actualValue} (expected integer 0..${MAX_COUNTER})`);
this.name = 'InvalidCounterError';
this.actualValue = actualValue;
}
}
/** Thrown when an envelope is empty or exceeds MAILBOX_MAX_ENVELOPE_SIZE. */
export class MailboxEnvelopeError extends MailboxError {
actualSize: number;
constructor(actualSize: number) {
super(
actualSize === 0
? 'Empty envelope: mailbox envelopes must carry at least 1 byte'
: `Envelope too large: ${actualSize} bytes (max ${MAILBOX_MAX_ENVELOPE_SIZE})`
);
this.name = 'MailboxEnvelopeError';
this.actualSize = actualSize;
}
}
/** Thrown when a fan-out group would exceed the consensus group limit. */
export class MailboxFanoutLimitError extends MailboxError {
actualLegs: number;
constructor(actualLegs: number) {
super(
`Fan-out of ${actualLegs} legs exceeds the limit of ${MAILBOX_MAX_FANOUT_LEGS} ` +
`(${MAILBOX_MAX_GROUP_SIZE} transactions per atomic group, 2 per leg)`
);
this.name = 'MailboxFanoutLimitError';
this.actualLegs = actualLegs;
}
}
/**
* Canonical ARC-4 method signatures of the raven router contract.
* Selectors are derived from these strings; do not edit casually.
*/
export const MAILBOX_METHODS = {
mailboxPut: 'mailboxPut(pay,byte[32],byte[])void',
mailboxBurn: 'mailboxBurn(byte[32],byte[32])void',
mailboxReclaim: 'mailboxReclaim(byte[32])void',
mailboxStatus: 'mailboxStatus(byte[32])(bool,byte[],uint64)',
} as const;
/**
* Computes the ARC-4 method selector for a signature: the first 4 bytes of
* SHA-512/256 over the UTF-8 signature string.
*
* @param signature - Canonical ARC-4 signature (see MAILBOX_METHODS)
* @returns 4-byte selector
*/
export function mailboxMethodSelector(signature: string): Uint8Array {
return sha512_256(new TextEncoder().encode(signature)).slice(0, 4);
}
/**
* ARC-4-encodes a dynamic `byte[]` application argument as
* `uint16_be(length) ‖ bytes`. Required for raven router `mailboxPut`
* envelopes (static `byte[32]` args are passed raw).
*
* @param bytes - Raw payload bytes (length must fit in a uint16)
* @returns Length-prefixed ARC-4 dynamic bytes
*/
export function arc4EncodeDynamicBytes(bytes: Uint8Array): Uint8Array {
if (bytes.length > 0xffff) {
throw new MailboxEnvelopeError(bytes.length);
}
const encoded = new Uint8Array(2 + bytes.length);
new DataView(encoded.buffer).setUint16(0, bytes.length, false);
encoded.set(bytes, 2);
return encoded;
}
/**
* Derives the per-message msg key from a shared view secret and counter.
*
* `msg_key = HMAC-SHA256(key = view_secret, "raven/mailbox/v1" ‖ counter_be32)`
*
* @param viewSecret - 32-byte secret shared sender↔recipient
* @param counter - uint32 rotation counter (advance per message)
* @returns 32-byte msg key
* @throws InvalidViewSecretError if the secret is not 32 bytes
* @throws InvalidCounterError if the counter is not a uint32
*/
export function deriveMsgKey(viewSecret: Uint8Array, counter: number): Uint8Array {
if (viewSecret.length !== VIEW_SECRET_SIZE) {
throw new InvalidViewSecretError(viewSecret.length);
}
if (!Number.isInteger(counter) || counter < 0 || counter > MAX_COUNTER) {
throw new InvalidCounterError(counter);
}
const message = new Uint8Array(MAILBOX_MSG_KEY_DOMAIN.length + 4);
const encoded = new TextEncoder().encodeInto(MAILBOX_MSG_KEY_DOMAIN, message);
const view = new DataView(message.buffer);
view.setUint32(encoded.written, counter, false);
return hmac(sha256, viewSecret, message.subarray(0, encoded.written + 4));
}
/**
* Derives the on-chain mailbox id for a msg key.
*
* `mailbox_id = SHA-256("raven/mailbox/v1/id" ‖ msg_key)`
*
* @param msgKey - 32-byte msg key from deriveMsgKey
* @returns 32-byte mailbox id (the box name)
* @throws InvalidMsgKeyError if the key is not 32 bytes
*/
export function deriveMailboxId(msgKey: Uint8Array): Uint8Array {
if (msgKey.length !== MSG_KEY_SIZE) {
throw new InvalidMsgKeyError(msgKey.length);
}
const prefix = new TextEncoder().encode(MAILBOX_ID_DOMAIN);
const message = new Uint8Array(prefix.length + msgKey.length);
message.set(prefix, 0);
message.set(msgKey, prefix.length);
return sha256(message);
}
/**
* Computes the exact box MBR the contract requires for an envelope, in
* microALGO: `2500 + 400 × (32 + 40 + envelope_len)`.
*
* @param envelopeLength - Envelope size in bytes
* @returns Required minimum balance in microALGO
* @throws MailboxEnvelopeError if the length is invalid for the contract
*/
export function mailboxMbr(envelopeLength: number): number {
validateEnvelopeLength(envelopeLength);
return MAILBOX_BOX_FLAT_MBR + MAILBOX_BOX_BYTE_MBR * (32 + MAILBOX_HEADER_SIZE + envelopeLength);
}
/**
* One fan-out leg input: the recipient channel's view secret, the message
* counter, and the opaque (already encrypted) envelope bytes.
*/
export interface MailboxLeg {
/** 32-byte secret shared with this recipient */
viewSecret: Uint8Array;
/** uint32 rotation counter for this message */
counter: number;
/** 1..2048 opaque envelope bytes */
envelope: Uint8Array;
}
/**
* A validated put leg: derived mailbox id, exact MBR, and the envelope.
*/
export interface MailboxLegPlan {
/** 32-byte box name the envelope will be stored under */
mailboxId: Uint8Array;
/** Exact MBR the leg's payment must fund, in microALGO */
mbr: number;
/** The envelope bytes (passed through untouched) */
envelope: Uint8Array;
}
function validateEnvelopeLength(length: number): void {
if (!Number.isInteger(length) || length <= 0 || length > MAILBOX_MAX_ENVELOPE_SIZE) {
throw new MailboxEnvelopeError(Number.isInteger(length) ? length : -1);
}
}
/**
* Validates and plans a single mailbox put.
*
* @param viewSecret - 32-byte secret shared with the recipient
* @param counter - uint32 rotation counter
* @param envelope - 1..2048 opaque envelope bytes
* @returns The leg plan (mailbox id, exact MBR, envelope)
*/
export function planMailboxPut(
viewSecret: Uint8Array,
counter: number,
envelope: Uint8Array
): MailboxLegPlan {
validateEnvelopeLength(envelope.length);
const msgKey = deriveMsgKey(viewSecret, counter);
return {
mailboxId: deriveMailboxId(msgKey),
mbr: mailboxMbr(envelope.length),
envelope,
};
}
/**
* Validates and plans an atomic fan-out: N independent put legs that must
* ride in one AVM group (all-or-nothing delivery).
*
* @param legs - 1..MAILBOX_MAX_FANOUT_LEGS legs
* @returns One plan per leg, in input order
* @throws MailboxFanoutLimitError if more than 8 legs (or zero) are given
*/
export function planMailboxFanout(legs: MailboxLeg[]): MailboxLegPlan[] {
if (legs.length === 0 || legs.length > MAILBOX_MAX_FANOUT_LEGS) {
throw new MailboxFanoutLimitError(legs.length);
}
return legs.map((leg) => planMailboxPut(leg.viewSecret, leg.counter, leg.envelope));
}