本页目前只有英文版。

Gift Vault

Overview

The Gift Vault lets you assign an X (Twitter) account as the Gift Owner for a token. The gift owner can prove control of that X account and direct the vault's fee flow to any EVM address of their choice — and change that address anytime by presenting a new proof.

You can use it as a way to give tax fee to an X user, or build more interesting use cases on top of it. It makes it easier for a web2 developer or an agent to manage tax fee routing through social proof. Some ideas:

  • Create an X account for an agent and launch a tax token that assigns the agent as the gift owner. The agent routes the tax fee to any EVM address, accepting funding requests via tweets. This could work as charity funds or a VC that supports new projects.
  • A social game where players tweet to a game X account and get the tax fee routed to their address based on social tasks or achievements.

This page documents the current Gift Vault V2 product line, which is what new integrations should target. The legacy Gift Vault V1 (FlapXVault) is documented in the Appendix: Gift Vault V1 (legacy) at the end of this page — the API integration is identical between versions, so if you already support V1 you mainly need to read the "What's different in V2" section.

Lifecycle

Gift Vault V2 has three states:

1. ACCUMULATING

  • Initial state after deployment.
  • Vault accepts native BNB fees from the token.
  • Funds stay inside the vault while waiting for a valid X proof to assign the streaming recipient.

2. STREAMING

  • Entered after a valid manageByProof() call.
  • All accumulated BNB is flushed immediately to streamingTarget.
  • Future BNB is forwarded to the same target on receipt.
  • The target can be updated again by a newer valid proof.

3. DIVIDEND

  • Entered after createdAt + timeoutPeriod if streaming was never activated. Default timeout is 7 days, configurable at the factory level for future vaults.
  • Sticky once entered.
  • Vault wraps native BNB into the dividend token (normally WBNB) and deposits the wrapped balance into the token's Dividend contract for holders.

This is a change from Gift Vault V1, whose grace-period fallback was buyback & burn instead of holder dividends. See the Appendix for the V1 lifecycle.

Network Details

BNB Smart Chain Mainnet

  • GiftV4VaultFactoryV2: 0x6909aD1822Ece349CDDAb98E6F62EeeD9fAa2e10
  • GiftTaxVaultFactoryV2: 0xFb7ccc4Fd09Da5b7016A18d51e227Af4ABE53f44

BNB Smart Chain Testnet

  • GiftV4VaultFactoryV2: 0xaa00b4CeFeE8b4BF7D2E23C21ae82cfC9AFC9D67
  • GiftTaxVaultFactoryV2: 0x2eDD880AB36b07bD030BDa28A13c3E9148C11622

Product matrix

Gift Vault V2 has two product options. The difference is mainly the launch constraints of the selected factory.

GiftV4VaultFactoryV2

Use this factory for the zero-tax Gift V4 product.

Launch constraints:

  • tokenVersion == TOKEN_V3_PERMIT
  • quoteToken == address(0)
  • buyTaxRate == 0
  • sellTaxRate == 0
  • dividendBps == 0
  • dividendToken == address(0)

GiftTaxVaultFactoryV2

Use this factory for the taxed Gift product.

Launch constraints:

  • tokenVersion == TOKEN_TAXED_V3
  • quoteToken == address(0)
  • buyTaxRate == sellTaxRate
  • buyTaxRate / sellTaxRate must both be one of: 100 (1%), 200 (2%), 300 (3%)
  • dividendBps == 0
  • dividendToken == address(0)

Shared launch-time vault data

For both Gift Vault V2 product options, the only launch-time vault parameter is the gift owner's X handle.

API integration guide

Gift Vault V2 uses the exact same off-chain API as the legacy Gift Vault V1 — the endpoints, request format, and response format are unchanged. If you already have a working V1 integration, you can reuse it as-is; the only thing that changes for V2 is the factory address and the on-chain launch constraints (product matrix above).

The mechanism, end to end:

  1. Assign Gift Owner — the X account you specify becomes the gift owner who controls where fees go.
  2. 7-Day Grace Period — if the gift owner doesn't manage the fees at least once within the timeout, control is forfeited to the fallback (holder dividends for V2, buyback & burn for legacy V1).

Step 1: Parse and validate tweet content

Integrators must fetch the tweet themselves. The tweet format is strictly enforced:

Tweet Format: gift the fee from [tax_token_address] to [target_address] #FlapGift

interface ParsedTweet {
  targetAddress: string;
  taxTokenAddress: string;
}

