We Taught a Snowflake Warehouse to Judge World Cup Conviction and Write the Verdict Back to Solana
DEV Community

We Taught a Snowflake Warehouse to Judge World Cup Conviction and Write the Verdict Back to Solana

We Taught a Snowflake Warehouse to Judge World Cup Conviction and Write the Verdict Back to Solana

This is a submission for Weekend Challenge: Passion Edition. Target categories: Best use of Snowflake + Best use of Solana.

The single most common mistake in cross-platform hackathon projects is using one platform as a database and the other as a logo. A dashboard that reads Solana data through an API and renders it in a UI is not a Solana project. It's a dashboard.

The actual first question is: what can each platform do that the other physically cannot? Solana has behavioral data that exists nowhere else - every buy, sell, and hold, public and permanent. But a blockchain cannot compute aggregates over its own history; that's not what the runtime is for. Snowflake can chew through millions of decoded transactions with a declarative SQL DAG - but it has no way to make a statement the rest of the on-chain world can read and build on.

So I built the loop that gives each side the other's missing half: Solana (mainnet) --read--> Snowflake --compute--> Snowflake --write back--> Solana (devnet) live swaps and streaming conviction, an oracle account transfers, decoded ingest + Dynamic scoring + Cortex, any program can read by Helius Tables DAG ML.

FERVOR: A Real-Time Conviction Oracle

FERVOR is a real-time conviction oracle for World Cup fandom on Solana. It tracks sixteen national token communities from the 2026 field - Argentina to Japan, Morocco to Canada - wired into eight derby rivalries (ARG-BRA, FRA-ENG, POR-ESP, GER-NOR, USA-MEX, NED-BEL, CRO-MAR, JPN-CAN), and it does not measure hype, because following a crowd is cheap.

It measures conviction: the wallet that keeps buying its country's token while the price bleeds, the holder who never sells through a losing run. Passion, defined mechanically: holding when it hurts.

Every 5 minutes, a Snowflake task stages the freshly computed index and a signing bridge writes it to Solana devnet as a confirmed transaction:

{
  "oracle": "FERVOR",
  "source": "Snowflake MARTS.TEAM_FERVOR_INDEX",
  "team_id": 1,
  "team": "ARGENTINA",
  "fervor_index": 290,
  "momentum": 4
}

That JSON is Snowflake output living on a block explorer. And this is the league it produces - real numbers from the running system: Argentina leads on conviction (109 average days held), Japan is the surprise runner-up, England has the deepest bench of believers, and Norway folds under pressure. The chain said so.

The Architecture, Actually Explained

Six stages. Every stage does load-bearing work; there is no component whose only job is to name-drop a technology.

Stage Tech Latency What it does
1 INGEST Node worker + Helius enhanced transactions 8 s batches Polls decoded mainnet activity for the 16 tracked country tokens
2 LAND micro-batch inserts seconds Raw VARIANT JSON into RAW.SOLANA_TX_LANDING
3 TRANSFORM Snowflake Dynamic Tables 1 min lag 7-table declarative DAG: decode, price, position, score
4 INTELLIGENCE Snowflake Cortex 5 min task ANOMALY_DETECTION, FORECAST, COMPLETE
5 WRITE-BACK Node signing bridge + Anchor 5 min task Queue staged in SQL, signed and confirmed on devnet
6 SERVE Streamlit-in-Snowflake live 8-tab analytics app: predicted movers, a live World Cup panel, AI briefs, zero external hosting

One database, six schemas, data flowing strictly left to right:

Schema Purpose Key objects
REF hand-loaded seed TEAM_TOKENS (mint, decimals, rival_team_id), PARAMS
RAW untouched VARIANT landing SOLANA_TX_LANDING
STAGING decoded events (Dynamic Tables) TOKEN_TRANSFERS, SWAP_EVENTS, PRICE_TICKS
MARTS conviction metrics (Dynamic Tables) WALLET_POSITIONS, WALLET_FERVOR, TEAM_FERVOR_INDEX, DEFECTION_FLOWS
ML Cortex outputs + time series TEAM_INDEX_HISTORY, FERVOR_ANOMALIES, FERVOR_FORECAST
ORACLE write-back queue + audit PUBLISH_QUEUE, PUBLISH_LOG

