元宇宙应用开发实例——以太坊里的智能合约和Decentraland里的3D前端交互组件

1. 元宇宙核心技术

腾讯最近发布了一个全真互联白皮书,虽然他们强调全真互联元宇宙不同,但怎么看都像是无奈之下的牵强附会。从核心技术上来看,其实元宇宙Web3.0和这个全真互联都是一回事儿,都是前端和后端两方面技术发展的产物:

  • 随着前端交互技术(既包括软件渲染技术,也包括硬件交互设备)的发展,互联网从只能在PC上看PGC的Web1.0,发展到还可以在手机上看UGC的Web2.0。到了今天,发展出了又能在各种智能穿戴设备上,用不同的人类感官,去交互三维内容的Web3.0,也就是元宇宙。
  • 内容的种类和规模的增加,必然要求后端计算、存储、网络的处理能力的增强。云计算全栈软硬件技术的不断演进支持了Web1.0、2.0到3.0的变化,只是到了3.0,也就是元宇宙,后端又增加了另一项配合社会形态转变的智能合约技术(比如NFT-非同质数字化资产、数字货币等),智能合约当然不见得要跟区块链连在一起,但区块链确实是实现它的一种有效方式。

腾讯的全真互联确实刻意的回避了智能合约,这也不奇怪,乖巧如腾讯者深谙个中道理,不可能碰这种危险的东西,这也是为啥国内元宇宙初创公司大多数都集中在前端技术的原因,而且腾讯自己是Web2.0的既得利益者,商业上,显然也不愿意去掉包括自己在内的这些中心互联网平台。

为了从技术上实操完整的元宇宙应用开发,本文选择Decentraland作为做实验的平台。

2. 元宇宙实例及应用实例

关于本实例完整的业务模式描述,请参见前面的文章。本文是从技术角度记录下验证的实际开发过程。

Decentraland是一个基于以太坊区块链实现的分布式虚拟现实平台。它是一个高度符合元宇宙定义的虚拟世界,这个世界的成员可以在它的土地上创建内容应用体验他人的内容和应用,用自己的内容和应用赚钱。Decentraland中的土地是用以太坊智能合约维护的一种NFT,里面的空间是3D的,用户可以在里面游逛,土地总数有限。土地被分为Parcel,Parcel用坐标来标识,这些Parcel被这个世界的成员们永久持有,可以用MANA(马那币)交易,MANA是Decentraland的官方加密数字货币。用户可以全权控制他们的土地,在上面用现成或自定义的3D前端组件,创建静态3D场景或可交互的游戏和应用。

本文实现的就是一个自定义的3D可交互前端组件(在Decentraland叫做Smart Item),组件里包含由一个以太坊智能合约维护的、可交互的广告内容。这个合约是个4方合约,参与方有组件运营者土地所有者广告发布者消费者。整个合约发生作用的过程如下:

  • 组件运营者把组件发布到Decentraland Builder
  • 广告发布者将广告发布到组件上
  • 土地所有者在Decentraland Builder里把组件装配在自己土地上的建筑上
  • 消费者来这个建筑里,点击广告,广告发布者按约定的金额,支付MANA给土地所有者和组件运营者
  • 消费者打开广告后,发生了购买行为,广告发布者按约定的金额,支付MANA给消费者

3. 以太坊里的智能合约开发

3.1. World Wide Web的访问能力

首先,使用代理或其他方式,获得World Wide网络访问能力。

