Starknet上基于Pragma预言机的价格条件金库合约实现

本文详细介绍如何在Starknet上使用Cairo语言集成Pragma预言机,实现一个基于STRK价格条件的简单金库合约。文章从Pragma的两层数据聚合机制(数据源与发布者)讲起,逐步构建合约:定义接口、处理ERC20转账、使用Pragma的get_data_median获取中位价格,并通过价格阈值控制提款。最后展示合约部署、存款、查询价格和提款的全流程交互,并指出可扩展至动态NFT、借贷协议等场景。

Pragma 预言机

模块 2

Pragma 是一个为 Starknet 构建的预言机协议,它将链下价格数据带到链上。它提供价格馈送和计算馈送(例如收益率曲线和波动率数据)。

在本文中,你将学习如何将 Pragma 的价格馈送预言机集成到你的 Cairo 合约中。

Pragma 工作原理概述

在集成价格馈送之前,我们先从高层解释一下 Pragma 的工作原理。

Pragma 从多个独立来源收集价格数据,并在链上聚合,以提供可靠、抗操纵的价格馈送。关键区别在于 Pragma 直接在 Starknet 上执行所有聚合,使整个过程透明且可验证。

Pragma 通过一个两层系统聚合数据:

  1. 来源 是价格产生的实际交易所、数据提供商和市场,例如币安、Coinbase、OKX、Ekubo、Chainlink 等。

  2. 发布者 是由 Pragma 管理员控制的注册表列入白名单的实体。它们监控来自各个来源的价格,并将这些数据提交给预言机合约。你可以在 Pragma 的发布者注册合约中找到完整的发布者列表。截至撰写本文时,当前的发布者如下:

区块浏览器显示 get_all_publishers 查询返回发布者名称数组

每个发布者独立监控多个来源,并将价格数据提交给预言机合约。然后,Pragma 的预言机聚合所有发布者的提交。如果某个发布者报告了异常价格,Pragma 的聚合会将其排除。

例如,以下是 ARGENT 发布者监控的来源:

区块浏览器显示 ARGENT 发布者的 get_publisher_sources 查询

因此,当 ARGENT(一个发布者)想要报告价格时,它会监控上方红框中高亮显示的多个来源,并将观察到的价格数据提交给 Pragma 预言机合约。

价格数据如何从来源流向你的合约

  1. 数据收集

发布者监控其指定的来源(交易所、预言机、数据提供商)并收集当前价格观测值。

  1. 链上提交

发布者为收集到的数据添加时间戳,然后直接提交到 Starknet 上的 Pragma 预言机合约。没有集中的链下聚合层;每个发布者独立地在链上提交其数据,数据在那里可以被公开验证。例如,如果 10 个发布者提交 ETH 价格,Pragma 就有 10 个链上数据点可以使用。

  1. 价格计算

Pragma 使用两步聚合过程来计算最终价格:

  • 按来源聚合:对于每个单独的来源(如币安),Pragma 计算所有为该来源报告数据的发布者所提供价格的中位数。例如,如果三个发布者各自提交了从币安观察到的 ETH/USD 价格,Pragma 取这三个值的中位数,得到币安的一个共识价格。
  • 跨来源聚合:在从每个来源(币安、Coinbase、OKX 等)获得共识价格后,你的智能合约可以选择如何将这些来源价格组合成最终价格。你可以根据需要使用中位数、平均值、时间加权平均价格(TWAP)或其他聚合方法。
  1. 智能合约查询

你的合约向 Pragma 的预言机查询当前的 ETH/USD 价格,并收到带有时间戳的聚合价格。

以下是整个流程的总结:

预言机数据流

构建一个带有价格条件的简单金库

让我们构建一个基础的金库合约,用户可以在其中存入 STRK 代币,但只有当 STRK 价格达到某个阈值时才能提取。

以下是该金库中存款和提现操作的流程:

金库存款和提现流程

项目设置

创建一个新的 scarb 项目并导航到目录:

scarb new simple_vault
cd simple_vault

打开 Scarb.toml 并在 [dependencies] 部分下添加 Pragma 预言机库作为依赖:

[dependencies]
pragma_lib = { git = "https://github.com/astraly-labs/pragma-lib" }

Scarb.toml 依赖项显示 pragma_lib Git 仓库 URL 高亮

现在运行 scarb build 来下载依赖并验证项目设置正确。首次运行可能需要一分钟来下载依赖。构建成功后,我们就可以开始编写金库合约了。

