Skip to content

Commit 63432d0

Browse files
0xLeifcursoragent
andcommitted
Fix: MailboxRouterTransport ARC-4 put encoding and burn accounts
Real algod rejected puts (missing uint16 length prefix on byte[]) and burns (depositor absent from foreign accounts for the inner MBR refund). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6a875de commit 63432d0

6 files changed

Lines changed: 85 additions & 4 deletions

File tree

specs/algochat/algochat.spec.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ Provides the TypeScript implementation of the AlgoChat encrypted-messaging proto
214214
| `planMailboxPut` | Pure mailbox put planning operation defined below. |
215215
| `planMailboxFanout` | Pure mailbox atomic fan-out planning operation defined below. |
216216
| `mailboxMethodSelector` | ARC-4 method selector derivation defined below. |
217+
| `arc4EncodeDynamicBytes` | ARC-4-encodes a dynamic `byte[]` app arg as `uint16_be(len) ‖ bytes` for router puts. |
217218
| `MAILBOX_METHODS` | Published protocol value, size boundary, preset, or search default. |
218219
| `MAILBOX_MSG_KEY_DOMAIN` | Published protocol value, size boundary, preset, or search default. |
219220
| `MAILBOX_ID_DOMAIN` | Published protocol value, size boundary, preset, or search default. |
@@ -295,3 +296,4 @@ Then SendQueue processes eligible entries in order, records failures for retry,
295296
| 2 | 2026-07-14 | Added the stable full-library contract for the existing implementation and tests |
296297
| 3 | 2026-07-14 | CHG-0002-replace-the-incomplete-no-spec-rationale-with-a-stable-full-library-algochat-con: Replace the incomplete no-spec rationale with a stable full-library AlgoChat contract covering every existing source, export, invariant, failure mode, and native test boundary |
297298
| 2026-07-19 | CHG-0006-add-an-opt-in-mailboxroutertransport-speaking-the-raven-mailbox-protocol-per-co: Add an opt-in MailboxRouterTransport speaking the raven mailbox protocol: per-counter key derivation, MBR-exact put groups, atomic N-recipient fan-out, burn/reclaim/status, off-chain box reads, gated behind service config |
299+
| 2026-07-19 | Fix MailboxRouterTransport for real algod: ARC-4-encode put envelopes; include depositor in burn/reclaim foreign accounts for inner refunds |

src/blockchain/mailbox.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
InvalidViewSecretError,
2626
deriveMailboxId,
2727
deriveMsgKey,
28+
arc4EncodeDynamicBytes,
2829
mailboxMbr,
2930
mailboxMethodSelector,
3031
planMailboxFanout,
@@ -171,6 +172,17 @@ describe('mailbox put planning', () => {
171172
});
172173
});
173174

