← Bond stages

ops/src/attacks.ts

Current source snapshot · revision c2ec38f1f438 · not an attestation of the historical deployed binary.

SHA-256 b7e010be1c8bfc084cd0f2fc8c6c7c54776be9d74c936797232ebd3e831303fb

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}