定义金库接口

首先,我们为金库合约定义接口。这指定了我们的金库将公开的所有函数:

  • deposit(amount):在金库中锁定 STRK 代币
  • withdraw():取回 STRK 代币(仅在满足价格条件时)
  • get_balance(user):检查特定用户已存入多少 STRK
  • get_strk_price():从 Pragma 预言机查询当前 STRK/USD 价格
use starknet::ContractAddress;

#[starknet::interface]
trait ISimpleVault<TContractState> {
    fn deposit(ref self: TContractState, amount: u256);
    fn withdraw(ref self: TContractState);
    fn get_balance(self: @TContractState, user: ContractAddress) -> u256;
    fn get_strk_price(self: @TContractState) -> u128;
}

定义 ERC20 接口

由于金库需要在存款和提现期间转移 STRK 代币,我们需要定义一个 ERC-20 接口,其中包含用于将代币从用户转移到金库的 transfer_from 和用于将代币从金库返回给用户的 transfer

#[starknet::interface]
trait IERC20<TContractState> {
    fn transfer_from(
        ref self: TContractState,
        sender: ContractAddress,
        recipient: ContractAddress,
        amount: u256
    ) -> bool;

    fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
}

合约导入

首先声明合约模块并导入所需的类型、特性和函数:

#[starknet::contract]
mod SimpleVault {
    use starknet::{ContractAddress, get_caller_address};
    use pragma_lib::abi::{IPragmaABIDispatcher, IPragmaABIDispatcherTrait};
    use pragma_lib::types::{DataType, PragmaPricesResponse};
    use starknet::storage::{Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess};
    use super::{IERC20Dispatcher, IERC20DispatcherTrait};

需要注意的关键导入来自 pragma_lib

  • IPragmaABIDispatcherIPragmaABIDispatcherTrait:用于调用 Pragma 预言机合约上函数的合约调度器和特性
  • DataType:指定我们想要的数据类型(当前市场价格的现货价格)
  • PragmaPricesResponse:保存 Pragma 返回的价格信息

定义合约常量

我们定义两个关键常量:

// 常量
const STRK_USD_PAIR_ID: felt252 = 6004514686061859652; // 来自 Pragma 的 STRK/USD 交易对 ID
const PRICE_THRESHOLD: u128 = 16000000; // 8 位小数的 $0.16 (0.16 * 10^8)

STRK_USD_PAIR_ID 标识要查询哪个 Pragma 价格馈送。在这个例子中,我们查询的是 STRK/USD 馈送。交易对 ID 6004514686061859652 对应于大写 ticker 字符串 'STRK/USD' 的 UTF-8 编码。这意味着交易对 ID 常量可以更易读地写成:

const STRK_USD_PAIR_ID: felt252 = 'STRK/USD';

相同的模式适用于其他价格对。每个交易对 ID 就是其大写 ticker 字符串的 UTF-8 编码。例如,'ETH/USD' 可以写成字符串 'ETH/USD' 或其 felt252 数值 19514442401534788,两者在 Cairo 中是等价的。其他资产交易对 ID 可从 Pragma 的价格馈送文档中获得。每个资产对都有自己独特的 ID 和小数精度;在选择不同交易对时,请务必检查 Decimals 列。相同的交易对 ID 用于 Sepolia 测试网和主网

Pragma 预言机价格馈送数据显示交易对,STRK/USD 交易对 ID 高亮

PRICE_THRESHOLD 设置提现所需的最低 STRK 价格($0.16)。由于 Pragma 以 8 位小数精度返回 STRK 价格,我们将 $0.16 表示为 16,000,000 (0.16 × 10^8)。

定义合约存储

存储结构体定义了我们的合约存储哪些数据:

#[storage]
struct Storage {
    pragma_oracle: ContractAddress,        // pragma_oracle 合约地址
    strk_token: ContractAddress,           // strk 代币合约地址
    balances: Map<ContractAddress, u256>,  // 用户余额
}

我们存储的内容:

