Treasure’s Quests V2 system (currently in alpha phase) allows you, our game partner, to push low-level game actions of your choice to our data platform.

At a later point in time, we can create quests that are simply database queries against those events.

(We can even ingest on-chain contract events from your smart contracts, if needed. Our on-chain data feed is powered by Goldsky’s Mirror pipelines, which provide simple and robust streaming of blockchain data into our infrastructure. This combination allows you to seamlessly configure richer quests that combine off-chain and on-chain criteria.)

Step 1: Fill out Quest metadata form

In order for us to set up a Quest for a game within the Treasure Platform, the following details will need to be collected (see here for a Google Sheets template to provide quests in bulk).

  1. Quest Name
  2. Name of the associated game
  3. Quest Description: A non-technical definition of what a player needs to do to achieve this quest (73 character limit is preferred).
  4. Start and End Dates: Provide a UTC date + timestamp (e.g., 2024-06-07T16:00:00Z).
  5. CTA Link: The URL that the player will need to navigate to to work on said quest
  6. Badge (optional): to accompany this specific quest. See the Badge Design Guide for more details.
  7. Quest Mechanics : To help us normalize rewards please provide approximate scores in the following categories:
    • Friction Score: On a scale of 1-10 with 10 being most friction, how much friction does a player need to go through in order to complete this quest?
    • Difficulty Score: On a scale of 1-10 with 10 being most difficult, how much skill does a player need to complete this quest?
    • Time Required: On average, how much time (in hrs) will a player need to commit to complete this quest?
  8. Quest Action Schema: Tell us the schema for your action payload. See this section.
  9. Quest Denominator (integer): This is the denominator field described in the technical criteria step.
  10. Quest Numerator (query): Tell us the SQL query to use to compute the numerator for this quest. The quest is complete when numerator >= denominator. See the “Create a Quest query” for details on the syntax (Treasure team can assist you with formulating this query if needed).

Step 2: Prepare the schema for your Action payload

Tell us the comma-delimited string to describe your Action schema.

string app_id,
string action_type,
uint256 action_time_ms,
address user_address,

{type1} {field_name1},
{type2} {field_name2},
...

Here, the {type} is an Ethereum data type.

This would correspond to a Action Payload schema in JSON:

{
    app_id: string; // Required. Value is assigned to you by Treasure.
    action_type: string; // Required. You decide this value.
    action_time_ms: string; // Required. UNIX milliseconds.
    user_address: string; // Required. Ethereum wallet address for player. Lowercase.

    // Any other data you want, up to 500 KB.
    // In general, please keep it concise.
    ...
}

When sending the actions to us, the examples of the payloads you send to us would be the following. For a game match result:

Schema:

string app_id,
string action_type,
uint256 action_time_ms,
address user_address,

bool completed,
uint256 score,
string result,
uint256 ants_defeated,
uint256 mosquitos_defeated,
uint256 health_max,
uint256 health_remaining,
string[] team_members,
uint256 player_level
{
  "app_id": "mygame",
  "action_type": "completed-match",
  "action_time_ms": "1717526677354",
  "user_address": "0xabcde123456789012345678901234567890abcde",

  "completed": true,
  "score": "513",
  "result": "victory",
  "ants_defeated": "8",
  "mosquitos_defeated": "5",
  "health_max": "100",
  "health_remaining": "90",
  "team_members": ["mantis", "dragonfly", "antlion"],
  "player_level": "3"
}

Example payload of a player status update:

Schema:

string app_id,
string action_type,
uint256 action_time_ms,
address user_address,

uint256 wins,
uint256 losses,
uint256 player_level,
bool active
{
  "app_id": "mygame",
  "action_type": "player-status",
  "action_time_ms": "1717526677354",
  "user_address": "0xabcde123456789012345678901234567890abcde",

  "wins": "103",
  "losses": "78",
  "player_level": "3",
  "active": true
}

Step 3: Push game actions to Treasure

From your backend, push data about your game action to our infra.

Option A (Kafka)

There is no need for you to set up your own Kafka cluster with this approach. Just send messages to a dedicated Kafka topic that Treasure manages for you.

  1. Receive Kafka client credentials (username, password, bootstrap URL) from Treasure.

  2. Send your JSON payloads using a Kafka producer client. Kafka client libraries are available in a number of langauges. The following example is in TypeScript (assumes you have already installed kafkajs@2.2.4):

import { Kafka as KafkaJs, logLevel } from 'kafkajs';

// Create a Kafka producer client.
const kafka = new KafkaJs({
    clientId: 'mygame-client-id', // You choose this.
    brokers: ['KAFKA_BOOTSTRAP_URL'], // Assigned by Treasure.
    logLevel: logLevel.WARN,
    ssl: true,
    sasl: {
        mechanism: 'scram-sha-256',
        username: 'KAFKA_USERNAME', // Assigned by Treasure.
        password: 'KAFKA_PASSWORD', // Assigned by Treasure.
    },
});
const producer = kafka.producer();
await producer.connect();