interface Tweet {
  __typename: string;
  text?: string;
  user?: {
    screen_name?: string;
    legacy?: { screen_name?: string };
  };
}

function parseTweetText(text: string): ParsedTweet | null {
  const regex = /gift\s+the\s+fee\s+from\s+(0x[a-fA-F0-9]{40})\s+to\s+(0x[a-fA-F0-9]{40})\s+#FlapGift/i;
  const match = text.match(regex);
  if (!match) return null;
  return { taxTokenAddress: match[1], targetAddress: match[2] };
}

function validateTweetContent(
  tweet: Tweet,
  expectedXHandle: string,
  expectedTaxToken: string
): ParsedTweet {
  if (!tweet || tweet.__typename !== "Tweet") {
    throw new Error("Tweet is not valid (deleted, suspended, or not found)");
  }
  if (!tweet.text) {
    throw new Error("Tweet has no text content");
  }
  if (!tweet.user) {
    throw new Error("Tweet has no user data");
  }

  const tweetScreenName = tweet.user.screen_name || tweet.user.legacy?.screen_name;
  if (!tweetScreenName) {
    throw new Error("Tweet author screen_name not found");
  }
  if (tweetScreenName.toLowerCase() !== expectedXHandle.toLowerCase()) {
    throw new Error(`Tweet author mismatch. Expected: @${expectedXHandle}, Found: @${tweetScreenName}`);
  }

  const parsed = parseTweetText(tweet.text);
  if (!parsed) {
    throw new Error(
      "Tweet text does not match the required format. " +
      "Expected: 'gift the fee from [tax token address] to [EVM address] #FlapGift'"
    );
  }
  if (parsed.taxTokenAddress.toLowerCase() !== expectedTaxToken.toLowerCase()) {
    throw new Error(`Tax token mismatch. Expected: ${expectedTaxToken}, Found: ${parsed.taxTokenAddress}`);
  }

  return parsed;
}

Step 2: ⚠️ MANDATORY PREFLIGHT CHECK

Always call canManageVault() on-chain before submitting to the backend. The backend is rate-limited to 1 request per minute per IP, and skipping this step risks an IP ban on rejected requests.

import { Contract, JsonRpcProvider } from 'ethers';

const VAULT_FACTORY_ABI = [
  {
    "inputs": [
      {"internalType": "address", "name": "taxToken", "type": "address"},
      {"internalType": "string", "name": "xHandle", "type": "string"},
      {"internalType": "uint128", "name": "tweetId", "type": "uint128"}
    ],
    "name": "canManageVault",
    "outputs": [
      {"internalType": "bool", "name": "canManage", "type": "bool"},
      {"internalType": "string", "name": "errorMessage", "type": "string"}
    ],
    "stateMutability": "view",
    "type": "function"
  }
];

async function preflightCheck(
  provider: JsonRpcProvider,
  factoryAddress: string,
  taxToken: string,
  xHandle: string,
  tweetId: string
): Promise<void> {
  const contract = new Contract(factoryAddress, VAULT_FACTORY_ABI, provider);

  // xHandle MUST be lowercase for contract compatibility
  const result = await contract.canManageVault(taxToken, xHandle.toLowerCase(), BigInt(tweetId));
  const [canManage, errorMessage]: [boolean, string] = result;

  if (!canManage) {
    throw new Error(`PREFLIGHT CHECK FAILED: ${errorMessage} — DO NOT call backend API, request will be rejected!`);
  }
}
  • xHandle must be lowercase when calling the contract.
  • This is a view function — no gas required, instant response.
  • Returns [bool canManage, string errorMessage].
  • Always check this before calling the backend to avoid rate-limit violations.

Step 3: Submit to backend API

Only call if Step 2 passed. Rate limit: 1 request per minute per IP; violating may result in an IP ban.

Endpoint: POST {relayerEndpoint}/submit

interface SubmitRequest {
  tax_token: string;
  tweet_id: string;
}

interface SubmitResponse {
  message: string;
  x_proof: {
    target_address: string;
    tax_token: string;
    x_handle: string;
    x_id: string;
    tweet_id: string;
  };
  signature: string;
}

async function submitToBackend(
  relayerEndpoint: string,
  taxToken: string,
  tweetId: string
): Promise<SubmitResponse> {
  const response = await fetch(`${relayerEndpoint}submit`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ tax_token: taxToken, tweet_id: tweetId })
  });

  if (!response.ok) {
    const errorData = await response.json();
    throw new Error(`Backend error: ${errorData.error}`);
  }

  return response.json();
}

