diff --git a/app/lib/covenant-idl.json b/app/lib/covenant-idl.json index 4ff1e1c..0c20375 100644 --- a/app/lib/covenant-idl.json +++ b/app/lib/covenant-idl.json @@ -26,6 +26,15 @@ ] } }, + { + "name": "program", + "address": "5hstj5grBUL1BeSaPLYpgkD6n3ALasmbseRvKRFfCVNT" + }, + { + "name": "program_data", + "writable": false, + "signer": false + }, { "name": "system_program", "address": "11111111111111111111111111111111" diff --git a/app/scripts/init-config.mjs b/app/scripts/init-config.mjs index 620427e..7a9c87c 100755 --- a/app/scripts/init-config.mjs +++ b/app/scripts/init-config.mjs @@ -94,6 +94,20 @@ async function main() { PROGRAM_ID, ); console.log(`▶ Config PDA: ${configPda.toBase58()}`); + + // init_config is gated on the program's upgrade authority, so the call + // must include the program + its ProgramData account (canonical PDA + // [programId] under the BPF upgradeable loader). The DEPLOYER_KEYPAIR must + // be the program's upgrade authority for this to succeed. + const BPF_UPGRADEABLE_LOADER = new PublicKey( + "BPFLoaderUpgradeab1e11111111111111111111111", + ); + const [programData] = PublicKey.findProgramAddressSync( + [PROGRAM_ID.toBuffer()], + BPF_UPGRADEABLE_LOADER, + ); + console.log(`▶ ProgramData PDA: ${programData.toBase58()}`); + const existing = await conn.getAccountInfo(configPda); if (existing) { console.log("✓ ProtocolConfig already initialized — nothing to do."); @@ -131,8 +145,10 @@ async function main() { minBondAbsAtomic, ) .accounts({ - authority: deployerKp.publicKey, + admin: deployerKp.publicKey, config: configPda, + program: PROGRAM_ID, + programData, systemProgram: SystemProgram.programId, }) .rpc({ commitment: "confirmed" }); diff --git a/programs/covenant/src/instructions/buy_claim.rs b/programs/covenant/src/instructions/buy_claim.rs index 14708e1..4c796e4 100644 --- a/programs/covenant/src/instructions/buy_claim.rs +++ b/programs/covenant/src/instructions/buy_claim.rs @@ -66,6 +66,15 @@ pub fn handler(ctx: Context) -> Result<()> { CovError::BuyerIsSeller, ); + // The poster funds the escrow, so letting them buy the claim is + // self-dealing: they would route the escrow payout back to themselves + // and could then grief the taker via a dispute. Block it, mirroring the + // buyer≠seller guard above. + require!( + ctx.accounts.buyer.key() != ctx.accounts.job_escrow.poster, + CovError::Unauthorized, + ); + let price = ctx.accounts.claim_listing.price; // 1. Transfer price from buyer → seller (atomic payment). diff --git a/programs/covenant/src/instructions/cancel_job.rs b/programs/covenant/src/instructions/cancel_job.rs index bbcc236..af21f35 100644 --- a/programs/covenant/src/instructions/cancel_job.rs +++ b/programs/covenant/src/instructions/cancel_job.rs @@ -32,8 +32,15 @@ pub struct CancelJob<'info> { )] pub poster: AccountInfo<'info>, + /// Pinned to the canonical escrow PDA `[b"escrow_token", job_escrow]` + /// that `create_job` derives. Without the seed constraint an attacker + /// could pass a self-created decoy token account (same mint, authority = + /// job_escrow, balance 0); the handler would transfer nothing, close the + /// decoy, close the job PDA, and permanently strand the real escrow. #[account( mut, + seeds = [b"escrow_token", job_escrow.key().as_ref()], + bump, constraint = escrow_token_account.owner == job_escrow.key(), constraint = escrow_token_account.mint == job_escrow.token_mint @ CovError::MintMismatch, )] diff --git a/programs/covenant/src/instructions/finalize_payment.rs b/programs/covenant/src/instructions/finalize_payment.rs index 2351691..fe35725 100644 --- a/programs/covenant/src/instructions/finalize_payment.rs +++ b/programs/covenant/src/instructions/finalize_payment.rs @@ -42,8 +42,13 @@ pub struct FinalizePayment<'info> { )] pub poster: AccountInfo<'info>, + /// Pinned to the canonical escrow PDA `[b"escrow_token", job_escrow]` + /// that `create_job` derives, so no attacker-supplied decoy token + /// account can be substituted for the real escrow. #[account( mut, + seeds = [b"escrow_token", job_escrow.key().as_ref()], + bump, constraint = escrow_token_account.owner == job_escrow.key(), constraint = escrow_token_account.mint == job_escrow.token_mint @ CovError::MintMismatch, )] @@ -151,14 +156,15 @@ pub fn handler(ctx: Context) -> Result<()> { ); token::transfer(transfer_ctx, amount)?; - // 2. Close escrow token account; SPL rent refund always to the taker - // (a few lamports; routing to buyer would add complexity with - // marginal benefit). + // 2. Close escrow token account; SPL rent refund to the poster, who + // funded the account's rent in create_job (init, payer = poster). + // This matches cancel_job and resolve_dispute, which also refund the + // escrow-account rent to the poster. let close_ctx = CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), CloseAccount { account: ctx.accounts.escrow_token_account.to_account_info(), - destination: ctx.accounts.taker.to_account_info(), + destination: ctx.accounts.poster.to_account_info(), authority: ctx.accounts.job_escrow.to_account_info(), }, signer_seeds, diff --git a/programs/covenant/src/instructions/init_config.rs b/programs/covenant/src/instructions/init_config.rs index 99b9917..2f8d437 100644 --- a/programs/covenant/src/instructions/init_config.rs +++ b/programs/covenant/src/instructions/init_config.rs @@ -17,6 +17,25 @@ pub struct InitConfig<'info> { )] pub config: Box>, + /// The Covenant program itself. `programdata_address()?` ties it to the + /// `program_data` account below so neither can be spoofed. + #[account( + constraint = program.programdata_address()? == Some(program_data.key()) + @ CovError::Unauthorized, + )] + pub program: Program<'info, crate::program::Covenant>, + + /// The program's ProgramData account. Only the program's upgrade + /// authority (the deployer) may initialize the protocol config. Without + /// this, `init_config` is first-call-wins on a fresh deployment: anyone + /// could front-run the operator, seize `admin`, and install their own + /// arbitrator multisig — handing themselves every disputed escrow + bond. + #[account( + constraint = program_data.upgrade_authority_address == Some(admin.key()) + @ CovError::Unauthorized, + )] + pub program_data: Account<'info, ProgramData>, + pub system_program: Program<'info, System>, } diff --git a/programs/covenant/src/instructions/resolve_dispute.rs b/programs/covenant/src/instructions/resolve_dispute.rs index 212ba01..2e167b5 100644 --- a/programs/covenant/src/instructions/resolve_dispute.rs +++ b/programs/covenant/src/instructions/resolve_dispute.rs @@ -41,8 +41,13 @@ pub struct ResolveDispute<'info> { )] pub poster: AccountInfo<'info>, + /// Pinned to the canonical escrow PDA `[b"escrow_token", job_escrow]` + /// that `create_job` derives, matching the bond account's seed + /// constraint below — no attacker-supplied decoy can be substituted. #[account( mut, + seeds = [b"escrow_token", job_escrow.key().as_ref()], + bump, constraint = escrow_token_account.owner == job_escrow.key(), constraint = escrow_token_account.mint == job_escrow.token_mint @ CovError::MintMismatch, )] diff --git a/sdk/idl/covenant.json b/sdk/idl/covenant.json index 4ff1e1c..0c20375 100644 --- a/sdk/idl/covenant.json +++ b/sdk/idl/covenant.json @@ -26,6 +26,15 @@ ] } }, + { + "name": "program", + "address": "5hstj5grBUL1BeSaPLYpgkD6n3ALasmbseRvKRFfCVNT" + }, + { + "name": "program_data", + "writable": false, + "signer": false + }, { "name": "system_program", "address": "11111111111111111111111111111111" diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 9c4db44..516e5f9 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -39,6 +39,11 @@ import type { import { withRetry, type RetryOptions } from "./retry"; import { classifyError, CovenantValidationError } from "./errors"; +/** BPF upgradeable loader — owner of every program's ProgramData account. */ +const BPF_UPGRADEABLE_LOADER = new PublicKey( + "BPFLoaderUpgradeab1e11111111111111111111111", +); + // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyProgram = Program; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -209,6 +214,13 @@ export class CovenantClient { minBondAbsolute?: BN | number; }): Promise { const [configPda] = deriveConfigPda(this.programId); + // The program gates init_config on its upgrade authority, so the call + // must include the program + its ProgramData account. ProgramData is the + // canonical PDA [programId] under the BPF upgradeable loader. + const [programData] = PublicKey.findProgramAddressSync( + [this.programId.toBuffer()], + BPF_UPGRADEABLE_LOADER, + ); return this.send((this.program.methods as any) .initConfig( params.arbitrators, @@ -221,6 +233,8 @@ export class CovenantClient { .accounts({ admin: params.admin.publicKey, config: configPda, + program: this.programId, + programData, systemProgram: SystemProgram.programId, }) .signers([params.admin]) diff --git a/sdk/src/idl/covenant.json b/sdk/src/idl/covenant.json index 4ff1e1c..0c20375 100644 --- a/sdk/src/idl/covenant.json +++ b/sdk/src/idl/covenant.json @@ -26,6 +26,15 @@ ] } }, + { + "name": "program", + "address": "5hstj5grBUL1BeSaPLYpgkD6n3ALasmbseRvKRFfCVNT" + }, + { + "name": "program_data", + "writable": false, + "signer": false + }, { "name": "system_program", "address": "11111111111111111111111111111111" diff --git a/tests/covenant.ts b/tests/covenant.ts index 1564dcf..07ca708 100644 --- a/tests/covenant.ts +++ b/tests/covenant.ts @@ -130,6 +130,11 @@ describe("covenant — optimistic settlement", () => { .accounts({ admin: admin.publicKey, config: configPda, + program: program.programId, + programData: PublicKey.findProgramAddressSync( + [program.programId.toBuffer()], + new PublicKey("BPFLoaderUpgradeab1e11111111111111111111111"), + )[0], systemProgram: SystemProgram.programId, }) .rpc();