「区块链」:Solidity-basic

Solidity的官方教程笔记:basic。

Part 1

第2章: 合约

从最基本的开始入手:

Solidity 的代码都包裹在合约里面. 一份合约就是以太应币应用的基本模块, 所有的变量和函数都属于一份合约, 它是你所有应用的起点.

一份名为 HelloWorld 的空合约如下:

1
2
3
contract HelloWorld {

}

版本指令

所有的 Solidity 源码都必须冠以 “version pragma” — 标明 Solidity 编译器的版本. 以避免将来新的编译器可能破坏你的代码。

例如: pragma solidity ^0.4.19; (当前 Solidity 的最新版本是 0.4.19).

要有分号!

1
2
3
4
pragma solidity ^0.4.19;
contract HelloWorld {

}

第3章: 状态变量和整数

状态变量是被永久地保存在合约中。也就是说它们被写入以太币区块链中. 想象成写入一个数据库。

无符号整数: uint

uint 无符号数据类型, 指其值不能是负数,对于有符号的整数存在名为 int 的数据类型。

注: Solidity中, uint 实际上是 uint256代名词, 一个256位的无符号整数。你也可以定义位数少的uints — uint8 uint16 uint32, 等…… 但一般来讲你愿意使用简单的 uint, 除非在某些特殊情况下,这我们后面会讲。

第4章: 数学运算

在 Solidity 中,数学运算很直观明了,与其它程序设计语言相同:

  • 加法: x + y
  • 减法: x - y,
  • 乘法: x * y
  • 除法: x / y
  • 取模 / 求余: x % y (例如, 13 % 53, 因为13除以5,余3)

Solidity 还支持 乘方操作\ (如:x 的 y次方)

如: 5 ** 2 = 25

1
uint x = 5 ** 2; // equal to 5^2 = 25

第5章: 结构体

1
2
3
4
struct Person {
uint age;
string name;
}

第6章: 数组

如果你想建立一个集合,可以用 数组这样的数据类型. Solidity 支持两种数组: 静态 数组和动态 数组:

1
2
3
4
5
6
// 固定长度为2的静态数组:
uint[2] fixedArray;
// 固定长度为5的string类型的静态数组:
string[5] stringArray;
// 动态数组,长度不固定,可以动态添加元素:
uint[] dynamicArray;

记住:状态变量被永久保存在区块链中。所以在你的合约中创建动态数组来保存成结构的数据是非常有意义的。

公共数组

你可以定义 public 数组, Solidity 会自动创建 getter 方法. 语法如下:

1
Person[] public people;

其它的合约可以从这个数组读取数据(但不能写入数据),所以这在合约中是一个有用的保存公共数据的模式。

第7章: 定义函数

在 Solidity 中函数定义的句法如下:

1
2
3
function eatHamburgers(string _name, uint _amount) {

}

注:习惯上函数里的变量都是以(*_)开头 (但不是硬性规定) 以区别全局变量。(整个教程都会沿用这个习惯。)

第8章: 使用结构体和数组

现在我们学习创建新的 Person 结构,然后把它加入到名为 people 的数组中.

1
2
3
4
5
// 创建一个新的Person:
Person satoshi = Person(172, "Satoshi");

// 将新创建的satoshi添加进people数组:
people.push(satoshi);

你也可以两步并一步,用一行代码更简洁:

1
people.push(Person(16, "Vitalik"));

注:array.push() 在数组的 尾部 加入新元素 ,所以元素在数组中的顺序就是我们添加的顺序, 如:

第9章: 私有 / 公共函数

Solidity 定义的函数的属性默认为公共。 这就意味着任何一方 (或其它合约) 都可以调用你合约里的函数。

显然,不是什么时候都需要这样,而且这样的合约易于受到攻击。 所以将自己的函数定义为私有是一个好的编程习惯,只有当你需要外部世界调用它时才将它设置为公共

如何定义一个私有的函数呢?

1
2
3
4
5
uint[] numbers;

function _addToArray(uint _number) private {
numbers.push(_number);
}

这意味着只有我们合约中的其它函数才能够调用这个函数,给 numbers 数组添加新成员。