3.2. 初始化以太坊钱包

  • chrome应用商店中安装chrome插件形式的加密货币钱包MetaMask,并依提示创建默认账户。
  • 在chrome右上角的MetaMask插件界面中,将默认账户的名字改为“组件运营者”。
  • 继续创建三个账户,分别命名为“土地所有者”、“广告发布者”、“消费者”。
  • 将当前所在链从以太坊Ethereum主网络,切换至Ropsten测试网络。注意:这个测试网络只能用到22年Q4末,随着以太坊完成了“Merge”,此测试网络将下线,测试需要切换至新网络,建议使用Sepolia test network,不过,现在Decentraland还不支持这个新测试网络。
  • 打开Ropsten测试网络的代币免费获取页面,将账户依次切换至“组件运营者”、“广告发布者”、“消费者”,为这三个账户分别充值1个以太币,转账需要在MetaMask面板上点击确定。这些以太币是用于支付在以太坊上执行交易所需支付的成本。如果使用“Sepolia test network”,则需要使用Sepolia的代币免费获取页面,不过它的代币到账时间需要大概2个小时。
  • 打开Decentraland在Ropster上的MANA测试代币免费获取页面,为“广告发布者”账户充值MANA代币,转账需要在MetaMask面板上点击确定。这些MANA是4方合约在执行交易时使用的货币。

3.3. 开发4方合约

以太坊智能合约开发使用的语言是Solidity,使用Solidity让人想起用c++写系统软件,对内存精细规划,对代码极致雕琢。这是因为合约要被存储到链上并在链上执行,代码体积和执行内存越大,消耗的资源越多,要付的钱就越多。它必须很干净,能删除掉的都要删掉,注释不要有,甚至变量名都应该越短越好。本文中代码注释是为了说明逻辑,实际生产运行时一定要删掉注释,缩短变量名。

合约的开发工具为Remix。Remix可进行本地部署,但验证过程就直接使用SaaS版本了。打开Remix,越过欢迎界面,进入开发界面,在左侧选择File explorer,然后在contracts文件夹下,创建一个新的Solidity程序文件,命名为dmall.sol(此实例中的合约名字定为DMall),并将以下代码复制进文件中:

//SPDX-License-Identifier: UNLICENSED

pragma solidity >=0.7.0 <0.9.0;

// 声明MANAToken的接口,用于DMall合约中调用
interface MANAToken {
    
    
    // 广告所有者必须在发布广告之前,调用MANAToken的这个接口,允许DMall从他的MANA账户里划转代币到其他账户,其中_sender参数为DMall部署后的地址,_value参数为允许划转的MANA代币数量的上限
    function approve(address _sender, uint256 _value) external returns (bool);
    // DMall合约会调用这个接口,将MANA代币从广告所有者转账到其他三个账户,_from是广告所有者,_to是其他三个账户,_value是转账金额
    function transferFrom(address _from, address _to, uint256 _value) external returns (bool);
}