  • pragma_oracle:Starknet 上 Pragma 预言机合约的地址,用于获取实时资产价格
  • strk_token:STRK 代币合约的地址,用于处理存款和提现
  • balances:从用户地址到其存入的 STRK 数量的映射

初始化合约

定义了存储之后,我们在部署期间通过将 Pragma 预言机和 STRK 代币地址传递给构造函数来初始化合约:

#[constructor]
fn constructor(
    ref self: ContractState,
    pragma_oracle: ContractAddress,
    strk_token: ContractAddress
) {
    self.pragma_oracle.write(pragma_oracle);
    self.strk_token.write(strk_token);
}

这些地址允许金库合约从 Pragma 的预言机查询价格并处理 STRK 代币转账,正如我们在讨论存款和提现函数时将会看到的那样。

定义合约事件

接下来,我们定义事件来追踪金库中的存款(Deposit)和提现(Withdrawal):

#[event]
#[derive(Drop, starknet::Event)]
enum Event {
    Deposit: Deposit,
    Withdrawal: Withdrawal,
}

#[derive(Drop, starknet::Event)]
struct Deposit {
    user: ContractAddress,
    amount: u256,
}

#[derive(Drop, starknet::Event)]
struct Withdrawal {
    user: ContractAddress,
    amount: u256,
}

每个事件记录用户的地址以及存入或提取的 STRK 数量。

实现接口函数

现在,让我们实现之前定义的金库接口函数:

deposit 函数

当用户调用 deposit(amount) 时,该函数验证金额大于零,然后使用 transfer_from 将 STRK 代币从用户账户转移到金库。这要求用户事先批准金库合约。转移成功后,用户的余额在存储中更新,并发出 Deposit 事件。

fn deposit(ref self: ContractState, amount: u256) {
    let caller = get_caller_address();
    assert(amount > 0, '金额必须大于 0');

    // 将 STRK 从用户转移到此合约
    let strk = IERC20Dispatcher { contract_address: self.strk_token.read() };
    let success = strk.transfer_from(caller, starknet::get_contract_address(), amount);
    assert(success, '转移失败');

    // 更新用户余额
    let current_balance = self.balances.entry(caller).read();
    self.balances.entry(caller).write(current_balance + amount);

    // 发出存款事件
    self.emit(Deposit { user: caller, amount });
}

withdraw 函数

提现函数确保用户有非零余额,然后从 Pragma 获取当前 STRK 价格。如果价格达到或超过 $0.16,它会重置用户余额(遵循检查-效果-交互模式),将他们的 STRK 代币转回他们的账户,并发出 Withdrawal 事件。

fn withdraw(ref self: ContractState) {
    let caller = get_caller_address();
    let balance = self.balances.entry(caller).read();

    assert(balance > 0, '没有余额可提现');

    // 获取当前 STRK 价格
    let current_price = self.get_strk_price();

    // 检查价格阈值是否满足
    assert(current_price >= PRICE_THRESHOLD, '价格阈值未满足');

    // 在转移前重置余额(CEI 模式)
    self.balances.entry(caller).write(0);

    // 将 STRK 转回用户
    let strk = IERC20Dispatcher { contract_address: self.strk_token.read() };
    let success = strk.transfer(caller, balance);
    assert(success, '转移失败');

    // 发出提现事件
    self.emit(Withdrawal { user: caller, amount: balance });
}

get_balance 函数

返回特定用户已存入金库的 STRK 数量:

fn get_balance(self: @ContractState, user: ContractAddress) -> u256 {
    self.balances.entry(user).read()
}

get_strk_price 函数

从 Pragma 预言机获取当前 STRK/USD 价格:

fn get_strk_price(self: @ContractState) -> u128 {
    let oracle = IPragmaABIDispatcher { contract_address: self.pragma_oracle.read() };
    let response: PragmaPricesResponse = oracle.get_data_median(DataType::SpotEntry(STRK_USD_PAIR_ID));
    response.price
}

首先,它创建一个合约调度器,使用存储的地址与 Pragma 预言机合约交互:

let oracle = IPragmaABIDispatcher { contract_address: self.pragma_oracle.read() };

然后调用 get_data_median(),传递 DataType::SpotEntry(STRK_USD_PAIR_ID) 以请求 STRK/USD 的当前现货价格。该方法聚合来自多个发布者的数据并返回中位数,这可以防止任何单个来源操纵结果:

let response: PragmaPricesResponse = oracle.get_data_median(DataType::SpotEntry(STRK_USD_PAIR_ID));

get_data_median() 返回一个 PragmaPricesResponse 结构体,包含以下字段:

struct PragmaPricesResponse {
    price: u128,                           // 聚合价格
    decimals: u32,                         // 小数位数
    last_updated_timestamp: u64,           // 价格最后更新时间
    num_sources_aggregated: u32,           // 聚合中使用的来源数量
    expiration_timestamp: Option<u64>,     // 可选过期时间
}

get_strk_price 仅返回该结构体中的 price 字段:

response.price

价格以 u128 返回,精度为 8 位小数。例如,如果 STRK 交易价格为 $0.15,则返回 15000000(因为 Pragma 使用 8 位小数:0.15 × 10^8)。

通过使用 get_data_median(),我们接受 Pragma 的默认初始聚合(所有发布者提交价格的中位数)。由于我们查询的是单个交易对,所有发布者对 STRK/USD 的中位数就是我们的最终价格。

将完整的金库合约复制到 lib.cairo 中,并使用 scarb build 编译:

use starknet::ContractAddress;

#[starknet::interface]
trait IERC20<TContractState> {
    fn transfer_from(
        ref self: TContractState,
        sender: ContractAddress,
        recipient: ContractAddress,
        amount: u256
    ) -> bool;
    fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
}

#[starknet::interface]
trait ISimpleVault<TContractState> {
    fn deposit(ref self: TContractState, amount: u256);
    fn withdraw(ref self: TContractState);
    fn get_balance(self: @TContractState, user: ContractAddress) -> u256;
    fn get_strk_price(self: @TContractState) -> u128;
}

#[starknet::contract]
mod SimpleVault {
    use starknet::{ContractAddress, get_caller_address};
    use pragma_lib::abi::{IPragmaABIDispatcher, IPragmaABIDispatcherTrait};
    use pragma_lib::types::{DataType, PragmaPricesResponse};
    use starknet::storage::{Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess};
    // 为此 ERC20 调度器添加导入
    use super::{IERC20Dispatcher, IERC20DispatcherTrait};

