第十八课 【ERC875】Hiblock黑客马拉松门票从定制到编码实现

1,摘要

【本文目标】
通过本文,可以从一个HiBlock黑客马拉松活动门票定制,转让,出售和签到为例,说明ERC875的设计初心,ERC875的标准接口分析,也给出了官网的ERC875的代码和本地测试,便于更多项目使用ERC875解决区块链业务中遇到的实际问题。
【前置条件】
(1)体验门票受让的用户不需要有任何技术门槛;
(2)做门票定制和开发的需要本地已安装好MetaMASK,在Reposton Test Net获取了几个测试ETH(免费)的,要懂Solidity语言。
不熟悉的建议参考文档《第六课 技术小白如何开发一个DAPP区块链应用(以宠物商店为例)》的“5. 安装 MetaMask和配置区块链网络”章节。

2,Hiblock黑客马拉松区块链门票全体验

2.1 门票定制创建 - [辉哥]

ALPHA WALLET团队已经封装好了ERC785协议实现,可以通过浏览器完成票务类ERC875的智能合约创建。对应的TOKEN工厂网址为https://alpha-wallet.github.io/ERC875-token-factory/index.html
测试使用,MetaMASK选择的测试网络为"Ropsten Test Net"。

1) “Deploy Contract”
定义名称和标识,对应的地址是以太坊钱包地址。Owner Address必须为MetaMast的当前账号地址,然后点击“Deploy Contract”按钮。[名称和标识命名跟一般使用的搞反了,将就用吧]
Contract Name: HHT
Ticket Symbol: Hiblock Hackathon Ticket
Owner Address:0xB51Fa936B744CFEbAeD8DbB79d2060903e689F89
Recipient Address:0xB51Fa936B744CFEbAeD8DbB79d2060903e689F89

1. 提交合约部署

2)“Submmit”按钮
“Gas Price”设置为30,点击“Submmit”按钮。该账号要有一定的ETH测试币,否则点击"Buy"找平台免费买点。

2. 确认交易

3)购买成功确认
购买成功的会有弹出提示。点击“确定”按钮后,拉到下方的按钮可以查看智能合约部署链接和ABI合约信息。

3.合约部署成功
4. 查看ABI信息和合约记录

4)查看部署合约成功地址
点击可知其部署成功:https://ropsten.etherscan.io/address/0x07fc44d796d30b317013cb907fadb6d738f5779e

2.2 安装APP,导入钱包,导入门票 - [辉哥]

1) 安装APP
辉哥在官网(https://awallet.io/)下载APP完成安装。

2) 导入钱包
点击配置页面,更换网络为"Ropsten(Test)"网络,导入创建门票的钱包私钥。

3)添加代币
输入之前的智能合约地址,符号和名称会自动联想出来的。

导入成功后,钱包页面可以看到对应的通证信息。如果是没有这个资产的钱包导入这个通证,钱包页面是看不到这个通证门票的。


2.3 转让门票 - [辉哥-欧阳哥哥]

通过报名渠道,辉哥知道欧阳哥哥已报名参加HiBlock黑客马拉松,所以把区块链门票转给他。
1) 辉哥点击“转让”按钮
选择HHT后,点击右下角的“转让”按钮进行票务转让。

2)点击“转让”按钮
选择“现在直接转让门票”,

获取欧阳哥哥的钱包地址,输入:


输入欧阳哥哥的钱包地址

3)确认转让

转让门票按钮

转账成功

2.4 出售门票 - [欧阳哥哥-小辉]

1)导入通证
欧阳哥哥在AlphaWallet钱包中输入HHT的合约地址(0x07fc44d796d30b317013cb907fadb6d738f5779e)即可查看到辉哥转账过来的门票通证。

2) 出售门票
小辉同学知道了黑客马拉松的事情,也很想参加。欧阳哥哥刚好弄了2张票,就同意把一张票低价转让给小辉。双方协商好价格是0.2个ETH。
欧阳哥哥点击出售按钮,设置好价格,最后链接通过微信发给小辉。

设置价格

设置截止时间
确认出售,把链接微信发给小辉

3) 导入支付
小辉安装好APP。复制链接打开APP时,会提示导入门票。点击购买,支付了0.2个ETH后即可完成支付。

门票
确认购买
购买成功

