# Skill: participate (buy into a sale) — SPENDS FUNDS

Use when the user wants to contribute to a sale. **This spends the user's money. Respect the
budget as a hard ceiling. Simulate before sending.**

## Preconditions (all must hold, re-checked at action time)

```
v = Sale(sale).getSaleData()
require v.state == 1            # Active  (multi-phase: a gap between phases reads as Pending(0), not Active)
require v.deposited == true     # tokens funded, buys enabled
require now < v.endsAt
# hard-cap sellout: compare in the CAP UNIT (USD for oracle, quote otherwise — see below)
```

## Step 0 — resolve the active terms (pricing mode + phase) BEFORE sizing

Two dimensions change the price/caps. Resolve both first.

**Pricing mode** — `v.pricingMode`: `0 = FIXED` (caps in quote units), `1 = ORACLE_USD` (caps in USD,
in the feed's decimals; a Chainlink-compatible `v.priceFeed` converts quote→USD at buy time, tokens
sell at `v.tokenPriceUsd`). The buy `amount` is ALWAYS in the quote token either way; only the
pricing/cap UNIT differs.

**Phases** — call `Sale(sale).getPhases()`. If it returns a non-empty array the sale is multi-phase
(ORACLE_USD is single-phase, so phases is always empty in oracle mode). Resolve the ACTIVE phase:

```
phases = Sale(sale).getPhases()
if phases.length == 0:
    rate, cap, maxBuy, root = v.tokensPerQuoteUnit, v.hardCap, v.maxBuy, v.merkleRoot   # flat config
else:
    # CLOSED intervals [startsAt, endsAt]; the earlier phase wins a shared boundary second.
    ap = first p in phases where p.startsAt <= now <= p.endsAt   # else you are in a gap → state is Pending, do not buy
    rate, cap, maxBuy, root = ap.tokensPerQuoteUnit, ap.phaseCap, ap.maxBuy, ap.merkleRoot
    # cap is CUMULATIVE (a high-water mark on total raised), strictly increasing per phase; last == hardCap
# minBuy stays global (v.minBuy) in every mode/phase.
```

## Size the buy correctly

Let `you` = the user's address, `amount` = quote tokens to spend (raw, in quote decimals).

**FIXED mode (single- or multi-phase)** — everything is in quote units:
```
room_to_cap = cap - v.totalRaised                    # active-phase cumulative cap
room_to_max = maxBuy - contributed[you]              # active-phase per-wallet (cumulative)
amount = min(user_budget, room_to_cap, room_to_max)
require contributed[you] + amount >= v.minBuy         # else "Below min"
require amount > 0
expected_tokens = amount * rate / v.quoteDecimalScale
require expected_tokens > 0
```

**ORACLE_USD mode** — caps are USD; convert with the live feed (read it the same way the contract does):
```
(roundId, answer, , updatedAt, answeredInRound) = AggregatorV3(v.priceFeed).latestRoundData()
require answer > 0 and answeredInRound >= roundId and now - updatedAt <= v.maxOracleDelay  # else buy reverts "Oracle ..."
usd_of(a)  = a * answer / v.quoteDecimalScale         # USD in feed decimals
room_to_cap_usd = v.hardCap - v.totalRaisedUsd
room_to_max_usd = v.maxBuy  - contributedUsd[you]
# pick a quote amount whose USD value fits both rooms and the budget:
amount = min(user_budget, room_to_cap_usd * v.quoteDecimalScale / answer, room_to_max_usd * v.quoteDecimalScale / answer)
require contributedUsd[you] + usd_of(amount) >= v.minBuy    # USD min, else "Below min"
require amount > 0
expected_tokens = usd_of(amount) * saleTokenScale / v.tokenPriceUsd   # saleTokenScale = 10**saleToken.decimals()
require expected_tokens > 0
```
The oracle price moves between blocks — re-read the feed immediately before sending and simulate.

If `amount` had to be clipped below what the user asked, tell them why (cap/maxBuy/budget) before proceeding.

## Whitelist (use the ACTIVE phase's root)

```
if root != 0x0:                 # `root` from Step 0 — the active phase's root in multi-phase, else v.merkleRoot
    # Obtain the project's published allowed-address list FOR THIS PHASE, then derive the proof for `you`.
    # The tree format matches OpenZeppelin / Sale._verifyProof:
    #   leaf = keccak256(bytes.concat(keccak256(abi.encode(addr))))   # addr abi-encoded, double-hashed
    #   parent = keccak256(sorted(a, b))                              # sorted-pair
    #   leaves are de-duped and sorted before building; odd node is promoted
    # Reuse ../../frontend/merkle.js (Node-compatible): Merkle.getProof(list, you).
    # Verify locally before sending: Merkle.buildRoot(list) == root AND Merkle.verify(proof, root, you).
    # If the list doesn't reproduce the root, you have the wrong/altered list (or the wrong phase) — stop.
    proof = Merkle.getProof(list, you)
    # you CANNOT forge this; the leaf is bound to msg.sender, so borrowing another member's
    # proof reverts "Not whitelisted". Each phase can have a DIFFERENT list — use the active phase's.
else:
    proof = []   # empty bytes32[]  (open phase / open sale)
```

## Execute

### Native quote (`v.quoteToken == 0x0`)

```
calldata = sale.buy(amount, proof)
simulate: eth_call {from: you, to: sale, value: amount, data: calldata}  # must not revert
send:     tx {to: sale, value: amount, data: calldata}
```

### ERC20 quote

```
# 1) ensure allowance
if erc20(v.quoteToken).allowance(you, sale) < amount:
    approve_tx = erc20(v.quoteToken).approve(sale, amount)   # or exact amount; avoid infinite unless asked
    send approve_tx; wait for receipt

# 2) buy (value MUST be 0)
calldata = sale.buy(amount, proof)
simulate: eth_call {from: you, to: sale, value: 0, data: calldata}        # must not revert
send:     tx {to: sale, value: 0, data: calldata}
```

> Fee-on-transfer / rebasing quote tokens are rejected by the contract ("Fee-on-transfer
> quote unsupported"). If the quote token has a transfer tax, the buy will revert — report it.

## Post-checks (confirm success, prevent double-spend)

```
receipt = wait(tx)            # status == 1
v2 = getSaleData()
new_contrib  = contributed[you]    # quote — should have increased by `amount` (refund/withdraw unit, both modes)
new_purchased = purchased[you]     # should have increased by ~expected_tokens
# ORACLE_USD only: contributedUsd[you] and v2.totalRaisedUsd should have increased by ~usd_of(amount)
```

Report: tx hash, block, quote spent (human + raw), tokens credited, new per-wallet
contribution, and remaining room to the active phase's `maxBuy`/cap (in USD for oracle mode).

## Idempotency / retries

Before any retry, re-read `contributed[you]`. If it already reflects the intended amount,
**do not resend** — the first tx landed. Never fire a second buy "to be safe".

## What you are NOT buying yet

Tokens are **not** transferred at buy time. You acquire a vested *claim* recorded in
`purchased[you]`. Tokens are claimable only after the sale finalizes and claims open — see
`skills/claim-refund.md`. If the sale fails, the contribution is refundable.