Stage 1: Ingest - A Cursor Trick That Makes Polling Feel Like Streaming

Helius decodes raw Solana transactions into clean JSON, so I ingest structured events instead of byte soup. The worker polls each tracked mint every 8 seconds, but the until cursor means each poll returns only transactions newer than the last one seen. Functionally, it's a stream:

// ingest/poller.ts - the cursor is the whole trick
const cursor = new Map<string, string>(); // address -> newest seen signature

async function fetchNewTxs(address: string): Promise<EnhancedTx[]> {
  const until = cursor.get(address);
  const url =
    `https://api.helius.xyz/v0/addresses/${address}/transactions` +
    `?api-key=${HELIUS_API_KEY}&limit=50` +
    (until ? `&until=${until}` : "");
  const res = await fetch(url);
  const txs = (await res.json()) as EnhancedTx[];
  if (txs.length > 0) cursor.set(address, txs[0].signature); // newest first
  return txs;
}

Landing is a multi-row INSERT ... SELECT PARSE_JSON(?) through the Node connector. Honest note: true Snowpipe Streaming needs the Java ingest SDK. 8-second micro-batches demo identically (rows appear in Snowflake seconds after they land on-chain) and were far more reliable to stand up in a weekend.

Stage 3: The Dynamic Tables DAG - No Cron, Declared Lag

I did not write a single scheduler for the transform layer. Each table declares TARGET_LAG = '1 minute' and Snowflake works out the refresh order:

RAW.SOLANA_TX_LANDING
  |- STAGING.TOKEN_TRANSFERS ----> MARTS.DEFECTION_FLOWS
  |- STAGING.SWAP_EVENTS -------> STAGING.PRICE_TICKS
    |- MARTS.WALLET_POSITIONS
      |- MARTS.WALLET_FERVOR ----> MARTS.TEAM_FERVOR_INDEX
        |- (1 min task) ML.TEAM_INDEX_HISTORY
          |- (5 min task) Cortex models
            |- (5 min task) ORACLE.PUBLISH_QUEUE

The result is a per-minute index series where each of the sixteen nations charts its own story.

Two pieces of the transform SQL earned their place the hard way.

Decoding BUY/SELL without per-DEX parsers. Helius does not emit a parsed swap event for every venue, so I classify from token-balance movement: fee payer receives the tracked token = BUY, sends it = SELL. Routed swaps get their legs summed, and self-arbitrage transactions (fee payer both sends AND receives the same token in one tx) are dropped entirely - an arb bot carries zero conviction signal:

-- warehouse/02_staging_dt.sql (excerpt)
team_legs AS (
  SELECT
    l.signature,
    MIN(l.block_time) AS block_time,
    l.raw_payload:feePayer::string AS wallet,
    tt.value:mint::string AS mint,
    r.team_id,
    IFF(
      tt.value:toUserAccount::string = l.raw_payload:feePayer::string,
      'BUY', 'SELL'
    ) AS side,
    SUM(tt.value:tokenAmount::float) AS token_amount
  FROM dedup l,
  LATERAL FLATTEN(input => l.raw_payload:tokenTransfers) tt
  JOIN REF.TEAM_TOKENS r ON r.mint = tt.value:mint::string
  WHERE
    tt.value:toUserAccount::string = l.raw_payload:feePayer::string
    OR tt.value:fromUserAccount::string = l.raw_payload:feePayer::string
  GROUP BY
    l.signature,
    l.raw_payload:feePayer::string,
    tt.value:mint::string,
    r.team_id,
    side
  -- self-arb filter: a signature with BOTH a BUY and a SELL row for the
  -- same token makes this window count 2, and the tx is excluded
  QUALIFY COUNT(*) OVER (PARTITION BY l.signature, r.team_id) = 1
)