    // 常量
    const STRK_USD_PAIR_ID: felt252 = 6004514686061859652; // STRK/USD 交易对 ID
    const PRICE_THRESHOLD: u128 = 16000000; // 8 位小数的 $0.16 (0.16 * 10^8)

    #[storage]
    struct Storage {
        pragma_oracle: ContractAddress,
        strk_token: ContractAddress,
        balances: Map<ContractAddress, u256>,
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        Deposit: Deposit,
        Withdrawal: Withdrawal,
    }

    #[derive(Drop, starknet::Event)]
    struct Deposit {
        user: ContractAddress,
        amount: u256,
    }

    #[derive(Drop, starknet::Event)]
    struct Withdrawal {
        user: ContractAddress,
        amount: u256,
    }

    #[constructor]
    fn constructor(
        ref self: ContractState,
        pragma_oracle: ContractAddress,
        strk_token: ContractAddress
    ) {
        self.pragma_oracle.write(pragma_oracle);
        self.strk_token.write(strk_token);
    }

    #[abi(embed_v0)]
    impl SimpleVaultImpl of super::ISimpleVault<ContractState> {
        fn deposit(ref self: ContractState, amount: u256) {
            let caller = get_caller_address();
            assert(amount > 0, '金额必须大于 0');

            // 将 STRK 从用户转移到此合约
            let strk = IERC20Dispatcher { contract_address: self.strk_token.read() };
            let success = strk.transfer_from(caller, starknet::get_contract_address(), amount);
            assert(success, '转移失败');

            // 更新用户余额
            let current_balance = self.balances.entry(caller).read();
            self.balances.entry(caller).write(current_balance + amount);

            // 发出存款事件
            self.emit(Deposit { user: caller, amount });
        }

        fn withdraw(ref self: ContractState) {
            let caller = get_caller_address();
            let balance = self.balances.entry(caller).read();

            assert(balance > 0, '没有余额可提现');

            // 获取当前 STRK 价格
            let current_price = self.get_strk_price();

            // 检查价格阈值是否满足
            assert(current_price >= PRICE_THRESHOLD, '价格阈值未满足');

            // 在转移前重置余额(CEI 模式)
            self.balances.entry(caller).write(0);

            // 将 STRK 转回用户
            let strk = IERC20Dispatcher { contract_address: self.strk_token.read() };
            let success = strk.transfer(caller, balance);
            assert(success, '转移失败');

            // 发出提现事件
            self.emit(Withdrawal { user: caller, amount: balance });
        }

        fn get_balance(self: @ContractState, user: ContractAddress) -> u256 {
            self.balances.entry(user).read()
        }

