1// Hostile actor for a bond run (scenario field "attacker"). At each step of the bond's life it tries the actions that
2// would take bonds or cash from someone, or let someone act without authority. Every attempt is sent without
3// simulation (skipPreflight), so a refusal is a failed transaction on the chain that anyone can open in an explorer.
4// An attempt passes only if it is refused by the guard named in `guard`; the run fails otherwise
5// (scenario.ts exits with an error after writing the report).
6// Also shows the bank's frozen cash account case: one holder cannot be paid, the others are paid, nobody waits.
7
8import * as anchor from "@anchor-lang/core";
9import {
10 ExtensionType,
11 TOKEN_2022_PROGRAM_ID,
12 TOKEN_PROGRAM_ID,
13 createAssociatedTokenAccountIdempotentInstruction,
14 createBurnCheckedInstruction,
15 createFreezeAccountInstruction,
16 createInitializeAccount3Instruction,
17 createInitializeMintInstruction,
18 createInitializeTransferFeeConfigInstruction,
19 createMintToInstruction,
20 createThawAccountInstruction,
21 createTransferCheckedInstruction,
22 createTransferCheckedWithTransferHookInstruction,
23 createTransferInstruction,
24 getAssociatedTokenAddressSync,
25 getMintLen,
26} from "@solana/spl-token";
27import {
28 Connection,
29 Keypair,
30 LAMPORTS_PER_SOL,
31 PublicKey,
32 SystemProgram,
33 Transaction,
34 TransactionInstruction,
35} from "@solana/web3.js";
36import BN from "bn.js";
37
38const LOADER = new PublicKey("BPFLoaderUpgradeab1e11111111111111111111111");
39
40export type HostileSpec = {
41 wallet: string;
42 // Whose bonds and coupons are attacked; the accomplice is the registered holder whose position a forgery would raise.
43 victim: string;
44 accomplice: string;
45 // The bank freezes this holder's cash account before this coupon's payments and thaws it after the others are paid.
46 frozenCash: { holder: string; coupon: number };
47};
48
49export type Attempt = {
50 id: string;
51 when: string;
52 actor: string;
53 tries: string;
54 wouldCost: string;
55 guard: string;
56 outcome: "refused" | "accepted";
57 reason: string;
58 ok: boolean;
59 signature: string;
60 explorer?: string;
61 chainTime: string;
62};
63
64export type Ctx = {
65 connection: Connection;
66 program: anchor.Program;
67 registrar: Keypair;
68 bank: Keypair;
69 attacker: Keypair;
70 spec: HostileSpec;
71 terms: any;
72 mint: PublicKey;
73 bond: PublicKey;
74 cashMint: PublicKey;
75 cashDecimals: number;
76 holders: Record<string, Keypair>;
77 bondsAta: (name: string) => PublicKey;
78 cashAta: (name: string) => PublicKey;
79 holderRecord: (name: string) => PublicKey;
80 pda: (...seeds: (Buffer | Uint8Array)[]) => PublicKey;
81 u16: (n: number) => Buffer;
82 offers: { id: number; kind: "buyback" | "tender"; actions: { afterRecordMoment: number; holder: string; sell?: number; tender?: number }[] }[];
83 offerPda: (id: number) => PublicKey;
84 retirementAccounts: (o: any, name: string, payer: PublicKey) => any;
85 send: (step: string, ixs: TransactionInstruction[], signers: Keypair[]) => Promise<string>;
86 chainNow: () => Promise<number>;
87 explorer: (sig: string) => string | undefined;
88};
89
90// Why a transaction failed, from its logs: the Anchor error code, a token program's error text, or the runtime's.
91const reasonOf = (meta: any) => {
92 const logs: string[] = meta.logMessages ?? [];
93 for (const l of logs) {
94 const m = /Error Code: (\w+)\. Error Number: \d+\. Error Message: (.*)$/.exec(l);
95 if (m) return `${m[1]}: ${m[2]}`;
96 }
97 for (const l of logs) {
98 const m = /^Program log: Error: (.*)$/.exec(l);
99 if (m) return m[1];
100 }
101 const failed = logs.map((l) => /^Program \w+ failed: (.*)$/.exec(l)).filter(Boolean).pop();
102 return failed ? failed[1] : JSON.stringify(meta.err);
103};
104
105// A signer the instruction names but that nobody can sign for (a program address): keep it in the instruction as a
106// non-signer, so the transaction reaches the chain and the program checking the signature refuses it there.
107const unsigned = (ix: TransactionInstruction, key: PublicKey) => {
108 for (const k of ix.keys) if (k.pubkey.equals(key)) k.isSigner = false;
109 return ix;
110};
111
112export function hostile(c: Ctx) {
113 const attempts: Attempt[] = [];
114 const A = c.attacker.publicKey;
115 const victim = c.spec.victim;
116 const accomplice = c.spec.accomplice;
117 const attackerBonds = getAssociatedTokenAddressSync(c.mint, A, false, TOKEN_2022_PROGRAM_ID);
118 const attackerCash = getAssociatedTokenAddressSync(c.cashMint, A, false, TOKEN_PROGRAM_ID);
119 const victimSecondAccount = Keypair.generate();
120 const asAttacker = new anchor.Program(c.program.idl, new anchor.AnchorProvider(c.connection, new anchor.Wallet(c.attacker), { commitment: "confirmed" }));
121
122 // Send without simulation and poll its status until it lands (failed or not); resend with a fresh blockhash if it
123 // expired first. (web3.js confirmTransaction throws a landed transaction's error on devnet.)
124 const land = async (ixs: TransactionInstruction[], signers: Keypair[]) => {
125 for (let tries = 1; tries <= 4; tries++) {
126 const { blockhash, lastValidBlockHeight } = await c.connection.getLatestBlockhash("confirmed");
127 const tx = new Transaction({ feePayer: signers[0].publicKey, blockhash, lastValidBlockHeight }).add(...ixs);
128 tx.sign(...signers);
129 const signature = await c.connection.sendRawTransaction(tx.serialize(), { skipPreflight: true, maxRetries: 5 });
130 for (;;) {
131 const st = (await c.connection.getSignatureStatuses([signature])).value[0];
132 if (st?.confirmationStatus === "confirmed" || st?.confirmationStatus === "finalized") return signature;
133 if ((await c.connection.getBlockHeight("confirmed")) > lastValidBlockHeight) break;
134 await new Promise((r) => setTimeout(r, 1_000));
135 }
136 }
137 throw new Error("attempt did not land after 4 sends");
138 };
139 const attempt = async (
140 a: { id: string; when: string; actor: string; tries: string; wouldCost: string; guard: string },
141 ixs: TransactionInstruction[],
142 signers: Keypair[],
143 ) => {
144 const signature = await land(ixs, signers);
145 let tx = null;
146 for (let i = 0; i < 20 && !tx; i++) {
147 tx = await c.connection.getTransaction(signature, { commitment: "confirmed", maxSupportedTransactionVersion: 0 });
148 if (!tx) await new Promise((r) => setTimeout(r, 1_000));
149 }
150 if (!tx) throw new Error(`${a.id}: transaction ${signature} not found`);
151 const outcome = tx.meta!.err ? "refused" : "accepted";
152 const reason = tx.meta!.err ? reasonOf(tx.meta) : "";
153 const ok = outcome === "refused" && reason.includes(a.guard);
154 const row: Attempt = { ...a, outcome, reason, ok, signature, explorer: c.explorer(signature), chainTime: new Date((await c.chainNow()) * 1000).toISOString() };
155 attempts.push(row);
156 console.log(`${ok ? "REFUSED " : "FAILED "} ${a.id}: ${a.tries} -> ${outcome}${reason ? ` (${reason})` : ""} ${row.explorer ?? signature}`);
157 return row;
158 };
159
160 // Accounts the attacks use: the attacker's own bond and cash accounts, and a second bond account owned by the
161 // victim but not its associated one. Anyone may open these; the attacker pays.
162 const setUp = async () => {
163 const have = await c.connection.getBalance(A);
164 if (have < 0.1 * LAMPORTS_PER_SOL)
165 await c.send("fund attacker fees", [SystemProgram.transfer({ fromPubkey: c.registrar.publicKey, toPubkey: A, lamports: 0.1 * LAMPORTS_PER_SOL - have })], [c.registrar]);
166 const size = (await c.connection.getAccountInfo(c.bondsAta(victim)))!.data.length;
167 await c.send("attacker opens its own bond and cash accounts, and a second bond account for the victim", [
168 createAssociatedTokenAccountIdempotentInstruction(A, attackerBonds, A, c.mint, TOKEN_2022_PROGRAM_ID),
169 createAssociatedTokenAccountIdempotentInstruction(A, attackerCash, A, c.cashMint, TOKEN_PROGRAM_ID),
170 SystemProgram.createAccount({
171 fromPubkey: A, newAccountPubkey: victimSecondAccount.publicKey, space: size,
172 lamports: await c.connection.getMinimumBalanceForRentExemption(size), programId: TOKEN_2022_PROGRAM_ID,
173 }),
174 createInitializeAccount3Instruction(victimSecondAccount.publicKey, c.mint, c.holders[victim].publicKey, TOKEN_2022_PROGRAM_ID),
175 ], [c.attacker, victimSecondAccount]);
176 };
177
178 // Token-2022 PermissionedBurnExtension (46), BurnChecked (2): account, mint, burn authority, owner or delegate
179 // (the layout settlement.rs uses).
180 const permissionedBurn = (account: PublicKey, authority: PublicKey, owner: PublicKey) => new TransactionInstruction({
181 programId: TOKEN_2022_PROGRAM_ID,
182 data: Buffer.concat([Buffer.from([46, 2]), new BN(1).toArrayLike(Buffer, "le", 8), Buffer.from([0])]),
183 keys: [
184 { pubkey: account, isSigner: false, isWritable: true },
185 { pubkey: c.mint, isSigner: false, isWritable: true },
186 { pubkey: authority, isSigner: true, isWritable: false },
187 { pubkey: owner, isSigner: true, isWritable: false },
188 ],
189 });
190
191 const hooked = (from: string | PublicKey, fromOwner: Keypair, to: PublicKey, amount: number) =>
192 createTransferCheckedWithTransferHookInstruction(
193 c.connection, typeof from === "string" ? c.bondsAta(from) : from, c.mint, to, fromOwner.publicKey, BigInt(amount), 0, [], "confirmed", TOKEN_2022_PROGRAM_ID,
194 );
195
196 const payCoupon = (sv: anchor.Program, payer: PublicKey, e: number, name: string, over: Record<string, PublicKey> = {}) => {
197 const event = c.pda(Buffer.from("event"), c.bond.toBuffer(), c.u16(e));
198 return sv.methods.payCoupon().accountsPartial({
199 payer, bond: c.bond, event, holder: c.holderRecord(name), owner: c.holders[name].publicKey,
200 vault: c.pda(Buffer.from("vault"), event.toBuffer()), cashMint: c.cashMint, holderCash: c.cashAta(name),
201 memoProgram: null, cashTokenProgram: TOKEN_PROGRAM_ID, ...over,
202 }).instruction();
203 };
204
205 // Before the first record moment: the register, the bond token and the program itself.
206 const beforeFirstRecordMoment = async () => {
207 await setUp();
208 const when = "before the first record moment";
209 await attempt({ id: "register-self", when, actor: "attacker", tries: "registers itself as a holder, signing as registrar",
210 wouldCost: "an unvetted wallet in the register", guard: "ConstraintHasOne" },
211 [await asAttacker.methods.registerHolder(A).accountsPartial({ registrar: A, bond: c.bond }).instruction()], [c.attacker]);
212 await attempt({ id: "mint-bonds", when, actor: "attacker", tries: "mints 1,000 bonds to itself",
213 wouldCost: "the issuer owes coupons and principal on bonds it never sold", guard: "owner does not match" },
214 [createMintToInstruction(c.mint, attackerBonds, A, 1_000, [], TOKEN_2022_PROGRAM_ID)], [c.attacker]);
215 await attempt({ id: "mint-as-program", when, actor: "attacker", tries: "mints 1,000 bonds naming the program as mint authority",
216 wouldCost: "the issuer owes coupons and principal on bonds it never sold", guard: "MissingRequiredSignature" },
217 [unsigned(createMintToInstruction(c.mint, attackerBonds, c.bond, 1_000, [], TOKEN_2022_PROGRAM_ID), c.bond)], [c.attacker]);
218 await attempt({ id: "take-bonds", when, actor: "attacker", tries: `moves ${victim}'s bonds to itself`,
219 wouldCost: `${victim} loses its bonds`, guard: "owner does not match" },
220 [createTransferCheckedInstruction(c.bondsAta(victim), c.mint, attackerBonds, A, 1, 0, [], TOKEN_2022_PROGRAM_ID)], [c.attacker]);
221 await attempt({ id: "take-as-delegate", when, actor: "attacker", tries: `moves ${victim}'s bonds naming the program, the permanent delegate`,
222 wouldCost: `${victim} loses its bonds`, guard: "MissingRequiredSignature" },
223 [unsigned(createTransferCheckedInstruction(c.bondsAta(victim), c.mint, attackerBonds, c.bond, 1, 0, [], TOKEN_2022_PROGRAM_ID), c.bond)], [c.attacker]);
224 // Token-2022 refuses every standard burn on a mint with permissioned burn (TokenError::InvalidInstruction).
225 await attempt({ id: "burn-bonds", when, actor: "attacker", tries: `burns ${victim}'s bonds`,
226 wouldCost: `${victim} loses coupons and principal`, guard: "Invalid instruction" },
227 [createBurnCheckedInstruction(c.bondsAta(victim), c.mint, A, 1, 0, [], TOKEN_2022_PROGRAM_ID)], [c.attacker]);
228 // InvalidAccountData: Token-2022's check that the approver is the mint's permissioned burn authority.
229 await attempt({ id: "burn-permissioned", when, actor: "attacker", tries: `burns ${victim}'s bonds with the permissioned burn, naming itself burn authority`,
230 wouldCost: `${victim} loses coupons and principal`, guard: "InvalidAccountData" },
231 [permissionedBurn(c.bondsAta(victim), A, A)], [c.attacker]);
232 await attempt({ id: "burn-as-program", when, actor: "attacker", tries: `burns ${victim}'s bonds with the permissioned burn, naming the program, the burn authority`,
233 wouldCost: `${victim} loses coupons and principal`, guard: "MissingRequiredSignature" },
234 [unsigned(permissionedBurn(c.bondsAta(victim), c.bond, c.bond), c.bond)], [c.attacker]);
235 await attempt({ id: "freeze-holder", when, actor: "attacker", tries: `freezes ${victim}'s bond account`,
236 wouldCost: `${victim} cannot sell`, guard: "cannot freeze" },
237 [createFreezeAccountInstruction(c.bondsAta(victim), c.mint, A, [], TOKEN_2022_PROGRAM_ID)], [c.attacker]);
238 await attempt({ id: "send-unregistered", when, actor: victim, tries: "sends a bond to an unregistered wallet (the attacker)",
239 wouldCost: "bonds held outside the register", guard: "AccountOwnedByWrongProgram" },
240 [await hooked(victim, c.holders[victim], attackerBonds, 1)], [c.registrar, c.holders[victim]]);
241 await attempt({ id: "second-account", when, actor: victim, tries: "moves a bond to a second account of its own",
242 wouldCost: "a position the register does not see", guard: "NotAssociatedTokenAccount" },
243 [await hooked(victim, c.holders[victim], victimSecondAccount.publicKey, 1)], [c.registrar, c.holders[victim]]);
244 // Token-2022 error 0x1f = TokenError::MintRequiredForTransfer: a hooked mint's accounts move only with transfer_checked.
245 await attempt({ id: "skip-hook", when, actor: victim, tries: `sends a bond to ${accomplice} with the old transfer instruction, which skips the transfer hook`,
246 wouldCost: "a bond moves without its record-moment position being saved", guard: "custom program error: 0x1f" },
247 [createTransferInstruction(c.bondsAta(victim), c.bondsAta(accomplice), c.holders[victim].publicKey, 1, [], TOKEN_2022_PROGRAM_ID)], [c.registrar, c.holders[victim]]);
248 await attempt({ id: "forge-position", when, actor: "attacker", tries: `calls the transfer hook directly to add 5 bonds to ${accomplice}'s record`,
249 wouldCost: "coupons paid on bonds nobody holds", guard: "NotTransferring" },
250 [await asAttacker.methods.transferHook(new BN(5)).accountsPartial({
251 sourceToken: c.bondsAta(victim), mint: c.mint, destinationToken: c.bondsAta(accomplice), authority: A,
252 extraAccountMetaList: c.pda(Buffer.from("extra-account-metas"), c.mint.toBuffer()), bond: c.bond,
253 sourceHolder: c.holderRecord(victim), destinationHolder: c.holderRecord(accomplice),
254 }).instruction()], [c.attacker]);
255 const [programData] = PublicKey.findProgramAddressSync([c.program.programId.toBuffer()], LOADER);
256 await attempt({ id: "take-program", when, actor: "attacker", tries: "makes itself the program's upgrade authority",
257 wouldCost: "a replaced program could empty every vault", guard: "Incorrect authority" },
258 [new TransactionInstruction({ programId: LOADER, data: Buffer.from([4, 0, 0, 0]), keys: [
259 { pubkey: programData, isSigner: false, isWritable: true },
260 { pubkey: A, isSigner: true, isWritable: false },
261 { pubkey: A, isSigner: false, isWritable: false },
262 ] })], [c.attacker]);
263
264 // A cash token that keeps a fee on every transfer would pay holders less than the terms.
265 const feeMint = Keypair.generate();
266 const len = getMintLen([ExtensionType.TransferFeeConfig]);
267 await c.send("registrar creates a cash token with a 1% transfer fee", [
268 SystemProgram.createAccount({ fromPubkey: c.registrar.publicKey, newAccountPubkey: feeMint.publicKey, space: len,
269 lamports: await c.connection.getMinimumBalanceForRentExemption(len), programId: TOKEN_2022_PROGRAM_ID }),
270 createInitializeTransferFeeConfigInstruction(feeMint.publicKey, c.registrar.publicKey, c.registrar.publicKey, 100, BigInt(1e12), TOKEN_2022_PROGRAM_ID),
271 createInitializeMintInstruction(feeMint.publicKey, c.cashDecimals, c.registrar.publicKey, null, TOKEN_2022_PROGRAM_ID),
272 ], [c.registrar, feeMint]);
273 const newMint = Keypair.generate();
274 await attempt({ id: "fee-cash-token", when, actor: "registrar", tries: "creates a bond paid in a cash token that keeps a 1% fee on every transfer",
275 wouldCost: "holders receive 1% less than the terms", guard: "UnsupportedCashToken" },
276 [await c.program.methods.createBond(c.terms).accountsPartial({
277 registrar: c.registrar.publicKey, mint: newMint.publicKey, bond: c.pda(Buffer.from("bond"), newMint.publicKey.toBuffer()), cashMint: feeMint.publicKey,
278 extraAccountMetaList: c.pda(Buffer.from("extra-account-metas"), newMint.publicKey.toBuffer()),
279 tokenProgram: TOKEN_2022_PROGRAM_ID, systemProgram: SystemProgram.programId,
280 }).instruction()], [c.registrar, newMint]);
281 };
282
283 // After a record moment's trades.
284 const afterRecordMoment = async (e: number) => {
285 if (e !== 0) return;
286 await attempt({ id: "place-late", when: "after the first record moment", actor: "registrar", tries: `places 100 new bonds with ${accomplice}`,
287 wouldCost: "bonds issued after the issue closed", guard: "PlacementClosed" },
288 [await c.program.methods.place(new BN(100)).accountsPartial({
289 registrar: c.registrar.publicKey, bond: c.bond, mint: c.mint, holder: c.holderRecord(accomplice), holderBonds: c.bondsAta(accomplice), tokenProgram: TOKEN_2022_PROGRAM_ID,
290 }).instruction()], [c.registrar]);
291 };
292
293 // Between opening the first coupon and its funding: the issuer funds it short, and the attacker tries to use that.
294 let underfundedShown = false;
295 const whileUnfunded = async (e: number, event: PublicKey, vault: PublicKey, budget: bigint) => {
296 if (underfundedShown) return;
297 underfundedShown = true;
298 const when = `coupon ${e}, vault funded short by 0.000001`;
299 await c.send(`issuer funds coupon ${e}'s vault 0.000001 short`, [createMintToInstruction(c.cashMint, vault, c.bank.publicKey, budget - 1n, [], TOKEN_PROGRAM_ID)], [c.registrar, c.bank]);
300 await attempt({ id: "confirm-short", when, actor: "attacker", tries: "confirms the coupon funded",
301 wouldCost: "the last holders find the vault empty", guard: "InsufficientFunding" },
302 [await asAttacker.methods.confirmFunding().accountsPartial({ event, vault }).instruction()], [c.attacker]);
303 await attempt({ id: "pay-unfunded", when, actor: "attacker", tries: `pays ${victim} from the unconfirmed vault`,
304 wouldCost: "the last holders find the vault empty", guard: "NotFunded" },
305 [await payCoupon(asAttacker, A, e, victim)], [c.attacker]);
306 await attempt({ id: "drain-vault", when, actor: "attacker", tries: "moves the vault's cash to itself",
307 wouldCost: "the coupon's cash is gone", guard: "owner does not match" },
308 [createTransferCheckedInstruction(vault, c.cashMint, attackerCash, A, 1, c.cashDecimals, [], TOKEN_PROGRAM_ID)], [c.attacker]);
309 await attempt({ id: "drain-as-program", when, actor: "attacker", tries: "moves the vault's cash to itself naming the program, the vault's owner",
310 wouldCost: "the coupon's cash is gone", guard: "MissingRequiredSignature" },
311 [unsigned(createTransferCheckedInstruction(vault, c.cashMint, attackerCash, c.bond, 1, c.cashDecimals, [], TOKEN_PROGRAM_ID), c.bond)], [c.attacker]);
312 };
313
314 // Payment is open; before anyone is paid. Returns the holders to pay after afterPayments.
315 const beforePayments = async (e: number): Promise<string[]> => {
316 const when = `coupon ${e}, payment open`;
317 if (e === 0) {
318 await attempt({ id: "redirect-coupon", when, actor: "attacker", tries: `pays ${victim}'s coupon into its own cash account`,
319 wouldCost: `${victim}'s coupon`, guard: "ConstraintTokenOwner" },
320 [await payCoupon(asAttacker, A, e, victim, { holderCash: attackerCash })], [c.attacker]);
321 await attempt({ id: "claim-as-owner", when, actor: "attacker", tries: `presents ${victim}'s holder record as its own`,
322 wouldCost: `${victim}'s coupon`, guard: "Constraint" },
323 [await payCoupon(asAttacker, A, e, victim, { owner: A, holderCash: attackerCash })], [c.attacker]);
324 }
325 if (e === 1) {
326 const prev = c.pda(Buffer.from("event"), c.bond.toBuffer(), c.u16(0));
327 await attempt({ id: "other-vault", when, actor: "attacker", tries: `pays ${victim}'s coupon 1 from coupon 0's vault`,
328 wouldCost: "one coupon's cash spent on another", guard: "ConstraintHasOne" },
329 [await payCoupon(asAttacker, A, e, victim, { vault: c.pda(Buffer.from("vault"), prev.toBuffer()) })], [c.attacker]);
330 }
331 if (e !== c.spec.frozenCash.coupon) return [];
332 const who = c.spec.frozenCash.holder;
333 await c.send(`bank freezes ${who}'s cash account`, [createFreezeAccountInstruction(c.cashAta(who), c.cashMint, c.bank.publicKey, [], TOKEN_PROGRAM_ID)], [c.registrar, c.bank]);
334 return [who];
335 };
336
337 // Everyone else is paid.
338 const afterPayments = async (e: number) => {
339 const when = `coupon ${e}, paid`;
340 if (e === 0) {
341 await attempt({ id: "pay-twice", when, actor: "attacker", tries: `pays ${victim}'s coupon again`,
342 wouldCost: "the issuer pays a coupon twice; the last holders find the vault empty", guard: "AlreadyPaid" },
343 [await payCoupon(asAttacker, A, e, victim)], [c.attacker]);
344 const recs: any[] = await c.program.account.holderRecord.fetchMultiple(Object.keys(c.holders).map(c.holderRecord));
345 const late = Object.keys(c.holders).find((n, i) => recs[i] && Number(recs[i].position.liveFrom) > e && Number(recs[i].position.at[e]) === 0 && Number(recs[i].position.quantity) > 0);
346 if (!late) throw new Error("scenario has no holder that bought after record moment 0 with none before (needed by attack late-buyer)");
347 await attempt({ id: "late-buyer", when, actor: late, tries: "bought after the record moment and asks for the coupon",
348 wouldCost: "the seller's coupon paid again to the buyer", guard: "NothingToPay" },
349 [await payCoupon(c.program, c.registrar.publicKey, e, late)], [c.registrar]);
350 }
351 if (e !== c.spec.frozenCash.coupon) return;
352 const who = c.spec.frozenCash.holder;
353 await attempt({ id: "frozen-cash", when, actor: "anyone", tries: `pays ${who}, whose cash account the bank froze`,
354 wouldCost: "nothing: the others are already paid, and the right stays open", guard: "Account is frozen" },
355 [await payCoupon(c.program, c.registrar.publicKey, e, who)], [c.registrar]);
356 await c.send(`bank thaws ${who}'s cash account`, [createThawAccountInstruction(c.cashAta(who), c.cashMint, c.bank.publicKey, [], TOKEN_PROGRAM_ID)], [c.registrar, c.bank]);
357 };
358
359 // After a phase's buyback sales and tenders.
360 const afterOfferActions = async (e: number) => {
361 for (const o of c.offers) {
362 if (!o.actions.some((a) => a.afterRecordMoment === e)) continue;
363 const when = `${o.kind} offer ${o.id} open`;
364 if (o.kind === "buyback") {
365 await attempt({ id: "sell-others-bonds", when, actor: "attacker", tries: `sells ${victim}'s bonds to the buyback`,
366 wouldCost: `${victim}'s bonds sold without its consent`, guard: "ConstraintRaw" },
367 [await asAttacker.methods.sellToOffer(new BN(1)).accountsPartial({ retirement: c.retirementAccounts(o, victim, A), seller: A, offer: c.offerPda(o.id) }).instruction()], [c.attacker]);
368 await attempt({ id: "over-maximum", when, actor: victim, tries: "sells 1 bond to the buyback after its maximum is reached",
369 wouldCost: "the issuer buys more bonds than it offered", guard: "ExceedsOffer" },
370 [await c.program.methods.sellToOffer(new BN(1)).accountsPartial({ retirement: c.retirementAccounts(o, victim, c.holders[victim].publicKey), seller: c.holders[victim].publicKey, offer: c.offerPda(o.id) }).instruction()],
371 [c.registrar, c.holders[victim]]);
372 } else {
373 const tenderer = o.actions.find((a) => a.afterRecordMoment === e && a.tender)!.holder;
374 const rec: any = await c.program.account.holderRecord.fetch(c.holderRecord(tenderer));
375 const to = tenderer === accomplice ? victim : accomplice;
376 await attempt({ id: "sell-tendered", when, actor: tenderer, tries: `transfers all its bonds to ${to}, including those tendered to the issuer`,
377 wouldCost: "the same bonds sold twice: to the issuer and to a buyer", guard: "BondsLocked" },
378 [await hooked(tenderer, c.holders[tenderer], c.bondsAta(to), Number(rec.position.quantity))], [c.registrar, c.holders[tenderer]]);
379 }
380 }
381 };
382
383 // Trading has closed for redemption.
384 const afterFinalRecordMoment = async () => {
385 await attempt({ id: "trade-at-maturity", when: "after the final record moment", actor: accomplice, tries: `sells 1 bond to ${victim}`,
386 wouldCost: "a bond changes hands while its principal is being paid", guard: "TransfersClosedAtMaturity" },
387 [await hooked(accomplice, c.holders[accomplice], c.bondsAta(victim), 1)], [c.registrar, c.holders[accomplice]]);
388 };
389
390 const redeemIx = (sv: anchor.Program, payer: PublicKey, event: PublicKey, vault: PublicKey, name: string, over: Record<string, PublicKey> = {}) =>
391 sv.methods.redeemHolder().accountsPartial({
392 payer, bond: c.bond, mint: c.mint, event, holder: c.holderRecord(name), owner: c.holders[name].publicKey, holderBonds: c.bondsAta(name),
393 vault, cashMint: c.cashMint, holderCash: c.cashAta(name), memoProgram: null, tokenProgram: TOKEN_2022_PROGRAM_ID, cashTokenProgram: TOKEN_PROGRAM_ID, ...over,
394 }).instruction();
395
396 const beforeRedemption = async (event: PublicKey, vault: PublicKey) => {
397 const when = "redemption, payment open";
398 await attempt({ id: "redirect-principal", when, actor: "attacker", tries: `redeems ${victim}'s bonds into its own cash account`,
399 wouldCost: `${victim}'s principal`, guard: "ConstraintTokenOwner" },
400 [await redeemIx(asAttacker, A, event, vault, victim, { holderCash: attackerCash })], [c.attacker]);
401 await attempt({ id: "coupon-from-principal", when, actor: "attacker", tries: `pays ${victim} a coupon from the redemption vault`,
402 wouldCost: "principal cash paid out as coupons", guard: "WrongEventKind" },
403 [await asAttacker.methods.payCoupon().accountsPartial({
404 payer: A, bond: c.bond, event, holder: c.holderRecord(victim), owner: c.holders[victim].publicKey, vault, cashMint: c.cashMint,
405 holderCash: c.cashAta(victim), memoProgram: null, cashTokenProgram: TOKEN_PROGRAM_ID,
406 }).instruction()], [c.attacker]);
407 };
408
409 const afterRedemption = async (event: PublicKey, vault: PublicKey) => {
410 await attempt({ id: "redeem-twice", when: "redemption, paid", actor: "attacker", tries: `redeems ${victim} again`,
411 wouldCost: "principal paid twice", guard: "NothingToPay" },
412 [await redeemIx(asAttacker, A, event, vault, victim)], [c.attacker]);
413 };
414
415 return { attempts, beforeFirstRecordMoment, afterRecordMoment, whileUnfunded, beforePayments, afterPayments, afterOfferActions, afterFinalRecordMoment, beforeRedemption, afterRedemption };
416}