The 24-hour price change that survives trading gaps. The naive version is LAG(price, 24) over hourly buckets. That counts ROWS, not HOURS - one quiet hour and your "24h change" silently becomes a 25h change. ASOF JOIN matches each tick to the nearest tick at or before 24 hours earlier, with a warm-up fallback for series younger than a day:

-- warehouse/02_staging_dt.sql (excerpt)
SELECT
  cur.team_id,
  cur.window_start,
  cur.price_usd,
  100.0 * (
    cur.price_usd - COALESCE(day_ago.price_usd, first_tick.price_usd)
  ) / NULLIF(COALESCE(day_ago.price_usd, first_tick.price_usd), 0)
    AS price_change_24h_pct
FROM hourly cur
ASOF JOIN hourly day_ago
  MATCH_CONDITION(
    DATEADD('hour', -24, cur.window_start) >= day_ago.window_start
  )
  ON cur.team_id = day_ago.team_id
LEFT JOIN (
  -- warm-up: earliest tick, for series younger than 24h
  SELECT team_id, price_usd
  FROM hourly
  QUALIFY ROW_NUMBER() OVER (PARTITION BY team_id ORDER BY window_start) = 1
) first_tick ON first_tick.team_id = cur.team_id;

Prices use the hourly median, not VWAP - SOL-leg attribution is heuristic, and one mis-attributed leg destroys a volume-weighted mean. The dips these series capture are the entire thesis.

The Conviction Score - The Creative Payload

Per wallet, per country, 0 to 100:

Component Max points What it rewards
25 x ln(1 + hold_days) / ln(366) 25 longevity of the relationship
30 x min(1, dip_buys / 5) 30 buying while THEIR token bled 5%+ in 24h
25 x min(1, underwater_buys / 5) 25 buying below their own average cost
-20 x (sold within 3 days) -20 punishes paper hands
-- warehouse/03_marts_dt.sql (excerpt)
ROUND(
  GREATEST(0, LEAST(100,
    25 * LN(1 + a.hold_duration_days) / LN(1 + 365)
    + 30 * LEAST(1, COALESCE(c.buy_against_decline, 0) / 5.0)
    + 25 * LEAST(1, COALESCE(c.buy_at_loss, 0) / 5.0)
    - 20 * IFF(a.last_sell_time > DATEADD('day', -3, SYSDATE()), 1, 0)
  )), 1
) AS fervor_score

Two engineering decisions worth stealing:

  • Tenure in fractional days (DATEDIFF('second', ...) / 86400.0). Whole-day granularity freezes longevity at zero for 24 hours and flat-lines the index on launch day.
  • Zero-signal wallets are excluded from the national average. The raw swap feed is dominated by one-shot MEV bots whose score is 0 by construction. Averaging them in measures bot traffic, not the fan base:
ROUND((
  COALESCE(AVG(IFF(f.fervor_score > 0, f.fervor_score, NULL)), 0) * 0.6
  + LEAST(100, COUNT_IF(f.fervor_score >= 60) / 10.0) * 0.4
) * 10, 0) AS fervor_index -- 0 to 1000, this exact number goes on-chain

And the emotional centerpiece, MARTS.DEFECTION_FLOWS: the same wallet selling one country and buying another within 24 hours. Selling ARGENTINA to buy BRAZIL is not a portfolio rebalance. It is treason, and it gets its own Sankey.

Stage 4: Cortex - ML Without Leaving SQL, Plus the Gotcha That Cost an Hour

Three models, retrained every 5 minutes by a task:

-- warehouse/04_ml_cortex.sql (excerpt)
CREATE OR REPLACE SNOWFLAKE.ML.ANOMALY_DETECTION ML.FERVOR_ANOMALY_MODEL(
  INPUT_DATA => SYSTEM$QUERY_REFERENCE(
    'SELECT team_id, ts, fervor_index
     FROM FERVOR.ML.TEAM_INDEX_HISTORY
     WHERE NOT COALESCE(is_synthetic, FALSE) -- never train on demo spikes
     AND ts < DATEADD(minute, -15, SYSDATE())'
  ), -- strict train/detect split
  SERIES_COLNAME => 'TEAM_ID',
  TIMESTAMP_COLNAME => 'TS',
  TARGET_COLNAME => 'FERVOR_INDEX',
  LABEL_COLNAME => ''
);

