Ethscriptions hosting & resolving domains (handles/words/ens), not just "user profiles" with `application/vnd.esc.user.profile+json`
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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