4) 导入代币地址完成呈现
小辉在钱包导入HHT智能合约的地址(0x07fc44d796d30b317013cb907fadb6d738f5779e)后,即可在APP上呈现购买的HHT门票一张。

2.5 兑现门票

欧阳哥哥和小辉到达HiBlock黑客马拉松现场,点击门票的“兑换”按钮,主办方Bob根据他们展示的二维码扫描完成。该门票的状态会变更为已兑换。

【后记】他们组队参加黑客马拉松,依靠其过硬的技术实力,获得了一个二等奖!

3,ERC875设计目标

AlphaWallet团队核心成员(左二:CEO张中南;右二:创始人兼CTO张韡武)

ERC875协议是由AlphaWallet团队提出的,他们希望基于ERC875协议族,能够实现人、事、物、权token化。

在创始人张中南看来,人、事、物、权全部token化,即可以用token来替代物理世界里面的任何商品。在此其中,token替代的是一个权益,可以指代各种各样的权益。比如,「人」的token化,「跟吴亦凡今天晚上6点钟到8点钟一起吃饭的权益,可以做成一个token」,「事」的token化,「用信用卡在商店买了一瓶水,也可以做成一个token」,而「物」、「权」的token化,就更好理解了。
将人、事、物、权token化,可以有不同层级的愿景和意义。张中南介绍:
第一层级,简单的来说,就是把 人、事、物、权做成token,放到区块链上面流通,或者说放到钱包里,做成APP,能够使用token做流转。
再往上一个级别,是这些token和token之间的交互。比如,可能有一件事,可以同时调用7、8个token,不再是简单的转让或流通。
再往上一个级别,「我们能够看到最远的地方就是这些token用来指代人、事、物、权之后,它们本身可以变成一个集成点,可以在用户端集成各种各样的服务和应用。比如,租车服务、保险、信用卡公司等,当需要调用他们的服务时,不再通过微信来使用,而是直接在用户端就能集成。
现阶段,为了实现初级目标,AlphaWallet选择从一款可编程钱包切入。今年5月23日,该公司正式发布了这款筹备已久的钱包产品——AlphaWallet 1.0版。
公开资料显示,这是一款直接支持不可替代性token的钱包,可作为连接虚拟世界和真实世界的网关。基于该钱包之上,真实世界内的生活服务可利用区块链技术而具备强有力的基础技术平台,从而拥有无限想象的可能性。
通常来说,大量token广泛使用的是ERC20协议。遵循ERC20的token可以跟踪任何人在任何时候拥有多少token。在一些开源组织的推动下,目前第三方基于ERC20接口5分钟即能发行一个ERC20的token。不过,相对来说,ERC20还存在两个问题:
第一,ERC20无法代表现实世界中无法拆分、独一无二的资产;
第二,现有的打包、转账流程复杂,ERC20缺乏可扩展性,无法实现更复杂的功能。
基于此,AlphaWallet自主开发了ERC875协议族。该协议不仅会让数字资产变得具有收藏价值,同时也能帮助现实世界中不可拆分替代、具有物权唯一性的资产上链,这就能为线下服务的链上操作提供了可能性。
虽然另一种协议ERC721也能实现token的不可置换性,但其存在需要交易双方支付gas费用、无法简单实现原子化交易等一些不易于用户使用的问题。
张中南向雷锋网AI金融评论介绍称,ERC875内置了两个密码学协议, 一方面能够简单实现原子化交易(atomic swap)——直接搭建去中心化市场、降低普通用户使用门槛,卖家无需持有以太币,买家支付一次gas即能完成;另外一方面可以简单打包处理大量交易。
拿基于ERC721的加密猫来说,换用ERC875协议的话,能够实现。用户在商家网站法币购猫,通过MagicLink免费把猫导入用户的钱包,之后用户还可以在不需要持有以太币的情况下,通过MagicLink把猫售出或者免费转让,全部过程都是无中心的原子化交易。另外商家可以一次批发100只猫给分销商。

首个落地应用:体育票务
或许与张中南在票务业务的经历有关,AlphaWallet选择从ERC875和钱包切入的第一个use case就是俄罗斯世界杯门票。
相较人、事而言,「票务」由于具备物理和权益属性,利用区块链技术来实现不可置换的token的流转,更具操作性和可行性。
目前 AlphaWallet 已与盛开体育达成合作。今年的俄罗斯世界杯,二者联合引入区块链技术以测试新的票务解决方案,将盛开体育世界杯票库内的部分门票转化为以太坊上的ERC875的token。由于这些token具有不可置换性,用户通过AlphaWallet钱包的动态二维码,以及线下的现场扫描,即可获得世界杯门票。考虑到进一步安全的问题,AlphaWallet钱包显示的动态二维码,每隔10s就会变一次。

