Skip to content

feat(campaign-escrow): add per-campaign applicant index and paged view - #79

Merged
JamesVictor-O merged 2 commits into
Ads-Bazaar:mainfrom
olathedev:feat/8-campaign-applicant-index
Aug 29, 2026
Merged

feat(campaign-escrow): add per-campaign applicant index and paged view#79
JamesVictor-O merged 2 commits into
Ads-Bazaar:mainfrom
olathedev:feat/8-campaign-applicant-index

Conversation

@olathedev

Copy link
Copy Markdown
Contributor

Problem

Closes #8.

Applications live under DataKey::Application(campaign_id, creator), so reading a campaign's applicants requires already knowing every creator's address. There is no way to enumerate them, which is what the business dashboard's applicant review panel needs.

Why this doesn't restore the Vec<Address> the issue asks for

The issue predates #43/#50. #50 removed a CampaignApplicants Vec<Address> precisely because it was rewritten on every apply_to_campaign, making the cost of applying grow with the number of prior applicants, and it shipped applying_with_many_prior_applicants_does_not_regress_write_cost to hold that line — an exact assert_eq! on write_bytes between the first and the 202nd apply.

That assertion rules out any growing container, including a chunked/paginated Vec: a partly-filled page writes fewer bytes than a full one, so the equality breaks. (Worth noting the Vec was never there to serve this issue anyway — #38 added it purely for update_campaign_metadata's boolean "has anyone applied?" check.)

So the index is keyed by ordinal instead:

DataKey::CampaignApplicant(CampaignId, u32) -> Address

One fixed-size entry per applicant, written at apply time alongside the existing ApplicantCount increment. Both writes are fixed-size, so applying costs the same whether you are the 1st or the 10,000th applicant. #50's regression test passes unmodified — I did not touch it.

New API

pub fn applicant_count(env: Env, campaign_id: CampaignId) -> Result<u32, Error>
pub fn campaign_applicants(env: Env, campaign_id: CampaignId, start: u32, limit: u32) -> Result<Vec<Address>, Error>

Two deliberate deviations from the signature in the issue:

It's paged. One ledger entry per applicant means a page of N costs N entries of footprint. My first cut used a 100-wide page and it failed outright on Soroban's footprint limit:

total footprint ledger entries: 103 > 100

MAX_APPLICANTS_PAGE is therefore 50, leaving headroom for the campaign, count and contract-instance entries. An unpaged campaign_applicants(campaign_id) -> Vec<Address> as literally specified would compile and pass a 3-applicant test, then fail in production on exactly the popular campaigns whose applicants a business most needs to review. A start at or past the end returns empty rather than erroring, so clients page until they get a short read.

Both views resolve the campaign first, so an unknown campaign reports CampaignNotFound rather than being indistinguishable from a campaign with no applicants — a bare count can't express that difference.

The index is status-agnostic by design, as the issue specifies: pair it with get_application(campaign_id, creator) to read each applicant's status. That keeps filtering a client concern and keeps this function's cost independent of how the applications are doing.

On the remaining acceptance criteria

  • AlreadyApplied guard — already present at lib.rs:360 via an O(1) get_application lookup, and Error::AlreadyApplied already exists at error.rs:23. Left as-is rather than rewritten to scan the new index; the direct key lookup is strictly cheaper. double_apply_same_creator already covers it, and I've added a test that a rejected retry leaves no duplicate ordinal behind.
  • TTL — new entries are bumped on write, and get_campaign_applicant bumps on read. These entries are written once and never rewritten, so reads are the only thing that can refresh them; without a read bump a stable applicant list would decay out from under an index that is only ever read.

Test coverage added

New test_campaign_applicants module (7 tests):

  • three_creators_apply_and_are_returned_in_order — the issue's stated criterion.
  • applicants_page_across_multiple_calls_without_gaps_or_repeats — walks 7 applicants in pages of 3 the way a client would, asserting the reassembled list equals application order exactly.
  • limit_is_capped_at_max_page — an over-large limit is clamped, and the remainder is still reachable from the next offset. This is the test that caught the footprint bug above.
  • start_at_or_past_end_returns_empty_rather_than_erroring
  • campaign_with_no_applicants_returns_empty_and_zero_count
  • unknown_campaign_is_distinguishable_from_no_applicants
  • rejected_double_application_is_not_indexed_twice

Test plan

Note on the first commit

cargo fmt --all -- --check is currently failing on main (four hunks in campaign-escrow from 43ba011, 7c41d08, 748ba2f on 2026-08-25), so the fmt job is red on every open PR. d65d999 here applies rustfmt so this PR's CI is green; it's the same commit as in #78, so it will drop out cleanly once either merges.

`cargo fmt --all -- --check` has been failing on main since 43ba011,
7c41d08 and 748ba2f landed on 2026-08-25, so the CI fmt job is red on
every open PR rather than on anything those PRs changed.

Pure rustfmt output on the four reported hunks — two call-site
rewrappings in lib.rs and two assert_eq! expansions in test.rs. No
semantic change.

Unrelated to the dispute-resolution TTL fix in this branch; kept as its
own commit so it can be dropped or cherry-picked independently if the
maintainers would rather land it separately.
Applications are keyed by DataKey::Application(campaign_id, creator), so
reading a campaign's applicants required already knowing every creator's
address. There was no way to enumerate them, which is what the business
dashboard's applicant review panel needs.

Add DataKey::CampaignApplicant(campaign_id, ordinal) -> Address: one
fixed-size entry per applicant, written at apply time next to the
existing ApplicantCount increment. This deliberately does not restore the
Vec<Address> that Ads-Bazaar#43/Ads-Bazaar#50 removed. A single growing vector — or a chunked
page of them — is rewritten on every apply, so its write cost climbs with
the number of prior applicants. Indexing by ordinal keeps both writes
fixed-size, and the regression test Ads-Bazaar#50 added,
applying_with_many_prior_applicants_does_not_regress_write_cost, still
passes unmodified.

Expose two views:

  applicant_count(campaign_id) -> u32
  campaign_applicants(campaign_id, start, limit) -> Vec<Address>

campaign_applicants is paged rather than all-at-once. One entry per
applicant means a page of N costs N of footprint, and the first cut at a
100-wide page failed on Soroban's footprint limit with "total footprint
ledger entries: 103 > 100". MAX_APPLICANTS_PAGE is 50, which leaves
headroom for the campaign, count and instance entries. A start at or past
the end returns empty rather than erroring, so clients can page until
they get a short read.

Both views resolve the campaign first, so an unknown campaign reports
CampaignNotFound instead of being indistinguishable from one with no
applicants.

The AlreadyApplied guard the issue also asks for already exists, via an
O(1) get_application lookup — left as is rather than rewritten to scan
the new index. A test asserts a rejected double-apply leaves no duplicate
ordinal behind.

Closes Ads-Bazaar#8
@JamesVictor-O
JamesVictor-O merged commit 0b75a0b into Ads-Bazaar:main Aug 29, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add application index per campaign to support listing all applicants for a campaign

2 participants