The core economic mechanism that determines who gets business.
Status: Phase 0 Testing (Testnet validation required)
Last Updated: 2026-08-07
Update (2026-09-21) — fee, value & filtering (supersedes parts below).
- Fee: the ranking divides by the seller’s declared current fee (carried on the listing), not
fee_actual_last_tx. The sender enforces it — before paying, the client blocks a quote that exceeds the listing’s fee. (An on-chain copy of the fee arrives with the Phase-0+ anchor.)- Trade value (
avg_value_eur): read from the covenant’s face (eurCents, revealed when the covenant settles) — not a BCH amount valued at today’s price (that would inflate old trades; “the pizza problem”).- Payment method is the filter: a recipient who picks “Cash in person” sees only the ads that buy BCH for cash.
- Completion filter: see the note under Step 1 — a completed trade is one the seller was able to fund; refunds/aborts are not the seller’s fault.
When María wants to send €100 to Elena, the bulletin board returns 50+ sellers who accept Bizum. How do we rank them so the best seller appears first?
Competing goals:
Key constraint: We can’t let users choose priority (e.g., “cheapest first” or “most experienced”). That creates decision paralysis and allows users to select bad sellers. The algorithm must protect users from themselves.
Only show sellers who meet minimum standards:
const qualified = sellers.filter(s =>
s.total_transactions >= 50 && // Proven track record
s.completed_transactions / s.total_transactions >= 0.85 && // 85%+ completion rate
s.fee_percent >= 0.3 && // No artificially low fees (gaming)
s.fee_percent <= 2.0 && // No predatory fees
s.last_activity < 86400 && // Active in last 24 hours
s.transaction_bracket[0] <= amount && // Can handle this amount
s.transaction_bracket[1] >= amount
);
Why these filters:
completed/total rate is vacuous (and penalizes honest sellers for refunds/aborts they don’t control). Rank the seller on volume / distinct counterparties / longevity; treat the outcome mix (refund/abort vs claim) as a fraud signal, not a quality gate.Score formula:
score = (total_transactions + avg_value_eur) / fee_percent // fee_percent = declared current listing fee
Sort descending (highest score first).
Why this formula:
Critical detail: Uses the seller’s declared current fee (carried on the listing). The sender enforces it — before paying, the client blocks any quote that exceeds the listing’s fee. (An on-chain copy of the exact fee arrives with the Phase-0+ anchor; see “Fee Enforcement” below.)
const topSellers = qualified
.map(s => ({ ...s, score: (s.total_transactions + s.avg_value_eur) / s.fee_percent }))
.sort((a, b) => b.score - a.score)
.slice(0, 5);
return {
selected: topSellers[0], // Auto-select best
alternatives: topSellers // Show all 5 for comparison
};
Key insight: there is no on-chain reputation UTXO — reputation is derived (Reputation On-Chain), and the ranking divides by the seller’s declared current fee, carried on the listing.
Why it can’t be gamed — the sender enforces it: the client checks the fee before the sender pays fiat. A quote that exceeds the listing’s fee is blocked, so the declared fee is what actually binds (a seller cannot advertise 0.3 % and charge 0.8 %).
⚠️ Historical (superseded 2026-09-21). The subsections below (How It Works → Optional Safeguard) describe the earlier on-chain-UTXO design — a reputation UTXO spent-and-recreated in the funding tx, tracking
fee_actual_last_tx. That mechanism was dropped; it is kept here for the decision trail. The anti-gaming reasoning (why enforcing the declared fee is hard to game) still holds — via sender enforcement, not an on-chain UTXO.
When a seller funds a covenant, their reputation updates in the SAME transaction:
Isabel funds María's covenant:
- Amount: €100
- Fee charged: 0.5%
- Total payment received: €100.50
Same transaction:
1. Spends old reputation UTXO (1234 txs, 0.4% fee)
2. Creates new reputation UTXO with updated stats:
- total_transactions: 1234 → 1235
- completed_transactions: 1209 → 1210
- avg_value_eur: recalculated
- fee_actual_last_tx: 0.5% ← ACTUAL fee charged!
The reputation update “piggybacks” on the covenant funding transaction (~€0.0002 cost, adds ~180 bytes to an already-necessary transaction).
Source: Reputation On-Chain
Scenario: Seller tries to game ranking
Step 1: Isabel sets listing fee to 0.3% (to rank high)
Step 2: María selects Isabel (ranked #1 due to low fee)
Step 3: María pays €100.50 via Bizum (actual fee: 0.5%)
Step 4: Isabel's bot funds covenant
Step 5: Reputation updates with fee_actual_last_tx: 0.5%
Step 6: Next user queries bulletin board
Step 7: Ranking now uses 0.5% (not 0.3%)
Step 8: Isabel drops to #3 in rankings
Result: Can’t claim low fee but charge high. Reputation updates with truth.
Could a seller fund their own covenant with a fake low fee to boost ranking?
Attacker creates two accounts:
- Alice#123 (seller, bank account 1)
- Bob#456 (fake buyer, bank account 2, same person)
Bob "buys" from Alice at 0.1% fee (100 times)
→ Alice's ranking skyrockets (low fee!)
But: Fraud detection catches this upstream
→ Payment detail matching (phone/IBAN/name hashing)
→ If 2/3 fields match → self-dealing detected
→ Transactions don't count toward reputation
Source: Fraud Protection
Bottom line: Can’t game fee_actual_last_tx without REAL users accepting your fee.
Fee updates are “staged” - they only change with real transactions:
Isabel wants to drop fee from 0.8% → 0.3% to rank higher
Old approach (claimed fee):
- Update listing: fee_percent = 0.3%
- Ranking immediately uses 0.3%
- Next user sees 0.3%, but gets charged 0.8%
- User complains, trust broken
New approach (actual fee):
- Update listing: fee_percent = 0.3% (claim only)
- Ranking still uses fee_actual_last_tx = 0.8%
- Next user pays 0.3% (accepting the new fee)
- Covenant funded, reputation updates: fee_actual_last_tx = 0.3%
- Ranking now uses 0.3%
- Fee change is REAL, not fake
Fee can only change as fast as real users accept it.
If rapid fee changes become a gaming vector, we can add rate limiting:
// Reject reputation update if fee swings too much
const fee_change = Math.abs(fee_actual_last_tx - fee_previous_tx);
if (fee_change > 0.1) {
// Flag as suspicious OR reject update
// Prevents jumping 0.8% → 0.3% in one transaction
}
But we probably DON’T need this because:
Decision point: Test in Phase 0. Add rate limiting only if gaming attempts succeed.
Input data:
| Seller | Fee | Total Txs | Avg Value | Completion Rate |
|---|---|---|---|---|
| Isabel | 0.5% | 1,234 | €145 | 98% (1,209/1,234) |
| Carlos | 0.4% | 456 | €145 | 95% (433/456) |
| Ana | 0.4% | 890 | €135 | 92% (819/890) |
| Pepe | 0.8% | 892 | €120 | 88% (785/892) |
| Felipe | 0.7% | 351 | €110 | 84% (295/351) |
Scoring:
Isabel: (1234 + 145) / 0.5 = 1379 / 0.5 = 2758
Carlos: (456 + 145) / 0.4 = 601 / 0.4 = 1502.5
Ana: (890 + 135) / 0.4 = 1025 / 0.4 = 2562.5
Pepe: (892 + 120) / 0.8 = 1012 / 0.8 = 1265
Felipe: FILTERED OUT (84% < 85% completion rate)
Rankings:
Analysis:
Input data:
| Seller | Fee | Total Txs | Avg Value | Completion Rate |
|---|---|---|---|---|
| Isabel | 0.5% | 1,234 | €145 | 98% |
| Scammer | 10.0% | 5,000 | €200 | 90% |
Scoring:
Isabel: (1234 + 145) / 0.5 = 2758
Scammer: FILTERED OUT (10.0% > 2.0% max fee)
Result: Scammer doesn’t appear in results. Users protected.
Input data:
| Seller | Fee | Total Txs | Avg Value | Completion Rate |
|---|---|---|---|---|
| Isabel | 0.5% | 1,234 | €145 | 98% |
| Newcomer | 0.1% | 10 | €80 | 100% (10/10) |
Scoring:
Isabel: (1234 + 145) / 0.5 = 2758
Newcomer: FILTERED OUT (10 txs < 50 minimum)
Result: Newcomer can’t game ranking with artificially low fee. Needs to build track record first.
Input data:
| Seller | Fee | Total Txs | Avg Value | Completion Rate |
|---|---|---|---|---|
| High Volume | 0.6% | 3,000 | €200 | 95% |
| Low Volume | 0.4% | 1,000 | €150 | 95% |
Scoring:
High Volume: (3000 + 200) / 0.6 = 3200 / 0.6 = 5333.3
Low Volume: (1000 + 150) / 0.4 = 1150 / 0.4 = 2875
Result: 3x volume overcomes 50% higher fee. Rewards liquidity providers.
How much does a 0.1% fee difference matter?
| Seller | Fee | Total Txs | Avg Value | Score |
|---|---|---|---|---|
| Seller A | 0.4% | 1,000 | €150 | 2,875 |
| Seller B | 0.5% | 1,000 | €150 | 2,300 |
Difference: 2875 - 2300 = 575 (20% score difference)
Analysis: A 0.1% fee difference (25% more expensive: 0.5 vs 0.4) creates a ~20% score difference. Fee matters, but doesn’t dominate.
1. Sort by fee only (cheapest first)
2. Sort by volume only (most experienced first)
3. Weighted ELO score with user-configurable weights
4. Simple addition: fee + total_txs + avg_value
5. Subtraction: (total_txs + avg_value) - (fee × multiplier)
Division naturally scales the problem:
1000 / 0.3 = 33331000 / 2.0 = 500Fee acts as a divisor (penalty), volume acts as numerator (reward).
No arbitrary constants needed. The market determines the balance.
Scenario: Seller sets fee = 0% (free).
Mitigation: Minimum fee filter (0.3%) prevents this.
Why 0.3% minimum:
Scenario: All sellers converge on 0.5% fee (market standard).
Result: Ranking becomes total_txs + avg_value (pure volume).
Is this okay? Yes! If fees are identical, volume/experience should decide. Formula degrades gracefully.
avg_valueScenario: Seller A handles €50 transactions (1000 txs, €50 avg). Seller B handles €500 transactions (100 txs, €500 avg).
Seller A: (1000 + 50) / 0.5 = 2100
Seller B: (100 + 500) / 0.5 = 1200
Result: Seller A ranks higher (more transactions).
Is this okay? Yes! 1000 small txs is more reliable than 100 large txs. Demonstrates consistent liquidity.
Scenario: Scammer creates 2 accounts, trades with themselves 500 times to inflate total_transactions.
Mitigation (already documented in fraud protection):
Formula doesn’t need to handle this — fraud prevention layer handles it upstream.
Unknown: Does this formula produce fair rankings in practice?
Test 1: Seed Reputation Data
Test 2: Run Ranking Algorithm
Test 3: User Testing
Test 4: Gaming Attempts
Test 5: Market Simulation
Current:
To test:
Validation: Track real seller costs, adjust minimums accordingly.
Hypothesis: Market pressure will push all sellers toward 0.4-0.6% (lowest sustainable fee).
Test: Monitor fee distribution over 3 months. Do sellers race to bottom? Cluster around a value?
If yes: Formula works as intended (competitive pricing).
If no: Why? Are there segments (high-volume sellers charge less, boutique sellers charge more)?
Hypothesis: A new seller with 50 txs @ 0.3% fee can compete with established seller (1000 txs @ 0.5%).
Math:
New: (50 + 100) / 0.3 = 500
Established: (1000 + 150) / 0.5 = 2300
Result: Established seller wins by 4.6x.
Is this fair? Should new sellers get a boost? Or is 4.6x gap appropriate (trust and track record matter)?
Test: Survey new sellers. Do they feel ranking is fair? Can they get customers?
Hypothesis: avg_value_eur signals capacity and reliability (handles large transactions).
Concern: Does this unfairly penalize sellers who serve small transactions?
Example:
Small-tx seller wins. Is this correct? Or should we weight avg_value higher?
Test: Do large-tx senders complain about being matched with small-tx sellers? Does capacity matter?
Currently not in formula. Should it be?
On-chain data available:
Possible formula:
score = (total_transactions + avg_value_eur) / (fee_percent × avg_response_minutes)
Problem: Overcomplicates. Response time already shown in UI (“⏱️ <2 min”). Does it need to affect ranking?
Test: Do users override #1 for faster response? How often?
| Metric | Target | Why It Matters |
|---|---|---|
| Auto-select trust rate | >80% pick #1 | Users trust the algorithm |
| Filter effectiveness | 100% gaming blocked | No scammers/newcomer exploits |
| Fee convergence | 0.4-0.6% cluster | Competitive market pricing |
| New seller entry rate | >5 new/month reach 50 txs | Market stays open |
| Completion rate | >90% avg for top 5 | Quality filter works |
| User complaints | <5% report “bad ranking” | Algorithm matches user expectations |
Timeline: 3 months testnet + 3 months mainnet Phase 0.
Iterate: Adjust filter values and formula weights based on data.
The scoring inputs are derived, not stored as an on-chain stats UTXO:
{
total_transactions: 1234, // derived from settled covenants
avg_value_eur: 145.80, // average covenant face (eurCents), not a BCH amount
fee_percent: 0.5, // declared current fee on the listing — USED for ranking
last_activity // from the listing's Nostr heartbeat / DM timestamps
}
Source: Reputation On-Chain — reputation is a derived view, not an on-chain record.
Update frequency: the listing’s fee_percent is the seller’s declared current fee (updated in the listing); trade count/value are derived when covenants settle. (The earlier fee_actual_last_tx + stats-UTXO piggyback is superseded — see “Fee Enforcement” above.)
The app calculates scores locally (not on-chain):
⚠️ Discovery mechanism (2026-09-21):
electrum.querySellers(...)below is illustrative — sellers are discovered from the Nostr bulletin board (NIP-99kind:30402), not by on-chain enumeration. The filtering and scoring logic is correct; only the source of the seller list differs.
// 1. Query the bulletin board index for candidate sellers (mechanism under review)
const sellers = await electrum.querySellers({
payment_methods: ["bizum"],
mode: ["seller"]
});
// 2. Filter locally
const qualified = sellers.filter(/* filters above */);
// 3. Score locally
qualified.forEach(s => {
s.score = (s.total_transactions + s.avg_value_eur) / s.fee_percent;
});
// 4. Sort locally
qualified.sort((a, b) => b.score - a.score);
// 5. Display top 5
return qualified.slice(0, 5);
Why client-side: No central server. Every app recalculates ranking independently. Trustless.
During Phase 0 testing, we may A/B test alternatives:
score = (total_transactions + avg_value_eur) / fee_percent
score = total_transactions + avg_value_eur - (fee_percent × 500)
score = (total_transactions × 2) + (avg_value_eur × 1) + ((2.0 - fee_percent) × 1000)
// Filter out fee > 0.6%, then sort by volume
if (fee_percent <= 0.6) {
score = total_transactions + avg_value_eur;
}
Testnet validation will determine which formula produces fairest results.
| 🏠 Home | ↑ Bulletin Board | 📖 Glossary |
Status: Phase 0 Testnet Validation
Confidence: Medium (needs real-world testing)
Next: Run Test Scenarios 1-5 on testnet, iterate based on data
Last Updated: 2026-08-06