The governance contract provides on-chain proposal creation, weighted voting, and multi-step execution for updating the credit-oracle's scoring weights. A successful proposal does not change weights immediately — it queues them through the credit-oracle's own 24-hour timelock, giving the community two separate reaction windows before any weight change takes effect.
Important note on naming: No separate governor role exists in the credit-oracle contract today. Older test snapshot filenames in the repository and sections of WEIGHTED_VOTING_DESIGN.md reference a governor-registration flow that was never merged into the deployed credit-oracle code. The actual mechanism by which governance controls the credit-oracle is that the governance contract itself becomes the stored admin address of the credit-oracle via the two-step admin-transfer flow (propose_new_admin + accept_admin). There is no whitelist of "governors" on the credit-oracle side — only a single admin, which after setup is the governance contract address. This distinction matters for security analysis: compromising the governance contract's admin is equivalent to compromising the credit-oracle's admin.
Current design decisions (not a tokenized DAO):
- Voting power is assigned by the governance contract's admin via
register_voter. This is a permissioned model suitable for an initial trusted set of stewards while the protocol stabilizes on testnet. - Token-based voting (e.g., SEP-41 token balance snapshot at proposal creation) is explicitly out of scope for the current implementation. See
WEIGHTED_VOTING_DESIGN.mdin the repository root for the original design rationale and out-of-scope items. - Each voter may cast multiple partial votes on a single proposal up to their registered weight, and weights are tracked per-proposal so the same voter can vote fully on concurrent proposals without conflict.
Related reading:
- Architecture Guide — ADR-003 (governance respects credit-oracle timelock) and the admin-transfer two-step flow.
- Epoch Model — Instance TTL requirements, weight timelock semantics, and the relationship between admin-gated calls and contract liveness.
- Scoring Specification — What the three scoring weights (vc, tx, repayment) actually control in the credit score formula.
Every proposal is a persisted struct with these fields (see DataKey::Proposal(u64) in the contract):
| Field | Type | Meaning |
|---|---|---|
id |
u64 |
Monotonically assigned at creation, starting from 1. |
proposed_weights |
ScoringWeights |
The weights that will be queued if the proposal passes. Must sum to exactly 100. |
votes_for |
i128 |
Accumulated registered voting weight cast in favor. Saturating add. |
votes_against |
i128 |
Accumulated registered voting weight cast against. Saturating add. |
expiry_ledger |
u32 |
Ledger sequence after which vote is rejected (ProposalExpired). Set to current_sequence + voting_period_ledgers at creation. |
execution_delay_ledgers |
u32 |
Extra ledgers after expiry_ledger that must pass before execute is allowed. Gives a reaction window independently of the credit-oracle timelock. |
executed |
bool |
Set to true the first time execute is called successfully; subsequent calls return ProposalAlreadyExecuted. |
quorum_required |
i128 |
Snapshot of QuorumRequired at proposal creation. A later set_quorum by the admin never changes the quorum of a proposal that is already up for a vote. Threshold is votes_for + votes_against >= quorum_required, checked at execute. |
Weight changes from a passing proposal are gated by two sequential waiting periods, enforced in different contracts, and require two separate transactions to complete. This is the Issue #8 behavior described as it actually works in the current code — several internal unit-test assertions assume the second timelock does not exist and are inconsistent with the real contract behavior (see §2.3).
| # | Timelock | Where enforced | Duration | Gates |
|---|---|---|---|---|
| 1 | Governance execution delay | Governance proposal field | Per-proposal, caller-chosen at create_proposal time via execution_delay_ledgers. Can be zero. |
governance.execute(proposal_id) |
| 2 | Credit-oracle weight timelock | TIMELOCK_LEDGERS = 17_280 constant inside credit-oracle |
Fixed at approximately 24 hours (17,280 ledgers at 5-second cadence). | credit-oracle.apply_weights(), which is called via governance.apply_weights() |
Full end-to-end timeline from proposal creation to active weights:
create_proposal(id=1, weights=W2, voting_period=VP, execution_delay=ED)
│
├─ current_sequence ─────────────────► proposal.expiry_ledger = seq + VP
│ │
│ Voting window: vote() accepted during this range │
│ ▼
│ seq == expiry + 1 ──► vote() now returns ProposalExpired
│ │
│ Governance execution-delay window ▼
│ seq == expiry + ED + 1 ──► execute() now allowed
│ │
└─ execute(id=1) │
└─ calls credit_oracle.propose_weights(W2) ▼
sets pending_weights.effective_ledger = seq + 17_280
│
▼
seq >= effective_ledger
│
▼
apply_weights() ──► W2 now active in get_scoring_weights()
Minimum total wall-clock time from proposal creation to active weights (with VP=0 and ED=0 and a 5-second ledger cadence):
17_280 ledgers × 5 seconds = 86_400 seconds = 24 hours
Any non-zero voting_period_ledgers or execution_delay_ledgers adds on top of that baseline.
The unit tests test_execution_timelock_delays_after_voting_ends inside contracts/governance/src/lib.rs and test_governance_execution_timelock_integration inside contracts/tests/src/integration_test.rs call execute() and then immediately read get_scoring_weights() to assert the new values, without waiting the additional 17,280 ledgers or calling apply_weights(). These assertions are inconsistent with the real contract behavior described in ADR-003 and implemented in both governance.execute (which calls propose_weights, not apply_weights) and credit-oracle.apply_weights (which panics timelock not expired before effective_ledger).
The accurate working test is test_governance_proposal_creation_voting_and_execution in the governance contract's own tests, which correctly:
- Calls
execute, checksget_pending_weights().is_some()on the credit-oracle, checks active weights are still default. - Advances 17,282 ledgers, extends instance TTL on both contracts.
- Calls
governance.apply_weights(). - Only then checks
get_scoring_weights()equals the proposal's values.
When reading tests, prefer the pattern in that test as the canonical flow. The inconsistent tests are a known documentation debt and should not be cited as evidence that weights activate immediately after execute.
- A voter has one global registered weight set by the admin (
DataKey::VoterWeight(Address)). - A separate per-proposal-per-voter counter tracks how much of that weight has been used (
DataKey::VoteWeightUsed(u64, Address)). - The available weight for a given voter on a given proposal is
total_weight - used_weight[proposal][voter]. vote_weight <= 0returnsInvalidVoteWeight.vote_weight > available_weightreturnsInsufficientVoteWeight.- Multiple partial votes across different
votetransactions are allowed, as long as the sum for a voter on a proposal does not exceed their registered total. - Weights are NOT locked across proposals: the same registered total is available independently per proposal (because
VoteWeightUsedis keyed by(proposal_id, Address)). A voter with weight 100 can vote 100 FOR on proposal 1 and 100 AGAINST on proposal 2 concurrently.
The public cancel(env, canceller, proposal_id, reason) entrypoint:
- Requires
canceller.require_auth()(any signer is accepted — the contract does not check that canceller is admin, proposer, or any specific role). - Emits a
PropCancevent with the canceller address and optional reason string. - Does not set any flag on the
GovernanceProposalstruct. - Does not prevent subsequent
votecalls. - Does not prevent subsequent
executecalls. - Does not refund or reset used vote weights.
It is currently an off-chain signaling hook only. Operators should not rely on it for on-chain cancellation guarantees.
All examples use the Soroban SDK test-style Address::generate and client pattern shown in the contract's own tests. On-chain calls via CLI or the TypeScript SDK are structurally identical: same function names, same argument order, same auth requirements.
Common setup shared by all examples below:
admin_addr = GADMIN... (initial governance admin)
credit_oracle_id = CCREDIT... (deployed credit-oracle, already initialized with admin_addr)
gov_id = CGOV... (deployed governance, not yet initialized)
proposer_addr = GPROP...
voter_A_addr = GVA... (to be registered with weight 1000)
voter_B_addr = GVB... (to be registered with weight 400)
Signature:
pub fn initialize(
env: Env,
admin: Address,
credit_oracle: Address,
quorum_required: i128,
) -> Result<(), GovernanceError>Auth: admin.require_auth().
Errors:
AlreadyInitializedif called more than once.InvalidQuorumifquorum_required <= 0.
Worked example — complete setup including two-step oracle admin transfer:
// 1. credit-oracle was already deployed and initialized:
credit_oracle_client.initialize(&admin_addr);
// Stores DataKey::Admin = admin_addr in credit-oracle instance storage.
// 2. Initialize governance, pointing at the credit-oracle and setting
// a default quorum of 1000 total vote-weight across all voters.
gov_client.initialize(&admin_addr, &credit_oracle_id, &1000);
// - Stores DataKey::Admin = admin_addr
// - Stores DataKey::CreditOracle = credit_oracle_id
// - Stores DataKey::NextProposalId = 1
// - Stores DataKey::QuorumRequired = 1000
// 3. Hand the credit-oracle's admin role to the governance contract address.
// Step A — the current credit-oracle admin (admin_addr) proposes governance:
credit_oracle_client.propose_new_admin(&gov_id);
// Stores DataKey::PendingAdmin = gov_id (credit-oracle side).
// admin_addr still retains full admin authority until step B runs.
// 4. Step B — governance admin triggers accept_oracle_admin:
gov_client.accept_oracle_admin();
// Internally calls credit_oracle.accept_admin(&gov_id).
// credit-oracle side:
// - reads PendingAdmin, checks it equals gov_id
// - writes DataKey::Admin = gov_id
// - removes DataKey::PendingAdmin
//
// After this call: the governance contract address is the sole admin of the
// credit-oracle. Only calls that originate from governance (cross-contract
// with caller = gov_id) will pass the require_auth() checks inside
// credit_oracle.propose_weights / register_feeder / upgrade / etc.Post-state sanity checks:
credit_oracle.get_admin()-equivalent behavior:propose_weightsrequires the caller (or cross-contract caller) to equal the stored admin; a direct call fromadmin_addrnow fails withNotAuthorizedbecause admin is nowgov_id.governance.get_quorum()returns1000.
Signature:
pub fn create_proposal(
env: Env,
proposer: Address,
weights: ScoringWeights,
voting_period_ledgers: u32,
execution_delay_ledgers: u32,
) -> Result<u64, GovernanceError>Auth: proposer.require_auth(). The proposer does not need to be a registered voter; any signer can create a proposal.
Errors:
InvalidWeightsifvc_weight + tx_weight + repayment_weight != 100.
Worked example — create a proposal shifting weights from tx to VC:
Default credit-oracle weights after initialization are (vc=40, tx=30, repayment=30). Propose (50, 20, 30), with a voting period of 14,400 ledgers (≈20 hours) and an execution delay of 1,000 ledgers (≈1.4 hours).
let new_weights = ScoringWeights {
vc_weight: 50,
tx_weight: 20,
repayment_weight: 30,
};
let proposal_id: u64 = gov_client.create_proposal(
&proposer_addr,
&new_weights,
&14_400, // voting_period_ledgers — ~20h at 5s/ledger
&1_000, // execution_delay_ledgers — extra reaction window after voting
);
// assert_eq!(proposal_id, 1); // NextProposalId started at 1
let p = gov_client.get_proposal(&proposal_id).unwrap();
assert_eq!(p.id, 1);
assert_eq!(p.proposed_weights.vc_weight, 50);
assert_eq!(p.votes_for, 0);
assert_eq!(p.votes_against, 0);
assert_eq!(p.execution_delay_ledgers, 1_000);
assert_eq!(p.quorum_required, 1_000); // snapshotted from default at creation
assert!(!p.executed);
// p.expiry_ledger == env.ledger().sequence() + 14_400Events: PropCreat(u64 id) with data (proposer, expiry_ledger).
Signature:
pub fn vote(
env: Env,
voter: Address,
proposal_id: u64,
vote_for: bool,
vote_weight: i128,
) -> Result<(), GovernanceError>Auth: voter.require_auth().
Errors:
InvalidVoteWeightifvote_weight <= 0.VoterNotRegisteredifDataKey::VoterWeight(voter)is missing.InsufficientVoteWeightifvote_weight > (total_weight - already_used_on_this_proposal).ProposalNotFoundifDataKey::Proposal(proposal_id)is missing.ProposalExpiredifenv.ledger().sequence() > proposal.expiry_ledger.ProposalAlreadyExecutedifproposal.executed == true.
Worked example — two voters, split vote, quorum exactly met:
// Admin registers voters before the vote window ends.
// (Registration can happen at any time — even mid-vote or after a proposal
// is created. The check is performed inside vote(), not create_proposal().)
gov_client.register_voter(&admin_addr, &voter_A_addr, &1000);
gov_client.register_voter(&admin_addr, &voter_B_addr, &400);
// Voter A votes FOR with all 1000 weight in a single call.
gov_client.vote(&voter_A_addr, &proposal_id, &true, &1000);
// Voter B votes AGAINST, splitting into two partial votes (60 + 340).
gov_client.vote(&voter_B_addr, &proposal_id, &false, &60);
// used_weight[(proposal_id, voter_B)] = 60; available = 400 - 60 = 340
gov_client.vote(&voter_B_addr, &proposal_id, &false, &340);
// used_weight[(proposal_id, voter_B)] = 400; available = 0
// Third vote by voter B would fail:
// gov_client.try_vote(&voter_B_addr, &proposal_id, &false, &1)
// → Err(Ok(GovernanceError::InsufficientVoteWeight))
let p = gov_client.get_proposal(&proposal_id).unwrap();
assert_eq!(p.votes_for, 1000);
assert_eq!(p.votes_against, 400);
// votes_for + votes_against = 1400 >= quorum_required (1000) ✓
// votes_for (1000) > votes_against (400) ✓ → will passEvents: Voted(u64 proposal_id) with data (voter, vote_for, vote_weight) — one event per vote call, even for partial votes on the same proposal.
Signature:
pub fn execute(env: Env, proposal_id: u64) -> Result<(), GovernanceError>Auth: none — permissionless. Any caller can trigger execute after the gates pass. The effect (changing weights) is gated by the conditions below, not by signer.
Errors (checked in order):
ProposalNotFoundProposalNotExpiredifsequence <= proposal.expiry_ledger. (Voting period still open.)TimelockNotExpiredifsequence <= proposal.expiry_ledger + execution_delay_ledgers. (Governance's own reaction window.)ProposalAlreadyExecutedQuorumNotMetifproposal.votes_for + proposal.votes_against < proposal.quorum_required.
Worked example — step 1 of 2 toward active weights:
Continuing the proposal above. expiry_ledger = seq_at_creation + 14_400; execution_delay = 1_000.
// Attempt 1 — during voting period (before expiry_ledger):
let res = gov_client.try_execute(&proposal_id);
assert_eq!(res, Err(Ok(GovernanceError::ProposalNotExpired)));
// Advance the ledger past voting AND past the governance execution delay:
env.ledger().with_mut(|l| l.sequence_number += 14_400 + 1_000 + 1);
// Now sequence > expiry_ledger AND sequence > expiry_ledger + execution_delay
// Attempt 2 — valid execute:
gov_client.execute(&proposal_id);
//
// Inside execute:
// 1. quorum check passes: 1400 >= 1000
// 2. votes_for (1000) > votes_against (400) → branch into apply path
// 3. loads credit_oracle_id from instance storage
// 4. CreditOracleClient::new(&env, &credit_oracle_id)
// 5. ⚠️ calls .propose_weights(&proposal.proposed_weights)
// NOT apply_weights.
// This STARTS the credit-oracle 17_280-ledger timelock.
// It does NOT change get_scoring_weights() return value.
// 6. proposal.executed = true, stored.
let p = gov_client.get_proposal(&proposal_id).unwrap();
assert!(p.executed);
// CRITICAL: active weights on credit-oracle are STILL the old values.
let still_active = credit_oracle_client.get_scoring_weights();
assert_eq!(still_active.vc_weight, 40); // unchanged default
assert_eq!(still_active.tx_weight, 30);
assert_eq!(still_active.repayment_weight, 30);
// The proposal values are now visible via get_pending_weights on the oracle:
let pending = credit_oracle_client.get_pending_weights().unwrap();
assert_eq!(pending.weights.vc_weight, 50);
assert_eq!(pending.weights.tx_weight, 20);
assert_eq!(pending.weights.repayment_weight, 30);
// pending.effective_ledger = sequence_now + 17_280What happens when a proposal fails the vote but quorum was met? If votes_for <= votes_against, execute skips the propose_weights call but still sets executed = true and emits the event. The proposal is finalized as rejected; no weights change, no credit-oracle timelock is started.
What happens if quorum is not met? execute returns QuorumNotMet, does NOT set executed = true. The proposal remains open for (re-)execution in the future — but there is no way to add votes, since vote rejects after expiry_ledger. In effect, a proposal that fails quorum is permanently stuck unexecuted. The cancel() stub cannot help here (see §2.5).
Events: PropExec(u64 id) with data (votes_for, votes_against).
Signature:
pub fn apply_weights(env: Env) -> Result<(), GovernanceError>Auth: none — permissionless. Any caller can finalize the credit-oracle's pending weights after its timelock expires. There is no proposal_id argument: there can be at most one set of pending weights in the credit-oracle at any given time, because credit-oracle.propose_weights overwrites PendingWeights and PendingWeightsEffectiveLedger each call.
Errors:
NotAuthorized(misleading error name) ifCreditOraclestorage key is missing. Returned asGovside only when governance was never initialized with a credit-oracle address. The actual credit-oracleapply_weightspanics with the string"timelock not expired"before the effective ledger and panics with"no pending weights"if nothing was proposed.
Worked example — step 2 of 2, weights finally go active:
// 17,280 ledgers ≈ 24 hours. Add a small safety margin of 2.
let jump = 17_280 + 2;
// ⚠️ Instance-TTL housekeeping — before jumping the ledger far ahead,
// extend instance storage TTL on BOTH contracts so the storage entries
// (PendingWeightsEffectiveLedger in credit-oracle, CreditOracle key in
// governance) don't get archived during the jump.
env.as_contract(&credit_oracle_id, || {
env.storage().instance().extend_ttl(jump, jump);
});
env.as_contract(&gov_id, || {
env.storage().instance().extend_ttl(jump, jump);
});
env.ledger().with_mut(|l| l.sequence_number += jump);
// Now anyone can call apply_weights:
gov_client.apply_weights();
let now_active = credit_oracle_client.get_scoring_weights();
assert_eq!(now_active.vc_weight, 50);
assert_eq!(now_active.tx_weight, 20);
assert_eq!(now_active.repayment_weight, 30);
// Pending state in credit-oracle has been cleared:
assert!(credit_oracle_client.get_pending_weights().is_none());Events: WtApplied with data (env.ledger().sequence()).
All three require admin.require_auth() AND verify the passed admin argument equals the stored DataKey::Admin (i.e., you can't pass an arbitrary authorized address — it has to be the actual stored admin).
| Function | Signature | Errors |
|---|---|---|
set_quorum |
(env, admin, quorum_required: i128) -> Result<()> |
InvalidQuorum if <= 0; NotAuthorized if caller mismatch. |
register_voter |
(env, admin, voter: Address, weight: i128) -> Result<()> |
InvalidVoteWeight if weight <= 0; NotAuthorized. |
update_voter_weight |
(env, admin, voter: Address, weight: i128) -> Result<()> |
InvalidVoteWeight if weight < 0; NotAuthorized. Passing weight = 0 deletes the VoterWeight key (same as deregister). |
deregister_voter |
(env, admin, voter: Address) -> Result<()> |
NotAuthorized. |
accept_oracle_admin |
(env) -> Result<()> |
NotAuthorized if gov admin missing or credit-oracle missing. Requires admin signer. |
Events:
VoterReg(voter_addr)with dataweightVoterUpd(voter_addr)with datanew_weightVoterDer(voter_addr)with unit data
Note on updating voter weights mid-proposal: If the admin calls update_voter_weight for a voter who has already partially voted on a proposal, the registered-total check in the next vote call uses the new total, but the used-weight counter is already persisted. This means:
- Lowering a voter's weight from 1000 to 200 after they already voted 600 on proposal 1 → subsequent
votecalls by that voter on that proposal will computeavailable = 200 - 600 = negative (saturates to 0 via i128 subtraction semantics that underflow in release — in practice, any furthervotereturnsInsufficientVoteWeightbecausevote_weight > available`). Already-cast votes (600) remain counted. - Raising a voter's weight after a partial vote grants them new available headroom for the same proposal.
Operators are advised against adjusting voter weights while proposals they have voted on are still open.
No auth, no side effects.
| Function | Signature | Returns |
|---|---|---|
get_quorum |
(env) -> i128 |
Default quorum for future proposals. Returns 0 if never set (shouldn't happen post-initialize). |
get_proposal |
(env, proposal_id: u64) -> Option<GovernanceProposal> |
Full GovernanceProposal struct copy, or None. |
get_voter_weight |
(env, voter: Address) -> Option<i128> |
Registered total weight, or None if not registered. |
get_vote_weight_used |
(env, proposal_id: u64, voter: Address) -> i128 |
Used weight on a specific proposal. 0 if never voted. |
get_available_vote_weight |
(env, proposal_id: u64, voter: Address) -> i128 |
total_weight - used_weight. 0 if not registered (total defaults to 0). |
Order is strict because governance initialization needs the credit-oracle's address, and the admin-transfer cannot run before both are initialized.
Step Contract Caller Action
──── ──────────────────── ───────────── ─────────────────────────────────────────────────────────
1 credit-oracle deployer deploy WASM, get contract id CC...
2 credit-oracle deployer initialize(initial_admin_addr)
3 governance deployer deploy WASM, get contract id CG...
4 governance initial_admin initialize(initial_admin, CC, quorum_default)
5 credit-oracle initial_admin propose_new_admin(CG) # admin transfer step A
6 governance initial_admin accept_oracle_admin() # admin transfer step B
7 governance initial_admin register_voter(admin, v1, w1)
8 governance initial_admin register_voter(admin, v2, w2)
9 governance initial_admin ...
At the end of step 6, confirm the transfer by attempting a credit-oracle admin-gated call from outside governance (it should fail with NotAuthorized) and from inside governance (it should succeed, as demonstrated by the accept_oracle_admin unit test calling propose_weights inside execute).
For operators running off-chain tooling or an indexer:
- Create.
create_proposal→ record the returnedproposal_idand theexpiry_ledgerfrom thePropCreatevent. - Register voters (before expiry). Any time is fine, but after expiry
votefails regardless of registration status. - Monitor voting. Call
get_proposal(id)and theVotedevents. Trackvotes_for,votes_against, and whether(for + against) >= quorum_required. - Wait two gates. Do not attempt
executeuntilsequence > expiry_ledger + execution_delay_ledgers. UseProposalNotExpired/TimelockNotExpiredas machine-checkable error codes for retry logic. - Call
execute. Anyone can call it. After success, checkcredit_oracle.get_pending_weights()— if the vote passed, the record should now exist; if failed or quorum-not-met, nothing is pending. - Wait the credit-oracle timelock. Roughly 24 hours.
credit_oracle.get_pending_weights().unwrap().effective_ledgergives the exact ledger number. - Call
apply_weights. Anyone can call it. After success, the new weights are visible viacredit_oracle.get_scoring_weights(). - Housekeeping. Periodically call any admin-gated governance function (e.g., a no-op-like
set_quorumwith the same value, or registering a voter that's already registered — the latter writes storage and bumps instance TTL) to ensure the governance contract's instance storage TTL stays healthy. Instance TTL is not extended byvote,execute, orapply_weightsin the current code because those functions do not write to instance storage; they write to persistent storage. Persistent storage has its own Soroban-managed default TTL.
All events use Soroban's (topics_tuple, data) shape with the first topic element being a short symbol:
| Topic 1 (symbol) | Extra topic | Data tuple | Emitted by |
|---|---|---|---|
PropCreat |
proposal_id: u64 |
(proposer: Address, expiry_ledger: u32) |
create_proposal |
Voted |
proposal_id: u64 |
(voter: Address, vote_for: bool, vote_weight: i128) |
vote |
PropExec |
proposal_id: u64 |
(votes_for: i128, votes_against: i128) |
execute |
WtApplied |
— | (ledger_sequence_when_applied: u32) |
apply_weights |
VoterReg |
voter: Address |
weight: i128 |
register_voter |
VoterUpd |
voter: Address |
weight: i128 |
update_voter_weight |
VoterDer |
voter: Address |
() (unit) |
deregister_voter |
PropCanc |
proposal_id: u64 |
(canceller: Address, reason: Option<String>) |
cancel (stub) |
Also subscribe to credit-oracle events for the downstream weight-change steps:
- Credit-oracle
WtPropwith data(vc, tx, repayment, effective_ledger)emitted bypropose_weights(called from governanceexecute). - Credit-oracle
WtApplywith data(vc, tx, repayment)emitted byapply_weights.
The governance contract is currently not exposed in packages/sdk/src/index.ts (as of the workspace state at time of writing). Score-reading methods are implemented; anchorDID, issueVC, and governance helpers are listed as open/planned. Integration from TypeScript today should use the generic SorobanRpc.Server + contract spec directly, calling the same function names and argument order documented above. The ScoringWeights struct type IS already exported by the SDK (per CHANGELOG #20), so callers can reuse it for proposal and pending-weight payloads.
- The governance
adminregisters/deregisters voters, sets voter weights, sets the default quorum, and triggersaccept_oracle_admin. - After
accept_oracle_adminruns, the governance contract address is itself the credit-oracle admin. This means governance admin = credit-oracle admin (transitive). - A malicious or compromised governance admin can:
- Register themselves (or sybil addresses) with arbitrarily high vote weight and push through any quorum.
- Deregister all opposing voters.
- Change the default quorum to 1 for future proposals.
- Call
credit_oracle.upgrade(admin, new_wasm_hash)via a cross-contract call from their admin position (they would need to add that call path — the current governance contract does not expose anupgradepassthrough, but the admin of the governance contract can deploy an upgrade TO governance itself through the normal Soroban upgrade flow if they have the wasm hash, and governance-as-credit-oracle-admin can thereafter callcredit_oracle.upgrade).
- This model is appropriate for testnet / initial trusted-steward deployment. A production DAO model requires token-weighted voting and an admin multisig or timelock on the governance admin itself.
cancel() emits an event but does not actually prevent vote or execute from running. Operators relying on a cancellation signal today must enforce it in the off-chain layer (e.g., by refusing to call execute). An attacker who can front-run the canceler can still vote and execute before any off-chain coordination completes.
- Reaction window #1 (governance
execution_delay_ledgers) starts when voting closes. Operators should set this to a non-zero value for mainnet proposals so the community can review the outcome of a contentious vote beforeexecutequeues the weights. - Reaction window #2 (credit-oracle 17,280 ledgers) is fixed and unavoidable even with
execution_delay_ledgers = 0. Users and integrators can inspectcredit-oracle.get_pending_weights()during this window and, if disagreeing, exit dependent positions. - There is NO
update_weightsbypass in the current credit-oracle code. Earlier documentation (docs/EPOCH_MODEL.md§5.2 and §8 table) mentions anupdate_weightsadmin bypass that was never merged intocredit-oracle/src/lib.rs. The code only exposespropose_weights→apply_weights. All weight changes, even admin-initiated, must wait the 17,280-ledger timelock. (The admin of the credit-oracle — i.e., the governance contract — can always deploy a new credit-oracle WASM viaupgrade, which changes behavior without going through the weight timelock. This is an orthogonal escape hatch.)
- If no admin-gated call is made on governance for longer than the instance TTL window (~30 days with the default pattern used in the other three contracts — see
EPOCH_MODEL.md), the governance contract's instance storage could be archived. If that happens beforeapply_weightsis called, theCreditOracleinstance key is lost. The pending weights inside the credit-oracle are still there (they live in credit-oracle's instance storage, managed by credit-oracle admin-gated calls), so an operator can callcredit-oracle.apply_weights()directly without going through governance. The governance contract being archived does not trap pending weights — but it does mean future proposals cannot be created until governance is redeployed and re-accepted as credit-oracle admin. - Moral: ensure at least one admin-gated call runs on governance every ~30 days. A cronned
set_quorum(admin, current_quorum_value)(no-op write) suffices.
IDs are assigned from NextProposalId, which starts at 1 and increments by 1 each create_proposal. The counter is stored in instance storage (not persistent), but the actual proposals are stored in persistent storage keyed by ID. There is no reverse index of proposer → proposal_ids or a paginated list; off-chain indexers should track PropCreat events to build a proposal list.
Proposals whose quorum fails at execute time are left in state with executed = false. They will never become executable (since votes can't be added after expiry_ledger), but they also aren't pruned. Storage for failed proposals persists for the persistent-storage TTL lifetime.
Vote totals (votes_for, votes_against) use saturating_add when accumulating. If a voter's weight is large enough that multiple votes reach i128::MAX, further votes silently stop adding to the counter. With admin-registered voter weights (not token supply based), reaching i128::MAX is practically impossible; the saturation is a defense-in-depth against overflow panics in release builds with overflow-checks off. (Note: the workspace Cargo.toml sets overflow-checks = true for release profiles, so the saturation is belt-and-suspenders.)
If the deployer calls credit_oracle.propose_new_admin(gov_id) in step 5A of the deployment sequence but step 6 (accept_oracle_admin) is never run:
- The credit-oracle's stored admin is still the original deployer address (unchanged until
accept_adminruns). PendingAdmin = gov_idis left dangling.- The original admin can overwrite it by calling
propose_new_admin(some_other_addr)again (overwritesPendingAdminin place). - Governance's
executewould fail insidepropose_weightsbecause the governance contract is not yet the stored admin.
Test before mainnet: after step 6, try credit_oracle.register_feeder(initial_admin, feeder). It should return NotAuthorized (authority was transferred away). If it succeeds, the transfer didn't happen.
Repeating the key point from §1 for visibility: there is no separate governor role or register_governor function in the credit-oracle today, despite older test snapshot filenames and the WEIGHTED_VOTING_DESIGN.md document using that language.
The actual control path is:
[Stored Admin: Address] ← single admin slot in credit-oracle instance storage
│
│ after setup: equals the governance contract address
▼
credit_oracle.propose_weights()
credit_oracle.register_feeder()
credit_oracle.register_lender()
credit_oracle.set_identity_oracle()
credit_oracle.propose_new_admin()
credit_oracle.upgrade()
→ all require stored_admin.require_auth()
How governance satisfies stored_admin.require_auth() in cross-contract calls: Soroban's auth model considers the calling contract address to be the authenticated principal when a contract invokes another contract (as opposed to the top-level EOA signer). When governance.execute does client.propose_weights(...), the credit-oracle sees the caller = gov_id — which IS the stored admin after the two-step transfer runs. This is why accept_oracle_admin is necessary: without it, the credit-oracle's stored admin is still the original EOA, and governance-initiated calls fail auth.
Why governance's execute calls propose_weights rather than applying weights immediately (ADR-003 from docs/architecture.md):
Early designs called
update_weights(which no longer exists) directly, bypassing the credit-oracle's built-in 24-hour reaction window.propose_weights+ a separateapply_weightsgives the community an auditable, non-bypassable pending-weight state during that window. Anyone watching the credit-oracle can see the exact weights that are about to activate, well in advance.
Issue #8 timelock interaction (accurate current behavior summary):
- Governance has its own post-vote execution delay (
execution_delay_ledgers). This is ONE wait. - After that delay clears,
governance.execute()queues weights in credit-oracle viapropose_weights(), which sets a fixed 17,280-ledger effective ledger. This is a SECOND wait. - Only after both waits and TWO function calls (
execute, thenapply_weights) do weights become active. - Operators, indexers, and downstream consumers should treat
executesuccess as "weights queued, pending timelock", not as "weights applied". The tests that conflate the two are the inconsistency documented in §2.3.