AlphaWallet钱包兑换俄罗斯世界杯门票(test)流程体验

据张中南介绍,这次合作,「盛开那边做了10张票,AlphaWallet则拿了10张开幕式的VIP门票,所以一共只有20张门票」。经过雷锋网AI金融评论现场测试体验,通过AlphaWallet钱包流转一张世界杯门票,所花时间在4-7s以内。而买方从卖方手里通过支付以太坊的方式买入一张门票,所需时间则在10s左右。
「这应该是目前世界上首个不可替代通证与现实物权交互的落地案例。」团队向雷锋网AI金融评论表示。
除票务外,AlphaWallet近期还会继续考虑在「物」上面开发use case,主要专注在物理商品这一块,如 奢侈手表和限量球鞋等等。
不过,也有业内人士指出,通过不可置换协议,从token到实物的映射,可能还是难以避免实物造假的情况,这点又该如何防范?在张中南看来,给物理商品配备数字身份证,是通过经济学原理来实现防伪的。这点与溯源、防伪等又不一样。

4,ERC875标准

function name() constant returns (string name)

返回智能合约的名字,例如CarLotContract。

function symbol() constant returns (string symbol)

返回智能合约通证的标识符。

function balanceOf(address _owner) public view returns (uint256[] balance)

返回一组账户余额的数组。

function transfer(address _to, uint256[] _tokens) public;

通过包含通证索引的数组参数,把一组独一无二的通证转移给一个账户地址。相比ERC721一次只能转账一个通证,ERC875更显友好,它可以一次批量转账一组通证。这样既便利又能节约大量的GAS消耗。

function transferFrom(address _from, address _to, uint256[] _tokens) public;

从一个账户给另一个账户转账批量通证。这个可由一个获得特定KEY例如合同创建者的授权的账号来完成。

【以下为可选函数】

function totalSupply() constant returns (uint256 totalSupply);

返回给定合同的通证总数。这个通证总数可能是可变的。

function ownerOf(uint256 _tokenId) public view returns (address _owner);

返回特定通证的拥有者。这个函数是可选的,因为并不是每一个通证合约都需要跟踪每一个独一无二通知的拥有者,并且每次查询需要消耗GAS用于遍历和匹配token id于拥有者的关系。

function trade(uint256 expiryTimeStamp, uint256[] tokenIndices, uint8 v, bytes32 r, bytes32 s) public payable

该函数允许用户出售一组非同质通证而不需要支付GAS费,只需要购买者支付。这是通过签署包含要销售的代币数量,合同地址,到期时间戳,价格和包含ERC规范名称和链ID的前缀的证明来实现的。然后,买方可以通过附加适当的以太币(ether)来满足交易,从而在一次交易中支付交易。
这种设计也更有效,因为它允许订单在离线前完成,而不是在智能合约中创建订单并更新订单。到期时间戳保护卖方免受使用旧订单的人的影响。
这为点对点(p2p)原子交换(atomic swap)打开了大门,但对于这个标准应该是可选的,因为有些可能没有用它。
需要在消息中添加一些保护,例如编码链ID,合同地址和ERC规范名称,以防止重放和欺骗人们签署允许交易的消息。

5,ERC875样例代码

官方给出的ERC875代码样例如下,函数含义参考第4章。

contract ERC
{
  event Transfer(address indexed _from, address indexed _to, uint256[] tokenIndices);
 
  function name() constant public returns (string name);
  function symbol() constant public returns (string symbol);
  function balanceOf(address _owner) public view returns (uint256[] _balances);
  //function ownerOf(uint256 _tokenId) public view returns (address _owner);
  function transfer(address _to, uint256[] _tokens) public;
  function transferFrom(address _from, address _to, uint256[] _tokens) public;
 
  //optional
  //function totalSupply() public constant returns (uint256 totalSupply);
  function trade(uint256 expiryTimeStamp, uint256[] tokenIndices, uint8 v, bytes32 r, bytes32 s) public payable;
}