175+
describe('ARC-4 encoding', () => {
176+
test('arc4EncodeDynamicBytes prefixes a big-endian uint16 length', () => {
177+
const payload = new Uint8Array([1, 2, 3, 4]);
178+
const encoded = arc4EncodeDynamicBytes(payload);
179+
expect(encoded.length).toBe(6);
180+
expect(encoded[0]).toBe(0);
181+
expect(encoded[1]).toBe(4);
182+
expect(Buffer.from(encoded.subarray(2)).equals(Buffer.from(payload))).toBe(true);
183+
});
184+
});
185+
174186
describe('ARC-4 selectors', () => {
175187
test('mailboxMethodSelector matches algosdk for every router method', () => {
176188
const expectations: Array<[string, algosdk.ABIMethodParams]> = [

src/blockchain/mailbox.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,24 @@ export function mailboxMethodSelector(signature: string): Uint8Array {
155155
return sha512_256(new TextEncoder().encode(signature)).slice(0, 4);
156156
}
157157

158+
/**
159+
* ARC-4-encodes a dynamic `byte[]` application argument as
160+
* `uint16_be(length) ‖ bytes`. Required for raven router `mailboxPut`
161+
* envelopes (static `byte[32]` args are passed raw).
162+
*
163+
* @param bytes - Raw payload bytes (length must fit in a uint16)
164+
* @returns Length-prefixed ARC-4 dynamic bytes
165+
*/
166+
export function arc4EncodeDynamicBytes(bytes: Uint8Array): Uint8Array {
167+
if (bytes.length > 0xffff) {
168+
throw new MailboxEnvelopeError(bytes.length);
169+
}
170+
const encoded = new Uint8Array(2 + bytes.length);
171+
new DataView(encoded.buffer).setUint16(0, bytes.length, false);
172+
encoded.set(bytes, 2);
173+
return encoded;
174+
}
175+
158176
/**
159177
* Derives the per-message msg key from a shared view secret and counter.
160178
*

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ export {
222222
planMailboxPut,
223223
planMailboxFanout,
224224
mailboxMethodSelector,
225+
arc4EncodeDynamicBytes,
225226
type MailboxLeg,
226227
type MailboxLegPlan,
227228
} from './blockchain/mailbox.js';

src/services/mailbox-router.service.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
MAILBOX_MAX_ENVELOPE_SIZE,
1616
MailboxError,
1717
MailboxFanoutLimitError,
18+
arc4EncodeDynamicBytes,
1819
mailboxMbr,
1920
mailboxMethodSelector,
2021
planMailboxPut,
@@ -124,7 +125,7 @@ describe('MailboxRouterTransport.send', () => {
124125
expect(args.length).toBe(3);
125126
expect(Buffer.from(args[0]).equals(Buffer.from(PUT_SELECTOR))).toBe(true);
126127
expect(Buffer.from(args[1]).equals(Buffer.from(expected.mailboxId))).toBe(true);
127-
expect(Buffer.from(args[2]).equals(Buffer.from(envelope))).toBe(true);
128+
expect(Buffer.from(args[2]).equals(Buffer.from(arc4EncodeDynamicBytes(envelope)))).toBe(true);
128129

129130
const boxes = call.applicationCall?.boxes ?? [];
130131
expect(boxes.length).toBe(1);
@@ -177,7 +178,7 @@ describe('MailboxRouterTransport.sendFanout', () => {
177178
const args = call.applicationCall?.appArgs ?? [];
178179
expect(Buffer.from(args[0]).equals(Buffer.from(PUT_SELECTOR))).toBe(true);
179180
expect(Buffer.from(args[1]).equals(Buffer.from(plan.mailboxId))).toBe(true);
180-
expect(Buffer.from(args[2]).equals(Buffer.from(legs[i].envelope))).toBe(true);
181+
expect(Buffer.from(args[2]).equals(Buffer.from(arc4EncodeDynamicBytes(legs[i].envelope)))).toBe(true);
181182
}
182183
// distinct recipients, distinct mailboxes
183184
const ids = result.legs.map((leg) => Buffer.from(leg.mailboxId).toString('hex'));
@@ -199,12 +200,20 @@ describe('MailboxRouterTransport.sendFanout', () => {
199200
});
200201

201202
describe('MailboxRouterTransport burn and reclaim', () => {
202-
test('burn submits the proof with the mailbox box reference', async () => {
203+
test('burn submits the proof with the mailbox box reference and depositor account', async () => {
203204
const account = algosdk.generateAccount();
205+
const depositor = algosdk.generateAccount();
204206
const stub = makeStubAlgod();
205207
const transport = makeTransport(stub);
206208

207209
const plan = planMailboxPut(new Uint8Array(32).fill(7), 4, new Uint8Array(64).fill(1));
210+
const header = new Uint8Array(40);
211+
header.set(algosdk.decodeAddress(depositor.addr.toString()).publicKey, 0);
212+
new DataView(header.buffer).setBigUint64(32, 100n, false);
213+
const value = new Uint8Array(40 + 64);
214+
value.set(header, 0);
215+
stub.boxes.set(Buffer.from(plan.mailboxId).toString('hex'), value);
216+
208217
const msgKey = new Uint8Array(32).fill(11);
209218
const result = await transport.burn(account, plan.mailboxId, msgKey);
210219
expect(result.confirmedRound).toBe(101);
@@ -217,6 +226,18 @@ describe('MailboxRouterTransport burn and reclaim', () => {
217226
expect(Buffer.from(args[1]).equals(Buffer.from(plan.mailboxId))).toBe(true);
218227
expect(Buffer.from(args[2]).equals(Buffer.from(msgKey))).toBe(true);
219228
expect(Buffer.from((call.applicationCall?.boxes ?? [])[0].name).equals(Buffer.from(plan.mailboxId))).toBe(true);
229+
const foreign = (call.applicationCall?.accounts ?? []).map((entry) => entry.toString());
230+
expect(foreign).toContain(depositor.addr.toString());
231+
});
232+
233+
test('burn omits foreign accounts when the mailbox is already absent', async () => {
234+
const account = algosdk.generateAccount();
235+
const stub = makeStubAlgod();
236+
const transport = makeTransport(stub);
237+
const mailboxId = new Uint8Array(32).fill(9);
238+
await transport.burn(account, mailboxId, new Uint8Array(32).fill(11));
239+
const [call] = decodeGroup(stub.captured[0]);
240+
expect(call.applicationCall?.accounts ?? []).toHaveLength(0);
220241
});
221242

222243
test('reclaim submits depositor-only call with the mailbox box reference', async () => {
@@ -225,6 +246,11 @@ describe('MailboxRouterTransport burn and reclaim', () => {
225246
const transport = makeTransport(stub);
226247

227248
const mailboxId = new Uint8Array(32).fill(21);
249+
const header = new Uint8Array(40);
250+
header.set(algosdk.decodeAddress(account.addr.toString()).publicKey, 0);
251+
new DataView(header.buffer).setBigUint64(32, 100n, false);
252+
stub.boxes.set(Buffer.from(mailboxId).toString('hex'), header);
253+
228254
await transport.reclaim(account, mailboxId);
229255

230256
const [call] = decodeGroup(stub.captured[0]);
@@ -233,6 +259,8 @@ describe('MailboxRouterTransport burn and reclaim', () => {
233259
expect(Buffer.from(args[0]).equals(Buffer.from(RECLAIM_SELECTOR))).toBe(true);
234260
expect(Buffer.from(args[1]).equals(Buffer.from(mailboxId))).toBe(true);
235261
expect(Buffer.from((call.applicationCall?.boxes ?? [])[0].name).equals(Buffer.from(mailboxId))).toBe(true);
262+
const foreign = (call.applicationCall?.accounts ?? []).map((entry) => entry.toString());
263+
expect(foreign).toContain(account.addr.toString());
236264
});
237265
});
238266

src/services/mailbox-router.service.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import algosdk from 'algosdk';
2222
import {
2323
MAILBOX_HEADER_SIZE,
2424
MAILBOX_METHODS,
25+
arc4EncodeDynamicBytes,
2526
mailboxMethodSelector,
2627
planMailboxFanout,
2728
planMailboxPut,
@@ -169,13 +170,17 @@ export class MailboxRouterTransport {
169170
msgKey: Uint8Array,
170171
options: MailboxSubmitOptions = {}
171172
): Promise<MailboxTxnResult> {
173+
// Inner refund pays the depositor from the box header; AVM requires
174+
// that address in the foreign accounts array (unless it is Txn.Sender).
175+
const accounts = await this.foreignAccountsForRefund(mailboxId);
172176
const call = algosdk.makeApplicationCallTxnFromObject({
173177
sender: account.addr,
174178
appIndex: this.appId,
175179
onComplete: algosdk.OnApplicationComplete.NoOpOC,
176180
appArgs: [BURN_SELECTOR, mailboxId, msgKey],
177181
boxes: [{ appIndex: 0, name: mailboxId }],
178182
suggestedParams: await this.algodClient.getTransactionParams().do(),
183+
...(accounts ? { accounts } : {}),
179184
});
180185
return this.signAndSubmit(account, [call], options.waitRounds ?? DEFAULT_WAIT_ROUNDS);
181186
}
@@ -193,13 +198,15 @@ export class MailboxRouterTransport {
193198
mailboxId: Uint8Array,
194199
options: MailboxSubmitOptions = {}
195200
): Promise<MailboxTxnResult> {
201+
const accounts = await this.foreignAccountsForRefund(mailboxId);
196202
const call = algosdk.makeApplicationCallTxnFromObject({
197203
sender: account.addr,
198204
appIndex: this.appId,
199205
onComplete: algosdk.OnApplicationComplete.NoOpOC,
200206
appArgs: [RECLAIM_SELECTOR, mailboxId],
201207
boxes: [{ appIndex: 0, name: mailboxId }],
202208
suggestedParams: await this.algodClient.getTransactionParams().do(),
209+
...(accounts ? { accounts } : {}),
203210
});
204211
return this.signAndSubmit(account, [call], options.waitRounds ?? DEFAULT_WAIT_ROUNDS);
205212
}
@@ -252,7 +259,8 @@ export class MailboxRouterTransport {
252259
sender: account.addr,
253260
appIndex: this.appId,
254261
onComplete: algosdk.OnApplicationComplete.NoOpOC,
255-
appArgs: [PUT_SELECTOR, plan.mailboxId, plan.envelope],
262+
// ARC-4 dynamic byte[]: uint16_be(len) ‖ envelope
263+
appArgs: [PUT_SELECTOR, plan.mailboxId, arc4EncodeDynamicBytes(plan.envelope)],
256264
boxes: [{ appIndex: 0, name: plan.mailboxId }],
257265
suggestedParams: params,
258266
})
@@ -261,6 +269,18 @@ export class MailboxRouterTransport {
261269
return this.signAndSubmit(account, txns, waitRounds);
262270
}
263271

272+
/**
273+
* Resolves foreign accounts needed for an inner MBR refund. Absent boxes
274+
* (idempotent burn) need no accounts.
275+
*/
276+
private async foreignAccountsForRefund(mailboxId: Uint8Array): Promise<string[] | undefined> {
277+
const current = await this.read(mailboxId);
278+
if (!current.exists || !current.depositor) {
279+
return undefined;
280+
}
281+
return [current.depositor];
282+
}
283+
264284
/**
265285
* Groups, signs, submits, and confirms a set of transactions.
266286
*/

0 commit comments

Comments
 (0)