从foundry工程化的角度详细解读Openzeppelin中的Base64库及对应测试。
[openzeppelin]:v4.8.3,[forge-std]:v1.5.6
Github: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.8.3/contracts/utils/Base64.sol
Base64库是一个专门用于Base64编码的工具库。
简单的说,一些通讯协议是不能传输或者储存二进制流的。一张图片的底层就是由许许多多的0和1构成的二进制数据,如果你想直接通过json来直接传输一张图片的二进制数据几乎是不可能的。8 bits组成一个byte,而ASCII中有许多码位是不可显示的(0x0~0x20)。那么我们就需要用一种编码方式将二进制流数据编码成全部可显示的东西,即可以用string来表示一切。Base64编码就是一种备选方案。
将8位的二进制字节序列按顺序以每6位为一块进行分割,如果最后不足6位,则在尾部补0(以=结尾)。
6位的二进制一共有2^6=64种可能。用64个可打印的字符组成一张表,依次将6位的块按照表内容进行映射,映射后的编码就是具体的Base64编码码文。
原来8位表示一个字符,现在缩短到了6位,那么编码后的码文长度就一定会更长。
ps:在Base64库的合约中,就可以看到由64个可显示字符组成的映射表:
string internal constant _TABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
封装Base64 library成为一个可调用合约:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "openzeppelin-contracts/contracts/utils/Base64.sol";
contract MockBase64 {
using Base64 for bytes;
function encode(bytes memory rawBytes) external pure returns (string memory){
return rawBytes.encode();
}
}
全部foundry测试合约:
返回传出bytes序列的Base64编码的字符串形式。
library Base64 {
// Base64编码映射表
string internal constant _TABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// ps:假设data为0x0102,内存layout为:
// 0x0000000000000000000000000000000000000000000000000000000002 | 0102
// 前32字节为待编码序列的长度2,后面按照字节顺序存储0x01,0x02
function encode(bytes memory data) internal pure returns (string memory) {
// 如果传入空bytes,返回 ""
if (data.length == 0) return "";
// 将映射表载入memory
string memory table = _TABLE;
// 为内存指针result分配内存空间(存放编码后的内容)
// 注:1. 将目标bytes序列3个字节分成一组(即3*8=24 bits一组),每组将用4个Base64码文表示(24/6=4)
// 2. 在编码前确定编码后码文的整体长度。由于可能存在目标字节数无法被3整除的情况,需要尾部补0。那么将目标序列字节长度+2后 (round up),再进行/3操作会将补0后的Base64的长度确定出来
string memory result = new string(4 * ((data.length + 2) / 3));
assembly {
// 定义指针tablePtr指向内存中映射表偏移1个字节长度位置,此时tablePtr指向的内存数据为:
// 0x0000000000000000000000000000000000000000000000000000000000004041 | 4243...
// (映射表长度)64 A B C
let tablePtr := add(table, 1)
// 定义指向result内容的memory指针resultPtr。(result指针的前32字节用于存放编码后码文长度)
let resultPtr := add(result, 32)
for {
// 循环初始part:
// 1. dataPtr指针指向待编码序列头部;
// 2. endPtr指针指向待编码序列尾部。
// 注:mload(data)为待编码序列的字节数
let dataPtr := data
let endPtr := add(data, mload(data))
// 循环条件:dataPtr<endPtr
} lt(dataPtr, endPtr) {
} {
// dataPtr指针前进3个字节,因为3*8=24位,刚好够24/6=4个编码码文
dataPtr := add(dataPtr, 3)
// input为从dataPtr指向地址读32字节内容
// 即:0x0000000000000000000000000000000000000000000000000000000002010200
// 注:由于0x02是结尾,input最后面的一个字节为空内存0x00
let input := mload(dataPtr)
// 1. shr(18, input): 将input右移18位,尾部只剩下24-18=6位
// 结果为:为:0x0000000000000000000000000000000000000000000000000000000000000080
// 2. and(shr(18, input), 0x3F): 将上述结果同0x3F做与运算,可以保留有效的6位而过滤掉input有效数据内容前的数组长度2。而该值按照Base64算法正好是本次6位数据对应映射表中的码文索引
// 结果为:0x0000000000000000000000000000000000000000000000000000000000000000
// 3. mload(add(tablePtr, and(shr(18, input), 0x3F))): 映射表指针偏移0字节,取32个字节。由于之前tablePtr只偏移了1个字节,那么对应的码文应该正好处于该32字节的最后一个字节
// 结果为:0x0000000000000000000000000000000000000000000000000000000000004041
// 4. mstore8(resultPtr, mload(add(tablePtr, and(shr(18, input), 0x3F)))): 向resultPtr指向内存地址写入上述结果的低8位,即0x41。此时完成了待编码序列数据前6位的编码,编码文为A
mstore8(resultPtr, mload(add(tablePtr, and(shr(18, input), 0x3F))))
// resultPtr偏移1个字节,指向下一个要写入码位的位置
resultPtr := add(resultPtr, 1)
// 进行第7~12位数据的编码:将input右移12位,尾部只剩下24-12=12位。通过与0x3F做与运算得到第7~12位数据:0x0000000000000000000000000000000000000000000000000000000000000010
// 编码后的码文为:Q
mstore8(resultPtr, mload(add(tablePtr, and(shr(12, input), 0x3F))))
resultPtr := add(resultPtr, 1)
// 进行第13~18位数据的编码:将input右移6位,尾部只剩下24-6=18位。通过与0x3F做与运算得到第13~18位数据:0x0000000000000000000000000000000000000000000000000000000000000008
// 编码后的码文为:I
mstore8(resultPtr, mload(add(tablePtr, and(shr(6, input), 0x3F))))
resultPtr := add(resultPtr, 1)
// 进行第19~24位数据的编码:此时待编码的已经是input中补0的内容。其中编码后的码文是什么无所谓,后面都会用=替换掉
mstore8(resultPtr, mload(add(tablePtr, and(input, 0x3F))))
resultPtr := add(resultPtr, 1)
// 注:for循环每3个字节一组,一直到dataPtr>=endPtr停止循环
}
// 如果待编码序列长度不是3的整倍数,即位数无法被6整除,即无法全部正好被Base64编码,需要在尾部补0凑到位数是6的整倍数,从编码后的字节层面看就是在尾部补'='
// data的字节长度对3取模
switch mod(mload(data), 3)
case 1 {
// 余1,表示待编码序列尾部补了2个0字节,需要用=替换掉最后一次循环中为补0内容。
// 将result中最后2个码文都替换成0x3d,即ASCII中的'='
mstore8(sub(resultPtr, 1), 0x3d)
mstore8(sub(resultPtr, 2), 0x3d)
}
case 2 {
// 余1,表示待编码序列尾部补了1个0字节,需要用=替换掉最后一次循环中为补0内容。
// 将result中最后1个码文替换成0x3d,即ASCII中的'='
mstore8(sub(resultPtr, 1), 0x3d)
}
}
return result;
}
}
foundry代码验证
contract Base64Test is Test {
MockBase64 mb = new MockBase64();
function test_Encode() external {
// case 1: 尾部补4个0 (字节长度 % 3==1)
// data: 0x01
// 8 bits split: 00000001
// 6 bits split: 000000| 01 0000 (补4个0)
// base64 bytes: A | Q==
assertEq("AQ==", mb.encode(hex"01"));
// case 2: 尾部补2个0 (字节长度 % 3==2)
// data: 0x0102
// 8 bits split: 00000001 | 00000010
// 6 bits split: 000000 | 010000 | 0010 00 (补2个0)
// base64 bytes: A | Q | I=
assertEq("AQI=", mb.encode(hex"0102"));
// case 3: 尾部不补0 (字节长度 % 3==0)
// data: 0x010203
// 8 bits split: 00000001 | 00000010 | 00000011
// 6 bits split: 000000| 010000 | 0001000 | 000011
// base64 bytes: A | Q | I | D
assertEq("AQID", mb.encode(hex"010203"));
}
}
ps:\ 本人热爱图灵,热爱中本聪,热爱V神。 以下是我个人的公众号,如果有技术问题可以关注我的公众号来跟我交流。 同时我也会在这个公众号上每周更新我的原创文章,喜欢的小伙伴或者老伙计可以支持一下! 如果需要转发,麻烦注明作者。十分感谢!
公众号名称:后现代泼痞浪漫主义奠基人
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!