contract DMall {
    
    
    // 广告的状态枚举,广告所有者首次调用uptAd接口创建接口后为Created,组件运营者调用apprAd接口同意后变为Approved,广告所有者再次调用uptAd接口后为Updated,此时还需要组件运营者调用apprAd接口。只有Approved状态的广告可以被看到和购买。Undefined是初始状态,无意义,Invalid是广告因为其他原因(如广告发布者没给MANA)失效。
    enum AdState {
    
     Undefined, Created, Approved, Updated, Invalid } 
    // 广告点击记录的状态,Undefined是初始状态,无意义,Clicked表示消费者点击过了(即调用clickAd),Bought表示消费者买过了(即调用buyAd)。
    enum ClickState {
    
     Undefined, Clicked, Bought } 
    // 在链上存储的一条广告的数据结构
    struct Ad {
    
    
        // 广告点击时,广告发布者给土地所有者的MANA数量
        uint m2L;
        // 广告点击时,广告发布者给组件运营者的MANA数量
        uint m2O;
        // 广告被购买时,广告发布者给消费者的MANA数量
        uint m2C;
        // 广告的状态
        AdState state;
        // 广告的点击记录数据集合,第一个地址是土地所有者的地址,第二个地址是消费者的地址,最终里面存的数据是一条点击数据的状态
        mapping(address => mapping(address => ClickState)) clicks;
    }
    // MANAToken合约的地址
    MANAToken manaToken;
    // 广告数据集合,第一个地址是广告发布者的地址,第二个整数是广告ID,数据是上面的Ad结构体
    mapping(address => mapping(uint => Ad)) public ads;
    // 合约运营者,就是组件运营者的地址
    address public op;

    // 合约构造函数,用调用者的地址初始化合约运营者地址,用输入的MANAToken合约地址初始化对应数据,由所在的链是生产链还是测试链决定
    constructor(address _manaToken) {
    
    
        op = msg.sender;
        manaToken = MANAToken(_manaToken);
    }
    
    // 因为链上的操作都是异步的,需要存在链上才算成功,以下事件是对应接口调用成功后的回调,可在前端应用程序中实现

    // 广告创建成功后的事件(uptAd接口首次调用后的事件)
    event AdCrted(address merchant, uint adId, uint m2L, uint m2O, uint m2C);
    // 广告更新成功后的事件(uptAd接口再次调用后的事件)
    event AdUpted(address merchant, uint adId, uint oM2L, uint oM2O, uint oM2C, uint m2L, uint m2O, uint m2C);
    // 广告批准成功后的事件(apprAd接口调用后的事件)
    event AdAppred(address merchant, uint adId);
    // 广告点击后的事件(clickAd接口调用后的事件)
    event AdClicked(address merchant, uint adId, address landowner, address consumer);
    // 广告失效事件(clickAd接口或buyAd接口中发生失败后的事件)
    event AdInvalid(address merchant, uint adId);
    // 广告被购买事件(buyAd接口调用后的事件)
    event AdBought(address merchant, uint adId, address landowner, address consumer);

    // 创建或更新广告,由广告发布者调用,_adId是广告的ID,_m2L是广告发布者愿意给土地所有者的MANA数量,_m2O是广告发布者愿意给组件运营者的MANA数量,_m2C是广告发布者愿意给消费者的MANA数量
    function uptAd(uint _adId, uint _m2L, uint _m2O, uint _m2C) public {
    
    
        // 初始化一个Ad对象
        Ad storage ad = ads[msg.sender][_adId];
        if (ad.state == AdState.Undefined) {
    
    
            // 如果这是个新广告,则设置参数,并触发AdCrted
            ad.state = AdState.Created;
            ad.m2L = _m2L;
            ad.m2O = _m2O;
            ad.m2C = _m2C;
            emit AdCrted(msg.sender, _adId, _m2L, _m2O, _m2C);
        }
        else {
    
    
            // 如果不是新广告,则记录当前参数,设置新参数,并触发AdUpted
            ad.state = AdState.Updated;
            uint oM2L = ad.m2L;
            uint oM2O = ad.m2O;
            uint oM2C = ad.m2C;
            ad.m2L = _m2L;
            ad.m2O = _m2O;
            ad.m2C = _m2C;
            emit AdUpted(msg.sender, _adId, oM2L, oM2O, oM2C, _m2L, _m2O, _m2C);
        }
    }
    // 批准广告,由组件运营者调用, _merchant是广告发布者地址,_adId是广告的ID
    function apprAd(address _merchant, uint _adId) public {
    
    
        // 要求只能是组件所有者调用
        require(msg.sender == op, "Only DMall operator can approve the deployment of Ad");
        // 要求广告不是初始状态和批准过的状态
        require(ads[_merchant][_adId].state != AdState.Undefined && ads[_merchant][_adId].state != AdState.Approved , "The ad of merchant with the ID does not exist or has been approved");
        // 设置广告状态
        ads[_merchant][_adId].state = AdState.Approved;
        // 触发AdAppred事件
        emit AdAppred(_merchant, _adId);
    }

    // 点击广告,由消费者调用,_merchant是广告发布者地址,_adId是广告的ID,_landowner是广告所在土地的土地所有者
    function clickAd(address _merchant, uint _adId, address _landowner) public {
    
    
        // 需要广告是Approved的
        require(ads[_merchant][_adId].state == AdState.Approved, "The ad of merchant with the ID has not been approved");
        // 需要广告不是已被此消费者点击状态
        require(ads[_merchant][_adId].clicks[_landowner][msg.sender] != ClickState.Clicked, "The clicking had been paid but does not lead to buying yet");
        // 广告发布者按约定给土地所有者和组件运营者转账
        if (manaToken.transferFrom(_merchant, _landowner, ads[_merchant][_adId].m2L) && manaToken.transferFrom(_merchant, op, ads[_merchant][_adId].m2O) ) {
    
    
            // 如果转账成功,则标记点击状态,触发AdClicked
            ads[_merchant][_adId].clicks[_landowner][msg.sender] = ClickState.Clicked;
            emit AdClicked(_merchant, _adId, _landowner, msg.sender);
        }
        else {
    
    
            // 转账失败,则标记广告失效,触发AdInvalid
            ads[_merchant][_adId].state = AdState.Invalid;
            emit AdInvalid(_merchant, _adId);
        }
    }

    // 购买广告商品,由消费者调用,_merchant是广告发布者地址,_adId是广告的ID,_landowner是广告所在土地的土地所有者
    function buyAd(address _merchant, uint _adId, address _landowner) public {
    
    
        // 需要消费者点击过此广告
        require(ads[_merchant][_adId].clicks[_landowner][msg.sender] == ClickState.Clicked, "The consumer does not click the ad or has bought");
        // 广告发布者转账给消费者
        if (manaToken.transferFrom(_merchant, msg.sender, ads[_merchant][_adId].m2C)) {
    
    
            // 转账成功,则将广告点击记录标记为Bought,并触发AdBought
            ads[_merchant][_adId].clicks[_landowner][msg.sender] = ClickState.Bought;
            emit AdBought(_merchant, _adId, _landowner, msg.sender);
        }
        else {
    
    
            // 转账失败,则标记广告失效,触发AdInvalid
            ads[_merchant][_adId].state = AdState.Invalid;
            emit AdInvalid(_merchant, _adId);
        }        
    }
}