Step 4: Poll for on-chain confirmation

The relayer processes transactions asynchronously and does not return a transaction hash — poll canManageVault to confirm success. Once processed, canManageVault returns false with error "The tweet is outdated", which signals success.

async function pollForConfirmation(
  provider: JsonRpcProvider,
  factoryAddress: string,
  taxToken: string,
  xHandle: string,
  tweetId: string
): Promise<boolean> {
  const TIMEOUT = 60000;      // 60 seconds
  const POLL_INTERVAL = 15000; // 15 seconds
  const startTime = Date.now();
  const contract = new Contract(factoryAddress, VAULT_FACTORY_ABI, provider);

  return new Promise((resolve, reject) => {
    const interval = setInterval(async () => {
      if (Date.now() - startTime >= TIMEOUT) {
        clearInterval(interval);
        reject(new Error("Polling timeout (60s) - transaction may still be processing"));
        return;
      }

      try {
        const [canManage, errorMessage] = await contract.canManageVault(taxToken, xHandle.toLowerCase(), BigInt(tweetId));
        if (!canManage && errorMessage === "The tweet is outdated") {
          clearInterval(interval);
          resolve(true);
        }
      } catch (error) {
        console.error("Polling error:", error);
      }
    }, POLL_INTERVAL);
  });
}

Manual submission fallback

If the backend relayer times out after 60 seconds, you can submit the proof directly on-chain using the proof data returned from the backend:

async function manualSubmit(
  factoryAddress: string,
  taxToken: string,
  proofData: SubmitResponse,
  signer: any
): Promise<void> {
  const MANUAL_SUBMIT_ABI = [
    {
      "inputs": [
        {"name": "taxToken", "type": "address"},
        {
          "name": "proof",
          "type": "tuple",
          "components": [
            {"name": "targetAddress", "type": "address"},
            {"name": "taxToken", "type": "address"},
            {"name": "xHandle", "type": "string"},
            {"name": "XId", "type": "uint256"},
            {"name": "tweetId", "type": "uint128"}
          ]
        },
        {"name": "signature", "type": "bytes"}
      ],
      "name": "manageByProof",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    }
  ];

  const contract = new Contract(factoryAddress, MANUAL_SUBMIT_ABI, signer);
  const tx = await contract.manageByProof(
    taxToken,
    {
      targetAddress: proofData.x_proof.target_address,
      taxToken: proofData.x_proof.tax_token,
      xHandle: proofData.x_proof.x_handle,
      XId: BigInt(proofData.x_proof.x_id),
      tweetId: BigInt(proofData.x_proof.tweet_id)
    },
    proofData.signature
  );
  await tx.wait();
}

canManageVault() reference

Parameters: taxToken (address), xHandle (string, must be lowercase), tweetId (uint128)

Returns: canManage (bool), errorMessage (string)

Possible error messages:

Error MessageMeaning
"GiftVaultV2 not found for this tax token"No vault exists
"xHandle does not match"Wrong X handle
"Vault is already in dividend mode"Vault cannot be managed anymore
"The tweet is outdated"Tweet already used (success!)
"" (empty string)Can manage (success)

Error handling summary

ErrorCauseSolution
"Tweet is not valid"Deleted/suspendedUse valid, active tweet
"Tweet has no text"Media-only tweetEnsure text content
"Author mismatch"Wrong X handleVerify correct user
"Format mismatch"Incorrect formatUse exact format
"Token mismatch"Wrong addressVerify addresses match
"Vault not found"No vault existsCheck token has a vault
"xHandle does not match"Wrong vault ownerUse correct X handle
"Vault is already in dividend mode"Timed out / lockedCannot manage anymore
"Tweet is outdated"Already used (success)Use new tweet ID for the next update
Rate limit errorToo many requestsWait 60 seconds
Network timeoutBackend slowRetry later

Key points:

  • NEVER skip the preflight check — it prevents IP bans.
  • The backend is rate-limited (1/min) — no exceptions.
  • xHandle must be lowercase for contract calls.
  • Polling confirms success — the relayer is asynchronous.
  • Consider showing the manual submission option to users after ~30 seconds of polling, while continuing to poll up to 60 seconds in the background.

