Skip to content
Merged
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
9 changes: 9 additions & 0 deletions app/lib/covenant-idl.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@
]
}
},
{
"name": "program",
"address": "5hstj5grBUL1BeSaPLYpgkD6n3ALasmbseRvKRFfCVNT"
},
{
"name": "program_data",
"writable": false,
"signer": false
},
{
"name": "system_program",
"address": "11111111111111111111111111111111"
Expand Down
18 changes: 17 additions & 1 deletion app/scripts/init-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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" });
Expand Down
9 changes: 9 additions & 0 deletions programs/covenant/src/instructions/buy_claim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ pub fn handler(ctx: Context<BuyClaim>) -> 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).
Expand Down
7 changes: 7 additions & 0 deletions programs/covenant/src/instructions/cancel_job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)]
Expand Down
14 changes: 10 additions & 4 deletions programs/covenant/src/instructions/finalize_payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)]
Expand Down Expand Up @@ -151,14 +156,15 @@ pub fn handler(ctx: Context<FinalizePayment>) -> 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,
Expand Down
19 changes: 19 additions & 0 deletions programs/covenant/src/instructions/init_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ pub struct InitConfig<'info> {
)]
pub config: Box<Account<'info, ProtocolConfig>>,

/// 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>,
}

Expand Down
5 changes: 5 additions & 0 deletions programs/covenant/src/instructions/resolve_dispute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)]
Expand Down
9 changes: 9 additions & 0 deletions sdk/idl/covenant.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@
]
}
},
{
"name": "program",
"address": "5hstj5grBUL1BeSaPLYpgkD6n3ALasmbseRvKRFfCVNT"
},
{
"name": "program_data",
"writable": false,
"signer": false
},
{
"name": "system_program",
"address": "11111111111111111111111111111111"
Expand Down
14 changes: 14 additions & 0 deletions sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -209,6 +214,13 @@ export class CovenantClient {
minBondAbsolute?: BN | number;
}): Promise<TransactionSignature> {
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,
Expand All @@ -221,6 +233,8 @@ export class CovenantClient {
.accounts({
admin: params.admin.publicKey,
config: configPda,
program: this.programId,
programData,
systemProgram: SystemProgram.programId,
})
.signers([params.admin])
Expand Down
9 changes: 9 additions & 0 deletions sdk/src/idl/covenant.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@
]
}
},
{
"name": "program",
"address": "5hstj5grBUL1BeSaPLYpgkD6n3ALasmbseRvKRFfCVNT"
},
{
"name": "program_data",
"writable": false,
"signer": false
},
{
"name": "system_program",
"address": "11111111111111111111111111111111"
Expand Down
5 changes: 5 additions & 0 deletions tests/covenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading