文章目录
私有数据
私有数据(Private Data)通常指的是只对特定主体可见或可访问的数据,在区块链环境中,它通常与隐私保护和访问控制相关。
在智能合约中,标记为 private 的变量或函数,仅在当前合约内部可访问,外部合约无法直接访问它们。
举个例子,对于该合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Vault {
// password 是一个 private 字段,只能在当前合约中使用
string private password = "super_secret_password";
address public owner;
constructor() {
owner = msg.sender;
}
function unlock(string memory _password) public view returns (bool) {
// 函数 unlock 接受一个字符串,调用时会判断输入的密码是否等于合约内的 password
return keccak256(abi.encodePacked(_password)) == keccak256(abi.encodePacked(password));
}
}
外部合约不能直接访问 password,如果你写一个别的合约来调用 Vault.password(),会直接报错:变量是 private。
但 Solidity 中的 private 并不意味着区块链层面上的真正“不可见”,因为所有链上数据都是公开透明的,可以通过区块链浏览器或节点还原查看。
我们可以使用 Etherscan 的“Read Contract → storage”工具,或者自己写脚本来读取私有数据。
然后调用 unlock(password),轻松通过验证,进而调用 Vault 合约的其他功能。
访问私有数据实例
1.打开 Remix IDE。
2.在左侧的 File Explorer 中创建一个新文件,命名为 vault.sol。
3.将上文所述的合约代码粘贴到文件中:
4.在 Remix 中,切换到 Solidity Compiler(左侧面板的第二个选项)。
5.确保编译器版本选择的是 0.8.0 或更高版本。
6.点击“Compile vault.sol”按钮来编译合约。
7.切换到 Deploy & Run Transactions 面板(左侧的第三个选项)。
8.环境需要选择线上环境,这里我们选择本地环境,点击“Deploy”按钮部署合约。
9.通过上图的控制台输出可以看到,我们部署的合约地址为:
0x358AA13c52544ECCEF6B0ADD0f801012ADAD5eE3
10.新建 JS 文件,添加以下代码:
(async () => {
const addr = "0x358AA13c52544ECCEF6B0ADD0f801012ADAD5eE3"; // 合约地址
const raw = await web3.eth.getStorageAt(addr, 0);
// 输出存储槽内容(原始十六进制数据)
console.log("Raw hex:", raw);
// 如果存储槽数据是字符串,解码
console.log("Decoded password:", web3.utils.hexToUtf8(raw));
})();
11.鼠标右键JS文件并运行:
12.输出如下:
running 1.js ...
Raw hex:
0x0
Decoded password:
为何返回的是 0x0 呢?
这是因为我们是在 Remix 的本地虚拟机(Remix VM)环境中部署的合约,而脚本访问的是主网上的同一个合约地址。尽管地址看起来相同,但由于本地环境与主网并不共享状态,脚本实际读取的是主网上该地址的存储数据,而不是我们本地部署的合约数据。因此返回的是 0x0,而不是我们在本地设置的值。
我们可以查询部署在以太坊主网的真实合约地址:
https://etherscan.io/address/0x358AA13c52544ECCEF6B0ADD0f801012ADAD5eE3
如果你想将合约部署到真实区块链上,可以在 Remix 中选择 WalletConnect 或 Injected Provider - MetaMask 环境,并连接你的 MetaMask 钱包。不过要注意,部署到链上会消耗实际的 Gas 费用:
最终,我们获取到 password,就可以使用 unlock 函数来验证密码。
示例 JS 代码如下:
const vaultInstance = new web3.eth.Contract(abi, vaultAddress);
const isUnlocked = await vaultInstance.methods.unlock("super_secret_password").call();
console.log(isUnlocked); // 输出 true
存储槽
从上文访问私有数据的 JavaScript 代码中你可以看到,我们通过如下语句读取合约中的存储数据:
const raw = await web3.eth.getStorageAt(addr, 0);
实际上获取的是合约地址 addr 下 slot 0 的数据。
那么,什么是 slot 0 呢?slot 称为存储槽,在 Solidity 中,所有的状态变量都会被存储在一个叫做 “存储槽(storage slot)” 的位置中。每个槽的大小为 32 字节(256 位),以太坊使用这些槽来组织和存储合约的数据。编译器会根据变量的声明顺序和类型,自动为它们分配对应的 slot 编号。
由于我们在合约中只定义了一个私有数据,因此其 slot 编号为 0(从 0 开始)。
接下来,我们顺带复习下 Solidity 中的数据存储方式。
Solidity 中的数据存储方式
Solidity 中的数据主要存储在三种位置:storage、memory 和 calldata。这三者在生命周期、读写成本、用途上都有明显区别。
1. storage(持久化存储)
在 Solidity 中,合约里的状态变量(包括 private、public 等)默认都存储在 storage 中。storage 是以太坊上的永久存储区域,写入的数据会记录在区块链上,因此读写成本较高。
底层上,Solidity 使用插槽(slot)来组织这些数据,每个 slot 固定为 32 字节(256 位)。编译器会根据变量在代码中声明的顺序为它们依次分配 slot。小于 32 字节的变量可能会被打包进同一个 slot(从右向左排列),而大于等于 32 字节的变量就会独占一个或多个新的 slot 来存储。
举个例子,假设我们有变量 varA(15 字节)、varB(10 字节)、bigVar2(35 字节),那么存储情况如下:
slot1: [ 剩余7字节,不满足vigVar2 | varA(15 字节) | varB(10 字节) ]
<---------------- 从右向左存储 --------------->
slot2: [ bigVar2(35 字节) ]
storage 中还有定长数组和变长数组的概念。
定长数组
定长数组的每个元素不管大小,都直接占用一个完整的插槽,连续存放在声明位置后面。
示例如下:
// 声明一个 uint16 类型的定长数组arr3,长度为 8,假设该数组分配在 slot 10 开始
uint16[8] arr3;
则:
slot 10: [ arr3[0](占用一个完整的 slot,虽然 uint16 是 2 字节,但仍占一个 32 字节的 slot) ]
slot 11: [ arr3[1](占用一个完整的 slot,虽然 uint16 是 2 字节,但仍占一个 32 字节的 slot) ]
slot 12: [ arr3[2](占用一个完整的 slot,虽然 uint16 是 2 字节,但仍占一个 32 字节的 slot) ]
slot 13: [ arr3[3](占用一个完整的 slot,虽然 uint16 是 2 字节,但仍占一个 32 字节的 slot) ]
变长数组
在处理变长数组时,Solidity 会采用特定的存储策略来应对其长度在编译期无法确定的问题。
当声明一个变长数组时,Solidity 会使用一个新的存储槽 slotA 来存储该数组的长度。数组的实际数据则不会紧接着存储在 slotA 后面,而是通过一个哈希运算确定其起始存储位置 slotV。
- slotA 表示变长数组在存储中的声明位置;
- length = sload(slotA):表示从 slotA 读取数组的长度;
- slotV = keccak256(slotA):通过 keccak256(slotA) 计算出数据部分的起始插槽;
- value = sload(slotV + index):第 index 个元素存储在 slotV + index 的位置,读取方式为 sload(slotV + index)。
由于变长数组在编译期无法确定其长度,因此无法预留连续的存储空间。为了解决这一问题,Solidity 选择将长度单独存储在 slotA,而将实际的数据映射到从 keccak256(slotA) 开始的一段存储空间中,以避免与其他变量产生冲突,并实现灵活的数据扩展。
2. memory(临时内存)
在 Solidity 中,memory 是一种临时的数据存储位置,数据仅在函数执行期间存在,函数调用结束后会被自动释放。它主要用于函数中临时变量的处理,比如函数参数、局部数组或结构体等。与永久存储的 storage 相比,memory 的读写成本更低一些,因此在处理临时数据时更加高效。不过,相较于 calldata,它的成本稍高一些。
需要注意的是,memory 中的数据不会被写入区块链,修改它也不会影响链上的状态,因此它适合用于函数内部的逻辑处理而非持久化存储。
3. calldata
在 Solidity 中,calldata 是一种只读的临时数据存储位置,专门用于存放函数的外部调用参数。它的数据存储在 EVM 的调用数据区,不会被写入链上,且在整个函数执行期间保持不变。
由于 calldata 是不可修改的,相比 memory 来说更省 gas,尤其是在处理大批量的外部输入数据时非常高效。因此,在 external 函数中,推荐将参数声明为 calldata,尤其是数组或结构体类型,以提升执行效率和节省成本。
可见性关键字
在了解 Solidity 中的三种存储方式后,我们再来看合约中的四种可见性关键字。Solidity 提供了四种可见性关键字:external、public、internal 和 private。默认情况下,函数的可见性为 public,而对于状态变量,除了不能使用 external 来定义,其他三个关键字都可以使用。状态变量的默认可见性为 internal。
external 关键字
使用 external 修饰的函数可以被外部合约调用。值得注意的是,external 函数不能在合约内部直接调用,必须使用 this.function() 的方式进行调用。
public 关键字
使用 public 修饰的函数不仅可以被外部调用,也可以在合约内部调用。对于使用 public 修饰的状态变量,Solidity 会自动生成一个 getter 函数,使得外部能够访问该变量。
internal 关键字
使用 internal 修饰的函数和状态变量只能在当前合约或其派生合约中访问。也就是说,internal 修饰的元素不能被外部合约直接访问。
private 关键字
使用 private 修饰的函数和状态变量仅对定义它的合约可见,派生合约无法访问或调用这些函数和状态变量。
综上所述,合约中使用可见性关键字修饰的变量和函数,主要限制了其调用范围,但并不限制其是否可读。因此,即使是 private 或 internal 的状态变量,只要能够通过特定方法访问,仍然是可以读取的。接下来,我们将深入探讨如何读取合约中的所有数据。
私有数据存储风险
所有状态变量都会被写入区块链的存储(storage)中,对所有链上用户公开可见。
从上述示例中我们也可以看出,任何人都可以访问链上任意合约的私有数据。因此,Solidity 中的 private
修饰符并不真正提供数据隐私,它仅在合约内部限制访问权限,对链外用户并无保护作用。
安全措施
1.敏感数据(如密码)不应以明文形式存储在链上;
2.在上链前对数据进行哈希处理(例如,仅存储 keccak256(password) 的结果);
3.避免在合约中保存明文密码,即使使用 private 也无法阻止链上其他人读取。
在下篇文章中,将通过具体合约代码进行案例分析。