pragma solidity ^0.4.17;
contract Token is ERC
{
    uint totalTickets;
    mapping(address => uint256[]) inventory;
    uint16 ticketIndex = 0; //to track mapping in tickets
    uint expiryTimeStamp;
    address owner;   // the address that calls selfdestruct() and takes fees
    address admin;
    uint transferFee;
    uint numOfTransfers = 0;
    string public name;
    string public symbol;
    uint8 public constant decimals = 0; //no decimals as tickets cannot be split

    event Transfer(address indexed _from, address indexed _to, uint256[] tokenIndices);
    event TransferFrom(address indexed _from, address indexed _to, uint _value);
    
    modifier adminOnly()
    {
        if(msg.sender != admin) revert();
        else _;
    }

    function() public { revert(); } //should not send any ether directly

    // example: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], "MJ comeback", 1603152000, "MJC", "0x007bEe82BDd9e866b2bd114780a47f2261C684E3"
    function Token(
        uint256[] numberOfTokens,
        string evName,
        uint expiry,
        string eventSymbol,
        address adminAddr) public
    {
        totalTickets = numberOfTokens.length;
        //assign some tickets to event admin
        expiryTimeStamp = expiry;
        owner = msg.sender;
        admin = adminAddr;
        inventory[admin] = numberOfTokens;
        symbol = eventSymbol;
        name = evName;
    }

    function getDecimals() public pure returns(uint)
    {
        return decimals;
    }
    
    // price is 1 in the example and the contract address is 0xfFAB5Ce7C012bc942F5CA0cd42c3C2e1AE5F0005
    // example: 0, [3, 4], 27, "0x2C011885E2D8FF02F813A4CB83EC51E1BFD5A7848B3B3400AE746FB08ADCFBFB", "0x21E80BAD65535DA1D692B4CEE3E740CD3282CCDC0174D4CF1E2F70483A6F4EB2"
    // price is encoded in the server and the msg.value is added to the message digest,
    // if the message digest is thus invalid then either the price or something else in the message is invalid
    function trade(uint256 expiry,
                   uint256[] tokenIndices,
                   uint8 v,
                   bytes32 r,
                   bytes32 s) public payable
    {
        //checks expiry timestamp,
        //if fake timestamp is added then message verification will fail
        require(expiry > block.timestamp || expiry == 0);
        //id 1 for mainnet
        bytes12 prefix = "ERC800-CNID1";
        bytes32 message = encodeMessage(prefix, msg.value, expiry, tokenIndices);
        address seller = ecrecover(message, v, r, s);
        
        for(uint i = 0; i < tokenIndices.length; i++)
        { // transfer each individual tickets in the ask order
            uint index = uint(tokenIndices[i]);
            require((inventory[seller][index] > 0)); // 0 means ticket sold.
            inventory[msg.sender].push(inventory[seller][index]);
            inventory[seller][index] = 0; // 0 means ticket sold.
        }
        seller.transfer(msg.value);
    }


    //must also sign in the contractAddress
    //prefix must contain ERC and chain id
    function encodeMessage(bytes12 prefix, uint value, 
        uint expiry, uint256[] tokenIndices)
        internal view returns (bytes32)
    {
        bytes memory message = new bytes(96 + tokenIndices.length * 2);
        address contractAddress = getContractAddress();
        for (uint i = 0; i < 32; i++)
        {   // convert bytes32 to bytes[32]
            // this adds the price to the message
            message[i] = byte(bytes32(value << (8 * i)));
        }

        for (i = 0; i < 32; i++)
        {
            message[i + 32] = byte(bytes32(expiry << (8 * i)));
        }
        
        for(i = 0; i < 12; i++)
        {
            message[i + 64] = byte(prefix << (8 * i));    
        }

        for(i = 0; i < 20; i++)
        {
            message[76 + i] = byte(bytes20(bytes20(contractAddress) << (8 * i)));
        }

        for (i = 0; i < tokenIndices.length; i++)
        {
            // convert int[] to bytes
            message[96 + i * 2 ] = byte(tokenIndices[i] >> 8);
            message[96 + i * 2 + 1] = byte(tokenIndices[i]);
        }

        return keccak256(message);
    }

    function name() public view returns(string)
    {
        return name;
    }

    function symbol() public view returns(string)
    {
        return symbol;
    }

    function getAmountTransferred() public view returns (uint)
    {
        return numOfTransfers;
    }

    function isContractExpired() public view returns (bool)
    {
        if(block.timestamp > expiryTimeStamp)
        {
            return true;
        }
        else return false;
    }

    function balanceOf(address _owner) public view returns (uint256[])
    {
        return inventory[_owner];
    }

    function myBalance() public view returns(uint256[])
    {
        return inventory[msg.sender];
    }

    function transfer(address _to, uint256[] tokenIndices) public
    {
        for(uint i = 0; i < tokenIndices.length; i++)
        {
            require(inventory[msg.sender][i] != 0);
            //pushes each element with ordering
            uint index = uint(tokenIndices[i]);
            inventory[_to].push(inventory[msg.sender][index]);
            inventory[msg.sender][index] = 0;
        }
    }

    function transferFrom(address _from, address _to, uint256[] tokenIndices)
        adminOnly public
    {
        bool isadmin = msg.sender == admin;
        for(uint i = 0; i < tokenIndices.length; i++)
        {
            require(inventory[_from][i] != 0 || isadmin);
            //pushes each element with ordering
            uint index = uint(tokenIndices[i]);
            inventory[_to].push(inventory[msg.sender][index]);
            inventory[_from][index] = 0;
        }
    }

    function endContract() public
    {
        if(msg.sender == owner)
        {
            selfdestruct(owner);
        }
        else revert();
    }

    function getContractAddress() public view returns(address)
    {
        return this;
    }
}

