Skip to content

Instantly share code, notes, and snippets.

@tunnckoCore
Created August 14, 2023 06:32
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tunnckoCore/55c49d249d7a6dc9a0a8359b10f960ab to your computer and use it in GitHub Desktop.
Save tunnckoCore/55c49d249d7a6dc9a0a8359b10f960ab to your computer and use it in GitHub Desktop.
Ethscriptions hosting & resolving domains (handles/words/ens), not just "user profiles" with `application/vnd.esc.user.profile+json`
import { verifyMessage } from "viem";
/**
* Ethscriptions Hosting & Resolving of ens domains & ethscriptions handles eg. `data,hirsch`
*
* `data:application/vnd.esc.wgw.deploy.`
* -> hex = 646174613a6170706c69636174696f6e2f766e642e6573632e7767772e6465706c6f792e
*
* `+json`
* -> hex = 2b6a736f6e
*
* - comma = 2c
* - colon = 3b
*
* `;base64,` = 3b6261736536342c
*/
let regex =
/646174613a6170706c69636174696f6e2f766e642e6573632e7767772e6465706c6f792e.+2b6a736f6e(2c|3b6261736536342c).+/;
function toHex(x) {
return x
.split("")
.map((c) => c.charCodeAt(0).toString(16).padStart(2, "0"))
.join("");
}
function fromHex(x) {
return x
.split(/(\w\w)/g)
.filter((p) => !!p)
.map((c) => String.fromCharCode(parseInt(c, 16)))
.join("");
}
const ethsHandle = `hirsch`;
const ensDomain = `wgw.eth`;
// all 3 are required
// base64-ed without signature
// const b64ed = `eyJ1cmkiOiJlc2M6Ly8weGVjMTI5OWM4NmNjN2M0NmY4ZGYwNjExNWMzMTQ1MTExY2NiYTlhYzJiMTE4Njg5N2Y0MzE4M2YyYzU3NTM4NTMiLCJtaW1lIjoidGV4dC9odG1sIiwib3duZXIiOiIweEEyMEMwN0Y5NEExMjdmRDc2RTYxZmJlQTEwMTljQ2U3NTkyMjUwMDIiLCJyZWRpcmVjdCI6ZmFsc2UsInR3aXR0ZXIiOiJ3Z3dfZXRoIiwiYXZhdGFyIjoiZXNjOi8vMHhlZWY1OGJlZjUyYjI5MmQyZGUxZDJjM2YzOWJlODRjN2VmYjkzZDg1OGIyNDY1Zjc0NWYxNmEzYTE4NWYwYjRiIiwiY292ZXIiOiJlc2M6Ly8weDI0MTEzOTZkNmZiMWVhZGFmNjhhMjlhMzgxZjQ0ZmQ5ZDY0Y2Y0Y2QxODUyODk2YzUzMDk3NjFkM2ExYjE4ZDAifQ==`;
// const b64withSig = `eyJ1cmkiOiJlc2M6Ly8weGVjMTI5OWM4NmNjN2M0NmY4ZGYwNjExNWMzMTQ1MTExY2NiYTlhYzJiMTE4Njg5N2Y0MzE4M2YyYzU3NTM4NTMiLCJtaW1lIjoidGV4dC9odG1sIiwib3duZXIiOiIweEEyMEMwN0Y5NEExMjdmRDc2RTYxZmJlQTEwMTljQ2U3NTkyMjUwMDIiLCJzaWduYXR1cmUiOiIweGQ1NTZjNTY2NTcyOTA4MzIzNDAyN2ZkY2Y1MGU3NmI1NmE3YjQwOGE5NDI3NTQwOGZkYTI3ZGEwM2U5Zjc1NTMyYTQyOTYwMzkzNWY0OThlNjIzMGIxMDdlMGEwYjIzNzg2YjMwODk0NmNlOGI0NTlhYmVkYTc5MmY0MzQwNzZkMWMiLCJyZWRpcmVjdCI6ZmFsc2UsInR3aXR0ZXIiOiJ3Z3dfZXRoIiwiYXZhdGFyIjoiZXNjOi8vMHhlZWY1OGJlZjUyYjI5MmQyZGUxZDJjM2YzOWJlODRjN2VmYjkzZDg1OGIyNDY1Zjc0NWYxNmEzYTE4NWYwYjRiIiwiY292ZXIiOiJlc2M6Ly8weDI0MTEzOTZkNmZiMWVhZGFmNjhhMjlhMzgxZjQ0ZmQ5ZDY0Y2Y0Y2QxODUyODk2YzUzMDk3NjFkM2ExYjE4ZDAifQ==`;
// const decodedData = `data:application/vnd.esc.wgw.deploy.tunnckocore+json;base64,${b64withSig}`;
// const decodedData = `data:application/vnd.esc.wgw.deploy.tunnckocore+json,${JSON.stringify(
// {
// // or ipfs://, or https:// and etc. (neko cat Ethscription #540102)
// uri: `esc://0xec1299c86cc7c46f8df06115c3145111ccba9ac2b1186897f43183f2c5753853`,
// // if application/json, then there can be `avatar`, `twitter` and etc fields instead of here
// mime: "text/html",
// // hirsch address is the owner of both that content uri, AND the `hirsch` handle
// owner: `0xA20C07F94A127fD76E61fbeA1019cCe759225002`,
// // required (but excluded when they sign) - real signature
// // signature: `0xe77a9a4bd1243b859c0e536728c852ea868cc89d6e47f4100f4aff8f4773589416cb7795d1499052eca2bf811d9b296a2229fbe78870d5178cba3c7d3b2f245c1b`,
// // optional
// redirect: false,
// twitter: "wgw_eth",
// avatar: `esc://0xeef58bef52b292d2de1d2c3f39be84c7efb93d858b2465f745f16a3a185f0b4b`,
// cover: `esc://0x2411396d6fb1eadaf68a29a381f44fd9d64cf4cd1852896c5309761d3a1b18d0`,
// }
// )}`;
const decodedData = `data:application/vnd.esc.wgw.deploy.twitty+json,${JSON.stringify(
{
// or ipfs://, or https:// and etc. (ethscolors mint site html, Ethscription #1041468)
uri: `esc://0x0e7d6411f513e489d5719077f9b626740d2add792adee29880e23837ad15643f`,
// if application/json, then there can be `avatar`, `twitter` and etc fields instead of here
mime: "text/html",
// hirsch address is the owner of both that content uri, AND the `hirsch` handle
owner: `0xA20C07F94A127fD76E61fbeA1019cCe759225002`,
// required (but excluded when they sign) - real signature
signature: `0x0504cb5e77e93c246999cf56ea47c457e9a100e7f1b0225cd78f75fea1a3bdb0529de305845e432a1ad6c7f4c5fef017e694599d6a073cb766ffe4f61e4b3eb01c`,
}
)}`;
// const encodedData = toHex(decodedData);
// console.log("decoded ->", decodedData);
// console.log("encodedData ->", encodedData);
// console.log("data decoded ->", decodedData);
// => data:application/vnd.esc.wgw.deploy.5848.eth+json,{"uri":"esc://0x9edf9e18a1c6e533010e59e16522cbdd88760aa23fedaa112cf1e4321131a963","mime":"text/html","owner":"0xA20C07F94A127fD76E61fbeA1019cCe759225002"}
// console.log("valid ->", regex.test(encodedData));
// => true
/**
* So how it's supposed to work:
*
* 1. User creates a handle, e.g. `hirsch`
* 2. User wants to host some content and use the handle/ens to access it (through some resolver sites)
* 3. User creates a data like this:
* `data:application/vnd.esc.wgw.deploy.<ens_or_handle>,<json_data>`
* or base64-ed `data:application/vnd.esc.wgw.deploy.<ens_or_handle>;base64,<base64_data>`
*
* 4. Signs it, and adds the signature to the json data as `signature` field.
* 5. Ethscribes the whole thing.
*
* So, a resolver should detect that "mime type", and:
* 1. validate it against the regex
* 2. get the handle/ens from mimetype
* 3. recover the address from the signature (eg. the "message" is the whole thing without the signature field)
* 4. verify the recovered address against
* - a) the owner field
* - b) this ethscription is owned by this address
* - c) this ens/handle is owned by this address
* 5. resolve the content from the given `uri`
* 6. return the content + plus the eventual metadata (eg. avatar, cover, twitter linsk and etc)
*/
// write that into db or something
function parseInput(calldata) {
const regex =
/646174613a6170706c69636174696f6e2f766e642e6573632e7767772e6465706c6f792e.+2b6a736f6e(2c|3b6261736536342c).+/;
const isValid = regex.test(calldata);
if (!isValid) {
console.log("invalid calldata");
return false;
}
const base64Hexed = "3b6261736536342c";
const isBase64 = /3b6261736536342c/.test(calldata);
const delimiter = isBase64 ? "2b6a736f6e" + base64Hexed : "2b6a736f6e" + "2c";
const jsonIndex = calldata.indexOf(delimiter);
const data = fromHex(calldata.slice(jsonIndex + delimiter.length));
let json = null;
try {
json = JSON.parse(isBase64 ? atob(data) : data);
} catch (error) {
console.log("invalid json data");
return false;
}
if (!json.uri || !json.mime || !json.owner || !json.signature) {
console.log("missing required fields");
return false;
}
const name = fromHex(
calldata.slice(
"646174613a6170706c69636174696f6e2f766e642e6573632e7767772e6465706c6f792e"
.length,
jsonIndex
)
);
json.domain = name;
json.deployUri = fromHex(calldata);
json.isBase64 = isBase64;
return { name, data: json };
}
async function sha256(message) {
const msgUint8 = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return hashHex;
}
async function getDataURI(blobOrFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => resolve(reader.error);
reader.readAsDataURL(blobOrFile);
});
}
// invalid calldata `;<base64_data>` missing the `base64,` prefix
// handleInput(
// `646174613a6170706c69636174696f6e2f766e642e6573632e7767772e6465706c6f792e74756e6e636b6f636f72652b6a736f6e3b7b22757269223a226573633a2f2f307861306337653730353765303837383065663535353932626166653938633662313036643861633734323661303264366331666234356262343263393666383966222c226d696d65223a22746578742f68746d6c222c226f776e6572223a22307832303532303531613034373466623062393832383362336633386331336230623062366133363737222c227369676e6174757265223a22307831623263376230623062366133363737222c227265646972656374223a66616c73652c2274776974746572223a227767775f657468222c22617661746172223a226573633a2f2f307865656635386265663532623239326432646531643263336633396265383463376566623933643835386232343635663734356631366133613138356630623462222c22636f766572223a226573633a2f2f307832343131333936643666623165616461663638613239613338316634346664396436346366346364313835323839366335333039373631643361316231386430227d`
// );
// valid, base64-ed calldata (tunnckocore.eth)
// const result = parseInput(
// `646174613a6170706c69636174696f6e2f766e642e6573632e7767772e6465706c6f792e74756e6e636b6f636f72652e6574682b6a736f6e3b6261736536342c7b22757269223a226573633a2f2f307861306337653730353765303837383065663535353932626166653938633662313036643861633734323661303264366331666234356262343263393666383966222c226d696d65223a22746578742f68746d6c222c226f776e6572223a22307832303532303531613034373466623062393832383362336633386331336230623062366133363737222c227369676e6174757265223a22307831623263376230623062366133363737222c227265646972656374223a66616c73652c2274776974746572223a227767775f657468222c22617661746172223a226573633a2f2f307865656635386265663532623239326432646531643263336633396265383463376566623933643835386232343635663734356631366133613138356630623462222c22636f766572223a226573633a2f2f307832343131333936643666623165616461663638613239613338316634346664396436346366346364313835323839366335333039373631643361316231386430227d`
// );
// console.log("res ->", result);
// valid, data plain json
// handleInput(
// `646174613a6170706c69636174696f6e2f766e642e6573632e7767772e6465706c6f792e74756e6e636b6f636f72652b6a736f6e2c7b22757269223a226573633a2f2f307861306337653730353765303837383065663535353932626166653938633662313036643861633734323661303264366331666234356262343263393666383966222c226d696d65223a22746578742f68746d6c222c226f776e6572223a22307832303532303531613034373466623062393832383362336633386331336230623062366133363737222c227369676e6174757265223a22307831623263376230623062366133363737222c227265646972656374223a66616c73652c2274776974746572223a227767775f657468222c22617661746172223a226573633a2f2f307865656635386265663532623239326432646531643263336633396265383463376566623933643835386232343635663734356631366133613138356630623462222c22636f766572223a226573633a2f2f307832343131333936643666623165616461663638613239613338316634346664396436346366346364313835323839366335333039373631643361316231386430227d`
// );
const db = Object.fromEntries(
[
// valid, base64-ed calldata (tunnckocore)
parseInput(
`646174613a6170706c69636174696f6e2f766e642e6573632e7767772e6465706c6f792e74756e6e636b6f636f72652b6a736f6e3b6261736536342c65794a31636d6b694f694a6c63324d364c7938776547566a4d5449354f574d344e6d4e6a4e324d304e6d59345a4759774e6a45784e574d7a4d5451314d54457859324e6959546c68597a4a694d5445344e6a67354e3259304d7a45344d325979597a55334e544d344e544d694c434a746157316c496a6f69644756346443396f6447317349697769623364755a5849694f694977654545794d454d774e3059354e4545784d6a646d52446332525459785a6d4a6c515445774d546c6a513255334e546b794d6a55774d4449694c434a7a6157647559585231636d55694f694977654751314e545a6a4e5459324e5463794f5441344d7a497a4e4441794e325a6b593259314d4755334e6d49314e6d4533596a51774f4745354e4449334e5451774f475a6b595449335a4745774d3255355a6a63314e544d79595451794f5459774d7a6b7a4e5759304f54686c4e6a497a4d4749784d44646c4d474577596a497a4e7a6732596a4d774f446b304e6d4e6c4f4749304e546c68596d566b595463354d6d59304d7a51774e7a5a6b4d574d694c434a795a575270636d566a644349365a6d467363325573496e5233615852305a5849694f694a335a3364665a58526f4969776959585a6864474679496a6f695a584e6a4f6938764d48686c5a5759314f474a6c5a6a5579596a49354d6d51795a4755785a444a6a4d32597a4f574a6c4f44526a4e32566d596a6b7a5a4467314f4749794e4459315a6a63304e5759784e6d457a595445344e575977596a526949697769593239325a5849694f694a6c63324d364c793877654449304d54457a4f545a6b4e6d5a694d5756685a47466d4e6a68684d6a6c684d7a67785a6a51305a6d51355a44593059325930593251784f4455794f446b32597a557a4d446b334e6a466b4d324578596a45345a44416966513d3d`
),
// valid, plain json calldata with ens domain (privacymatters.eth)
parseInput(
`646174613a6170706c69636174696f6e2f766e642e6573632e7767772e6465706c6f792e707269766163796d6174746572732e6574682b6a736f6e2c7b22757269223a226573633a2f2f307839656466396531386131633665353333303130653539653136353232636264643838373630616132336665646161313132636631653433323131333161393633222c226d696d65223a22746578742f68746d6c222c226f776e6572223a22307841323043303746393441313237664437364536316662654131303139634365373539323235303032222c227369676e6174757265223a22307839613162363736383062636265623366386664373630363033653435643562653730656139323531656433326232316631396434643531336134616637363537376533653033396232666532653362613130333063643865613033616234636435303334396437646132616561383534326166633164636433343738303935653162227d`
),
// valid, plain json, with handle (twitty)
parseInput(
`646174613a6170706c69636174696f6e2f766e642e6573632e7767772e6465706c6f792e7477697474792b6a736f6e2c7b22757269223a226573633a2f2f307830653764363431316635313365343839643537313930373766396236323637343064326164643739326164656532393838306532333833376164313536343366222c226d696d65223a22746578742f68746d6c222c226f776e6572223a22307841323043303746393441313237664437364536316662654131303139634365373539323235303032222c227369676e6174757265223a22307830353034636235653737653933633234363939396366353665613437633435376539613130306537663162303232356364373866373566656131613362646230353239646533303538343565343332613161643663376634633566656630313765363934353939643661303733636237363666666534663631653462336562303163227d`
),
].map((x) => [x.name, x.data])
);
const FAKE_RESPONSE_FOR_TWITTY = {
current_owner: "0xA20C07F94A127fD76E61fbeA1019cCe759225002",
content_uri: `data:application/vnd.esc.wgw.deploy.twitty+json,{"uri":"esc://0x0e7d6411f513e489d5719077f9b626740d2add792adee29880e23837ad15643f","mime":"text/html","owner":"0xA20C07F94A127fD76E61fbeA1019cCe759225002","signature":"0x0504cb5e77e93c246999cf56ea47c457e9a100e7f1b0225cd78f75fea1a3bdb0529de305845e432a1ad6c7f4c5fef017e694599d6a073cb766ffe4f61e4b3eb01c"}`,
};
const FAKE_RESPONSE_FOR_TCK = {
current_owner: "0xA20C07F94A127fD76E61fbeA1019cCe759225002",
content_uri: `data:application/vnd.esc.wgw.deploy.tunnckocore+json;base64,eyJ1cmkiOiJlc2M6Ly8weGVjMTI5OWM4NmNjN2M0NmY4ZGYwNjExNWMzMTQ1MTExY2NiYTlhYzJiMTE4Njg5N2Y0MzE4M2YyYzU3NTM4NTMiLCJtaW1lIjoidGV4dC9odG1sIiwib3duZXIiOiIweEEyMEMwN0Y5NEExMjdmRDc2RTYxZmJlQTEwMTljQ2U3NTkyMjUwMDIiLCJzaWduYXR1cmUiOiIweGQ1NTZjNTY2NTcyOTA4MzIzNDAyN2ZkY2Y1MGU3NmI1NmE3YjQwOGE5NDI3NTQwOGZkYTI3ZGEwM2U5Zjc1NTMyYTQyOTYwMzkzNWY0OThlNjIzMGIxMDdlMGEwYjIzNzg2YjMwODk0NmNlOGI0NTlhYmVkYTc5MmY0MzQwNzZkMWMiLCJyZWRpcmVjdCI6ZmFsc2UsInR3aXR0ZXIiOiJ3Z3dfZXRoIiwiYXZhdGFyIjoiZXNjOi8vMHhlZWY1OGJlZjUyYjI5MmQyZGUxZDJjM2YzOWJlODRjN2VmYjkzZDg1OGIyNDY1Zjc0NWYxNmEzYTE4NWYwYjRiIiwiY292ZXIiOiJlc2M6Ly8weDI0MTEzOTZkNmZiMWVhZGFmNjhhMjlhMzgxZjQ0ZmQ5ZDY0Y2Y0Y2QxODUyODk2YzUzMDk3NjFkM2ExYjE4ZDAifQ==`,
};
const FAKE_RESPONSE = FAKE_RESPONSE_FOR_TCK;
const API_ETHSCRIPTIONS_ENDPOINT = `https://api.ethscriptions.com/api/ethscriptions`;
async function resolveDomain(name) {
name = name.toLowerCase();
let isEnsDomain = name.indexOf(".") > 1;
let isHandle = !isEnsDomain;
if (isEnsDomain) {
throw new Error("not implemented yet");
}
const entry = db[name];
if (!entry) {
throw new Error("no deploy found for this name");
}
entry.owner = entry.owner.toLowerCase();
// Validate ethscription plaintext handles/words
if (isHandle) {
console.log("handle ->", entry);
// 1. verify this ethscription is owned by the `owner` in it (ethscription.current_owner === `owner`)
// 2. verify that the content_uri of that ethscription is the same as the one parsed/resolved from calldata
// const res = await fetch(
// `https://goerli-api.ethscriptions.com/api/ethscriptions/${ethscriptionId}`
// ).then((x) => x.json());
// const res = FAKE_RESPONSE;
// if (res.current_owner.toLowerCase() !== entry.owner) {
// throw new Error("invalid owner");
// }
// if (res.content_uri !== entry.deployUri) {
// throw new Error("invalid deploy");
// }
let isValidSignature = false;
if (entry.isBase64) {
const [prefix, b64data] = entry.deployUri.split("base64,");
const decoded = atob(b64data);
const parsed = JSON.parse(decoded);
// console.log("parsed", parsed);
delete parsed.signature;
const encoded = btoa(JSON.stringify(parsed));
// console.log(`${prefix}base64,${encoded}`);
// const sigAddress = await recoverAddress({
// message: `${prefix}base64,${encoded}`,
// signature: entry.signature,
// });
isValidSignature = await verifyMessage({
address: entry.owner,
message: `${prefix}base64,${encoded}`,
signature: entry.signature,
});
} else {
const signedDeployUri = entry.deployUri.replace(
`,"signature":"${entry.signature}"`,
""
);
isValidSignature = await verifyMessage({
address: entry.owner,
message: signedDeployUri,
signature: entry.signature,
});
}
if (!isValidSignature) {
throw new Error("invalid signature");
}
const nameHash = await sha256(`data:,${entry.domain}`);
const existsResp = await fetch(
`${API_ETHSCRIPTIONS_ENDPOINT}/exists/${nameHash}`
).then((x) => x.json());
if (!existsResp.result) {
throw new Error(
`no such handle/name/word registered -> data:,${entry.domain}`
);
}
if (existsResp.ethscription.current_owner.toLowerCase() !== entry.owner) {
throw new Error("name not owned by this address (deployer)");
}
// then everythign is fine and we can proceed with resolving the content/avatar and etc
}
// 3. resolve the content from the given `uri`
if (entry.redirect) {
// redirect to the uri, IF it's an ethscription;
return;
}
const resolvedEntry = Object.fromEntries(
await Promise.all(
Object.entries(entry).map(async ([key, value]) => {
if (/uri|avatar|cover/.test(key)) {
return [key, await resolveUriFromEscProtocol(value)];
}
return [key, value];
})
)
);
console.log(resolvedEntry);
}
async function resolveUriFromEscProtocol(uri) {
uri = uri.endsWith("/") ? uri.slice(0, -1) : uri;
const isEscProtocol = uri.startsWith("esc://");
const isContentPrefixed =
uri.startsWith("esc://content/") || uri.startsWith("esc://data/");
const isDataEndpoint =
uri.endsWith("/data") || uri.endsWith("/content") || isContentPrefixed;
if (isEscProtocol && !isDataEndpoint) {
// it's a bare ethscription_id, so we need to fetch the ethscription
// and get the content_uri from it
const id = uri.replace("esc://", "");
const ethsc = await fetch(`${API_ETHSCRIPTIONS_ENDPOINT}/${id}`).then((x) =>
x.json()
);
return ethsc.content_uri;
} else if (isEscProtocol && isDataEndpoint) {
const id = isContentPrefixed
? uri.replace(/esc:\/\/(content|data)\//, "")
: uri.replace(/\/(content|data)/, "");
return fetch(`${API_ETHSCRIPTIONS_ENDPOINT}/${id}/data`)
.then((x) => x.blob())
.then(getDataURI);
} else {
return uri;
}
}
// await resolveDomain("tunnckocore");
await resolveDomain("twitty");
// console.log(
// fromHex(
// `646174613a6170706c69636174696f6e2f766e642e6573632e7767772e6465706c6f792e74756e6e636b6f636f72652b6a736f6e3b6261736536342c65794a31636d6b694f694a6c63324d364c7938776547566a4d5449354f574d344e6d4e6a4e324d304e6d59345a4759774e6a45784e574d7a4d5451314d54457859324e6959546c68597a4a694d5445344e6a67354e3259304d7a45344d325979597a55334e544d344e544d694c434a746157316c496a6f69644756346443396f6447317349697769623364755a5849694f694977654545794d454d774e3059354e4545784d6a646d52446332525459785a6d4a6c515445774d546c6a513255334e546b794d6a55774d4449694c434a7a6157647559585231636d55694f694977654751314e545a6a4e5459324e5463794f5441344d7a497a4e4441794e325a6b593259314d4755334e6d49314e6d4533596a51774f4745354e4449334e5451774f475a6b595449335a4745774d3255355a6a63314e544d79595451794f5459774d7a6b7a4e5759304f54686c4e6a497a4d4749784d44646c4d474577596a497a4e7a6732596a4d774f446b304e6d4e6c4f4749304e546c68596d566b595463354d6d59304d7a51774e7a5a6b4d574d694c434a795a575270636d566a644349365a6d467363325573496e5233615852305a5849694f694a335a3364665a58526f4969776959585a6864474679496a6f695a584e6a4f6938764d48686c5a5759314f474a6c5a6a5579596a49354d6d51795a4755785a444a6a4d32597a4f574a6c4f44526a4e32566d596a6b7a5a4467314f4749794e4459315a6a63304e5759784e6d457a595445344e575977596a526949697769593239325a5849694f694a6c63324d364c793877654449304d54457a4f545a6b4e6d5a694d5756685a47466d4e6a68684d6a6c684d7a67785a6a51305a6d51355a44593059325930593251784f4455794f446b32597a557a4d446b334e6a466b4d324578596a45345a44416966513d3d`
// )
// );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment