⚠️ 本文仅为海外产品技术案例科普,不构成任何投资、参与项目的建议,不鼓励参与境外相关应用,虚拟资产相关业务在国内不受法律保护。
引言
如果说 2022 年《STEPN》掀起了运动 + 数字化产品的设计浪潮,那么 2026 年,海外户外互动产品赛道正在探索一套实体藏品数字化确权的全新产品思路。
【注:往期内容,我拆解过从《STEPN》到《STEPN GO》的产品演进逻辑,重点分析它的设备行为校验系统与经济循环设计,对运动类互动产品感兴趣,可以翻看往期文章。】
在拆解《STEPN GO》的过程中我们看到,它通过资产共享机制降低用户参与门槛,依靠道具消耗体系延缓产品经济系统的衰退。但很多人看完之后会思考一个问题:除了纯虚拟数字道具,运动类户外互动产品,能不能加入实体藏品的联动玩法,给用户带来真实的实物收藏体验?
海外 Solana 生态有一款案例项目 GoCollect,设计了一套AR 户外地图寻宝 + 实体评级卡牌开箱的创新玩法。可以理解为:结合户外地理位置探索玩法的藏品收集类产品。本文将沿用之前拆解 STEPN 系列的技术分析思路,从产品背景、核心玩法设计、EVM 架构技术复刻方案三个维度,聊聊这款虚实结合项目的底层设计思路。
一、产品背景:实体藏品结合数字化存证的设计思路
要理解 GoCollect 的设计思路,首先看全球集换式卡牌 TCG 收藏市场。宝可梦卡牌、游戏王、万智牌、体育球星卡,经过 PSA、CGC 鉴定评级后的实体收藏卡牌,已经形成成熟的收藏品市场。
但传统实体卡牌收藏,存在保管、权属核验、流转验证的诸多壁垒。GoCollect 的产品思路,是把存放在专业金库、经过权威评级认证的实体收藏卡牌,生成对应的链上数字化权益凭证。每一张实体卡牌,对应一份唯一的数字凭证。想要获取这份凭证,用户需要走出家门,在现实空间完成户外探索任务。
注:该方案为海外项目设计构想,国内不支持此类资产数字化确权与权益流转。
二、核心玩法拆解:GoCollect 的产品交互设计
这套产品和普通资金盘项目有本质区别,它的核心设计思路是线下地理位置触发的户外探索玩法:
- 物理定位寻宝(GPS 地理围栏开箱) 玩家在移动端打开应用,开启 GPS 定位。在地图上,系统会刷新不同等级的虚拟宝箱。玩家无法通过脚本修改定位,必须实地走到宝箱对应的现实坐标,当定位匹配后,才能开启宝箱,每人每天拥有 25 次免费开箱机会。
- 开箱随机掉落与碎片收集机制 开箱后会触发两种结果:小概率获取实体卡牌对应的数字化权益凭证;大部分情况会获得游戏内碎片道具。碎片可以用来兑换抽奖资格,参与定时公开抽奖,奖品为金库托管的实体收藏卡牌。
- 虚实联动:藏品提取逻辑 当用户获得卡牌对应的数字凭证之后,有两种选择: ① 申领实体藏品:销毁这份数字凭证,金库仓库直接邮寄实体卡牌给到用户; ② 项目内权益清算:在产品内发起凭证回收,由项目方按预设规则收回这份数字凭证。
三、联动思考:和《STEPN GO》架构对比
结合往期对《STEPN GO》的系统拆解,我们能看到两代产品设计思路的迭代差异:
| 核心维度 | 《STEPN GO》产品设计总结(往期内容) | GoCollect 设计演进思路 |
|---|---|---|
| 行为校验(防作弊) | 中心化 SMART 算法,依靠手机传感器、AI 模型识别模拟器、定位篡改等作弊行为 | 地理围栏校验 + 密码学签名校验。作弊行为在服务端拦截,合约只校验签名,把校验逻辑下沉到密码学层面 |
| 价值循环设计 | 纯虚拟道具内循环,依靠道具消耗。一旦新增用户减少,虚拟道具容易出现通胀问题 | 实体藏品作为底层载体,数字凭证只是实体藏品的权属存证,藏品价值依托实体收藏市场 |
| 准入门槛 | 资产租赁共享机制,老用户出借道具,新用户可以直接参与体验 | 采用权限准入设计,需要持有指定数字凭证获得参与资格 |
四、核心复刻技术指南(基于 EVM + Solidity + Hardhat)
下面是技术原型开发方案,仅用于学习研究,不建议部署上线用于资产交易。使用 Solidity 语言 + OpenZeppelin v5,搭建一套类似这套户外寻宝产品的底层合约原型。
4.1 核心智能合约骨架 (GoCollectContracts.sol)
- GameToken.sol
js
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract GameToken is ERC20, Ownable {
constructor() ERC20("Game Demo Token", "GDT") Ownable(msg.sender) {
_mint(msg.sender, 1_000_000 * 10**decimals());
}
// OpenZeppelin v5 统一转移钩子,重写 _update 用于自定义流转逻辑,仅做Demo演示
function _update(address from, address to, uint256 value) internal override {
super._update(from, to, value);
}
}
- PhysicalCardNFT.sol
js
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract PhysicalCardNFT is ERC721URIStorage, Ownable {
uint256 private _nextTokenId;
address public engineAddress;
modifier onlyEngine() {
require(msg.sender == engineAddress, "Only Engine can invoke");
_;
}
constructor() ERC721("Physical Card Demo Token", "CARDDemo") Ownable(msg.sender) {}
function setEngine(address _engine) external onlyOwner {
engineAddress = _engine;
}
function mintCard(address to, string memory uri) external onlyEngine returns (uint256) {
uint256 tokenId = _nextTokenId++;
_mint(to, tokenId);
_setTokenURI(tokenId, uri);
return tokenId;
}
// 用户申领实体藏品时销毁数字凭证
function burn(uint256 tokenId) external {
require(ownerOf(tokenId) == msg.sender || isApprovedForAll(ownerOf(tokenId), msg.sender), "Not owner nor approved");
_burn(tokenId);
}
}
- GoCollectEngine.sol
js
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import "./PhysicalCardNFT.sol";
contract GoCollectEngine is Ownable {
using ECDSA for bytes32;
IERC20 public demoTokenGate;
PhysicalCardNFT public cardNFT;
address public gpsSigner; //后端GPS验证签名对应的公链地址
uint256 public minTicketThreshold; //准入权限门槛
uint256 public constant RECYCLE_AMOUNT = 50 * 10**18; //Demo:凭证回收返还的演示代币数量
mapping(bytes32 => bool) public usedSignatures; //防止签名重复使用,抵御重放攻击
event CrateOpened(address indexed player, uint256 indexed tokenId, string cardUri);
event CardRecycled(address indexed player, uint256 indexed tokenId, uint256 recycleAmount);
constructor(
address _tokenGate,
address _cardNFT,
address _gpsSigner,
uint256 _minTicketThreshold
) Ownable(msg.sender) {
demoTokenGate = IERC20(_tokenGate);
cardNFT = PhysicalCardNFT(_cardNFT);
gpsSigner = _newSigner;
minTicketThreshold = _minTicketThreshold;
}
function setGpsSigner(address _newSigner) external onlyOwner {
gpsSigner = _newSigner;
}
/**
* @notice 核心户外开箱逻辑,定位校验防篡改
* @param lat 经度乘以10**7
* @param lng 纬度乘以10**7
* @param nonce 随机值,防止重放攻击
* @param cardUri 藏品元数据地址,空字符串代表未中奖
* @param signature 后端对坐标、玩家地址生成的密码学签名
*/
function openCrate(
int256 lat,
int256 lng,
uint256 nonce,
string memory cardUri,
bytes memory signature
) external {
//1. 权限校验:账户需要持有对应演示凭证
require(demoTokenGate.balanceOf(msg.sender) >= minTicketThreshold, "Insufficient permission token");
//2. 哈希校验,防重放
bytes32 messageHash = keccak256(abi.encodePacked(msg.sender, lat, lng, nonce, cardUri));
bytes32 ethSignedMessageHash = MessageHashUtils.toEthSignedMessageHash(messageHash);
require(!usedSignatures[ethSignedMessageHash], "Signature already used");
require(ethSignedMessageHash.recover(signature) == gpsSigner, "Invalid GPS signature");
usedSignatures[ethSignedMessageHash] = true;
//3. 中奖生成数字凭证
if (bytes(cardUri).length > 0) {
uint256 tokenId = cardNFT.mintCard(msg.sender, cardUri);
emit CrateOpened(msg.sender, tokenId, cardUri);
}
}
/**
* @notice 演示:数字凭证回收逻辑(仅Demo,不用于真实资产交易)
*/
function recycleCard(uint256 tokenId) external {
require(cardNFT.ownerOf(tokenId) == msg.sender, "Not the card owner");
cardNFT.transferFrom(msg.sender, address(this), tokenId);
cardNFT.burn(tokenId);
require(demoTokenGate.balanceOf(address(this)) >= RECYCLE_AMOUNT, "Demo pool insufficient");
demoTokenGate.transfer(msg.sender, RECYCLE_AMOUNT);
emit CardRecycled(msg.sender, tokenId, RECYCLE_AMOUNT);
}
}
4.2 基于 Viem + node:test 的集成测试脚本 (GoCollect.test.ts)
这套合约在 Hardhat 环境下做原型验证,使用 Viem 客户端 + node 原生测试模块,重点测试权限拦截、GPS 签名防篡改、重放攻击防护、凭证回收流转。 测试用例清单:
- 权限拦截:账户没有足够凭证,无法触发开箱
- 户外开箱与防篡改:满足准入条件 + 后端合法 GPS 签名,成功生成藏品数字凭证
- 防重放攻击:同一签名不能重复调用开箱接口
- 凭证回收:持有者可以发起凭证回收,触发合约内演示流转逻辑
js
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { parseEther, zeroAddress, getAddress, keccak256, encodePacked } from "viem";
import { network } from "hardhat";
describe("GoCollect Protocol Full Integration", function () {
// 部署及初始化测试夹具
async function deployFixture() {
const { viem } = await (network as any).connect();
// owner: 项目方, user: 散户玩家, backEndSigner: 用于GPS签名的后端私钥角色
const [owner, user, backEndSigner] = await viem.getWalletClients();
const publicClient = await viem.getPublicClient();
// 1. 部署门票代币 ERC-20
const tokenGate = await viem.deployContract("GameToken");
// 2. 部署实体卡牌 NFT
const cardNFT = await viem.deployContract("PhysicalCardNFT");
// 设置门槛为 10,000 COLLECT 代币
const minThreshold = parseEther("10000");
// 3. 部署核心游戏引擎
const engine = await viem.deployContract("GoCollectEngine", [
tokenGate.address,
cardNFT.address,
backEndSigner.account.address,
minThreshold
]);
// 4. 配置 NFT 权限,允许 Engine 进行铸造
await cardNFT.write.setEngine([engine.address], { account: owner.account });
// 5. 游戏引擎资金池注入清算代币资金
await tokenGate.write.transfer([engine.address, parseEther("50000")], { account: owner.account });
return {
tokenGate,
cardNFT,
engine,
owner,
user,
backEndSigner,
publicClient,
minThreshold
};
}
it("门槛拦截:如果钱包没有持有 10,000 代币,开箱应该被拒绝", async function () {
const { engine, user, backEndSigner } = await deployFixture();
// 模拟后端为没有代币的 user 生成的 GPS 签名数据
const lat = 37774929n; // 假定经纬度
const lng = -122419416n;
const nonce = 123456n;
const cardUri = "ipfs://QmPokemonCardMetadata";
// 后端对参数进行打包并哈希
const messageHash = keccak256(
encodePacked(
["address", "int256", "int256", "uint256", "string"],
[user.account.address, lat, lng, nonce, cardUri]
)
);
// 模拟后端私钥对哈希签名
const signature = await backEndSigner.signMessage({ message: { raw: messageHash } });
// 散户未持有门票,应当触发 Revert 拦截
await assert.rejects(
async () => {
await engine.write.openCrate([lat, lng, nonce, cardUri, signature], {
account: user.account,
});
},
/Insufficient gate tokens/,
"没有持币门票的用户不应允许开箱"
);
});
it("物理开箱与防作弊:满足持币条件、拥有后端合法的 GPS 签名,则成功爆出 NFT 卡牌", async function () {
const { tokenGate, cardNFT, engine, user, backEndSigner, minThreshold } = await deployFixture();
// 1. 给散户玩家发放足额门票代币
await tokenGate.write.transfer([user.account.address, minThreshold], { account: (await deployFixture()).owner.account });
const lat = 37774929n;
const lng = -122419416n;
const nonce = 99999n;
const cardUri = "ipfs://GoldPikachuPSA10";
// 后端验证真实到达该经纬度,进行哈希并签名
const messageHash = keccak256(
encodePacked(
["address", "int256", "int256", "uint256", "string"],
[user.account.address, lat, lng, nonce, cardUri]
)
);
const signature = await backEndSigner.signMessage({ message: { raw: messageHash } });
// 2. 玩家拿着签名发送到链上引擎开箱
await engine.write.openCrate([lat, lng, nonce, cardUri, signature], {
account: user.account,
});
// 3. 校验卡牌所有权:散户成功获得了 TokenID 0 且 URI 正确的 RWA 卡牌
const cardOwner = await cardNFT.read.ownerOf([0n]);
const tokenUri = await cardNFT.read.tokenURI([0n]);
assert.equal(getAddress(cardOwner), getAddress(user.account.address), "爆出的卡牌未发放到玩家账户");
assert.equal(tokenUri, cardUri, "卡牌元数据不匹配");
});
it("防重放攻击:相同的后端签名不应该能被二次提交开箱", async function () {
const { tokenGate, engine, user, backEndSigner, minThreshold } = await deployFixture();
await tokenGate.write.transfer([user.account.address, minThreshold], { account: (await deployFixture()).owner.account });
const lat = 37774929n;
const lng = -122419416n;
const nonce = 88888n;
const cardUri = "ipfs://CharizardFirstEdition";
const messageHash = keccak256(
encodePacked(
["address", "int256", "int256", "uint256", "string"],
[user.account.address, lat, lng, nonce, cardUri]
)
);
const signature = await backEndSigner.signMessage({ message: { raw: messageHash } });
// 第一次提交成功
await engine.write.openCrate([lat, lng, nonce, cardUri, signature], { account: user.account });
// 恶意用户尝试重放同一批参数与签名,应当遭到拦截
await assert.rejects(
async () => {
await engine.write.openCrate([lat, lng, nonce, cardUri, signature], { account: user.account });
},
/Signature already used/,
"重放攻击应该被阻止"
);
});
it("一键变现流转:玩家可以对抽中的卡牌进行链上清算,自动回收并返还流动性代币", async function () {
const { tokenGate, cardNFT, engine, user, backEndSigner, minThreshold } = await deployFixture();
await tokenGate.write.transfer([user.account.address, minThreshold], { account: (await deployFixture()).owner.account });
// 1. 成功爆卡开箱
const nonce = 77777n;
const cardUri = "ipfs://Mewtwo";
const messageHash = keccak256(encodePacked(["address", "int256", "int256", "uint256", "string"], [user.account.address, 0n, 0n, nonce, cardUri]));
const signature = await backEndSigner.signMessage({ message: { raw: messageHash } });
await engine.write.openCrate([0n, 0n, nonce, cardUri, signature], { account: user.account });
const tokenId = 0n;
const balanceBefore = await tokenGate.read.balanceOf([user.account.address]);
// 2. 玩家授权 Engine 能够回收处理其获得的 NFT 卡牌
await cardNFT.write.approve([engine.address, tokenId], { account: user.account });
// 3. 玩家调用游戏引擎的变现清算接口
await engine.write.liquidateCard([tokenId], { account: user.account });
// 4. 断言验证:NFT 已被销毁或流转(查询所有权报错提示不属于任何人或已被烧毁)
await assert.rejects(
async () => {
await cardNFT.read.ownerOf([tokenId]);
},
/ERC721NonexistentToken/
);
// 5. 断言验证:玩家应该获得额外的 50 枚代币清算返还金
const balanceAfter = await tokenGate.read.balanceOf([user.account.address]);
const expectedPayout = parseEther("50");
assert.equal(balanceAfter, balanceBefore + expectedPayout, "一键回收没有正确返还变现代币");
});
});
五、结语:虚实结合互动产品的设计思路演进
从《STEPN》纯数字道具的设计,到《STEPN GO》的循环机制优化,再到 GoCollect 实体藏品联动的方案,海外户外互动产品,一直在探索实体藏品和线上数字凭证结合的产品形态。
这也印证了之前拆解得出的规律:只依靠纯虚拟道具的产品循环,天花板很低。如果引入实体收藏品作为载体,会带来完全不一样的产品体验。
这套由权限准入 + 链下 GPS 密码学签名校验 + 链上凭证存证 / 回收组成的技术方案,为线下活动营销、潮玩线下互动提供了有意思的工程参考思路。如果之前看过 STEPN GO 的拆解,两篇对照阅读,可以更好理解这类户外互动产品的迭代逻辑。