【函数说明】
1,trade函数是发起批量转让的智能合约函数
trade(uint256 expiry,/超时时间,以s计算/
uint256[] tokenIndices, /通证索引/
uint8 v, /*v,r,s是卖家签名的3个部分,产生的方法参考文件 */
bytes32 r,
bytes32 s )

6,ERC875测试(REMIX+MetaMASK环境)

6.1 创建合约

[1] 管理员(0xca35b7d915458ef540ade6068dfe2f44e8fa733c)构建函数CREATE

 [101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115], "DJ Family", 1603152000, "DJ", "0xca35b7d915458ef540ade6068dfe2f44e8fa733c"

【结果】
智能合约创建成功,得到智能合约地址:0x692a70d2e424a56d2c6c27aa97d1a86395877b3a

6.2 门票转让

管理员(0xca35b7d915458ef540ade6068dfe2f44e8fa733c)转移2张座位号为101,102的门票给李四(0x4b0897b0513fdc7c541b6d9d7e929c4e5364d2db)

transfer("0x4b0897b0513fdc7c541b6d9d7e929c4e5364d2db", [0,1])

*【结果】:门票已转让给李四,李四并没有消耗GAS,是管理员消耗了GAS。

6.3 trade门票

管理员(0xca35b7d915458ef540ade6068dfe2f44e8fa733c)把门票trade给赵六(0xdd870fa1b7c4700f2bd7f44238821c26f7392148)
当智能合约地址为0xfFAB5Ce7C012bc942F5CA0cd42c3C2e1AE5F0005,price is 1时,

trade(0, [3, 4], 27, "0x2C011885E2D8FF02F813A4CB83EC51E1BFD5A7848B3B3400AE746FB08ADCFBFB", "0x21E80BAD65535DA1D692B4CEE3E740CD3282CCDC0174D4CF1E2F70483A6F4EB2")

【结果】操作失败了,也无法触发购买。
【官方答复】
那个Trade function的功能是,在卖家发了签名信息给买家,然后买家联合卖家的签名信息和自己的签名信息一起call trade fundction来完成交易。你在现在的模式,是创建不出来卖家签名信息的, 你需要参考AlphaWallet的代码。
源码参考地址:
https://github.com/alpha-wallet/AlphaWallet-Mobile-Apps
【详细说明】
(1) START TO TRANSFER:
transferTicketDetailVeiwModel.java - CreateTicketTransfer
(2) HOW TO BUY A TICKET
ImportTokenViewModel.java - PerformImport

7 参考

1) 2018世界杯门票的一笔交易记录
2) 深入浅出以太坊ERC875标准(不可替代性通证标准)
3) AlphaWallet野心有点大:基于ERC875协议族,实现人、事、物、权token化
4)ERC875 for non fungible tokens and simple atomic swaps
5) AlphaWallet代码
6) ERC875智能合约案例 (TradeImplementationExample.java 和ERCTokenImplementation.sol)
7) AlphaWallet钱包下载-支持测试网络代币

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容