        fn get_strk_price(self: @ContractState) -> u128 {
            let oracle = IPragmaABIDispatcher { contract_address: self.pragma_oracle.read() };
            let response: PragmaPricesResponse = oracle.get_data_median(DataType::SpotEntry(STRK_USD_PAIR_ID));
            response.price
        }
    }
}

部署金库合约

首先,使用 sncast 声明合约:

sncast --account <YOUR_ACCOUNT_NAME> \
declare \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY> \
--contract-name SimpleVault

终端显示 SimpleVault 合约的 sncast declare 命令,带有 class hash 输出

Pragma 提供了部署所需的两个地址。我们将它们作为参数传递给构造函数:

  • Pragma 预言机0x036031daa264c24520b11d93af622c848b2499b66b41d611bac95e13cfca131a
  • STRK 代币0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d

你可以在 Pragma 的 GitHub 仓库 中找到最新的部署地址。

使用构造函数参数部署合约:

sncast \
--account new_account1 \
deploy \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY> \
--class-hash <CLASS_HASH> \
--constructor-calldata "0x036031daa264c24520b11d93af622c848b2499b66b41d611bac95e13cfca131a" "0x04718f5a0Fc34cC1AF16A1cdee98fFB20C31f5cD61D6Ab07201858f4287c938D"

从 declare 输出中复制 <CLASS_HASH> 并粘贴到 deploy 命令中。终端将显示包含已部署金库合约地址的交易收据。

终端显示 sncast deploy 命令,包含金库 class hash、Pragma 预言机和 STRK 代币合约地址作为构造函数参数

保存地址以供后续步骤使用。

与金库交互

部署好金库后,让我们通过 Voyager 逐步完成 STRK 代币的存款和提现。

批准金库

在存入之前,必须批准金库转移 STRK 代币。导航到 Voyager 上 STRK 代币合约的“Write Contract”选项卡,并调用 approve 函数,将金库合约地址作为 spender,并输入你要存入的 STRK 数量。完成后,执行交易并等待确认。

区块浏览器显示 approve 函数,将金库合约地址作为 spender 并批准 STRK 金额

进行存款

获得批准后,现在可以存入代币。在金库合约的“Write Contract”选项卡上,使用你之前批准的金额调用 deposit 函数。执行此交易后,STRK 代币被锁定在金库中。

区块浏览器显示 deposit 函数,带有已批准的 STRK 金额

检查余额

要验证存款是否成功,导航到“Read Contract”选项卡并使用钱包地址调用 get_balance。它应该返回余额,确认 STRK 存款。

区块浏览器显示 get_balance 查询,带有用户地址,返回已存入的金库余额

检查当前 STRK 价格

在“Read Contract”选项卡中调用 get_strk_price 以检查来自 Pragma 的当前 STRK 价格。该函数返回一个具有 8 位小数的整数价格。要将其转换为美元价值,请将返回的数字除以 100,000,000。

区块浏览器显示 get_strk_price 查询,返回当前 STRK 价格值

例如,这里函数返回 14716561。除以 100,000,000:

14716561 ÷ 100,000,000 = 0.14716561

因此,此查询时的当前 STRK 价格为 $0.147

尝试提现

现在尝试提取已存入的 STRK。在“Write Contract”选项卡中,调用 withdraw 函数并执行交易。

结果取决于当前价格。如果 STRK 低于 $0.16,交易将失败并显示错误消息“价格阈值未满足”。如果 STRK 已达到 $0.16 或更高,代币将返回给钱包。

Voyager 区块浏览器显示 withdraw 函数执行失败,出现价格阈值未满足错误

要立即测试成功的提现,可以重新声明并重新部署合约,使用更低的阈值,例如 10000000(代表 $0.10),或者等待 STRK 价格自然上涨至 $0.16 以上。

结论

集成 Pragma 的价格馈送使借贷协议、预测市场、动态 NFT 和其他应用程序能够在链上访问经过验证的价格数据。目前,金库仅根据价格锁定和释放资金。除了价格条件之外,没有收益或奖励机制。合约可以通过添加适合特定用例的功能进行扩展:为存款人提供质押奖励、为不同用户层级提供多个价格阈值,或者时间加权解锁。

Pragma 的计算馈送提供的不仅仅是现货价格。波动率馈送可衡量市场波动,适用于风险调整协议。TWAP 提供时间平均价格,可抵抗闪电贷攻击的操纵。探索这些馈送、尝试不同的资产对以及组合条件,可以揭示预言机如何转换合约以动态响应现实世界的条件。

  • 原文链接: rareskills.io/post/cairo...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/