Skip to content
Merged
  •  
  •  
  •  
4 changes: 4 additions & 0 deletions .github/workflows/rustfmt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
branches:
- main
- develop
- testnet
workflow_call:
secrets:
STELLAR_SECRET_KEY:
Expand Down Expand Up @@ -43,6 +44,9 @@ jobs:

deploy:
needs: rustfmt
# Never deploy from a PR run. Deploy only on direct pushes (the workflow
# is chained here for the push-to-branch CD path, not for PR checks).
if: github.event_name == 'push'
uses: ./.github/workflows/contract-deploy.yml
secrets:
STELLAR_SECRET_KEY: ${{ secrets.STELLAR_SECRET_KEY }}
Expand Down
12 changes: 10 additions & 2 deletions .github/workflows/verify-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@ on:
paths:
- 'contracts/**'
- '.github/workflows/verify-build.yml'
pull_request_target:
# Use pull_request (not pull_request_target): the check must run the workflow
# from the PR head so branch-filter changes take effect on the PR itself, and
# the build/test job needs no secrets, so the pull_request_target foot-gun
# (secrets + write token against PR head code) is avoided.
pull_request:
branches:
- main
- develop
- testnet
paths:
- 'contracts/**'
- '.github/workflows/verify-build.yml'
- '.github/workflows/rustfmt.yml'
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -54,7 +60,9 @@ jobs:

rustfmt:
needs: build
if: success()
# On PRs the standalone rustfmt workflow posts the `rustfmt` check; this
# chained call (which fans out to deploy) runs only on push, never on a PR.
if: success() && github.event_name == 'push'
uses: ./.github/workflows/rustfmt.yml
secrets:
STELLAR_SECRET_KEY: ${{ secrets.STELLAR_SECRET_KEY }}
Expand Down
1 change: 1 addition & 0 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Legend: `[ ]` open, `[~]` in flight, `[x]` done.
- [x] Non-USDC token end-to-end smoke. 2026-06-05: registered the testnet native XLM SAC (`CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC`) on the new events contract; ran `scripts/smoke/contract-non-usdc-token.ts`. Bounty published in XLM, alice applied + submitted + won + got paid; payout tx `a6c8daad…`. Smoke updated to tolerate native-asset tx fees (~143k stroops / 0.014 XLM for 3 alice-signed txs); for non-native tokens the assertion still demands exact-budget delta.
- [ ] Mainnet admin multi-sig provisioned per `docs/admin-custody-policy.md` (3 signers, 2-of-3). 2026-06-05: launch baseline updated to software multi-sig (Freighter on 3 isolated machines) with a hardware-upgrade trigger codified in `admin-custody-policy.md` §10 (TVL $250k OR daily volume $50k/day for 7 days OR first signer-machine incident → mandatory Yubikey upgrade). Pre-flight gate codified for both paths — see `docs/multisig-preflight.md` (Section 1.A hardware, Section 1.B Freighter; everything else identical) and `scripts/admin/verify-multisig.sh` (chain-state gate, hardware-agnostic). The verify script was sanity-tested against a single-sig account and correctly fails with the per-check breakdown.
- [ ] Mainnet deploy executed per `docs/mainnet-deploy-runbook.md`. Result committed to `deployments/mainnet.json`.
- [~] **On-chain credit removal: 1.0.0 -> 1.1.0 in-place upgrade (both contracts).** 2026-07-03: APPLIED on mainnet. Both contracts now report `version() == "1.1.0"`. propose (events tx `03b1b30b`, profile `531ff5a2`, 2026-06-29) -> ~1-day timelock -> apply (events tx `b2c84d7c` ledger 63315871, profile `663085fc` ledger 63315890, 2026-07-03). Live wasm: events `2a8789bf`, profile `b9e3500c`. Recorded in `deployments/mainnet.json` + `deployments/mainnet-upgrades.jsonl`. Remaining: `migrate()` no-op stamp on both (audit-trail marker), then deploy the credit-free `boundless-nestjs`. Original notes below. 2026-06-28: dropped the on-chain credit surface from both contracts (source + tests green, 41 profile / 150 events; wasm builds). Profile struct went `{ bootstrapped_at, credits, reputation }` -> `{ bootstrapped_at, reputation }`; `spend_credits` / `earn_credits` / `refund_credits` / `admin_grant_credits` / `set_default_bootstrap_credits` removed; events contract dropped `application_credit_cost` (EventRecord / CreateEventParams) and `credit_earn` (WinnerSpec). Credits are off-chain only now (see `boundless-credits-reputation-prd.md` §2). Versioned as a minor bump: `INITIAL_VERSION` + `contractmeta` are now `1.1.0` on both. **No data migration required:** the live contracts were freshly deployed with ZERO bootstrapped profiles and ZERO events, so there are no `Profile` / `EventRecord` rows in the old shape to rewrite. The dropped-field structs only ever have to deserialize rows that do not exist. **Timelock kept as-is** (audited H6 control; cheap insurance as real escrow lands; revisit only if operationally painful). Upgrade sequence (multisig governance op per `docs/mainnet-deploy-runbook.md` + `docs/admin-custody-policy.md`, NOT yet executed): build + commit the 1.1.0 wasm -> `propose_upgrade(new_wasm_hash, "1.1.0")` -> wait out `UPGRADE_TIMELOCK_LEDGERS` -> `apply_upgrade()` -> `migrate()` (no-op stamp; emits the `Migrated` audit event) -> verify `version()` reads `1.1.0` -> cut backend over to confirm the credit-free contract surface. Do BOTH contracts. Re-confirm zero on-chain profiles/events at upgrade time (the no-migration assumption rests on it).