可以看到,在函数名字后面使用关键字 private 即可。和函数的参数类似,私有函数的名字用(_)起始。

第10章: 函数的更多属性

本章中我们将学习函数的返回值和修饰符。

返回值

要想函数返回一个数值,按如下定义:

1
2
3
4
5
string greeting = "What's up dog";

function sayHello() public returns (string) {
return greeting;
}

Solidity 里,函数的定义里可包含返回值的数据类型(如本例中 string)。

函数的修饰符

上面的函数实际上没有改变 Solidity 里的状态,即,它没有改变任何值或者写任何东西。

这种情况下我们可以把函数定义为 view, 意味着它只能读取数据不能更改数据:

1
function sayHello() public view returns (string) {

Solidity 还支持 pure 函数, 表明这个函数甚至都不访问应用里的数据,例如:

1
2
3
function _multiply(uint a, uint b) private pure returns (uint) {
return a * b;
}

这个函数甚至都不读取应用里的状态 — 它的返回值完全取决于它的输入参数,在这种情况下我们把函数定义为 pure.

注:可能很难记住何时把函数标记为 pure/view。 幸运的是, Solidity 编辑器会给出提示,提醒你使用这些修饰符。

第11章: Keccak256 和 类型转换

如何让 _generateRandomDna 函数返回一个全(半) 随机的 uint?

Ethereum 内部有一个散列函数keccak256,它用了SHA3版本。一个散列函数基本上就是把一个字符串转换为一个256位的16进制数字。字符串的一个微小变化会引起散列数据极大变化。

例子:

1
2
3
4
//6e91ec6b618bb462a4a6ee5aa2cb0e9cf30f7a052bb467b0ba58b8748c00d2e5
keccak256("aaaab");
//b1f078126895a1424524de5321b339ab00408010b7cf0e6ed451514981e58aa9
keccak256("aaaac");

注: 在区块链中安全地产生一个随机数是一个很难的问题, 本例的方法不安全,但是在我们的Zombie DNA算法里不是那么重要,已经很好地满足我们的需要了。*

类型转换

有时你需要变换数据类型。例如:

1
2
3
4
5
6
uint8 a = 5;
uint b = 6;
// 将会抛出错误,因为 a * b 返回 uint, 而不是 uint8:
uint8 c = a * b;
// 我们需要将 b 转换为 uint8:
uint8 c = a * uint8(b);

上面, a * b 返回类型是 uint, 但是当我们尝试用 uint8 类型接收时, 就会造成潜在的错误。如果把它的数据类型转换为 uint8, 就可以了,编译器也不会出错。

第13章: 事件

事件 是合约和区块链通讯的一种机制。你的前端应用“监听”某些事件,并做出反应。

例子:

1
2
3
4
5
6
7
8
9
// 这里建立事件
event IntegersAdded(uint x, uint y, uint result);

function add(uint _x, uint _y) public {
uint result = _x + _y;
//触发事件,通知app
IntegersAdded(_x, _y, result);
return result;
}

你的 app 前端可以监听这个事件。JavaScript 实现如下:

1
2
3
YourContract.IntegersAdded(function(error, result) { 
// 干些事
}

array.push() 返回数组的长度类型是uint - 因为数组的第一个元素的索引是 0, array.push() - 1 将是我们加入的僵尸的索引。 zombies.push() - 1 就是 id,数据类型是 uint

第14章: Web3.js

以太坊有一个 JavaScript 库,名为Web3.js

在后面的课程里,我们会进一步地教你如何安装一个合约,如何设置Web3.js。 但是现在我们通过一段代码来了解 Web3.js 是如何和我们发布的合约交互的吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 下面是调用合约的方式:
var abi = /* abi是由编译器生成的 */
var ZombieFactoryContract = web3.eth.contract(abi)
var contractAddress = /* 发布之后在以太坊上生成的合约地址 */
var ZombieFactory = ZombieFactoryContract.at(contractAddress)
// `ZombieFactory` 能访问公共的函数以及事件

// 某个监听文本输入的监听器:
$("#ourButton").click(function(e) {
var name = $("#nameInput").val()
//调用合约的 `createRandomZombie` 函数:
ZombieFactory.createRandomZombie(name)
})

// 监听 `NewZombie` 事件, 并且更新UI
var event = ZombieFactory.NewZombie(function(error, result) {
if (error) return
generateZombie(result.zombieId, result.name, result.dna)
})

// 获取 Zombie 的 dna, 更新图像
function generateZombie(id, name, dna) {
let dnaStr = String(dna)
// 如果dna少于16位,在它前面用0补上
while (dnaStr.length < 16)
dnaStr = "0" + dnaStr

let zombieDetails = {
// 前两位数构成头部.我们可能有7种头部, 所以 % 7
// 得到的数在0-6,再加上1,数的范围变成1-7
// 通过这样计算:
headChoice: dnaStr.substring(0, 2) % 7 + 1
// 我们得到的图片名称从head1.png 到 head7.png

// 接下来的两位数构成眼睛, 眼睛变化就对11取模:
eyeChoice: dnaStr.substring(2, 4) % 11 + 1,
// 再接下来的两位数构成衣服,衣服变化就对6取模:
shirtChoice: dnaStr.substring(4, 6) % 6 + 1,
//最后6位控制颜色. 用css选择器: hue-rotate来更新
// 360度:
skinColorChoice: parseInt(dnaStr.substring(6, 8) / 100 * 360),
eyeColorChoice: parseInt(dnaStr.substring(8, 10) / 100 * 360),
clothesColorChoice: parseInt(dnaStr.substring(10, 12) / 100 * 360),
zombieName: name,
zombieDescription: "A Level 1 CryptoZombie",
}
return zombieDetails
}

我们的 JavaScript 所做的就是获取由zombieDetails 产生的数据, 并且利用浏览器里的 JavaScript 神奇功能 (我们用 Vue.js),置换出图像以及使用CSS过滤器。在后面的课程中,你可以看到全部的代码。


Part 2

第2章: 映射(Mapping)和地址(Address)

如此一来,我们需要引入2个新的数据类型:mapping(映射) 和 address(地址)。

Addresses (地址)

以太坊区块链由 account ** (账户)组成,你可以把它想象成银行账户。一个帐户的余额是 **以太 (在以太坊区块链上使用的币种),你可以和其他帐户之间支付和接受以太币,就像你的银行帐户可以电汇资金到其他银行帐户一样。

每个帐户都有一个“地址”,你可以把它想象成银行账号。这是账户唯一的标识符,它看起来长这样:

1
0x0cE446255506E92DF41614C46F1d6df9Cc969183

现在你只需要了解地址属于特定用户(或智能合约)的

Mapping(映射)

在第1课中,我们看到了 结构体 ** 和 **数组映射 是另一种在 Solidity 中存储有组织数据的方法。

映射是这样定义的:

1
2
3
4
//对于金融应用程序,将用户的余额保存在一个 uint类型的变量中:
mapping (address => uint) public accountBalance;
//或者可以用来通过userId 存储/查找的用户名
mapping (uint => string) userIdToName;

映射本质上是存储和查找数据所用的键-值对。

第3章: Msg.sender

msg.sender

在 Solidity 中,有一些全局变量可以被所有函数调用。 其中一个就是 msg.sender,它指的是当前调用者(或智能合约)的 address

注意:在 Solidity 中,功能执行始终需要从外部调用者开始。 一个合约只会在区块链上什么也不做,除非有人调用其中的函数。所以 msg.sender总是存在的。

1
2
3
4
5
6
7
8
9
10
11
12
13
mapping (address => uint) favoriteNumber;

function setMyNumber(uint _myNumber) public {
// 更新我们的 `favoriteNumber` 映射来将 `_myNumber`存储在 `msg.sender`名下
favoriteNumber[msg.sender] = _myNumber;
// 存储数据至映射的方法和将数据存储在数组相似
}

function whatIsMyNumber() public view returns (uint) {
// 拿到存储在调用者地址名下的值
// 若调用者还没调用 setMyNumber, 则值为 `0`
return favoriteNumber[msg.sender];
}

在这个小小的例子中,任何人都可以调用 setMyNumber 在我们的合约中存下一个 uint 并且与他们的地址相绑定。 然后,他们调用 whatIsMyNumber 就会返回他们存储的 uint

使用 msg.sender 很安全,因为它具有以太坊区块链的安全保障 —— 除非窃取与以太坊地址相关联的私钥,否则是没有办法修改其他人的数据的。

跟在 JavaScript 中一样, 在 Solidity 中你也可以用 ++ 使 uint 递增。

1
2
3
uint number = 0;
number++;
// `number` 现在是 `1`了

第4章: Require

require使得函数在执行过程中,当不满足某些条件时抛出错误,并停止执行:

1
2
3
4
5
6
7
8
function sayHiToVitalik(string _name) public returns (string) {
// 比较 _name 是否等于 "Vitalik". 如果不成立,抛出异常并终止程序
// (敲黑板: Solidity 并不支持原生的字符串比较, 我们只能通过比较
// 两字符串的 keccak256 哈希值来进行判断)
require(keccak256(_name) == keccak256("Vitalik"));
// 如果返回 true, 运行如下语句
return "Hi!";
}

如果你这样调用函数 sayHiToVitalik(“Vitalik”) ,它会返回“Hi!”。而如果调用的时候使用了其他参数,它则会抛出错误并停止执行。

因此,在调用一个函数之前,用 require 验证前置条件是非常有必要的。

第5章: 继承(Inheritance)

当代码过于冗长的时候,最好将代码和逻辑分拆到多个不同的合约中,以便于管理。

有个让 Solidity 的代码易于管理的功能,就是合约 inheritance (继承):

1
2
3
4
5
6
7
8
9
10
11
contract Doge {
function catchphrase() public returns (string) {
return "So Wow CryptoDoge";
}
}

contract BabyDoge is Doge {
function anotherCatchphrase() public returns (string) {
return "Such Moon BabyDoge";
}
}

由于 BabyDoge 是从 Doge 那里 inherits (继承)过来的。 这意味着当你编译和部署了 BabyDoge,它将可以访问 catchphrase()anotherCatchphrase()和其他我们在 Doge 中定义的其他公共函数。

这可以用于逻辑继承(比如表达子类的时候,Cat 是一种 Animal)。 但也可以简单地将类似的逻辑组合到不同的合约中以组织代码。

第6章: 引入(Import)

在 Solidity 中,当你有多个文件并且想把一个文件导入另一个文件时,可以使用 import 语句:

1
2
3
4
5
import "./someothercontract.sol";

contract newContract is SomeOtherContract {

}

这样当我们在合约(contract)目录下有一个名为 someothercontract.sol 的文件( ./ 就是同一目录的意思),它就会被编译器导入。

第7章: Storage与Memory

在 Solidity 中,有两个地方可以存储变量 —— storagememory

Storage 变量是指永久存储在区块链中的变量。 Memory 变量则是临时的,当外部函数对某合约调用完成时,内存型变量即被移除。 你可以把它想象成存储在你电脑的硬盘或是RAM中数据的关系。

大多数时候你都用不到这些关键字,默认情况下 Solidity 会自动处理它们。 状态变量(在函数之外声明的变量)默认为“存储”形式,并永久写入区块链;而在函数内部声明的变量是“内存”型的,它们函数调用结束后消失。

然而也有一些情况下,你需要手动声明存储类型,主要用于处理函数内的 结构体数组 时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
contract SandwichFactory {
struct Sandwich {
string name;
string status;
}

Sandwich[] sandwiches;

function eatSandwich(uint _index) public {
// Sandwich mySandwich = sandwiches[_index];

// ^ 看上去很直接,不过 Solidity 将会给出警告
// 告诉你应该明确在这里定义 `storage` 或者 `memory`。

// 所以你应该明确定义 `storage`:把他当指针
Sandwich storage mySandwich = sandwiches[_index];
// ...这样 `mySandwich` 是指向 `sandwiches[_index]`的指针
// 在存储里,另外...
mySandwich.status = "Eaten!";
// ...这将永久把 `sandwiches[_index]` 变为区块链上的存储

// 如果你只想要一个副本,可以使用`memory`:在内存的临时副本
Sandwich memory anotherSandwich = sandwiches[_index + 1];
// ...这样 `anotherSandwich` 就仅仅是一个内存里的副本了
// 另外
anotherSandwich.status = "Eaten!";
// ...将仅仅修改临时变量,对 `sandwiches[_index + 1]` 没有任何影响
// 不过你可以这样做:
sandwiches[_index + 1] = anotherSandwich;
// ...如果你想把副本的改动保存回区块链存储
}
}

当你不得不使用到这些关键字的时候,Solidity 编译器会发警示提醒你的。

第9章: 更多关于函数可见性

错误在于,我们尝试从 ZombieFeeding 中调用 _createZombie 函数,但 _createZombie 却是 ZombieFactoryprivate (私有)函数。这意味着任何继承自 ZombieFactory 的子合约都不能访问它。

internal 和 external

publicprivate 属性之外,Solidity 还使用了另外两个描述函数可见性的修饰词:internal(内部) 和 external(外部)。

internalprivate 类似,不过, 如果某个合约继承自其父合约,这个合约即可以访问父合约中定义的“内部”函数。而private只允许合约内部函数访问。

externalpublic 类似,只不过这些函数只能在合约之外调用,它们不能被合约内的其他函数调用。稍后我们将讨论什么时候使用 externalpublic。而public允许合约内和合约外的函数调用。

声明函数 internalexternal 类型的语法,与声明 privatepublic类 型相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
contract Sandwich {
uint private sandwichesEaten = 0;

function eat() internal {
sandwichesEaten++;
}
}

contract BLT is Sandwich {
uint private baconSandwichesEaten = 0;

function eatWithBacon() public returns (string) {
baconSandwichesEaten++;
// 因为eat() 是internal 的,所以我们能在这里调用
eat();
}
}

与其他合约的交互

如果我们的合约需要和区块链上的其他的合约会话,则需先定义一个 interface (接口)。

先举一个简单的栗子。 假设在区块链上有这么一个合约:

1
2
3
4
5
6
7
8
9
10
11
contract LuckyNumber {
mapping(address => uint) numbers;

function setNum(uint _num) public {
numbers[msg.sender] = _num;
}

function getNum(address _myAddress) public view returns (uint) {
return numbers[_myAddress];
}
}

这是个很简单的合约,您可以用它存储自己的幸运号码,并将其与您的以太坊地址关联。 这样其他人就可以通过您的地址查找您的幸运号码了。

现在假设我们有一个外部合约,使用 getNum 函数可读取其中的数据。

首先,我们定义 LuckyNumber 合约的 interface

1
2
3
contract NumberInterface {
function getNum(address _myAddress) public view returns (uint);
}

请注意,这个过程虽然看起来像在定义一个合约,但其实内里不同:

首先,我们只声明了要与之交互的函数 (在本例中为 getNum ),在其中我们没有使用到任何其他的函数或状态变量。

其次,我们并没有使用大括号({})定义函数体,我们单单用分号(;)结束了函数声明。这使它看起来像一个合约框架。

编译器就是靠这些特征认出它是一个接口的。

在我们的 app 代码中使用这个接口,合约就知道其他合约的函数是怎样的,应该如何调用,以及可期待什么类型的返回值。

我们已经为你查看过了 CryptoKitties 的源代码,并且找到了一个名为 getKitty的函数,它返回所有的加密猫的数据,包括它的“基因”(我们的僵尸游戏要用它生成新的僵尸)。

该函数如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function getKitty(uint256 _id) external view returns (
bool isGestating,
bool isReady,
uint256 cooldownIndex,
uint256 nextActionAt,
uint256 siringWithId,
uint256 birthTime,
uint256 matronId,
uint256 sireId,
uint256 generation,
uint256 genes
) {
Kitty storage kit = kitties[_id];

// if this variable is 0 then it's not gestating
isGestating = (kit.siringWithId != 0);
isReady = (kit.cooldownEndBlock <= block.number);
cooldownIndex = uint256(kit.cooldownIndex);
nextActionAt = uint256(kit.cooldownEndBlock);
siringWithId = uint256(kit.siringWithId);
birthTime = uint256(kit.birthTime);
matronId = uint256(kit.matronId);
sireId = uint256(kit.sireId);
generation = uint256(kit.generation);
genes = kit.genes;
}

这个函数看起来跟我们习惯的函数不太一样。 它竟然返回了…一堆不同的值! 在 Solidity中,可以让一个函数返回多个值。

第11章: 使用接口

继续前面 NumberInterface 的例子,我们既然将接口定义为:

1
2
3
contract NumberInterface {
function getNum(address _myAddress) public view returns (uint);
}

我们可以在合约中这样使用:

  1. 获得合约地址(该合约必须为external或者public)
  2. 定义一个变量:指向该合约地址的合约对象(之前定义的与之交互的接口对象)
  3. 调用该合约对象中的函数
1
2
3
4
5
6
7
8
9
10
11
12
contract MyContract {
address NumberInterfaceAddress = 0xab38...;
// ^ 这是FavoriteNumber合约在以太坊上的地址
NumberInterface numberContract = NumberInterface(NumberInterfaceAddress);
// 现在变量 `numberContract` 指向另一个合约对象

function someFunction() public {
// 现在我们可以调用在那个合约中声明的 `getNum`函数:
uint num = numberContract.getNum(msg.sender);
// ...在这儿使用 `num`变量做些什么
}
}

通过这种方式,只要将您合约的可见性设置为public(公共)或external(外部),它们就可以与以太坊区块链上的任何其他合约进行交互。

第12章: 处理多返回值

getKitty 是我们所看到的第一个返回多个值的函数。我们来看看是如何处理的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function multipleReturns() internal returns(uint a, uint b, uint c) {
return (1, 2, 3);
}

function processMultipleReturns() external {
uint a;
uint b;
uint c;
// 这样来做批量赋值:
(a, b, c) = multipleReturns();
}

// 或者如果我们只想返回其中一个变量:
function getLastReturnValue() external {
uint c;
// 可以对其他字段留空:
(,,c) = multipleReturns();
}

第13章: 奖励: Kitty 基因

if 语句

if语句的语法在 Solidity 中,与在 JavaScript 中差不多:

1
2
3
4
5
6
function eatBLT(string sandwich) public {
// 看清楚了,当我们比较字符串的时候,需要比较他们的 keccak256 哈希码
if (keccak256(sandwich) == keccak256("BLT")) {
eat();
}
}

第14章: 放在一起

JavaScript 实现

我们只用编译和部署 ZombieFeeding,就可以将这个合约部署到以太坊了。我们最终完成的这个合约继承自 ZombieFactory,因此它可以访问自己和父辈合约中的所有 public 方法。

我们来看一个与我们的刚部署的合约进行交互的例子, 这个例子使用了 JavaScript 和 web3.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
var abi = /* abi generated by the compiler */
var ZombieFeedingContract = web3.eth.contract(abi)
var contractAddress = /* our contract address on Ethereum after deploying */
var ZombieFeeding = ZombieFeedingContract.at(contractAddress)

// 假设我们有我们的僵尸ID和要攻击的猫咪ID
let zombieId = 1;
let kittyId = 1;

// 要拿到猫咪的DNA,我们需要调用它的API。这些数据保存在它们的服务器上而不是区块链上。
// 如果一切都在区块链上,我们就不用担心它们的服务器挂了,或者它们修改了API,
// 或者因为不喜欢我们的僵尸游戏而封杀了我们
let apiUrl = "https://api.cryptokitties.co/kitties/" + kittyId
$.get(apiUrl, function(data) {
let imgUrl = data.image_url
// 一些显示图片的代码
})

// 当用户点击一只猫咪的时候:
$(".kittyImage").click(function(e) {
// 调用我们合约的 `feedOnKitty` 函数
ZombieFeeding.feedOnKitty(zombieId, kittyId)
})

// 侦听来自我们合约的新僵尸事件好来处理
ZombieFactory.NewZombie(function(error, result) {
if (error) return
// 这个函数用来显示僵尸:
generateZombie(result.zombieId, result.name, result.dna)
})

「区块链」:Solidity-basic

https://f7ed.com/2020/11/03/solidity-basic/

Author

f7ed

Posted on

2020-11-03

Updated on

2021-05-22

Licensed under

CC BY-NC-SA 4.0


Comments