Discord Tipping Bot Deep Dive

TACo conditions are arbitrarily expressive — there is no upper bound on what you can compose. This page works through one real-world example that happens to exercise many features at once: nested compound logic, sequential variables, on-chain CREATE2 derivation, JSONPath extraction, snowflake arithmetic, ECDSA verification, and nested ABI calldata validation. Paste the whole page into an LLM's context when you want it to author conditions in this style.

The condition guards a TACo Action Control flow built by an integrator: a Discord user types /tip 0.50 0xfriend in a Discord server running a tipping bot, and a smart account derived deterministically from their Discord ID sends 0.50 USDC on Base — but only if every clause below is satisfied. The TACo network performs threshold signing on the resulting UserOperation exactly when the condition evaluates true.

Addresses anonymised. Where you see 0xUSDC_ON_BASE, 0xAA_FACTORY, 0xACCOUNT_BYTECODE_HASH, <ED25519_PUBKEY>, etc., substitute the real values from the discord-taco-webarrow-up-right repo. The structure is what matters.

What the condition enforces

  1. The Discord interaction payload was actually signed by a known relay's Ed25519 key (proves the request originated from Discord and passed through the trusted relay).

  2. The Discord account is at least 6 months old (anti-spam).

  3. The smart account submitting the UserOperation is the deterministically derived account for that exact Discord user — nobody can spend someone else's tip allowance.

  4. The UserOperation calldata is exactly execute(USDC, 0, transfer(recipient, amount)), with the recipient and amount lifted from the Discord slash-command parameters and the amount converted to USDC base units.

If any of those fail, no signature.

Top-level shape

{
  "version": "1.0.0",
  "condition": {
    "conditionType": "compound",
    "operator": "and",
    "operands": [
      { "...": "1. ECDSA signature on Discord payload" },
      { "...": "2. SequentialCondition: account-age + AA derivation + sender check" },
      { "...": "3. SequentialCondition: parse tip params + ABI calldata validation" }
    ]
  }
}

A CompoundCondition with and over three operands. Two of those operands are themselves SequentialConditions — that is how we get more than the 5-condition limit's worth of expressive power inside a compound.

Clause 1: Verify the relay's signature

When the bot relays a Discord interaction to TACo, it passes:

  • :timestamp — Discord interaction timestamp

  • :discordPayload — the full JSON of the slash-command interaction

  • :signature — the Ed25519 signature the relay applied to timestamp || discordPayload

  • :verifyingKey — the relay's well-known public key (a constant, baked into the condition)

If this fails, the network refuses to sign. Nobody can fake a Discord interaction without the relay's private key.

Clause 2: Sequential — derive the AA and prove sender identity

This is the heart of the condition. Six steps, each binding a variable subsequent steps depend on.

Step 2.1 — Pull the Discord user ID

:discordPayload is supplied by the bot. JSONPath extracts member.user.id (a Discord snowflake — a 64-bit integer encoded as a string). The > 0 test is a "not empty" sentinel; the real validation happens in step 2.3.

Step 2.2 — Compute the cutoff timestamp (now − 6 months) in milliseconds

TimeCondition returns Base's latest block timestamp in seconds. The two operations on the variable convert it to milliseconds (*1000) and subtract six months in ms (15768000000). The result is bound as :minAccountCreationTime.

Why operations on the variable, not on returnValueTest? The > 0 here is a no-op truthiness check — we just want the timestamp itself. The arithmetic transforms the stored value, not the comparison.

Step 2.3 — Validate Discord snowflake → account age

This is the snowflake decode trick. Discord snowflakes are 64-bit integers where the upper 42 bits encode (unix_ms - DISCORD_EPOCH). To get the account creation time:

  1. int — coerce the snowflake string to integer

  2. /= 4194304 — right-shift by 22 bits (divide by 2^22)

  3. += 1420070400000 — add the Discord epoch (2015-01-01 in ms)

