Near Learn

Pattern: accept deposits from any chain

Credit players in a NEAR contract when they pay from Base or Solana: one 1Click quote per deposit, ft_on_transfer attribution, idempotency, security checklist.

Advanced15 min read3-question check

The goal: a player on Base, Solana or any chain 1Click supports pays into your NEAR game, and the contract credits the right player. Nobody installs a NEAR wallet, and you run no bridge or liquidity. This is the near.mom motto made concrete: anyone can participate from any chain.

The design below has three parts: a backend that creates one 1Click quote per deposit, a contract that credits on ft_on_transfer, and reconciliation that ties the two together.

The flow#

  1. The player picks a chain and amount. Your backend requests a live 1Click quote: origin asset on their chain → USDC on NEAR, recipient: "game.near", and a customRecipientMsg naming the player and a deposit ID.
  2. The player sends funds to the returned depositAddress. Each quote has its own address, so the address itself attributes the deposit off-chain.
  3. 1Click settles and withdraws USDC from intents.near with ft_transfer_call carrying your message. The USDC contract calls game.near.ft_on_transfer(sender_id = "intents.near", amount, msg).
  4. The contract credits the player and emits an event with the deposit ID. Your backend marks the deposit done when both 1Click reports SUCCESS and the event is on-chain.

Backend: one quote per deposit#

deposits.ts (server only: it holds the API key)
TypeScript
const ONECLICK = 'https://1click.chaindefuser.com'
const USDC_NEAR = 'nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1'
const ORIGIN = {
  base: 'nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near', // USDC on Base
  solana: 'nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near', // USDC on Solana
} as const

const headers = { 'Content-Type': 'application/json', 'X-API-Key': process.env.ONECLICK_JWT! }

export async function startDeposit(playerId: string, chain: keyof typeof ORIGIN, amount: string, refundTo: string) {
  const depositId = crypto.randomUUID()
  const res = await fetch(`${ONECLICK}/v0/quote`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      dry: false,
      swapType: 'FLEX_INPUT', // wallets and exchanges rarely send the exact amount
      slippageTolerance: 100,
      originAsset: ORIGIN[chain],
      depositType: 'ORIGIN_CHAIN',
      destinationAsset: USDC_NEAR,
      amount,
      refundTo, // the player's own address on the origin chain
      refundType: 'ORIGIN_CHAIN',
      recipient: 'game.near',
      recipientType: 'DESTINATION_CHAIN',
      customRecipientMsg: JSON.stringify({ player: playerId, deposit_id: depositId }),
      deadline: new Date(Date.now() + 60 * 60_000).toISOString(),
    }),
  })
  const body = await res.json()
  if (!res.ok) throw new Error(`quote failed: ${body.message}`)

  // The deposit address is the idempotency key for everything that follows
  await db.deposits.insert({
    depositId,
    playerId,
    depositAddress: body.quote.depositAddress,
    quote: body, // keep the signed quote for support
    status: 'PENDING_DEPOSIT',
  })
  return { depositAddress: body.quote.depositAddress, minAmountIn: body.quote.minAmountIn, deadline: body.quote.deadline }
}

// Poll (or run from a queue). Statuses only move forward; terminal ones are never overwritten.
export async function refreshDeposit(depositAddress: string) {
  const res = await fetch(`${ONECLICK}/v0/status?depositAddress=${encodeURIComponent(depositAddress)}`, { headers })
  const s = await res.json()
  await db.deposits.updateWhere(
    { depositAddress, status: { notIn: ['SUCCESS', 'REFUNDED', 'FAILED'] } },
    { status: s.status, swapDetails: s.swapDetails },
  )
}

declare const db: any

Contract: credit in ft_on_transfer#

game.near: accepts USDC on NEAR and credits the player named in msg
Rust
use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver;
use near_sdk::json_types::U128;
use near_sdk::serde_json::{self, json, Value};
use near_sdk::store::LookupMap;
use near_sdk::{env, near, require, AccountId, PanicOnDefault, PromiseOrValue};