// Prepare action key, payload, optional headers.
const actionKey = 'upsert-key-for-this-actions'; // You determine this.
const actionPayload = {
    app_id: 'mygame',
    action_type: 'completed-match',
    action_time_ms: '1717526677354',
    user_address: '0xabcde123456789012345678901234567890abcde',

    completed: true,
    score: '513',
    result: 'victory',
    ants_defeated: '8',
    mosquitos_defeated: '5',
    health_max: '100',
    health_remaining: '90',
    team_members: ['mantis', 'dragonfly', 'antlion'],
    player_level: '3',
};
// Headers are optional, but can inform us what to do with entries
// that share the same actionKey / upsert key.
const actionHeaders: String<string, string | string[] | undefined. = {
    "op": "upsert", // Valid values are "u" / "upsert" / "d" / "delete".
};

// Send message to Treasure Kafka.
await producer.send({
    topic: 'KAFKA_TOPIC', // Assigned by Treasure.
    messages: [
        {
            key: actionkey,
            value: JSON.stringify(actionPayload),
            headers: actionHeaders,
        },
    ],
});

Option B (SNS on AWS)

If you choose this method, let us know and we’ll provide you the {TREASURE_AWS_ACCOUNT} in a private channel.

  1. Create an SNS topic. Let’s say you called it actions-for-treasure. Then its topicArn is going to be arn:aws:sns:{your AWS region}:{your AWS account}:actions-for-treasure.
  2. Allow Treasure’s AWS account to subscribe to your SNS topic by adding this to your SNS topic’s access policy:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "0",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::{TREASURE_AWS_ACCOUNT}:root"
      },
      "Action": "sns:Subscribe",
      "Resource": "arn:aws:sns:{your AWS region}:{your AWS account}:actions-for-treasure"
    }
  ]
}
  1. Please tell us your SNS topic’s topicArn so we can update our infrastructure to subscribe to it.

  2. Grant your own lambdas/servers sns:Publish permission to send payloads to the SNS topic.

  3. Publish action payloads to your SNS topic. AWS SDKs support multiple languages. The following example is in TypeScript:

import { SNS } from '@aws-sdk/client-sns';

const topicArn =
    'arn:aws:sns:{your AWS region}:{your AWS account}:actions-for-treasure';

const payload = {
    app_id: 'mygame',
    action_type: 'completed-match',
    action_time_ms: '1717526677354',
    user_address: '0xabcde123456789012345678901234567890abcde',

    completed: true,
    score: '513',
    result: 'victory',
    ants_defeated: '8',
    mosquitos_defeated: '5',
    health_max: '100',
    health_remaining: '90',
    team_members: ['mantis', 'dragonfly', 'antlion'],
    player_level: '3',
}

const sns = new SNS({ ... });

await sns.publish({
    TopicArn: topicArn,
    Message: JSON.stringify(payload),
});

Options in development

HTTP POST

If you choose this step, let us know and we’ll provide you the {DATA_PLATFORM_URL} in a private channel. Make a POST call to https:// {DATA_PLATFORM_URL} where the body of the quest is your action payload JSON.

Step 4: Create a Quest query

Aside from the usual Quest metadata (name, description, optional image, start/end dates), you’ll need to tell us

  1. What app_id + action_type to filter for.
  2. The value for the quest’s denominator.
  3. How to count/sum/aggregate the quest’s numerator from the set of filtered actions.

A quest is complete when numerator >= denominator. You can express the numerator computation as an aggregate query over the DB.

We currently support PostgreSQL syntax. The following special variables will be subbed into your SQL query:

  • {{START_DATE_MILLIS}} (the start date of the quest in UNIX milliseconds)
  • {{END_DATE_MILLIS}} (the end date of the quest in UNIX milliseconds)
  • {{START_DATE_SECONDS}} (the start date of the quest in UNIX seconds)
  • {{END_DATE_SECONDS}} (the end date of the quest in UNIX seconds)
  • {{USER_ADDRESS}} (the player’s wallet address)

The action payload will be stored as a JSONB column named body. Due to the nature of JSONB, you may have to explicitly cast certain fields into numeric or other types.

Appendix

Quest query examples

We provide a few simple example queries here.

Example Quest A: “Has won 5 matches.”

  • app_id = mygame
  • action_type = completed-match
  • denominator = 5
  • The numerator query over Treasure’s data platform.
SELECT
    -- Count matching action rows.
    COUNT(*) AS numerator
FROM
    -- This is a table prepared just for actions matching app_id/action_type.
    actions.mygame_completed_match
WHERE
    -- Filter for actions within the quest period.
    (body->>'action_time_ms')::numeric
        BETWEEN {{START_DATE_MILLIS}}
            AND {{END_DATE_MILLIS}}
    -- Filter for current user.
    AND body->>'user_address' = '{{USER_ADDRESS}}'
    -- Filter for victories.
    AND body->>'result' = 'victory'