### From the 2026-06 Stellar-skill audit

Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion contracts/events/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "boundless-events"
version = "0.1.0"
version = "1.1.0"
edition = "2021"
publish = false

Expand Down
25 changes: 10 additions & 15 deletions contracts/events/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400;
// Initial contract version. Written by __constructor and bumped on
// apply_upgrade. Bump alongside any storage-layout or public-surface change
// that warrants a migration entrypoint.
pub const INITIAL_VERSION: &str = "0.2.0";
pub const INITIAL_VERSION: &str = "1.1.0";

// ============================================================
// INITIALIZATION
Expand All @@ -52,11 +52,7 @@ pub fn initialize(
) {
// Refuse double-init by checking the admin key in instance storage (the
// new home for admin/config per the 2026-06 audit).
if env
.storage()
.instance()
.has(&crate::types::DataKey::Admin)
{
if env.storage().instance().has(&crate::types::DataKey::Admin) {
panic_with_error!(env, Error::AlreadyInitialized);
}
if fee_bps > MAX_FEE_BPS {
Expand Down Expand Up @@ -221,7 +217,7 @@ pub fn propose_upgrade(
// soroban contracterror 50-variant cap (a dedicated InvalidVersion
// would push us over). Off-chain monitors should treat InvalidPillar
// on propose_upgrade as "bad version label."
if new_version.len() == 0 {
if new_version.is_empty() {
return Err(Error::InvalidPillar);
}
let now = env.ledger().sequence();
Expand Down Expand Up @@ -347,10 +343,11 @@ pub fn migrate(env: &Env) -> Result<(), Error> {
// helpers and call from inside the body.
// ============================================================

// No-op for the initial 0.2.0 deploy. __constructor populates storage
// in the current shape, so admin can call migrate() once just to stamp
// the marker and unlock the audit trail (the Migrated event signals
// off-chain runbooks that the post-upgrade cleanup ran).
// No-op for the 1.0.0 -> 1.1.0 credit-removal upgrade: the contracts hold
// no events yet, so there are no EventRecord rows to rewrite. __constructor
// populates storage in the current shape, so admin can call migrate() once
// just to stamp the marker and unlock the audit trail (the Migrated event
// signals off-chain runbooks that the post-upgrade cleanup ran).

storage::set_migrated_to_version(env, &current);
storage::touch_instance(env);
Expand All @@ -366,8 +363,7 @@ pub fn migrate(env: &Env) -> Result<(), Error> {
// READS
// ============================================================
pub fn get_admin(env: &Env) -> Address {
storage::get_admin(env)
.unwrap_or_else(|_| panic_with_error!(env, Error::NotInitialized))
storage::get_admin(env).unwrap_or_else(|_| panic_with_error!(env, Error::NotInitialized))
}

pub fn get_fee_bps(env: &Env) -> u32 {
Expand All @@ -387,8 +383,7 @@ pub fn is_paused(env: &Env) -> bool {
}

pub fn get_version(env: &Env) -> String {
storage::get_version(env)
.unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized))
storage::get_version(env).unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized))
}

pub fn get_pending_upgrade(env: &Env) -> Option<PendingUpgrade> {
Expand Down
39 changes: 8 additions & 31 deletions contracts/events/src/bounty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
//
// Spec: boundless-platform-contract-prd.md Sections 6.3, 7.
//
// Bounties use ReleaseKind::Single. Apply / submit are gated by credits.
// Bounties use ReleaseKind::Single. Credits (apply cost / refunds) are handled
// off-chain; the contract only records applicants and ensures their profile.

use soroban_sdk::{Address, BytesN, Env, Symbol};
use soroban_sdk::{Address, BytesN, Env};

use crate::admin;
use crate::errors::Error;
Expand All @@ -15,11 +16,7 @@ use crate::profile_client;
use crate::storage;
use crate::types::{EventRecord, EventStatus, Pillar, ReleaseKind};

pub fn validate_create(
_env: &Env,
record: &EventRecord,
_owner: &Address,
) -> Result<(), Error> {
pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Result<(), Error> {
if !matches!(record.release_kind, ReleaseKind::Single) {
return Err(Error::InvalidReleaseKind);
}
Expand All @@ -43,27 +40,18 @@ pub fn apply(

applicant.require_auth();

// append_applicant returns Err on duplicate or cap exceeded; both are
// caught here so we don't bill credits before reserving the slot.
// append_applicant returns Err on duplicate or cap exceeded.
storage::append_applicant(env, bounty_id, &applicant, MAX_APPLICANTS_PER_EVENT)?;

// Cross-contract: bootstrap (idempotent), then spend credits.
// Cross-contract: ensure the applicant has a profile (idempotent). Credits
// are charged off-chain now, so there is no on-chain spend here.
let profile = profile_client::client(env);
let bootstrap_op = idempotency::derive_child(env, &op_id, tag::BOOTSTRAP);
profile.bootstrap(&applicant, &bootstrap_op);

let spend_op = idempotency::derive_child(env, &op_id, tag::SPEND_CREDITS);
profile.spend_credits(
&applicant,
&event.application_credit_cost,
&Symbol::new(env, "apply"),
&spend_op,
);

evt::Applied {
event_id: bounty_id,
applicant,
credit_cost: event.application_credit_cost,
}
.publish(env);

Expand Down Expand Up @@ -97,18 +85,7 @@ pub fn withdraw_application(
// the prior O(n) linear scan even at the cap.
storage::remove_applicant(env, bounty_id, &applicant)?;

// Cross-contract: refund 50% of the application credit cost.
let refund = event.application_credit_cost / 2;
if refund > 0 {
let profile = profile_client::client(env);
let refund_op = idempotency::derive_child(env, &op_id, tag::REFUND_CREDITS);
profile.refund_credits(
&applicant,
&refund,
&Symbol::new(env, "wd_refund"),
&refund_op,
);
}
// Credits (including any withdrawal refund) are handled off-chain.

evt::ApplicationWithdrawn {
event_id: bounty_id,
Expand Down
16 changes: 1 addition & 15 deletions contracts/events/src/crowdfunding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,7 @@ use soroban_sdk::{Address, Env};
use crate::errors::Error;
use crate::types::{EventRecord, ReleaseKind};

pub fn validate_create(
_env: &Env,
record: &EventRecord,
_owner: &Address,
) -> Result<(), Error> {
pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Result<(), Error> {
// Multi(n) required.
match record.release_kind {
ReleaseKind::Multi(n) if n > 0 => {}
Expand All @@ -56,15 +52,5 @@ pub fn validate_create(
return Err(Error::DistributionMismatch);
}

// L7 (2026-06 audit): crowdfunding has no apply flow. A nonzero
// application_credit_cost would silently never be charged; rejecting
// here keeps wallet UIs and indexers from displaying a misleading
// value. Reuse Error::InvalidPillar to stay inside the contracterror
// 50-variant cap (a dedicated InvalidApplicationCreditCost would push
// the events contract over).
if record.application_credit_cost != 0 {
return Err(Error::InvalidPillar);
}

Ok(())
}
3 changes: 1 addition & 2 deletions contracts/events/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub enum Error {
ApplicantNotApplied = 41,
SubmissionNotFound = 42,
SubmissionAlreadyExists = 43,
InsufficientCredits = 44,
// 44 (InsufficientCredits) retired with on-chain credits; left as a gap.

// Winners
NoSubmissions = 50,
Expand Down Expand Up @@ -78,7 +78,6 @@ pub enum Error {
UpgradeProposalExpired = 68,
MigrationAlreadyApplied = 69,


// Pause
Paused = 70,

Expand Down
51 changes: 11 additions & 40 deletions contracts/events/src/event_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ use crate::types::{
};

const MAX_TITLE_LEN: u32 = 120;
const MAX_APPLY_COST: u32 = 100;
const MAX_WINNERS_PER_SELECT: u32 = 50;

// Per-event list caps. Lifted from 100 to 5_000 once paged cancel landed:
Expand Down Expand Up @@ -60,11 +59,7 @@ fn resolve_manager(env: &Env, event_id: u64, owner: &Address) -> Address {
storage::get_event_manager(env, event_id).unwrap_or_else(|| owner.clone())
}

pub fn create_event(
env: &Env,
params: CreateEventParams,
op_id: BytesN<32>,
) -> Result<u64, Error> {
pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> Result<u64, Error> {
admin::require_not_paused(env)?;
idempotency::require_unseen(env, &op_id)?;

Expand Down Expand Up @@ -102,11 +97,6 @@ pub fn create_event(
}
}

// Application credit cost cap.
if params.application_credit_cost > MAX_APPLY_COST {
return Err(Error::InvalidPillar);
}

if let Some(bps) = params.fee_bps_override {
if bps > MAX_FEE_BPS {
return Err(Error::InvalidFeeBps);
Expand All @@ -117,7 +107,11 @@ pub fn create_event(
// Crowdfunding flips total_budget into a funding goal; escrow starts at 0
// and grows only via add_funds. Every other pillar deposits at create.
let is_crowdfunding = matches!(params.pillar, Pillar::Crowdfunding);
let initial_escrow: i128 = if is_crowdfunding { 0 } else { params.total_budget };
let initial_escrow: i128 = if is_crowdfunding {
0
} else {
params.total_budget
};

let provisional = EventRecord {
id: 0,
Expand All @@ -133,7 +127,6 @@ pub fn create_event(
created_at: env.ledger().timestamp(),
deadline: params.deadline,
winner_distribution: params.winner_distribution.clone(),
application_credit_cost: params.application_credit_cost,
fee_bps_override: params.fee_bps_override,
};
match params.pillar {
Expand All @@ -155,10 +148,7 @@ pub fn create_event(

// Assign id and persist.
let id = idempotency::next_event_id(env);
let record = EventRecord {
id,
..provisional
};
let record = EventRecord { id, ..provisional };
storage::set_event(env, id, &record);

// Record the management authority override when the owner delegates it (so
Expand Down Expand Up @@ -720,36 +710,17 @@ pub fn select_winners(
idempotency::derive_child_indexed(env, &op_id, tag::BOOTSTRAP, sub_idx);
profile.bootstrap(&spec.recipient, &bootstrap_op);

let earn_op =
idempotency::derive_child_indexed(env, &op_id, tag::EARN_CREDITS, sub_idx);
profile.earn_credits(
&spec.recipient,
&spec.credit_earn,
&reason_win,
&earn_op,
);

let rep_op =
idempotency::derive_child_indexed(env, &op_id, tag::BUMP_REP, sub_idx);
let rep_op = idempotency::derive_child_indexed(env, &op_id, tag::BUMP_REP, sub_idx);
profile.bump_reputation(
&spec.recipient,
&spec.reputation_bump,
&reason_win,
&rep_op,
);

let earnings_op = idempotency::derive_child_indexed(
env,
&op_id,
tag::REGISTER_EARNINGS,
sub_idx,
);
profile.register_earnings(
&spec.recipient,
&event.token,
&amount,
&earnings_op,
);
let earnings_op =
idempotency::derive_child_indexed(env, &op_id, tag::REGISTER_EARNINGS, sub_idx);
profile.register_earnings(&spec.recipient, &event.token, &amount, &earnings_op);

storage::append_winner(
env,
Expand Down
2 changes: 0 additions & 2 deletions contracts/events/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ pub struct OwnerResidualRefunded {
pub struct Applied {
pub event_id: u64,
pub applicant: Address,
pub credit_cost: u32,
}

#[contractevent]
Expand Down Expand Up @@ -169,4 +168,3 @@ pub struct Migrated {
pub from_version: String,
pub to_version: String,
}

Loading
Loading