3.4. 手动运行DMall智能合约

  • 在chrome右上角MetaMask插件的面板里,确保切换到“Ropsten测试网络”链和“组件运营者”账号
  • 在Remix左侧,选择Solidity Compiler,点击Compile dmall.sol按钮
  • ENEIRONMENT选择Injected Provide - MetamaskCONTRACT选择DMall
  • Decentraland中的智能合约地址页面,找到Ropsten网络的MANAToken地址,即0x2a8fd99c19271f4f04b1b7b9c4f7cf264b626edb
  • 将上述地址填写进Deploy按钮后的输入框,然后点击Deploy按钮,等待MetaMask面板上弹出确认并点击,完成DMall合约部署
  • 重选选择CONTRACT为MANAToken,将前面的MANAToken地址填入At Address按钮后的输入框,然后点击At Address按钮,等待MetaMask面板上弹出确认并点击,完成MANAToken合约在Remix上的显示,这个不是我们部署的,只是为了能调用接口
  • 在MetaMask面板上,切换至广告发布者账号
  • Deployed Contracts下,找到并复制DMALL合约的地址,然后打开MANATOKEN合约,打开approve接口,输入_sender为上面复制的DMALL地址,_value输入个大数,如100000。点击transact按钮,等待MetaMask面板上弹出确认并点击,完成广告发布者对DMALL合约的授权
  • 打开DMALL合约的uptAd接口,输入_adId=1,_m2L=11,_m2O=12,_m2C=13,点击transact按钮,等待MetaMask面板上弹出确认并点击,完成广告创建
  • 在MetaMask面板上,复制广告发布者的地址,然后切换至组件运营者账号
  • 打开DMALL合约的apprAd接口,输入_merchant为刚才复制的地址,_adId为1,点击transact按钮,等待MetaMask面板上弹出确认并点击,完成广告批准
  • 在MetaMask面板上,切换至消费者账号
  • 打开DMALL合约的clickAd接口,输入_merchant为广告发布者地址,_adId为1,_landowner为土地所有者的地址,点击transact按钮,等待MetaMask面板上弹出确认并点击,完成广告点击
  • 此时,可以通过MetaMask面板上的账户详情页面,查看以太币和MANA币的各账户变化情况
  • 打开DMALL合约的buyAd接口,输入_merchant为广告发布者地址,_adId为1,_landowner为土地所有者的地址,点击transact按钮,等待MetaMask面板上弹出确认并点击,完成广告购买
  • 此时,可以再通过MetaMask面板上的账户详情页面,查看以太币和MANA币的各账户变化情况

