Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

onchain metadata support for multi delegate #25

Merged
merged 11 commits into from
Dec 13, 2023
108 changes: 94 additions & 14 deletions contracts/ERC20MultiDelegate.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@
pragma solidity ^0.8.21;

import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {UniversalResolver} from "@ensdomains/ens-contracts/contracts/utils/UniversalResolver.sol";
import {NameEncoder} from "@ensdomains/ens-contracts/contracts/utils/NameEncoder.sol";
import {HexUtils} from "./utils/HexUtils.sol";
import {StringUtils} from "./utils/StringUtils.sol";

/**
* @dev A child contract which will be deployed by the ERC20MultiDelegate utility contract
Expand All @@ -27,8 +33,14 @@ contract ERC20ProxyDelegator {
*/
contract ERC20MultiDelegate is ERC1155, Ownable {
using Address for address;
using NameEncoder for string;
using HexUtils for address;
using StringUtils for string;

ERC20Votes public immutable token;
UniversalResolver public immutable metadataResolver;

error InvalidDelegateAddress();

/** ### EVENTS ### */

Expand All @@ -43,13 +55,14 @@ contract ERC20MultiDelegate is ERC1155, Ownable {
/**
* @dev Constructor.
* @param _token The ERC20 token address
* @param _metadata_uri ERC1155 metadata uri
* @param _metadataResolver The Universal Resolver address
*/
constructor(
ERC20Votes _token,
string memory _metadata_uri
) ERC1155(_metadata_uri) {
UniversalResolver _metadataResolver
) ERC1155("") {
token = _token;
metadataResolver = _metadataResolver;
}

/**
Expand Down Expand Up @@ -104,19 +117,21 @@ contract ERC20MultiDelegate is ERC1155, Ownable {
"Delegate: The number of amounts must be equal to the greater of the number of sources or targets"
);

uint256 maxLength = Math.max(sourcesLength, targetsLength);
// Iterate until all source and target delegates have been processed.
for (
uint transferIndex = 0;
transferIndex < Math.max(sourcesLength, targetsLength);
) {
for (uint transferIndex = 0; transferIndex < maxLength; ) {
address source = address(0);
address target = address(0);
if (transferIndex < sourcesLength) {
require((sources[transferIndex] >> 160) == 0, "Upper 96 bits of source uint256 must be zero");
if ((sources[transferIndex] >> 160) != 0) {
revert InvalidDelegateAddress();
}
source = address(uint160(sources[transferIndex]));
}
if (transferIndex < targetsLength) {
require((targets[transferIndex] >> 160) == 0, "Upper 96 bits of target uint256 must be zero");
if ((targets[transferIndex] >> 160) != 0) {
revert InvalidDelegateAddress();
}
target = address(uint160(targets[transferIndex]));
}

Expand All @@ -133,7 +148,9 @@ contract ERC20MultiDelegate is ERC1155, Ownable {
_createProxyDelegatorAndTransfer(target, amount);
}

unchecked { transferIndex++; }
unchecked {
transferIndex++;
}
}

if (sourcesLength > 0) {
Expand Down Expand Up @@ -173,9 +190,72 @@ contract ERC20MultiDelegate is ERC1155, Ownable {
require(token.transferFrom(proxyAddressFrom, msg.sender, amount));
}

function setUri(string memory uri) external onlyOwner {
_setURI(uri);
emit MetadataURIUpdated(uri);
/**
* @dev Generates an onchain metadata for a given tokenId.
*
* @param tokenId The token ID (address) of the delegate.
* @return Onchain metadata in base64 format "data:application/json;base64,<encoded-json>".
*/
function tokenURI(uint256 tokenId) public view returns (string memory) {
// convert tokenId to a hex string representation of the address
string memory hexAddress = address(uint160(tokenId)).addressToHex();

// construct the encoded reversed name
bytes memory encodedReversedName = bytes.concat(
"\x28",
bytes(hexAddress),
"\x04addr\x07reverse\x00"
);

string memory resolvedName;
// attempt to resolve the reversed name using the metadataResolver
try metadataResolver.reverse(encodedReversedName) returns (
string memory _resolvedName,
address,
address,
address
) {
resolvedName = _resolvedName;
} catch {}

string memory imageUri = "";

if (bytes(resolvedName).length > 0) {
(bytes memory encodedName, bytes32 namehash) = resolvedName
.dnsEncodeName();
bytes memory data = abi.encodeWithSignature(
"text(bytes32,string)",
[namehash, "avatar"]
);

// attempt to resolve the avatar using the universal resolver
try metadataResolver.resolve(encodedName, data) returns (
bytes memory _imageUri,
address
) {
imageUri = _imageUri.length == 0
? ""
: abi.decode(_imageUri, (string));
} catch {}
} else {
resolvedName = hexAddress;
}

string memory json = Base64.encode(
bytes(
string.concat(
'{"name": "',
resolvedName.escape(),
" Delegate Token",
'", "token_id": "',
Strings.toString(tokenId),
'", "description": "This NFT is a proof for your ENS delegation strategy.", "image": "',
imageUri.escape(),
'"}'
)
)
);
return string.concat("data:application/json;base64,", json);
}

function _createProxyDelegatorAndTransfer(
Expand Down
2 changes: 1 addition & 1 deletion contracts/deps.sol
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT
// These imports are here to force Hardhat to compile contracts we depend on in our tests but don't need anywhere else.
import "@ensdomains/ens-contracts/contracts/registry/ENSRegistry.sol";
import "@ensdomains/ens-contracts/contracts/resolvers/PublicResolver.sol";
import "@ensdomains/ens-contracts/contracts/reverseRegistrar/ReverseRegistrar.sol";

10 changes: 10 additions & 0 deletions contracts/mocks/StringUtilsTest.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: MIT
import "../utils/StringUtils.sol";

library StringUtilsTest {
function testEscape(
string calldata testStr
) public pure returns (string memory) {
return StringUtils.escape(testStr);
}
}
102 changes: 102 additions & 0 deletions contracts/utils/HexUtils.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

library HexUtils {
/**
* @dev Attempts to parse bytes32 from a hex string
* @param str The string to parse
* @param idx The offset to start parsing at
* @param lastIdx The (exclusive) last index in `str` to consider. Use `str.length` to scan the whole string.
*/
function hexStringToBytes32(
bytes memory str,
uint256 idx,
uint256 lastIdx
) internal pure returns (bytes32 r, bool valid) {
valid = true;
assembly {
// check that the index to read to is not past the end of the string
if gt(lastIdx, mload(str)) {
revert(0, 0)
}

function getHex(c) -> ascii {
// chars 48-57: 0-9
if and(gt(c, 47), lt(c, 58)) {
ascii := sub(c, 48)
leave
}
// chars 65-70: A-F
if and(gt(c, 64), lt(c, 71)) {
ascii := add(sub(c, 65), 10)
leave
}
// chars 97-102: a-f
if and(gt(c, 96), lt(c, 103)) {
ascii := add(sub(c, 97), 10)
leave
}
// invalid char
ascii := 0xff
}

let ptr := add(str, 32)
for {
let i := idx
} lt(i, lastIdx) {
i := add(i, 2)
} {
let byte1 := getHex(byte(0, mload(add(ptr, i))))
let byte2 := getHex(byte(0, mload(add(ptr, add(i, 1)))))
// if either byte is invalid, set invalid and break loop
if or(eq(byte1, 0xff), eq(byte2, 0xff)) {
valid := false
break
}
let combined := or(shl(4, byte1), byte2)
r := or(shl(8, r), combined)
}
}
}

/**
* @dev Attempts to parse an address from a hex string
* @param str The string to parse
* @param idx The offset to start parsing at
* @param lastIdx The (exclusive) last index in `str` to consider. Use `str.length` to scan the whole string.
*/
function hexToAddress(
bytes memory str,
uint256 idx,
uint256 lastIdx
) internal pure returns (address, bool) {
if (lastIdx - idx < 40) return (address(0x0), false);
(bytes32 r, bool valid) = hexStringToBytes32(str, idx, lastIdx);
return (address(uint160(uint256(r))), valid);
}

/**
* @dev Attempts to convert an address to a hex string
* @param addr The _addr to parse
*/
function addressToHex(address addr) internal pure returns (string memory) {
bytes memory hexString = new bytes(40);
for (uint i = 0; i < 20; i++) {
bytes1 byteValue = bytes1(uint8(uint160(addr) >> (8 * (19 - i))));
bytes1 highNibble = bytes1(uint8(byteValue) / 16);
bytes1 lowNibble = bytes1(
uint8(byteValue) - 16 * uint8(highNibble)
);
hexString[2 * i] = _nibbleToHexChar(highNibble);
hexString[2 * i + 1] = _nibbleToHexChar(lowNibble);
}
return string(hexString);
}

function _nibbleToHexChar(
bytes1 nibble
) internal pure returns (bytes1 hexChar) {
if (uint8(nibble) < 10) return bytes1(uint8(nibble) + 0x30);
else return bytes1(uint8(nibble) + 0x57);
}
}
51 changes: 51 additions & 0 deletions contracts/utils/StringUtils.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

library StringUtils {
function escape(string memory str) internal pure returns (string memory) {
bytes memory strBytes = bytes(str);
uint extraChars = 0;

// count extra space needed for escaping
for (uint i = 0; i < strBytes.length; i++) {
if (_needsEscaping(strBytes[i])) {
extraChars++;
}
}

// allocate buffer with the exact size needed
bytes memory buffer = new bytes(strBytes.length + extraChars);
uint index = 0;

// escape characters
for (uint i = 0; i < strBytes.length; i++) {
if (_needsEscaping(strBytes[i])) {
buffer[index++] = "\\";
buffer[index++] = _getEscapedChar(strBytes[i]);
} else {
buffer[index++] = strBytes[i];
}
}

return string(buffer);
}

// determine if a character needs escaping
function _needsEscaping(bytes1 char) private pure returns (bool) {
return
char == '"' ||
char == "/" ||
char == "\\" ||
char == "\n" ||
char == "\r" ||
char == "\t";
}

// get the escaped character
function _getEscapedChar(bytes1 char) private pure returns (bytes1) {
if (char == "\n") return "n";
if (char == "\r") return "r";
if (char == "\t") return "t";
return char;
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"author": "ENS Team (@ensdomains)",
"license": "MIT",
"dependencies": {
"@ensdomains/ens-contracts": "^0.0.7",
"@ensdomains/ens-contracts": "^0.0.22",
"@openzeppelin/contracts": "^4.9.3",
"keccak256": "^1.0.3"
},
Expand Down