The gotcha: my first version trained on ts < now and detected on the last hour. Cortex rejected it with "All evaluation timestamps must be after the last timestamp in fitting data." Training and detection must not overlap - split both at the same boundary, strict < on one side, >= on the other.

The is_synthetic flag is the honesty mechanism. A demo needs a passion spike on camera, so scripts/demo_spike.py injects labeled rows that detection SEES but training NEVER fits. The detector flags the surge because it genuinely is one.

The crowd-pleaser is one function call:

SNOWFLAKE.CORTEX.COMPLETE(
  'mistral-large2',
  'In two punchy sentences, hype up the fanbase of the '
  || team_name || ' token community. Fervor index '
  || fervor_index || '/1000, '
  || devoted_wallet_count || ' diamond-hand wallets, average hold '
  || avg_hold_days || ' days. No preamble, no hashtags.'
)

And it goes further than a scheduled task: the dashboard's AI insights tab calls Cortex on demand, from inside the app - no external API, the LLM runs where the data lives. Pick a team and mistral-large2 writes an analyst brief from the real numbers:

# warehouse/streamlit_app.py (excerpt) - LLM inference inside the warehouse
@st.cache_data(ttl=300)
def cortex_brief(prompt: str) -> str:
    esc = prompt.replace("'", "''")
    return str(
        session.sql(
            f"SELECT SNOWFLAKE.CORTEX.COMPLETE('mistral-large2', '{esc}')"
        ).collect()[0][0]
    )

prompt = (
    f"You are a crypto market analyst covering World Cup fan tokens. "
    f"3 bullets: one strength, one risk, one outlook. {team} index "
    f"{idx}/1000 ({chg:+.0f} over {window}), {bel} believers of {tot} "
    f"wallets, buy pressure {bp:.0f}%, arch-rival is {rival}."
)

The same tab shows a conviction-health table in percentages per nation: share of wallets with a signal, share of believers, dip buys per wallet, 6-hour buy pressure, and Cortex's forecast for the next 12 minutes.

The FORECAST model does more than fill a column. The AI tab opens with a Conviction outlook: every nation's index projected 12 minutes out, drawn as a dumbbell (live index to forecast) so you read who is strengthening and who is cracking in one glance, a durability score blending forecast trajectory, hold time, momentum and buy pressure, and a league-wide brief Cortex writes from the ranked board - naming the best-positioned fan base and the one most at risk, with an explicit note that it forecasts holding behavior, not token price.

The market tabs render real 1-hour candlesticks built in SQL - no OHLC API, just MIN_BY / MAX_BY over decoded swap executions:

SELECT
  TIME_SLICE(s.block_time, 1, 'HOUR') AS h,
  MIN_BY(s.price_usd, s.block_time) AS open,
  MAX(s.price_usd) AS high,
  MIN(s.price_usd) AS low,
  MAX_BY(s.price_usd, s.block_time) AS close,
  SUM(s.sol_amount) AS volume
FROM STAGING.SWAP_EVENTS s
WHERE s.team_id = ? AND s.block_time >= DATEADD('hour', -72, CURRENT_TIMESTAMP())
GROUP BY 1
ORDER BY 1;

And the USD leg is genuinely live: the bridge pushes the real SOL/USD rate into the warehouse every 60 seconds, so the whole DAG's pricing tracks the market (SOL was $78.22 while I wrote this):

// bridge/server.ts (excerpt) - the warehouse gets a live price artery
async function updateSolPrice() {
  const res = await fetch(
    "https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd"
  );
  const px = (await res.js

Comments

No comments yet. Start the discussion.