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#
- 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 acustomRecipientMsgnaming the player and a deposit ID. - The player sends funds to the returned
depositAddress. Each quote has its own address, so the address itself attributes the deposit off-chain. - 1Click settles and withdraws USDC from
intents.nearwithft_transfer_callcarrying your message. The USDC contract callsgame.near.ft_on_transfer(sender_id = "intents.near", amount, msg). - The contract credits the player and emits an event with the deposit ID. Your backend marks the deposit done when both 1Click reports
SUCCESSand the event is on-chain.
Backend: one quote per deposit#
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: anyContract: credit in ft_on_transfer#
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_idin the event. Mark a deposit credited only when you have seen its event; alert ifSUCCESSarrives without one, or an event arrives for an unknown ID. - Late or wrong deposits:
INCOMPLETE_DEPOSITandREFUNDEDare normal outcomes. Show them to the player with the refund address and amount fromswapDetails. - 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