const MAX_MSG_LEN: usize = 256;
const MAX_ID_LEN: usize = 64;
const MIN_CREDIT: u128 = 1_000_000; // 1 USDC: below this, dust could be used to bloat storage

#[near(serializers = [json])]
pub struct DepositMsg {
    pub player: String,
    pub deposit_id: String,
}

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Game {
    token: AccountId,                  // USDC on NEAR
    balances: LookupMap<String, u128>, // player id -> credits
    unattributed: u128,                // received but not credited; resolved by support
}

#[near]
impl Game {
    #[init]
    pub fn new(token: AccountId) -> Self {
        Self { token, balances: LookupMap::new(b"b"), unattributed: 0 }
    }

    pub fn balance_of(&self, player: String) -> U128 {
        U128(self.balances.get(&player).copied().unwrap_or(0))
    }
}

#[near]
impl FungibleTokenReceiver for Game {
    fn ft_on_transfer(&mut self, sender_id: AccountId, amount: U128, msg: String) -> PromiseOrValue<U128> {
        // Anyone can call ft_on_transfer. Only the token contract calling it proves tokens moved.
        require!(env::predecessor_account_id() == self.token, "only USDC is accepted");

        match parse_msg(&msg).filter(|_| amount.0 >= MIN_CREDIT) {
            Some(d) => {
                let balance = self.balances.get(&d.player).copied().unwrap_or(0);
                self.balances.insert(d.player.clone(), balance + amount.0);
                emit("deposit", json!({
                    "player": d.player, "deposit_id": d.deposit_id,
                    "amount": amount, "sender_id": sender_id,
                }));
            }
            None => {
                // Never panic or hand back tokens here: a transfer withdrawn from
                // intents.near with a msg is not refunded to the player, so a
                // rejection would strand the funds. Keep them and flag them.
                self.unattributed += amount.0;
                emit("unattributed_deposit", json!({ "amount": amount, "sender_id": sender_id }));
            }
        }
        PromiseOrValue::Value(U128(0)) // used all tokens
    }
}

fn parse_msg(msg: &str) -> Option<DepositMsg> {
    if msg.len() > MAX_MSG_LEN {
        return None;
    }
    let d: DepositMsg = serde_json::from_str(msg).ok()?;
    let valid = !d.player.is_empty() && d.player.len() <= MAX_ID_LEN && d.deposit_id.len() <= MAX_ID_LEN;
    valid.then_some(d)
}

fn emit(event: &str, data: Value) {
    let log = json!({ "standard": "mygame", "version": "1.0.0", "event": event, "data": [data] });
    env::log_str(&format!("EVENT_JSON:{log}"));
}

Before going live, register game.near on the USDC contract (NEP-145): read storage_balance_bounds on 17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1 and call storage_deposit with at least min. Without it the delivery fails and, with customRecipientMsg, the funds are not returned.

MoreFallback without customRecipientMsg

Drop customRecipientMsg and 1Click delivers with a plain ft_transfer: safe, but the contract is not notified. Your backend then calls an operator-only credit(deposit_id, player, amount) after SUCCESS, using swapDetails.amountOut. The contract must store processed deposit_ids and reject repeats (idempotency on-chain), and should keep total_credited no greater than what it actually received (compare with its ft_balance_of). The trust moves to the operator key, so restrict it to a function-call access key for credit only.

Idempotency and reconciliation#

  • Off-chain key: the deposit address. Polling, webhooks and retries update one row; terminal statuses are never overwritten.
  • On-chain key: deposit_id in the event. Mark a deposit credited only when you have seen its event; alert if SUCCESS arrives without one, or an event arrives for an unknown ID.
  • Late or wrong deposits: INCOMPLETE_DEPOSIT and REFUNDED are normal outcomes. Show them to the player with the refund address and amount from swapDetails.
  • Unattributed balance: give an owner-only method to move it to a player after a support check, and alert whenever it grows.

Check yourself

3 questions · progress saved in this browser

  1. 1.A 1Click delivery reaches ft_on_transfer with a malformed msg. What should the contract do?
  2. 2.Why does ft_on_transfer compare the predecessor with the configured token?
  3. 3.What makes off-chain attribution reliable even if polling runs twice?