Skip to content

feat(connection): implement TrialOffer contract with subscription auth and cross-contract progress promotion#237

Merged
Petah1 merged 2 commits into
scout-off:mainfrom
marshalfleet:feat/201-implement-trial-offer-contract
Jun 20, 2026
Merged

feat(connection): implement TrialOffer contract with subscription auth and cross-contract progress promotion#237
Petah1 merged 2 commits into
scout-off:mainfrom
marshalfleet:feat/201-implement-trial-offer-contract

Conversation

@marshalfleet

@marshalfleet marshalfleet commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #220 . Implements the connection Soroban smart contract module for recording trial offers between scouts and players, with full authorization, idempotency, progress promotion, and retrieval — without breaking any existing contract tests.


Changes

contracts/connection/src/lib.rs — full implementation (was a stub)

Storage types

  • TrialOfferData { details_uri: String, created_at: u64 } — the persisted record
  • TrialOfferRecord { scout, player_id, details_uri, created_at } — the return type for read queries
  • DataKey variants: TrialOfferKey(Address, u64), ScoutOffers(Address), PlayerConnections(u64), RegisterContract, SubscriptionContract

initialize(admin, register_contract, subscription_contract)

  • Extended from single-arg stub; stores addresses of both dependency contracts for cross-contract dispatch

log_trial_offer(scout, player_id: u64, details_uri)

  1. Requires scout auth()
  2. Idempotency guard — if TrialOfferKey(scout, player_id) already exists in storage, returns Ok(()) immediately; no duplicate state, no re-emission
  3. Authorization — calls into the subscription contract via cross-contract client:
    • is_subscribed(scout) — passes if scout has an active subscription
    • has_paid_contact(scout, player_id) — passes if scout paid the per-player contact fee
    • Returns Error::Unauthorized if neither condition is met
  4. Persists TrialOfferData under TrialOfferKey(scout, player_id)
  5. Appends player_id to ScoutOffers(scout) list
  6. Appends scout to PlayerConnections(player_id) list
  7. Cross-contract call: register.update_progress_level(player_id, 3) → promotes player to Elite Tier
  8. Emits event with topics [Symbol::new("trial_offer_logged"), scout, player_id]

get_connections(player_id: u64) -> Vec<TrialOfferRecord>

  • Reads PlayerConnections(player_id) for the ordered scout list
  • Joins each scout against TrialOfferKey storage
  • Returns records in insertion order (deterministic)

get_trial_offers(scout: Address) -> Vec<TrialOfferRecord>

  • Reads ScoutOffers(scout) for the ordered player_id list
  • Same join pattern; deterministic insertion ordering

contracts/register/src/lib.rs — additive only, no existing tests broken

  • DataKey::AuthorizedUpdater — new storage key
  • set_authorized_updater(env, updater: Address) — admin-auth gate; registers an external contract (the connection contract) as the sole entity allowed to update player progress levels
  • update_progress_level(env, player_id: u64, level: u32) — requires authorized_updater.require_auth(); Soroban's cross-contract auth model satisfies this automatically when the connection contract is the caller in a live transaction; mock_all_auths() covers it in tests

contracts/subscription/src/lib.rs — promoted from stub

  • DataKey::Subscription(Address)u32 expiry ledger sequence
  • DataKey::ContactFee(Address, u64)bool flag
  • subscribe — now stores current_sequence + duration_ledgers as the expiry
  • is_subscribed — checks current_sequence < expiry (was always-false stub)
  • pay_to_contact — stores the contact fee flag; player_id type changed from Address to u64 to align with the register contract's numeric ID model
  • has_paid_contact(scout, player_id: u64) -> bool — new; checks the ContactFee key

contracts/connection/Cargo.toml

  • Added register and subscription as path dependencies (needed for cross-contract client types)
  • Added soroban-sdk with testutils feature to [dev-dependencies]

Test coverage (8 tests in connection)

Test What it verifies
log_trial_offer_with_subscription_sets_progress_to_3 Subscribed scout can log offer; player promoted 0 → 3
log_trial_offer_promotes_level_2_to_3 Player pre-set to level 2 is promoted to 3 (Elite Tier)
unauthorized_scout_cannot_log_trial_offer No subscription, no contact fee → Unauthorized
log_trial_offer_with_contact_fee_succeeds Per-player contact fee path accepted
duplicate_log_trial_offer_is_idempotent Second call for same (scout, player) succeeds silently; list stays length 1; original URI preserved
get_connections_returns_all_scouts_for_player Two scouts → two records returned
get_trial_offers_returns_all_offers_by_scout One scout, two players → two records returned
double_initialize_fails Second initialize returns AlreadyInitialized

All 8 existing register contract tests continue to pass without modification.


Architecture notes

  • No duplicated state — trial offer existence is checked via a single TrialOfferKey storage lookup; the ScoutOffers and PlayerConnections index lists are only updated on first write
  • No panic paths — all error conditions return typed Error variants; cross-contract calls that fail trap the whole transaction (correct Soroban behaviour)
  • No bypass of existing checksupdate_progress_level in the register contract enforces authorized_updater.require_auth() before mutating player state; the authorized updater is set explicitly by the admin via set_authorized_updater
  • Soroban runtime compatibility#![no_std] throughout; Symbol::new(&env, "trial_offer_logged") used for the >9-char event topic (cannot use symbol_short!); all storage via instance storage with bump_instance on every state-changing call

Test plan

  • CI Soroban contracts job passes: cargo build --release and cargo test --target x86_64-unknown-linux-gnu
  • All 8 new connection tests pass
  • All 8 existing register tests pass
  • No compiler warnings

…h and cross-contract progress promotion

- Full `log_trial_offer(scout, player_id, details_uri)` with idempotency guard, subscription/contact-fee auth check, persistent TrialOfferData storage keyed by TrialOfferKey(scout, player_id), cross-contract call to promote player to progress_level 3, and trial_offer_logged event emission
- `get_connections(player_id)` and `get_trial_offers(scout)` read-only functions returning TrialOfferRecord vecs with deterministic insertion ordering
- register: add `set_authorized_updater` (admin-only) and `update_progress_level` (authorized-updater-only) without touching existing functions or tests
- subscription: implement subscription expiry storage, `is_subscribed` check, `has_paid_contact` flag, active `pay_to_contact` with u64 player_id
- connection: add register/subscription as Cargo deps; 8 integration tests covering level-2-to-3 promotion, subscription auth, contact-fee auth, idempotency, connection/offer retrieval, and double-init guard
…avoid duplicate WASM symbols

Importing register and subscription as [dependencies] caused rust-lld to
fail with duplicate symbol errors (initialize, etc.) when linking the
connection cdylib, because all three contracts export identically-named
WASM entry points.

Fix: move register and subscription to [dev-dependencies] only (used
exclusively by tests), and replace RegisterContractClient /
SubscriptionContractClient usage in production code with
env.invoke_contract, which dispatches calls at the host level without
merging WASM symbol tables.
@Petah1 Petah1 merged commit 2e6c690 into scout-off:main Jun 20, 2026
2 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.

Implement connection.rs Soroban contract (scout-player agreements)

2 participants