From 260e082ed10e86e5870c4e5859750a8271eeb2b9 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 6 Feb 2023 09:59:25 +0100 Subject: [PATCH] Add a library for handling short strings in a gas efficient way (#4023) Co-authored-by: Francisco --- .changeset/violet-frogs-hide.md | 5 ++ contracts/utils/README.adoc | 2 + contracts/utils/ShortStrings.sol | 94 ++++++++++++++++++++++++++++++++ package-lock.json | 14 ++--- package.json | 2 +- test/utils/ShortStrings.test.js | 44 +++++++++++++++ 6 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 .changeset/violet-frogs-hide.md create mode 100644 contracts/utils/ShortStrings.sol create mode 100644 test/utils/ShortStrings.test.js diff --git a/.changeset/violet-frogs-hide.md b/.changeset/violet-frogs-hide.md new file mode 100644 index 000000000..21d2bf984 --- /dev/null +++ b/.changeset/violet-frogs-hide.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ShortStrings`: Added a library for handling short strings in a gas efficient way, with fallback to storage for longer strings. diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index 7fef825a6..5e8af93e3 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -106,6 +106,8 @@ Note that, in all cases, accounts simply _declare_ their interfaces, but they ar {{Strings}} +{{ShortStrings}} + {{StorageSlot}} {{Multicall}} diff --git a/contracts/utils/ShortStrings.sol b/contracts/utils/ShortStrings.sol new file mode 100644 index 000000000..9f253df82 --- /dev/null +++ b/contracts/utils/ShortStrings.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.8; + +import "./StorageSlot.sol"; + +type ShortString is bytes32; + +/** + * @dev This library provides functions to convert short memory strings + * into a `ShortString` type that can be used as an immutable variable. + * Strings of arbitrary length can be optimized if they are short enough by + * the addition of a storage variable used as fallback. + * + * Usage example: + * + * ```solidity + * contract Named { + * using ShortStrings for *; + * + * ShortString private immutable _name; + * string private _nameFallback; + * + * constructor(string memory contractName) { + * _name = contractName.toShortStringWithFallback(_nameFallback); + * } + * + * function name() external view returns (string memory) { + * return _name.toStringWithFallback(_nameFallback); + * } + * } + * ``` + */ +library ShortStrings { + error StringTooLong(string str); + + /** + * @dev Encode a string of at most 31 chars into a `ShortString`. + * + * This will trigger a `StringTooLong` error is the input string is too long. + */ + function toShortString(string memory str) internal pure returns (ShortString) { + bytes memory bstr = bytes(str); + if (bstr.length > 31) { + revert StringTooLong(str); + } + return ShortString.wrap(bytes32(uint256(bytes32(bstr)) | bstr.length)); + } + + /** + * @dev Decode a `ShortString` back to a "normal" string. + */ + function toString(ShortString sstr) internal pure returns (string memory) { + uint256 len = length(sstr); + // using `new string(len)` would work locally but is not memory safe. + string memory str = new string(32); + /// @solidity memory-safe-assembly + assembly { + mstore(str, len) + mstore(add(str, 0x20), sstr) + } + return str; + } + + /** + * @dev Return the length of a `ShortString`. + */ + function length(ShortString sstr) internal pure returns (uint256) { + return uint256(ShortString.unwrap(sstr)) & 0xFF; + } + + /** + * @dev Encode a string into a `ShortString`, or write it to storage if it is too long. + */ + function toShortStringWithFallback(string memory value, string storage store) internal returns (ShortString) { + if (bytes(value).length < 32) { + return toShortString(value); + } else { + StorageSlot.getStringSlot(store).value = value; + return ShortString.wrap(0); + } + } + + /** + * @dev Decode a string that was encoded to `ShortString` or written to storage using {setWithFallback}. + */ + function toStringWithFallback(ShortString value, string storage store) internal pure returns (string memory) { + if (length(value) > 0) { + return toString(value); + } else { + return store; + } + } +} diff --git a/package-lock.json b/package-lock.json index 51d350ac7..a663055fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "glob": "^8.0.3", "graphlib": "^2.1.8", "hardhat": "^2.9.1", - "hardhat-exposed": "^0.3.0", + "hardhat-exposed": "^0.3.1", "hardhat-gas-reporter": "^1.0.4", "hardhat-ignore-warnings": "^0.2.0", "keccak256": "^1.0.2", @@ -7923,9 +7923,9 @@ } }, "node_modules/hardhat-exposed": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/hardhat-exposed/-/hardhat-exposed-0.3.0.tgz", - "integrity": "sha512-1p2Aou7QW3VVI0iJhh3q9hgPyF66zggeW7v/PrcipniQqaXK+KxJnnJvzGsLvXYzB8lVp23GIK7MXoTjjyXkHQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/hardhat-exposed/-/hardhat-exposed-0.3.1.tgz", + "integrity": "sha512-qyHXdS3NmzrtXF+XL547BMsTAK+IEMW9OOYMH4d362DlPn4L2B2KXKG6OpuJczxYjgMFJ0LunLxNgu6jtRvjRg==", "dev": true, "dependencies": { "micromatch": "^4.0.4", @@ -22753,9 +22753,9 @@ } }, "hardhat-exposed": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/hardhat-exposed/-/hardhat-exposed-0.3.0.tgz", - "integrity": "sha512-1p2Aou7QW3VVI0iJhh3q9hgPyF66zggeW7v/PrcipniQqaXK+KxJnnJvzGsLvXYzB8lVp23GIK7MXoTjjyXkHQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/hardhat-exposed/-/hardhat-exposed-0.3.1.tgz", + "integrity": "sha512-qyHXdS3NmzrtXF+XL547BMsTAK+IEMW9OOYMH4d362DlPn4L2B2KXKG6OpuJczxYjgMFJ0LunLxNgu6jtRvjRg==", "dev": true, "requires": { "micromatch": "^4.0.4", diff --git a/package.json b/package.json index de2951bd1..66d44888a 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "glob": "^8.0.3", "graphlib": "^2.1.8", "hardhat": "^2.9.1", - "hardhat-exposed": "^0.3.0", + "hardhat-exposed": "^0.3.1", "hardhat-gas-reporter": "^1.0.4", "hardhat-ignore-warnings": "^0.2.0", "keccak256": "^1.0.2", diff --git a/test/utils/ShortStrings.test.js b/test/utils/ShortStrings.test.js new file mode 100644 index 000000000..f41084a7e --- /dev/null +++ b/test/utils/ShortStrings.test.js @@ -0,0 +1,44 @@ +const { expect } = require('chai'); +const { expectRevertCustomError } = require('../helpers/customError'); + +const ShortStrings = artifacts.require('$ShortStrings'); + +function decode(sstr) { + const length = parseInt(sstr.slice(64), 16); + return web3.utils.toUtf8(sstr).slice(0, length); +} + +contract('ShortStrings', function () { + before(async function () { + this.mock = await ShortStrings.new(); + }); + + for (const str of [0, 1, 16, 31, 32, 64, 1024].map(length => 'a'.repeat(length))) { + describe(`with string length ${str.length}`, function () { + it('encode / decode', async function () { + if (str.length < 32) { + const encoded = await this.mock.$toShortString(str); + expect(decode(encoded)).to.be.equal(str); + + const length = await this.mock.$length(encoded); + expect(length.toNumber()).to.be.equal(str.length); + + const decoded = await this.mock.$toString(encoded); + expect(decoded).to.be.equal(str); + } else { + await expectRevertCustomError(this.mock.$toShortString(str), `StringTooLong("${str}")`); + } + }); + + it('set / get with fallback', async function () { + const { logs } = await this.mock.$toShortStringWithFallback(str, 0); + const { ret0 } = logs.find(({ event }) => event == 'return$toShortStringWithFallback').args; + + expect(await this.mock.$toString(ret0)).to.be.equal(str.length < 32 ? str : ''); + + const recovered = await this.mock.$toStringWithFallback(ret0, 0); + expect(recovered).to.be.equal(str); + }); + }); + } +});