Example Quest B: “Has reached level 5.”

  • app_id = mygame
  • action_type = player-status
  • denominator = 5
  • The numerator query over Treasure’s data platform (picks player_level from the latest game entry).
SELECT
    -- Cast player_level as an integer and assign as the numerator.
    (body->>'player_level')::int AS numerator
FROM
    -- This is a table prepared just for actions matching app_id/action_type.
    actions.mygame_player_status
WHERE
    -- Filter for actions within the quest period.
    (body->>'action_time_ms')::numeric
        BETWEEN {{START_DATE_MILLIS}}
            AND {{END_DATE_MILLIS}}
    -- Filter for current user.
    AND body->>'user_address' = '{{USER_ADDRESS}}'

-- Pick the latest action in the quest period.
ORDER BY (body->>'action_time_ms')::numeric DESC
LIMIT 1

Example Quest C: “Has defeated 100 ants.”

  • app_id = mygame
  • action_type = completed-match
  • denominator = 100
  • The numerator query over Treasure’s data platform.
SELECT
    -- Sum the ants_defeated field over the matching action rows.
    SUM((body->>'ants_defeated')::int) AS numerator
FROM
    -- This is a table prepared just for actions matching app_id/action_type.
    actions.mygame_completed_match
WHERE
    -- Filter for actions within the quest period.
    (body->>'action_time_ms')::numeric
        BETWEEN {{START_DATE_MILLIS}}
            AND {{END_DATE_MILLIS}}
    -- Filter for current user.
    AND body->>'user_address' = '{{USER_ADDRESS}}'

Example Quest D: “Has completed 7 matches, in each defeating 10 ants.”

  • app_id = mygame
  • action_type = completed-match
  • denominator = 7
  • The numerator query over Treasure’s data platform.
SELECT
    COUNT(*) AS numerator
FROM
    actions.mygame_completed_match
WHERE
    (body->>'action_time_ms')::numeric
        BETWEEN {{START_DATE_MILLIS}}
            AND {{END_DATE_MILLIS}}
    AND body->>'user_address' = '{{USER_ADDRESS}}'
    AND (body->>'ants_defeated') >= 10

Combining on-chain + off-chain quest criteria

  • Step 1: Give Treasure the event signature that you want us to index. Example: Stamp(uint256 indexed stampId, address indexed caller, uint256 nonce)
    • NOTE: if your event is defined in a widely adopted standard (like ERC20, ERC721, etc), then please tell us which chain and contract_address to listen for as well.
  • Step 2: Treasure will set up a table containing those events. In our example, that table would be contract_events.stamp. The JSON layout of the table’s body column will have the following format. Note that body->event_params captures the event parameters for an emitted event Stamp(13, 0xabcde123456789012345678901234567890abcde, 69).
{
  "chain": "ruby",
  "address": "0x5167e9a422aced95c2d0b62bf05a7847a9a942b2",
  "block_timestamp": 1717526700,
  "block_number": 640297,
  "transaction_index": 1,
  "log_index": 0,
  "transaction_hash": "0x53cad22fdb2283e9c35415e73a638efb3731712dd3b27ca4ed73631b8bd099ea",
  "event_name": "Stamp",
  "event_signature_indexed": "Stamp(uint256 indexed,address indexed,uint256)",
  "event_signature": "Stamp(uint256,address,uint256)",
  "event_params": {
    "stampId": "13",
    "caller": "0xabcde123456789012345678901234567890abcde",
    "nonce": "69"
  }
}
  • Step 3: Contruct an SQL query to compute the numerator for you quest. Example for a quest whose goal is to “win at least 1 match and produce an on-chain Stamp with nonce 69 or 420”:
WITH victory_count AS (
    SELECT
        COUNT(*) AS count
    FROM
        actions.mygame_completed_match
    WHERE
        (body->>'action_time_ms')::numeric
            BETWEEN {{START_DATE_MILLIS}}
                AND {{END_DATE_MILLIS}}
        AND body->>'user_address' = '{{USER_ADDRESS}}'
        AND body->>'result' = 'victory'
),
stamp_count AS (
    SELECT
        COUNT(*) AS count
    FROM
        contract_events.stamp
    WHERE
        (body->>'block_timestamp')::numeric
            BETWEEN {{START_DATE_SECONDS}}
                AND {{END_DATE_SECONDS}}
        AND body->'event_params'->>'caller' = '{{USER_ADDRESS}}'
        AND (body->'event_params'->>'nonce')::numeric IN (69, 420)
)
SELECT
    (COALESCE(victory_count.count, 0) > 0)::int
    + (COALESCE(stamp_count.count, 0) > 0)::int AS numerator
FROM
    victory_count FULL JOIN stamp_count ON TRUE