Summary checklist

  1. Fetch the tweet yourself.
  2. Validate tweet content (author, format, tax token match).
  3. ⚠️ MANDATORY: call canManageVault(). If false, stop — do not call the backend.
  4. Submit to backend (only if preflight passed; rate limited to 1/min).
  5. Poll canManageVault() every 15s for up to 60s. Success = error "The tweet is outdated".
  6. Implement timeouts and the manual submission fallback.

Appendix: Gift Vault V1 (legacy)

This section documents the original Gift Vault (FlapXVault), kept for integrators who still support the legacy factory. New integrations should use Gift Vault V2 (documented above) — the API integration mechanics above apply unchanged to V1; only the factory address, states, and fallback differ.

V1 vs V2 summary

TopicGift Vault V1Gift Vault V2
Product optionsone legacy Gift Vault flowtwo product options: GiftV4VaultFactoryV2 and GiftTaxVaultFactoryV2
API integrationsame API as V2same API, request format, and response format as V1
Fee routing lifecycleACCUMULATING -> STREAMING -> FALLBACK_SNOWBALLACCUMULATING -> STREAMING -> DIVIDEND
Timeout fallbackbuyback & burnholder dividends

V1 lifecycle

  1. Assign Gift Owner — the X account you specify becomes the gift owner who controls where fees go.
  2. Flexible Beneficiary — the gift owner can assign fees to any EVM address and change it anytime.
  3. 7-Day Grace Period — if the gift owner doesn't manage the fees at least once in the first 7 days, control is forfeited.
  4. Fallback: Buyback & Burn — if control is forfeited, fees are automatically used for buyback and burn.

V1 Network Details

BNB Smart Chain Mainnet

  • Gift Vault Factory: 0x025549F52B03cF36f9e1a337c02d3AA7Af66ab32
  • Relayer API Endpoint: https://bnb-x-relayer.taxed.fun/submit

BNB Smart Chain Testnet

  • Gift Vault Factory: 0xa02DA44D67DB6D692efa7f751b5952bd670d5326
  • Relayer API Endpoint: https://bnbtest-x-relayer.taxed.fun/submit

V1 Gift Vault interface (IFlapXVault)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

/// @title IFlapXVault
/// @notice Interface for the legacy Gift Vault (FlapXVault) implementation.
interface IFlapXVault {
    enum State {
        ACCUMULATING,
        STREAMING,
        FALLBACK_SNOWBALL
    }

    enum BalanceUpdateType {
        ACCUMULATION,
        SNOWBALL
    }

    struct VaultStats {
        uint128 totalBNBSpent;
        uint128 totalTokenBurn;
    }

    struct ProofRecord {
        uint128 XId;
        uint128 tweetId;
        address targetAddress;
    }

    struct XProof {
        address targetAddress;
        address taxToken;
        string xHandle;
        uint128 XId;
        uint128 tweetId;
    }

    event FlapTaxVaultStateChanged(address token, uint8 newState);
    event FlapTaxVaultStreamingTargetUpdated(address token, address newTarget);
    event FlapSnowballBalanceUpdated(address token, address vault, uint256 newBalance, BalanceUpdateType updateType);
    event FlapStreamingForwardFailed(address token, address target, uint256 amount);

    error OutdatedProof(uint128 providedTweetId, uint128 lastTweetId);
    error InvalidState(State currentState);
    error EmptyXHandle();
    error InvalidProof();
    error MismatchedTaxToken();
    error MismatchedXHandle();
    error CannotRevokeGuardianRole();

    function SNOWBALL_ROLE() external view returns (bytes32);
    function DEAD_ADDRESS() external view returns (address);

    function initialize(address _taxToken, address _quoteToken, string calldata _xHandle, uint256 _timeoutPeriod)
        external;

    function state() external view returns (State);
    function transitState() external;
    function manageByProof(XProof calldata proof, bytes calldata signature) external;
    function snowball(uint256 quoteAmt) external;

    function getHistoricalProofs(uint256 offset, uint256 limit)
        external
        view
        returns (ProofRecord[] memory records, uint256 total);

    function description() external view returns (string memory);
    function revokeRole(bytes32 role, address account) external;
    function stats() external view returns (uint128 totalBNBSpent, uint128 totalTokenBurn);
    function taxToken() external view returns (address);
    function quoteToken() external view returns (address);
    function xHandle() external view returns (string memory);
    function timeoutPeriod() external view returns (uint256);
    function createdAt() external view returns (uint256);
    function factory() external view returns (address);
    function streamingTarget() external view returns (address);
    function streamedAmount(address beneficiary) external view returns (uint256);
    function lastTweetId() external view returns (uint128);
}