Then assert: creation time < :minAccountCreationTime. In English: "the account was created before the cutoff", i.e. it is at least 6 months old.

Step 2.4 — Derive the salt for CREATE2

We take the Discord ID, stringify it, append |Discord|TipBot, and hash it with keccak. The result is the deterministic salt for the smart account derivation. Two different Discord users → two different salts → two different smart accounts. The same Discord user always derives the same salt.

Step 2.5 — Compute the smart account address

A real on-chain view call into the AA factory. Given the bytecode hash (constant) and the salt (computed in 2.4), the factory returns the CREATE2 address — the smart account that would exist (or already exists) for this Discord user. We bind it as :senderAA.

Alternative: the schema also exposes a create2 operation that computes the address locally without an RPC round-trip. The bot uses the on-chain version because the factory's address-derivation logic may evolve; a local computation would have to be kept in sync.

Step 2.6 — Assert the UserOperation sender matches

:signingConditionObject is the UserOperation the network is being asked to sign. Read its sender field and require it to equal the address we derived in step 2.5.

This is the linchpin. Without this clause, anyone could submit a UserOperation from any smart account and ride on the rest of the validation. With it, the only smart account that can ever pass is the one deterministically tied to that Discord user.

Clause 3: Sequential — parse tip params and validate calldata

Step 3.1 — Extract and normalise the tip amount

A JSONPath filter (?(@.name == "amount")) finds the slash-command argument named amount. The return-value test coerces it to a float and asserts a 0.25 USDC minimum. The variable-level operations then multiply the float by 10^6 to get USDC base units, and bind that as :amountUSDC.

Step 3.2 — Extract the recipient address

Same trick — pull the address argument out of the slash-command payload, bind as :recipientDirect.

Step 3.3 — Validate the UserOperation calldata exactly

Walking it:

  • The UserOperation's call_data must decode as execute((address,uint256,bytes)) — the standard ERC-4337 single-call entry point. It takes one tuple parameter.

  • parameterIndex: 0, subIndices: [0] — the first field of the tuple (the address target) must equal the USDC contract on Base. No other token can be transferred.

  • parameterIndex: 0, subIndices: [1] — the tuple's value field must be 0 (no native ETH attached).

  • parameterIndex: 0, subIndices: [2] — the tuple's bytes field is itself ABI-encoded calldata. We decode it with nestedAbiValidation and assert it is exactly transfer(address,uint256) with:

    • parameter 0 (address) equal to :recipientDirect — the recipient extracted from the Discord payload in step 3.2

    • parameter 1 (uint256) equal to :amountUSDC — the normalised amount from step 3.1

The chain of inference: Discord said tip 0.50 USDC to 0xfriend → bot built a UserOperation that calls execute(USDC, 0, transfer(0xfriend, 500_000)) → TACo signs it only if the calldata literally matches what the Discord message asked for. Front-running, calldata mutation, or token substitution all fail.

What this example teaches

  • Compound + sequential is how you build deep logic. The 5-condition compound limit is not a limit on expressivity if you embed sequentials inside the operands.

  • Sequential variables are the workhorse. Use them whenever a check depends on a value computed elsewhere in the condition.

  • operations lets you do real arithmetic on-chain-ish — bit shifts via division, hashing, type coercion, unit conversions. Many things you would otherwise need a smart contract for can be done in the condition itself.

  • signing-abi-attribute with nestedAbiValidation is how you enforce policy on ERC-4337 calldata structure. This is the killer feature for Action Control.

  • Anonymise constants only when necessary. In a production deployment the bytecode hash, factory address, and relay verifying key are all hardcoded and public. They are anonymised on this page for documentation hygiene; an integrator's own conditions normally do not need to be.

Want to run this?

The full, un-anonymised condition lives at discord-taco-web/conditions-mainnet.jsonarrow-up-right. Validate it locally with the validate-conditions.ts script before shipping any modifications.

Last updated