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 thediscord-taco-webrepo. The structure is what matters.
What the condition enforces
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).
The Discord account is at least 6 months old (anti-spam).
The smart account submitting the
UserOperationis the deterministically derived account for that exact Discord user — nobody can spend someone else's tip allowance.The
UserOperationcalldata is exactlyexecute(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 totimestamp || 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> 0here 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:
int— coerce the snowflake string to integer/= 4194304— right-shift by 22 bits (divide by2^22)+= 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
create2operation 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
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'scall_datamust decode asexecute((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 (theaddresstarget) must equal the USDC contract on Base. No other token can be transferred.parameterIndex: 0, subIndices: [1]— the tuple'svaluefield must be 0 (no native ETH attached).parameterIndex: 0, subIndices: [2]— the tuple'sbytesfield is itself ABI-encoded calldata. We decode it withnestedAbiValidationand assert it is exactlytransfer(address,uint256)with:parameter 0 (
address) equal to:recipientDirect— the recipient extracted from the Discord payload in step 3.2parameter 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.
operationslets 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-attributewithnestedAbiValidationis 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.json. Validate it locally with the validate-conditions.ts script before shipping any modifications.
Last updated