Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"scripts": {
"test": "anchor test",
"cleanup-pdas": "npx --yes tsx scripts/cleanup-pdas.ts",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

npx --yes tsx silently installs tsx at runtime - slow on first run, no version pinning, and the --yes skips the install confirmation prompt. Add tsx to devDependencies and use it directly:

"cleanup-pdas": "tsx scripts/cleanup-pdas.ts"

"litesvm-initial": "node ./tests-litesvm-ts/trustscore.ts",
"litesvm-trustscore": "node ./tests-litesvm-ts/clock-tests.ts"
},
Expand Down
181 changes: 181 additions & 0 deletions scripts/cleanup-pdas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import * as anchor from "@coral-xyz/anchor";
import { createHash } from "crypto";
import * as fs from "fs";
import * as path from "path";

const IAM_VERIFIER_PROGRAM_ID = new anchor.web3.PublicKey(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale naming from before the org rebrand. Pluto's PR #35 swept all IAM_* references to ENTROS_* across the codebase. Rename to ENTROS_VERIFIER_PROGRAM_ID. The pubkey itself stays the same (programs were upgraded in place during the rebrand), just the variable name changes.

"4F97jNoxQzT2qRbkWpW3ztC3Nz2TtKj3rnKG8ExgnrfV",
);

const CHALLENGER_OFFSET = 8;
const EXPIRES_AT_OFFSET = 80;
const USED_OFFSET = 88;
const VERIFIER_OFFSET = 8;

function accountDiscriminator(name: string): Buffer {
return createHash("sha256").update(`account:${name}`).digest().subarray(0, 8);
}

function readI64LE(data: Buffer, offset: number): bigint {
return data.readBigInt64LE(offset);
}

function readBool(data: Buffer, offset: number): boolean {
return data.readUInt8(offset) !== 0;
}

async function main(): Promise<void> {
const walletArg = process.argv[2];
if (!walletArg) {
console.error("Usage: npx tsx scripts/cleanup-pdas.ts <wallet-address>");
process.exit(1);
}

const targetWallet = new anchor.web3.PublicKey(walletArg);
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);

const signerWallet = provider.wallet.publicKey;
if (!signerWallet.equals(targetWallet)) {
throw new Error(
`Signer wallet (${signerWallet.toBase58()}) must match target wallet (${targetWallet.toBase58()}) to close PDA accounts.`,
);
}

const idlPath = path.resolve(__dirname, "../target/idl/iam_verifier.json");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file doesn't exist on current develop. The Rust crate was renamed in PR #33, so Anchor outputs the IDL at target/idl/entros_verifier.json. Script throws ENOENT at runtime. After rebasing:

const idlPath = path.resolve(__dirname, '../target/idl/entros_verifier.json');

const idl = JSON.parse(fs.readFileSync(idlPath, "utf8")) as anchor.Idl;
const program = new anchor.Program(idl, provider);

const challengeDisc = accountDiscriminator("Challenge");
const verificationResultDisc = accountDiscriminator("VerificationResult");

const challengeAccounts = await provider.connection.getProgramAccounts(
IAM_VERIFIER_PROGRAM_ID,
{
filters: [
{
memcmp: {
offset: 0,
bytes: anchor.utils.bytes.bs58.encode(challengeDisc),
},
},
{
memcmp: {
offset: CHALLENGER_OFFSET,
bytes: targetWallet.toBase58(),
},
},
],
},
);

const nowTs = BigInt(Math.floor(Date.now() / 1000));
const closeableChallenges = challengeAccounts.filter(({ account }) => {
const expiresAt = readI64LE(account.data, EXPIRES_AT_OFFSET);
const used = readBool(account.data, USED_OFFSET);
return used || expiresAt <= nowTs;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filter doesn't match the program's contract. close_challenge at programs/entros-verifier/src/lib.rs:205 constrains challenge.used and returns ChallengeNotUsed (6005) for unused challenges regardless of expiry. The current logic flags expired-unused challenges as closeable, which silently fails in the try/catch below and logs spurious errors. Fix:

return used;

Expired-unused challenges are a known protocol gap (no instruction exists to close them) - not this script's job to solve.

});

const verificationAccounts = await provider.connection.getProgramAccounts(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VR closes every result owned by the signer with no filter. The program doesn't constrain this (just the ownership check at programs/entros-verifier/src/lib.rs:218), so it works, but a fresh VR still bound to the current identity.current_commitment represents an unspent proof - closing it discards work the user paid CU for.

Consider filtering for either:

  • VRs whose commitment_prev (offset 56, 32 bytes) no longer matches the current identity state's current_commitment - i.e. already consumed by update_anchor
  • VRs whose verified_at (offset 49, 8 bytes i64) is older than MAX_PROOF_AGE_SECS (600s) and therefore expired anyway

At minimum, add a header comment noting the script is destructive to unspent proofs.

IAM_VERIFIER_PROGRAM_ID,
{
filters: [
{
memcmp: {
offset: 0,
bytes: anchor.utils.bytes.bs58.encode(verificationResultDisc),
},
},
{
memcmp: {
offset: VERIFIER_OFFSET,
bytes: targetWallet.toBase58(),
},
},
],
},
);

const challengeLamports = closeableChallenges.reduce(
(sum, { account }) => sum + account.lamports,
0,
);

const verificationLamports = verificationAccounts.reduce(
(sum, { account }) => sum + account.lamports,
0,
);

let closedChallenges = 0;
let closedVerificationResults = 0;
let reclaimedLamports = 0;

for (const { pubkey, account } of closeableChallenges) {
try {
await program.methods
.closeChallenge()
.accounts({
challenger: targetWallet,
challenge: pubkey,
})
.rpc();

closedChallenges += 1;
reclaimedLamports += account.lamports;
} catch (error) {
console.error(
`Failed to close challenge ${pubkey.toBase58()}: ${(error as Error).message}`,
);
}
}

for (const { pubkey, account } of verificationAccounts) {
try {
await program.methods
.closeVerificationResult()
.accounts({
verifier: targetWallet,
verificationResult: pubkey,
})
.rpc();

closedVerificationResults += 1;
reclaimedLamports += account.lamports;
} catch (error) {
console.error(
`Failed to close verification result ${pubkey.toBase58()}: ${(error as Error).message}`,
);
}
}

const totalCandidates =
closeableChallenges.length + verificationAccounts.length;
const totalClosed = closedChallenges + closedVerificationResults;

console.log("\nCleanup Summary");
console.log("---------------");
console.log(`Target wallet: ${targetWallet.toBase58()}`);
console.log(`Challenge PDAs found: ${challengeAccounts.length}`);
console.log(`Challenge PDAs eligible: ${closeableChallenges.length}`);
console.log(`Challenge PDAs closed: ${closedChallenges}`);
console.log(`VerificationResult PDAs found: ${verificationAccounts.length}`);
console.log(`VerificationResult PDAs closed: ${closedVerificationResults}`);
console.log(`Total close attempts: ${totalCandidates}`);
console.log(`Total closed: ${totalClosed}`);
console.log(
`Estimated reclaimable SOL before tx fees: ${(
(challengeLamports + verificationLamports) /
anchor.web3.LAMPORTS_PER_SOL
).toFixed(9)}`,
);
console.log(
`SOL reclaimed (excluding tx fees): ${(
reclaimedLamports / anchor.web3.LAMPORTS_PER_SOL
).toFixed(9)}`,
);
}

main().catch((error) => {
console.error(error);
process.exit(1);
});