3.5. Python调用DMall智能合约

  • 创建可供代码调用的以太坊API接口地址
    • infura页面上,注册用户并登录,选择右上角的Dashboard进入
    • 选择右上角的CREATE NEW KEY按钮
    • 网络选择Web3 API
    • Ethereum下,从MAINNET切换到Ropsten,不过现在Ropsten,已经消失了,大家可以按照以下格式,将key替换进去即可,或者直接用也行:https://ropsten.infura.io/v3/dd4cc999659f448d905400a4e8fb4e9d
  • 安装PyCharm
  • 创建合约接口文件
    • 在PyCharm中创建一个工作区,在里面增加一个contract_abi.py文件
    • 在Remix左侧,选择Solidity Compiler,CONTRACT选择DMALL,点击下方的ABI复制按钮
    • 在contract_abi.py中,输入dmall_abi = """""",在六个分号的正中间粘贴上面的复制内容
    • MANAToken的代码页面,点击Contract ABI部分右上角的Copy ABI to clipboard
    • 在contract_abi.py中,换行输入manatoken_abi = """""",在六个分号的正中间粘贴上面的复制内容
  • 在MetaMask面板中,导出广告发布者的Private Key
  • 在PyCharm中,为项目安装Web3包(pip install web3),创建dmalltest.py,粘贴以下代码,按注释调试验证合约的部分接口
import time
from web3 import Web3, HTTPProvider
from web3.logs import IGNORE
import contract_abi

# DMall合约的地址,从Remix中获得,输入单引号之间
dmall_address = Web3.toChecksumAddress('')
# MANAToken合约的地址
manatoken_address = Web3.toChecksumAddress('0x2a8fd99c19271f4f04b1b7b9c4f7cf264b626edb')

# 广告发布者的Private Key,将前文导出的值输入单引号之间
merchant_private_key = ''
# 广告发布者的账号地址,从MetaMask面板中获得,输入单引号之间
merchant_address = ''
# 土地所有者的账号地址,从MetaMask面板中获得,输入单引号之间
landowner_address = ''
# 消费者的账号地址,从MetaMask面板中获得,输入单引号之间
consumer_address = ''

# 以太坊API地址,将前文infura获取的https地址输入单引号之间
w3 = Web3(HTTPProvider(''))

# 初始化DMall和MANAToken合约的接口和事件
dmall_contract = w3.eth.contract(address=dmall_address, abi=contract_abi.dmall_abi)
manatoken_contract = w3.eth.contract(address=manatoken_address, abi=contract_abi.manatoken_abi)

# 广告所有者在MANAToken合约上批准DMall合约对其账户的MANA进行操作
def m_appr_dmall(dmall, amount):
    nonce = w3.eth.getTransactionCount(merchant_address)
    # 创建MANAToken上的一个交易,内容就是执行approve接口
    txn_dict = manatoken_contract.functions.approve(dmall, amount).buildTransaction({
    
    
        'chainId': 3,
        'gas': 140000,
        'gasPrice': w3.toWei('40', 'gwei'),
        'nonce': nonce,
    })
    # 用广告所有者的Private Key给上述交易签名
    signed_txn = w3.eth.account.signTransaction(txn_dict, private_key=merchant_private_key)
    # 执行交易
    result = w3.eth.sendRawTransaction(signed_txn.rawTransaction)
    # 轮询等待交易成功
    tx_receipt = None
    count = 0
    while tx_receipt is None and (count < 30):
        time.sleep(10)
        try:
            tx_receipt = w3.eth.get_transaction_receipt(result)
            print(tx_receipt)
        except Exception as e:
            print('error: ', e)

    if tx_receipt is None:
        return {
    
    'status': 'failed', 'error': 'timeout'}

    # 处理异步返回的事件
    processed_receipt = manatoken_contract.events.Approval().processReceipt(tx_receipt, errors=IGNORE)
    print(processed_receipt)

    return {
    
    'status': 'added', 'processed_receipt': processed_receipt}


# 广告所有者发布广告
def m_pub_ad(ad_id, m_2_l, m_2_o, m_2_c):
    nonce = w3.eth.getTransactionCount(merchant_address)
    # 创建DMall上的一个交易,内容就是执行创建广告的接口
    txn_dict = dmall_contract.functions.uptAd(ad_id, m_2_l, m_2_o, m_2_c).buildTransaction({
    
    
        'chainId': 3,
        'gas': 140000,
        'gasPrice': w3.toWei('40', 'gwei'),
        'nonce': nonce,
    })
    # 用广告所有者的Private Key给上述交易签名
    signed_txn = w3.eth.account.signTransaction(txn_dict, private_key=merchant_private_key)
    # 执行交易
    result = w3.eth.sendRawTransaction(signed_txn.rawTransaction)
    # 轮询等待交易成功
    tx_receipt = None
    count = 0
    while tx_receipt is None and (count < 30):
        time.sleep(10)
        try:
            tx_receipt = w3.eth.get_transaction_receipt(result)
            print(tx_receipt)
        except Exception as e:
            print('error: ', e)

    if tx_receipt is None:
        return {
    
    'status': 'failed', 'error': 'timeout'}

    # 处理异步返回的事件
    processed_receipt = dmall_contract.events.AdCrted().processReceipt(tx_receipt)
    print(processed_receipt)

    return {
    
    'status': 'added', 'processed_receipt': processed_receipt}


if __name__ == "__main__":
    # 可分别打开以下两行注释符号,执行对应函数,验证合约执行情况
    m_appr_dmall(dmall_address, 100)
    #m_pub_ad(2, 21, 22, 23)

4. Decentraland里的Smart Item开发

Decentraland使用Builder(需使用以太坊Ethereum主网络登录)搭建Scene,Scene被部署到实际的Land中,就成为建筑物。搭建Scene的组件有普通和Smart Item两种,Smart Item就是可以响应用户操作,执行代码的组件。可从github上获取现有Smart Item代码,修改代码,验证合约执行:

git clone [email protected]:decentraland/smart-items.git

可以选择一个现有的Smart Item,在其item.ts源代码的spawn函数中,增加如下代码,调用智能合约

    ent.addComponent(
      new OnPointerDown(
        async function () {
    
    
            const provider = await getProvider()
            const requestManager = new RequestManager(provider)
            const factory = new ContractFactory(requestManager, abi)
            // 需将从Remix中获取的DMall合约地址,输入双引号之间
            const contract = (await factory.at(
              ""
            )) as any
            const address = await getUserAccount()
            log(address)
        
            const res = await contract.clickAd(
              // 需将从MetaMask中获取的广告发布者账号地址,输入双引号之间
              "",
              0,
              // 需将从MetaMask中获取的土地所有者账号地址,输入双引号之间
              "",
			  {
    
    
				from: address,
			  }
            )
            log(res)
            // 打开外部的商品购买链接
            openExternalURL("https://item.jd.com/10045659650093.html")
        },
        {
    
    
          button: ActionButton.PRIMARY,
          hoverText: locationString,
        }
      )
    )

接下来可按如下步骤上传Smart Item到Builder进行验证使用:

npm install -g decentraland
  • 到对应的Smart Item源代码文件夹下,在本地启动进行交互验证
dcl install
dcl start
  • 也可以打包上传到Builder使用
dcl pack

猜你喜欢

转载自blog.csdn.net/cloudguru/article/details/127121638
今日推荐