diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d2706a1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +storage/*.sqlite3 +log/* +tmp/* +.git +node_modules \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..35f61f4 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,63 @@ +name: Docker Image CI + +on: + push: + branches: [ "*v*", "main" ] + tags: + - '*v*' + release: + types: [published] + pull_request: + branches: [ "*v*", "main" ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/ethscriptions-protocol/ethscriptions-node + tags: | + # Latest commit SHA + type=sha,format=short + + # Branch names (v1.0.0, main, etc) + type=ref,event=branch + + # Git tags (v1.0.0, etc) + type=ref,event=tag + + # Semantic versioning for releases + type=semver,pattern={{version}},event=tag + type=semver,pattern={{major}}.{{minor}},event=tag + type=semver,pattern={{major}},event=tag + + # Latest release tag + type=raw,value=latest-release,enable=${{ github.event_name == 'release' }} + + # Latest on main branch + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 7e6b54f..3b6f0ae 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,11 @@ # Ignore master key for decrypting credentials and more. /config/master.key + +.DS_Store + +# Ignore uncompressed collection JSON files (keep only the tar.gz archive) +items_by_ethscription.json +collections_by_name.json +# Keep the compressed archive +# collections_data.tar.gz diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ee798c5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "contracts/lib/forge-std"] + path = contracts/lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "contracts/lib/openzeppelin-contracts"] + path = contracts/lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "contracts/lib/solady"] + path = contracts/lib/solady + url = https://github.com/vectorized/solady +[submodule "contracts/lib/openzeppelin-contracts-upgradeable"] + path = contracts/lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable diff --git a/.ruby-version b/.ruby-version index 9e79f6c..e3cc07a 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-3.2.2 +ruby-3.4.4 diff --git a/.sample.env b/.sample.env index b8e93dd..1da536b 100644 --- a/.sample.env +++ b/.sample.env @@ -3,5 +3,7 @@ ETHEREUM_CLIENT_API_KEY="YOUR KEY" ETHEREUM_NETWORK="eth-mainnet" ETHEREUM_CLIENT_BASE_URL="https://eth-mainnet.g.alchemy.com/v2" ETHEREUM_BEACON_NODE_API_BASE_URL="https://something.quiknode.pro/" -BLOCK_IMPORT_BATCH_SIZE="2" +L1_PREFETCH_THREADS="2" +VALIDATION_ENABLED="false" +JOB_CONCURRENCY="2" TESTNET_START_BLOCK="5192600" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c042a7d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +# syntax = docker/dockerfile:1 + +ARG RUBY_VERSION=3.4.4 +FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base + +WORKDIR /rails + +ENV SECRET_KEY_BASE_DUMMY=1 + +# Set environment variables +ENV RAILS_ENV="production" \ + BUNDLE_PATH="/usr/local/bundle" \ + LANG="C.UTF-8" \ + RAILS_LOG_TO_STDOUT="enabled" + +# Throw-away build stage +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + build-essential \ + git \ + pkg-config \ + libsecp256k1-dev \ + libssl-dev \ + libyaml-dev \ + zlib1g-dev \ + automake \ + autoconf \ + libtool && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + +# Install application gems +COPY Gemfile Gemfile.lock ./ +RUN bundle config build.rbsecp256k1 --use-system-libraries && \ + bundle install --jobs 4 --retry 3 + +# Copy application code and precompile bootsnap +COPY . . +ENV BOOTSNAP_COMPILE_CACHE_THREADS=4 +RUN bundle exec bootsnap precompile app/ lib/ + +# Final stage +FROM base + +# Install only runtime dependencies +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + libsecp256k1-dev \ + libyaml-0-2 \ + ca-certificates \ + tini \ + bash && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + +# Copy built artifacts +COPY --from=build /usr/local/bundle /usr/local/bundle +COPY --from=build /rails /rails + +# Copy and set up entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Set up non-root user +RUN useradd rails --create-home --shell /bin/bash && \ + chown -R rails:rails /rails +USER rails:rails + +# Database initialization moved to runtime in entrypoint script + +ENTRYPOINT ["tini", "--", "/usr/local/bin/docker-entrypoint.sh"] diff --git a/Gemfile b/Gemfile index f53504c..c94b785 100644 --- a/Gemfile +++ b/Gemfile @@ -1,15 +1,11 @@ source "https://rubygems.org" -ruby "3.2.2" +ruby "3.4.4" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem "rails", "~> 7.1.2" +gem "rails", "8.0.2.1" # Use postgresql as the database for Active Record -gem "pg", "~> 1.1" - -# Use the Puma web server [https://github.com/puma/puma] -gem "puma", ">= 5.0" # Build JSON APIs with ease [https://github.com/rails/jbuilder] # gem "jbuilder" @@ -41,45 +37,38 @@ group :development do # Speed up commands on slow machines / big apps [https://github.com/rails/spring] # gem "spring" gem "stackprof", "~> 0.2.25" - gem "active_record_query_trace", "~> 1.8" end gem "dotenv-rails", "~> 2.8", groups: [:development, :test] -gem "httpparty", "~> 0.2.0" - -gem "clockwork", "~> 3.0" - -gem "dalli", "~> 3.2" - -gem "kaminari", "~> 1.2" - -gem "airbrake", "~> 13.0" +gem "eth", github: "0xFacet/eth.rb", branch: "sync/v0.5.16-nohex" -gem "rack-cors", "~> 2.0" +gem 'sorbet', :group => :development +gem 'sorbet-runtime' +gem 'tapioca', require: false, :group => [:development, :test] -gem "eth", "~> 0.5.11" - -gem "activerecord-import", "~> 1.5" - -gem "scout_apm", "~> 5.3" +gem "awesome_print", "~> 1.9" -gem "memoist", "~> 0.16.2" +gem 'facet_rails_common', git: 'https://github.com/0xfacet/facet_rails_common.git', branch: 'lenient_base64' -gem "awesome_print", "~> 1.9" +gem "memery", "~> 1.5" -gem "clipboard" +gem "httparty", "~> 0.22.0" -gem "redis", "~> 5.0" +gem "jwt", "~> 2.8" -gem "httparty", "~> 0.21.0" +gem "clockwork", "~> 3.0" -gem "order_query", "~> 0.5.3" +gem "airbrake", "~> 13.0" +gem "clipboard", "~> 2.0", :group => [:development, :test] -gem 'facet_rails_common', git: 'https://github.com/0xfacet/facet_rails_common.git' +gem "net-http-persistent", "~> 4.0" -gem "cbor", "~> 0.5.9" +gem 'benchmark' +gem 'ostruct' -gem 'rswag-api' +gem "retriable", "~> 3.1" -gem 'rswag-ui' +# Database and job processing +gem "sqlite3", ">= 2.1" +gem "solid_queue" diff --git a/Gemfile.lock b/Gemfile.lock index fe1bf41..86c4341 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,23 @@ +GIT + remote: https://github.com/0xFacet/eth.rb.git + revision: 089128a7703aa5eeafe41d538f05732b29340188 + branch: sync/v0.5.16-nohex + specs: + eth (0.5.16) + bigdecimal (~> 3.1) + bls12-381 (~> 0.3) + forwardable (~> 1.3) + httpx (~> 1.6) + keccak (~> 1.3) + konstructor (~> 1.0) + openssl (~> 3.3) + rbsecp256k1 (~> 6.0) + scrypt (~> 3.0) + GIT remote: https://github.com/0xfacet/facet_rails_common.git - revision: dd72807b5e51dc6fd7969f320cbb9bedfa92c4a5 + revision: 52b9b5e34183028d095b6e8fc0f1126bfb703f97 + branch: lenient_base64 specs: facet_rails_common (0.1.0) order_query (~> 0.5.3) @@ -8,108 +25,102 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.1.2) - actionpack (= 7.1.2) - activesupport (= 7.1.2) + actioncable (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.2) - actionpack (= 7.1.2) - activejob (= 7.1.2) - activerecord (= 7.1.2) - activestorage (= 7.1.2) - activesupport (= 7.1.2) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.2) - actionpack (= 7.1.2) - actionview (= 7.1.2) - activejob (= 7.1.2) - activesupport (= 7.1.2) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) + mail (>= 2.8.0) + actionmailer (8.0.2.1) + actionpack (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activesupport (= 8.0.2.1) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.2) - actionview (= 7.1.2) - activesupport (= 7.1.2) + actionpack (8.0.2.1) + actionview (= 8.0.2.1) + activesupport (= 8.0.2.1) nokogiri (>= 1.8.5) - racc rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.2) - actionpack (= 7.1.2) - activerecord (= 7.1.2) - activestorage (= 7.1.2) - activesupport (= 7.1.2) + useragent (~> 0.16) + actiontext (8.0.2.1) + actionpack (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.2) - activesupport (= 7.1.2) + actionview (8.0.2.1) + activesupport (= 8.0.2.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - active_record_query_trace (1.8.2) - activerecord (>= 6.0.0) - activejob (7.1.2) - activesupport (= 7.1.2) + activejob (8.0.2.1) + activesupport (= 8.0.2.1) globalid (>= 0.3.6) - activemodel (7.1.2) - activesupport (= 7.1.2) - activerecord (7.1.2) - activemodel (= 7.1.2) - activesupport (= 7.1.2) + activemodel (8.0.2.1) + activesupport (= 8.0.2.1) + activerecord (8.0.2.1) + activemodel (= 8.0.2.1) + activesupport (= 8.0.2.1) timeout (>= 0.4.0) - activerecord-import (1.5.1) - activerecord (>= 4.2) - activestorage (7.1.2) - actionpack (= 7.1.2) - activejob (= 7.1.2) - activerecord (= 7.1.2) - activesupport (= 7.1.2) + activestorage (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activesupport (= 8.0.2.1) marcel (~> 1.0) - activesupport (7.1.2) + activesupport (8.0.2.1) base64 + benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) airbrake (13.0.4) airbrake-ruby (~> 6.0) airbrake-ruby (6.2.2) rbtree3 (~> 0.6) - ast (2.4.2) awesome_print (1.9.2) - base64 (0.2.0) - bigdecimal (3.1.5) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (3.3.1) + bls12-381 (0.3.0) + h2c (~> 0.2.0) bootsnap (1.17.0) msgpack (~> 1.2) builder (3.2.4) - cbor (0.5.9.8) - clipboard (1.3.6) + clipboard (2.0.0) clockwork (3.0.2) activesupport tzinfo coderay (1.1.3) - concurrent-ruby (1.2.2) - connection_pool (2.4.1) + concurrent-ruby (1.3.5) + connection_pool (2.5.5) crass (1.0.6) - dalli (3.2.6) - date (3.3.4) + csv (3.3.5) + date (3.4.1) debug (1.9.0) irb (~> 1.10) reline (>= 0.3.8) @@ -118,50 +129,46 @@ GEM dotenv-rails (2.8.1) dotenv (= 2.8.1) railties (>= 3.2) - drb (2.2.0) - ruby2_keywords + drb (2.2.3) + ecdsa (1.2.0) erubi (1.12.0) - eth (0.5.11) - forwardable (~> 1.3) - keccak (~> 1.3) - konstructor (~> 1.0) - openssl (>= 2.2, < 4.0) - rbsecp256k1 (~> 6.0) - scrypt (~> 3.0) - ffi (1.16.3) - ffi-compiler (1.0.1) - ffi (>= 1.0.0) + et-orbi (1.3.0) + tzinfo + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi-compiler (1.3.2) + ffi (>= 1.15.5) rake forwardable (1.3.3) + fugit (1.11.2) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - httparty (0.21.0) + h2c (0.2.1) + ecdsa (~> 1.2.0) + http-2 (1.1.1) + httparty (0.22.0) + csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) - httpparty (0.2.0) - httparty (> 0) - i18n (1.14.1) + httpx (1.6.2) + http-2 (>= 1.0.0) + i18n (1.14.7) concurrent-ruby (~> 1.0) io-console (0.7.1) - irb (1.10.1) - rdoc - reline (>= 0.3.8) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) json-schema (4.3.0) addressable (>= 2.8) - kaminari (1.2.2) - activesupport (>= 4.1.0) - kaminari-actionview (= 1.2.2) - kaminari-activerecord (= 1.2.2) - kaminari-core (= 1.2.2) - kaminari-actionview (1.2.2) - actionview - kaminari-core (= 1.2.2) - kaminari-activerecord (1.2.2) - activerecord - kaminari-core (= 1.2.2) - kaminari-core (1.2.2) - keccak (1.3.1) + jwt (2.10.2) + base64 + keccak (1.3.2) konstructor (1.0.2) + logger (1.7.0) loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -170,52 +177,51 @@ GEM net-imap net-pop net-smtp - marcel (1.0.2) - memoist (0.16.2) + marcel (1.1.0) + memery (1.8.0) method_source (1.0.0) mini_mime (1.1.5) - mini_portile2 (2.8.5) - minitest (5.20.0) + mini_portile2 (2.8.9) + minitest (5.26.2) msgpack (1.7.2) - multi_xml (0.6.0) - mutex_m (0.2.0) - net-imap (0.4.8) + multi_xml (0.7.2) + bigdecimal (~> 3.1) + net-http-persistent (4.0.6) + connection_pool (~> 2.2, >= 2.2.4) + net-imap (0.5.10) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.4.0) + net-smtp (0.5.1) net-protocol + netrc (0.11.0) nio4r (2.7.0) - nokogiri (1.15.5-aarch64-linux) - racc (~> 1.4) - nokogiri (1.15.5-arm64-darwin) + nokogiri (1.15.5) + mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.15.5-x86_64-linux) - racc (~> 1.4) - openssl (3.2.0) - order_query (0.5.3) - activerecord (>= 5.0, < 7.2) - activesupport (>= 5.0, < 7.2) - parser (3.2.2.4) - ast (~> 2.4.1) - racc - pg (1.5.4) - pkg-config (1.5.6) + openssl (3.3.0) + order_query (0.5.6) + activerecord (>= 5.0, < 8.2) + activesupport (>= 5.0, < 8.2) + ostruct (0.6.3) + parallel (1.27.0) + pkg-config (1.6.4) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + prism (1.5.0) pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) psych (5.1.1.1) stringio public_suffix (5.0.5) - puma (6.4.0) - nio4r (~> 2.0) + raabro (1.4.0) racc (1.7.3) rack (3.0.8) - rack-cors (2.0.1) - rack (>= 2.0.0) rack-session (2.0.0) rack (>= 3.0.0) rack-test (2.1.0) @@ -223,20 +229,20 @@ GEM rackup (2.1.0) rack (>= 3) webrick (~> 1.8) - rails (7.1.2) - actioncable (= 7.1.2) - actionmailbox (= 7.1.2) - actionmailer (= 7.1.2) - actionpack (= 7.1.2) - actiontext (= 7.1.2) - actionview (= 7.1.2) - activejob (= 7.1.2) - activemodel (= 7.1.2) - activerecord (= 7.1.2) - activestorage (= 7.1.2) - activesupport (= 7.1.2) + rails (8.0.2.1) + actioncable (= 8.0.2.1) + actionmailbox (= 8.0.2.1) + actionmailer (= 8.0.2.1) + actionpack (= 8.0.2.1) + actiontext (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activemodel (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) bundler (>= 1.15.0) - railties (= 7.1.2) + railties (= 8.0.2.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -244,15 +250,20 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - railties (7.1.2) - actionpack (= 7.1.2) - activesupport (= 7.1.2) - irb + railties (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) - rake (13.1.0) + rake (13.3.0) + rbi (0.3.6) + prism (~> 1.0) + rbs (>= 3.4.4) + rbs (3.9.5) + logger rbsecp256k1 (6.0.0) mini_portile2 (~> 2.8) pkg-config (~> 1.5) @@ -260,12 +271,10 @@ GEM rbtree3 (0.7.1) rdoc (6.6.2) psych (>= 4.0.0) - redis (5.0.8) - redis-client (>= 0.17.0) - redis-client (0.19.0) - connection_pool - reline (0.4.1) + reline (0.6.2) io-console (~> 0.5) + retriable (3.1.2) + rexml (3.4.4) rspec-core (3.12.2) rspec-support (~> 3.12.0) rspec-expectations (3.12.3) @@ -283,76 +292,109 @@ GEM rspec-mocks (~> 3.12) rspec-support (~> 3.12) rspec-support (3.12.1) - rswag-api (2.13.0) - activesupport (>= 3.1, < 7.2) - railties (>= 3.1, < 7.2) - rswag-specs (2.13.0) - activesupport (>= 3.1, < 7.2) - json-schema (>= 2.2, < 5.0) - railties (>= 3.1, < 7.2) + rswag-specs (2.16.0) + activesupport (>= 5.2, < 8.1) + json-schema (>= 2.2, < 6.0) + railties (>= 5.2, < 8.1) rspec-core (>= 2.14) - rswag-ui (2.13.0) - actionpack (>= 3.1, < 7.2) - railties (>= 3.1, < 7.2) - ruby2_keywords (0.0.5) - rubyzip (2.3.2) - scout_apm (5.3.5) - parser - scrypt (3.0.7) + rubyzip (2.4.1) + scrypt (3.1.0) ffi-compiler (>= 1.0, < 2.0) + rake (~> 13) + securerandom (0.4.1) + solid_queue (1.2.1) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11.0) + railties (>= 7.1) + thor (>= 1.3.1) + sorbet (0.6.12521) + sorbet-static (= 0.6.12521) + sorbet-runtime (0.6.12521) + sorbet-static (0.6.12521-aarch64-linux) + sorbet-static (0.6.12521-universal-darwin) + sorbet-static (0.6.12521-x86_64-linux) + sorbet-static-and-runtime (0.6.12521) + sorbet (= 0.6.12521) + sorbet-runtime (= 0.6.12521) + spoom (1.6.3) + erubi (>= 1.10.0) + prism (>= 0.28.0) + rbi (>= 0.3.3) + rexml (>= 3.2.6) + sorbet-static-and-runtime (>= 0.5.10187) + thor (>= 0.19.2) + sqlite3 (2.7.4-aarch64-linux-gnu) + sqlite3 (2.7.4-arm64-darwin) + sqlite3 (2.7.4-x86_64-linux-gnu) stackprof (0.2.25) stringio (3.1.0) - thor (1.3.0) - timeout (0.4.1) + tapioca (0.16.11) + benchmark + bundler (>= 2.2.25) + netrc (>= 0.11.0) + parallel (>= 1.21.0) + rbi (~> 0.2) + sorbet-static-and-runtime (>= 0.5.11087) + spoom (>= 1.2.0) + thor (>= 1.2.0) + yard-sorbet + thor (1.4.0) + timeout (0.4.4) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + uri (1.1.1) + useragent (0.16.11) webrick (1.8.1) - websocket-driver (0.7.6) + websocket-driver (0.8.0) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + yard (0.9.37) + yard-sorbet (0.9.0) + sorbet-runtime + yard zeitwerk (2.6.12) PLATFORMS aarch64-linux arm64-darwin-20 arm64-darwin-22 + arm64-darwin-24 x86_64-linux DEPENDENCIES - active_record_query_trace (~> 1.8) - activerecord-import (~> 1.5) airbrake (~> 13.0) awesome_print (~> 1.9) + benchmark bootsnap - cbor (~> 0.5.9) - clipboard + clipboard (~> 2.0) clockwork (~> 3.0) - dalli (~> 3.2) debug dotenv-rails (~> 2.8) - eth (~> 0.5.11) + eth! facet_rails_common! - httparty (~> 0.21.0) - httpparty (~> 0.2.0) - kaminari (~> 1.2) - memoist (~> 0.16.2) - order_query (~> 0.5.3) - pg (~> 1.1) + httparty (~> 0.22.0) + jwt (~> 2.8) + memery (~> 1.5) + net-http-persistent (~> 4.0) + ostruct pry - puma (>= 5.0) - rack-cors (~> 2.0) - rails (~> 7.1.2) - redis (~> 5.0) + rails (= 8.0.2.1) + retriable (~> 3.1) rspec-rails - rswag-api rswag-specs - rswag-ui - scout_apm (~> 5.3) + solid_queue + sorbet + sorbet-runtime + sqlite3 (>= 2.1) stackprof (~> 0.2.25) + tapioca tzinfo-data RUBY VERSION - ruby 3.2.2p53 + ruby 3.4.4p34 BUNDLED WITH 2.4.14 diff --git a/Procfile b/Procfile index 50b39a7..0e9bf8c 100644 --- a/Procfile +++ b/Procfile @@ -1,3 +1,3 @@ web: bundle exec puma -C config/puma.rb release: rake db:migrate -main_importer_clock: bundle exec clockwork config/main_importer_clock.rb +importer: bundle exec clockwork config/derive_ethscriptions_blocks.rb diff --git a/README.md b/README.md index 71a9ed7..258b94b 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,638 @@ -# Welcome to the official Open Source Ethscriptions Indexer! +# Ethscriptions L2 Derivation Node -This indexer has been extracted and streamlined from the indexer that runs Ethscriptions.com. This indexer has been validated as producing the same results against the live Ethscriptions.com indexer. +This repo houses the Ruby app and Solidity predeploys that build the Ethscriptions chain on top of Ethereum. It started life as a Postgres-backed indexer; it now runs the derivation pipeline that turns L1 activity into canonical L2 blocks. You run it alongside an [ethscriptions-geth](https://github.com/ethscriptions-protocol/ethscriptions-geth) execution client. -## Important: When pulling changes! +## Table of Contents -Always run `bundle install && rails db:migrate` after you pull in the latest changes. +- [Overview](#overview) +- [Run with Docker Compose](#run-with-docker-compose) +- [How Ethscriptions Work](#how-ethscriptions-work) +- [Protocol System](#protocol-system) +- [ERC-721 Collections Protocol](#erc-721-collections-protocol) +- [ERC-20 Fixed Denomination Tokens](#erc-20-fixed-denomination-tokens) +- [Technical Architecture](#technical-architecture) +- [Validator](#validator-optional) +- [Local Development](#local-development-optional) +- [Directory Structure](#directory-structure) +- [Testing](#testing) -## Installation Instructions +--- -This is a Ruby on Rails app. +## Overview -Run this command inside the directory of your choice to clone the repository: +### How the Pipeline Works -```!bash -git clone https://github.com/ethscriptions-protocol/ethscriptions-indexer -``` +1. **Observe** Ethereum L1 via JSON-RPC. The importer follows L1 blocks, receipts, and logs to find Ethscriptions intents (Data URIs plus ESIP events). +2. **Translate** matching intents into deposit-style EVM transactions that call Ethscriptions predeploy contracts (storage, transfers, collections tooling). +3. **Send** those transactions to geth through the Engine API, producing new L2 payloads. Geth seals the block, the predeploys mutate state, and the chain advances with the Ethscriptions rules baked in. + +The result is an OP-style Stage-2 "app chain" that keeps Ethscriptions UX unchanged while providing Merkle-state, receipts, and compatibility with standard EVM tooling. + +### What Lives Here + +- **Ruby derivation app** — importer loop and Engine API driver; it is meant to stay stateless across runs. +- **Solidity contracts** — the Ethscriptions and token/collection predeploys plus Foundry scripts for generating the L2 genesis allocations. The Ethscriptions contract stores content with SSTORE2 chunked pointers and routes protocol calls through on-chain handlers. +- **Genesis + tooling** — scripts in `lib/` and `contracts/script/` to produce the genesis file consumed by geth. +- **Reference validator** — optional job queue that compares L2 receipts/storage against a reference Ethscriptions API to make sure derivation matches expectations. + +Anything that executes L2 transactions (the `ethscriptions-geth` client) runs out-of-repo. This project focuses on deriving state and providing reference contracts. + +### What Stays the Same for Users + +Ethscriptions behavior and APIs remain identical to the pre-chain era: inscribe and transfer as before, and existing clients can keep using the public API. The difference is that the data now lives in an L2 with cryptographic state, receipts, and interoperability with EVM tooling. + +--- + +## Run with Docker Compose -If you don't already have Ruby Version Manager installed, install it: +### Prerequisites + +- Docker Desktop (includes the Compose plugin) +- Access to an Ethereum L1 RPC endpoint (archive-quality recommended for historical sync) + +### Quick Start ```bash -curl -sSL https://get.rvm.io | bash -s stable +# 1. Copy the environment template +cp docker-compose/.env.example docker-compose/.env + +# 2. Edit .env with your settings (see Environment Reference below) +# At minimum, set L1_RPC_URL to your L1 endpoint + +# 3. Bring up the stack +cd docker-compose +docker compose --env-file .env up -d + +# 4. Follow logs while it syncs +docker compose logs -f node + +# 5. Query the L2 RPC (default port 8545) +curl -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' + +# 6. Shut down when done +docker compose down ``` -You might need to run this if there is an issue with gpg: +### Services -```bash -gpg2 --keyserver keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB +The stack runs two containers: + +| Service | Description | +|---------|-------------| +| `geth` | Ethscriptions-customized Ethereum execution client (L2) | +| `node` | Ruby derivation app that processes L1 data into L2 blocks | + +The node waits for geth to be healthy before starting. Both services communicate via a shared IPC socket. + +### Environment Reference + +Key variables in `docker-compose/.env`: + +#### Core Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `COMPOSE_PROJECT_NAME` | Docker resource naming prefix | `ethscriptions-evm` | +| `JWT_SECRET` | 32-byte hex for Engine API auth (must match geth) | — | +| `L1_NETWORK` | Ethereum network (mainnet, sepolia, etc.) | `mainnet` | +| `L1_RPC_URL` | Archive-quality L1 RPC endpoint | — | +| `L1_GENESIS_BLOCK` | L1 block where the rollup anchors | `17478949` | +| `GENESIS_FILE` | Genesis snapshot filename | `ethscriptions-mainnet.json` | +| `GETH_EXTERNAL_PORT` | Host port for L2 RPC | `8545` | + +#### Performance Tuning + +| Variable | Description | Default | +|----------|-------------|---------| +| `L1_PREFETCH_FORWARD` | Blocks to prefetch ahead | `200` | +| `L1_PREFETCH_THREADS` | Prefetch worker threads | `10` | +| `JOB_CONCURRENCY` | SolidQueue worker concurrency | `6` | +| `JOB_THREADS` | Job worker threads | `3` | + +#### Geth Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `GC_MODE` | `full` (pruned) or `archive` (full history) | `full` | +| `STATE_HISTORY` | State trie history depth | `100000` | +| `TX_HISTORY` | Transaction history depth | `100000` | +| `ENABLE_PREIMAGES` | Retain preimages | `true` | +| `CACHE_SIZE` | State cache size | `25000` | + +#### Validation (Optional) + +| Variable | Description | Default | +|----------|-------------|---------| +| `VALIDATION_ENABLED` | Enable validator against reference API | `false` | +| `ETHSCRIPTIONS_API_BASE_URL` | Reference API endpoint | — | +| `ETHSCRIPTIONS_API_KEY` | API authentication key | — | + +--- + +## How Ethscriptions Work + +Ethscriptions are digital artifacts created by encoding data in Ethereum transaction calldata or emitting specific events. This section explains how to create and transfer them. + +### Creating Ethscriptions + +#### Method 1: Calldata (Direct) + +Send a transaction to any address with hex-encoded Data URI as calldata: + +``` +To: 0xAnyAddress +Value: 0 ETH +Data: 0x646174613a2c48656c6c6f (hex of "data:,Hello") ``` -Now install ruby 3.2.2: +The derivation node recognizes the Data URI pattern and creates an ethscription: +- **Creator**: The transaction sender (`msg.sender`) +- **Initial Owner**: The transaction recipient (`to` address) +- **Content**: Decoded payload from the Data URI -```bash -rvm install 3.2.2 +#### Method 2: Events (ESIP-3) + +Smart contracts can create ethscriptions by emitting: + +```solidity +event ethscriptions_protocol_CreateEthscription( + address indexed initialOwner, + string contentUri +); ``` -On a Mac you might run into an issue with openssl. If you do you might need to run something like this: +This allows contracts to programmatically create ethscriptions on behalf of users. -```bash -rvm install 3.2.2 --with-openssl-dir=$(brew --prefix openssl@1.1) +### Data URI Format + +The basic format: +``` +data:[][;base64], ``` -Install the gems (libraries) the app needs: +Examples: +``` +data:,Hello World # Plain text +data:text/plain,Hello World # Explicit MIME type +data:image/png;base64,iVBORw0KGgo... # Base64-encoded image +data:application/json,{"name":"test"} # JSON data +``` -```bash -bundle install +Extended format with protocol parameters: +``` +data:;rule=esip6;p=;op=;d=;base64, ``` -Install postgres if you don't already have it: +| Parameter | Description | +|-----------|-------------| +| `rule=esip6` | Allow duplicate content URIs | +| `p=` | Protocol handler name | +| `op=` | Operation to invoke on handler | +| `d=` | Base64-encoded operation parameters | -Mac: `brew install postgresql` +### Transferring Ethscriptions -Ubuntu: `sudo apt-get install libpq-dev` +#### Method 1: Calldata Transfer -RHEL: `yum install postgresql-devel` +Send a transaction with the ethscription ID (L1 tx hash) as calldata: -Alpine: `apk add postgresql-dev` +**Single transfer** (32 bytes): +``` +To: 0xRecipient +Value: 0 ETH +Data: 0xa1654c9db8847e197bbc72c880d1c269d974b15a2e606e4f5b1be2c5da81ba86 +``` -Set up your env vars by renaming `.sample.env` to `.env`, `.sample.env.development` to `.env.development`, and `.sample.env.test` to `.env.test`. These environment-specific env files just set the database you're using in each environment. You have the option of using a replica database for reads, but you can just leave this blank if you don't want to use it. There's also a `.sample.env.production` but you'll probably want to set production env vars at the system level. +**Batch transfer** (multiple of 32 bytes, ESIP-5): +``` +To: 0xRecipient +Value: 0 ETH +Data: 0x (concatenated 32-byte hashes) +``` -Create the database: +#### Method 2: Event Transfer -```bash -rails db:create +Contracts can transfer by emitting: + +**ESIP-1** (basic transfer): +```solidity +event ethscriptions_protocol_TransferEthscription( + address indexed recipient, + bytes32 indexed ethscriptionId +); +``` + +**ESIP-2** (with previous owner validation): +```solidity +event ethscriptions_protocol_TransferEthscriptionForPreviousOwner( + address indexed previousOwner, + address indexed recipient, + bytes32 indexed ethscriptionId +); ``` -Migrate the database schema: +### ESIP Standards Reference + +| ESIP | Name | Description | +|------|------|-------------| +| ESIP-1 | Event Transfers | Transfer via `TransferEthscription` event | +| ESIP-2 | Previous Owner Validation | Transfer with chain-of-custody check | +| ESIP-3 | Event Creation | Create via `CreateEthscription` event | +| ESIP-5 | Batch Transfers | Multiple 32-byte transfers in one tx | +| ESIP-6 | Duplicate Content | Allow same content URI to be inscribed multiple times | +| ESIP-7 | Gzip Compression | Support for gzip-compressed content | + +### Example: Creating an Ethscription + +Consider mainnet transaction [`0xa165...ba86`](https://etherscan.io/tx/0xa1654c9db8847e197bbc72c880d1c269d974b15a2e606e4f5b1be2c5da81ba86): + +- **From**: `0x42e8...d67d` +- **To**: `0xC217...a97` +- **Calldata**: `0x646174613a2c6d6964646c656d61726368` +- **Decoded**: `data:,middlemarch` + +The derivation node: +1. Recognizes the Data URI pattern +2. Builds creation parameters: + - `ethscriptionId`: The L1 tx hash + - `contentUriHash`: SHA256 of the raw Data URI + - `initialOwner`: `0xC217...a97` (the recipient) + - `content`: `middlemarch` (decoded bytes) + - `mimetype`: `text/plain;charset=utf-8` +3. Creates a deposit transaction calling `Ethscriptions.createEthscription()` +4. The L2 contract stores content via SSTORE2, mints an NFT to the initial owner + +--- + +## Protocol System + +Ethscriptions supports pluggable protocol handlers that extend functionality. When an ethscription includes protocol parameters, the main contract routes the call to a registered handler. + +### How It Works + +1. **Registration**: Handlers register with the main contract: + ```solidity + Ethscriptions.registerProtocol("my-protocol", handlerAddress); + ``` + +2. **Invocation**: When creating an ethscription with protocol params: + ``` + data:image/png;p=my-protocol;op=mint;d=;base64, + ``` + +3. **Handler Call**: The contract calls `handler.op_mint(params)` after storing the ethscription. + +### Built-in Protocols + +| Protocol | Purpose | +|----------|---------| +| `erc-721-ethscriptions-collection` | Curated NFT collections with merkle enforcement | +| `erc-20-fixed-denomination` | Fungible tokens with fixed-denomination notes | + +### Protocol Data URI Format + +Two encoding styles are supported: + +**Header-based** (for binary content like images): +``` +data:image/png;p=erc-721-ethscriptions-collection;op=add_self_to_collection;d=;base64, +``` + +**JSON body** (for text-based operations): +``` +data:application/json,{"p":"erc-20-fixed-denomination","op":"deploy","tick":"mytoken","max":"1000000","lim":"1000"} +``` + +--- + +## ERC-721 Collections Protocol + +The collections protocol allows creators to build curated NFT collections with optional merkle proof enforcement. + +### Overview + +- **Collection**: A named set of ethscriptions with metadata (name, symbol, description, max supply) +- **Items**: Individual ethscriptions added to a collection +- **Merkle Enforcement**: Optional cryptographic restriction on which items can be added + +### Creating a Collection + +Use the `create_collection_and_add_self` operation: + +``` +data:image/png;rule=esip6;p=erc-721-ethscriptions-collection;op=create_collection_and_add_self;d=;base64, +``` + +Where the base64-decoded JSON contains: +```json +{ + "name": "My Collection", + "symbol": "MYC", + "maxSupply": 100, + "description": "A curated collection of...", + "logoImageUri": "data:image/png;base64,...", + "bannerImageUri": "data:image/png;base64,...", + "website": "https://example.com", + "twitterHandle": "myhandle", + "discordUrl": "https://discord.gg/...", + "backgroundColor": "#000000", + "merkleRoot": "0x06fbc22a...", + "itemIndex": 0, + "itemName": "Item #1", + "itemBackgroundColor": "#FF0000", + "itemDescription": "The first item", + "itemAttributes": [["Rarity", "Legendary"], ["Color", "Red"]] +} +``` + +### Merkle Proof Enforcement + +When a collection has a non-zero `merkleRoot`, non-owners must provide a merkle proof to add items. This ensures only pre-approved items with exact metadata can be added. + +**How it works:** + +1. Creator generates a merkle tree from approved items +2. Each leaf is computed as: + ```solidity + keccak256(abi.encode( + contentHash, // keccak256 of content bytes + itemIndex, // uint256 + name, // string + backgroundColor, // string + description, // string + attributes // (string,string)[] + )) + ``` +3. Creator sets the merkle root when creating the collection +4. Non-owners provide proofs when adding items: + ```json + { + "collectionId": "0x...", + "itemIndex": 1, + "itemName": "Item #2", + "merkleProof": ["0xaab5a305...", "0x58672b0c..."] + } + ``` + +**Owner bypass**: Collection owners can always add items without proofs. + +### Operations Reference + +| Operation | Description | +|-----------|-------------| +| `create_collection_and_add_self` | Create collection and add first item | +| `add_self_to_collection` | Add item to existing collection | +| `edit_collection` | Update collection metadata | +| `edit_collection_item` | Update item metadata | +| `transfer_ownership` | Transfer collection ownership | +| `renounce_ownership` | Surrender ownership | +| `remove_items` | Delete items from collection | +| `lock_collection` | Prevent further additions | + +For a complete walkthrough with code, see [`docs/merkle-collection-demo.md`](docs/merkle-collection-demo.md). + +--- + +## ERC-20 Fixed Denomination Tokens + +The fixed-denomination protocol creates fungible tokens where balances move in fixed batches (denominations) tied to NFT notes. + +### How It Differs from Standard ERC-20 + +- **No direct transfers**: ERC-20 `transfer()` is disabled +- **Note-based**: Each mint creates an NFT "note" representing a fixed token amount +- **Coupled movement**: Transferring the note automatically moves the ERC-20 balance +- **Inscription-driven**: All operations flow through ethscription creation/transfer + +### Deploy a Token + +Create an ethscription with JSON content: +```json +{ + "p": "erc-20-fixed-denomination", + "op": "deploy", + "tick": "mytoken", + "max": "1000000", + "lim": "1000" +} +``` + +| Field | Description | +|-------|-------------| +| `tick` | Token symbol (lowercase alphanumeric, max 28 chars) | +| `max` | Maximum total supply | +| `lim` | Amount per mint note (the denomination) | + +### Mint Notes + +After deployment, mint notes with: +```json +{ + "p": "erc-20-fixed-denomination", + "op": "mint", + "tick": "mytoken", + "id": "0", + "amt": "1000" +} +``` + +| Field | Description | +|-------|-------------| +| `tick` | Token symbol | +| `id` | Unique note identifier | +| `amt` | Token amount (typically equals `lim`) | + +### Transfer Mechanics + +When you transfer the mint inscription (the NFT note): +1. The inscription moves to the new owner +2. The ERC-20 balance automatically moves with it +3. Both the ERC-721 note and ERC-20 balance are synchronized + +This ensures tokens can only move in fixed denominations and are always tied to their note NFTs. + +--- + +## Technical Architecture + +### Pipeline Flow + +``` +L1 Block + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ L1 RPC Prefetcher (threaded) │ +│ - Fetches blocks, receipts, logs ahead of import │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ EthBlockImporter │ +│ - Parses calldata for Data URIs │ +│ - Extracts ESIP events from receipts │ +│ - Builds EthscriptionTransaction objects │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ GethDriver (Engine API) │ +│ - Creates L1Attributes system transaction │ +│ - Combines with ethscription transactions │ +│ - engine_forkchoiceUpdatedV3 │ +│ - engine_getPayloadV3 │ +│ - engine_newPayloadV3 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +L2 Block Sealed + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Ethscriptions Contract (L2) │ +│ - createEthscription() → SSTORE2 storage │ +│ - transferEthscription() → NFT transfer │ +│ - Protocol handler invocations │ +│ - Events emitted │ +└─────────────────────────────────────────────────────────┘ +``` + +### Deposit Transactions + +The derivation node creates EIP-2718 type `0x7d` deposit transactions: + +``` +Type: 0x7d (Deposit) +Fields: + - sourceHash: Unique identifier derived from L1 tx + - from: Original L1 sender (spoofed via deposit semantics) + - to: Ethscriptions contract address + - mint: 0 (no ETH minted) + - value: 0 + - gasLimit: Allocated gas for execution + - isSystemTx: false + - data: ABI-encoded contract call +``` + +Deposit semantics allow the derivation process to set `msg.sender` to the original L1 transaction sender, even though the payload is submitted by the node. + +### Content Storage (SSTORE2) + +Large content is stored using SSTORE2: +- Content is split into chunks +- Each chunk is deployed as contract bytecode +- Pointers are stored in the main contract +- Retrieval concatenates chunks + +Benefits: +- Cheaper than SSTORE for large content +- Content is immutable +- Gas-efficient reads + +### Source Hashing + +Each deposit transaction gets a unique `sourceHash` following Optimism conventions: +``` +sourceHash = keccak256( + domain (0) || + keccak256(blockHash || sourceTypeHash || selector || sourceIndex) +) +``` + +This ensures deterministic, reproducible derivation. + +--- + +## Validator (Optional) + +The validator reads expected creations/transfers from your Ethscriptions API and compares them with receipts and storage pulled from geth. It pauses the importer when discrepancies appear so you can investigate mismatches or RPC issues. + +### Enable Validation ```bash -rails db:migrate +VALIDATION_ENABLED=true +ETHSCRIPTIONS_API_BASE_URL=https://your-api-endpoint.com +ETHSCRIPTIONS_API_KEY=your-api-key ``` -You will also need memcache to use this. You can install it with homebrew as well. Consult ChatGPT! +The temporary SQLite databases in `storage/` and the SolidQueue worker pool exist only to support this reconciliation. Once historical import is verified, the goal is to remove that persistence and keep the derivation app stateless. -You will need an Alchemy API key for this to work! +--- -Run the tests to make sure everything is set up correctly: +## Local Development (Optional) + +If you want to modify the Ruby code outside of Docker: ```bash -rspec +# Install Ruby 3.4.x (via rbenv, rvm, or asdf) +ruby --version # Should show 3.4.x + +# Install dependencies +bundle install + +# Initialize local SQLite files +bin/setup + +# Run the derivation (requires running ethscriptions-geth and L1 RPC) +# See bin/jobs and config/derive_ethscriptions_blocks.rb ``` -Now run the process to index ethscriptions! +The Compose stack is the recommended path for production-like runs. + +--- + +## Directory Structure + +``` +ethscriptions-indexer/ +├── app/ +│ ├── models/ # Ethscription transaction models, protocol parsers +│ └── services/ # Derivation logic, Engine API driver +├── contracts/ +│ ├── src/ # Solidity predeploys +│ │ ├── Ethscriptions.sol +│ │ ├── ERC721EthscriptionsCollectionManager.sol +│ │ └── ERC20FixedDenominationManager.sol +│ ├── script/ # Genesis allocation scripts +│ └── test/ # Foundry tests +├── docker-compose/ +│ ├── docker-compose.yml +│ ├── .env.example +│ └── docs/ # Protocol documentation +├── docs/ # Additional documentation +├── lib/ # Genesis builders, utilities +├── spec/ # RSpec tests +└── storage/ # SQLite databases (validation) +``` + +--- + +## Testing + +### Ruby Tests ```bash -bundle exec clockwork config/main_importer_clock.rb +bundle exec rspec ``` -You'll want to keep this running in the background so you can process everything. If your indexer instance is behind and you want to catch up quickly you can adjust the `BLOCK_IMPORT_BATCH_SIZE` in your `.env`. With Alchemy you can set this as high as 30 and still see a performance improvement. +### Solidity Tests -Now start the web server on a port of your choice, for example 4000: +```bash +cd contracts +forge test +``` + +### With Verbose Output ```bash -rails s -p 4000 +bundle exec rspec --format documentation +forge test -vvv ``` -You can use this web server to access the API! +--- + +## Questions or Contributions -Try `http://localhost:4000/ethscriptions/0/data` to see the cat made famous in the first ethscription or `http://localhost:4000/blocks/:number` to see details of any block. +Open an issue or reach out in the Ethscriptions community channels. diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb deleted file mode 100644 index 855a724..0000000 --- a/app/controllers/application_controller.rb +++ /dev/null @@ -1,3 +0,0 @@ -class ApplicationController < ActionController::API - include FacetRailsCommon::ApplicationControllerMethods -end diff --git a/app/controllers/blocks_controller.rb b/app/controllers/blocks_controller.rb deleted file mode 100644 index b2fc462..0000000 --- a/app/controllers/blocks_controller.rb +++ /dev/null @@ -1,44 +0,0 @@ -class BlocksController < ApplicationController - cache_actions_on_block - - def index - results, pagination_response = paginate(EthBlock.all) - - render json: { - result: numbers_to_strings(results), - pagination: pagination_response - } - end - - def show - scope = EthBlock.all.where(block_number: params[:id]) - - block = Rails.cache.fetch(["block-api-show", scope]) do - scope.first - end - - if !block - render json: { error: "Not found" }, status: 404 - return - end - - render json: block - end - - def newer_blocks - limit = [params[:limit]&.to_i || 100, 2500].min - requested_block_number = params[:block_number].to_i - - scope = EthBlock.where("block_number >= ?", requested_block_number). - limit(limit). - where.not(imported_at: nil) - - res = Rails.cache.fetch(['newer_blocks', scope]) do - scope.to_a - end - - render json: { - result: res - } - end -end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/app/controllers/ethscription_transfers_controller.rb b/app/controllers/ethscription_transfers_controller.rb deleted file mode 100644 index 2062f59..0000000 --- a/app/controllers/ethscription_transfers_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -class EthscriptionTransfersController < ApplicationController - cache_actions_on_block - - def index - scope = filter_by_params(EthscriptionTransfer.all, - :from_address, - :to_address, - :transaction_hash - ) - - to_or_from = parse_param_array(params[:to_or_from]) - - if to_or_from.present? - scope = scope.where(from_address: to_or_from) - .or(scope.where(to_address: to_or_from)) - end - - ethscription_token_tick = parse_param_array(params[:ethscription_token_tick]).first - ethscription_token_protocol = parse_param_array(params[:ethscription_token_protocol]).first - - if ethscription_token_tick && ethscription_token_protocol - tokens = Ethscription.with_token_tick_and_protocol( - ethscription_token_tick, - ethscription_token_protocol - ).select(:transaction_hash) - - scope = scope.where(ethscription_transaction_hash: tokens) - end - - results, pagination_response = paginate(scope) - - render json: { - result: numbers_to_strings(results), - pagination: pagination_response - } - end -end diff --git a/app/controllers/ethscriptions_controller.rb b/app/controllers/ethscriptions_controller.rb deleted file mode 100644 index 4221548..0000000 --- a/app/controllers/ethscriptions_controller.rb +++ /dev/null @@ -1,299 +0,0 @@ -class EthscriptionsController < ApplicationController - cache_actions_on_block only: [:index, :show, :newer_ethscriptions] - - def index - if params[:owned_by_address].present? - params[:current_owner] = params[:owned_by_address] - end - - scope = filter_by_params(Ethscription.all, - :current_owner, - :creator, - :initial_owner, - :previous_owner, - :mimetype, - :media_type, - :mime_subtype, - :content_sha, - :transaction_hash, - :block_number, - :block_timestamp, - :block_blockhash, - :ethscription_number, - :attachment_sha, - :attachment_content_type - ) - - if params[:after_block].present? - scope = scope.where('block_number > ?', params[:after_block].to_i) - end - - if params[:before_block].present? - scope = scope.where('block_number < ?', params[:before_block].to_i) - end - - scope = scope.where.not(attachment_sha: nil) if params[:attachment_present] == "true" - scope = scope.where(attachment_sha: nil) if params[:attachment_present] == "false" - - include_latest_transfer = params[:include_latest_transfer].present? - - if include_latest_transfer - scope = scope.includes(:ethscription_transfers) - end - - token_tick = parse_param_array(params[:token_tick]).first - token_protocol = parse_param_array(params[:token_protocol]).first - transferred_in_tx = parse_param_array(params[:transferred_in_tx]) - - if token_tick && token_protocol - scope = scope.with_token_tick_and_protocol(token_tick, token_protocol) - end - - if transferred_in_tx.present? - sub_query = EthscriptionTransfer.where(transaction_hash: transferred_in_tx).select(:ethscription_transaction_hash) - scope = scope.where(transaction_hash: sub_query) - end - - transaction_hash_only = params[:transaction_hash_only].present? && !include_latest_transfer - - if transaction_hash_only - scope = scope.select(:id, :transaction_hash) - end - - results_limit = if transaction_hash_only - 1000 - elsif include_latest_transfer - 50 - else - 100 - end - - results, pagination_response = paginate( - scope, - results_limit: results_limit - ) - - results = results.map do |ethscription| - ethscription.as_json(include_latest_transfer: include_latest_transfer) - end - - render json: { - result: numbers_to_strings(results), - pagination: pagination_response - } - end - - def show - scope = Ethscription.all.includes(:ethscription_transfers) - - id_or_hash = params[:id].to_s.downcase - - scope = id_or_hash.match?(/\A0x[0-9a-f]{64}\z/) ? - scope.where(transaction_hash: id_or_hash) : - scope.where(ethscription_number: id_or_hash) - - ethscription = Rails.cache.fetch(["ethscription-api-show", scope]) do - scope.first - end - - if !ethscription - render json: { error: "Not found" }, status: 404 - return - end - - json = numbers_to_strings(ethscription.as_json(include_transfers: true)) - - render json: { - result: json - } - end - - def data - scope = Ethscription.all - - id_or_hash = params[:id].to_s.downcase - - scope = id_or_hash.match?(/\A0x[0-9a-f]{64}\z/) ? - scope.where(transaction_hash: id_or_hash) : - scope.where(ethscription_number: id_or_hash) - - blockhash, block_number = scope.pick(:block_blockhash, :block_number) - - unless blockhash.present? - head 404 - return - end - - response.headers.delete('X-Frame-Options') - - set_cache_control_headers( - max_age: 6, - s_max_age: 1.minute, - etag: blockhash, - extend_cache_if_block_final: block_number - ) do - item = scope.first - - uri_obj = item.parsed_data_uri - - send_data(uri_obj.decoded_data, type: uri_obj.mimetype, disposition: 'inline') - end - end - - def attachment - scope = Ethscription.all - - id_or_hash = params[:id].to_s.downcase - - scope = id_or_hash.match?(/\A0x[0-9a-f]{64}\z/) ? - scope.where(transaction_hash: id_or_hash) : - scope.where(ethscription_number: id_or_hash) - - sha, blockhash, block_number = scope.pick(:attachment_sha, :block_blockhash, :block_number) - - attachment_scope = EthscriptionAttachment.where(sha: sha) - - unless attachment_scope.exists? - head 404 - return - end - - response.headers.delete('X-Frame-Options') - - set_cache_control_headers( - max_age: 6, - s_max_age: 1.minute, - etag: [sha, blockhash], - extend_cache_if_block_final: block_number - ) do - attachment = attachment_scope.first - - send_data(attachment.content, type: attachment.content_type_with_encoding, disposition: 'inline') - end - end - - def exists - existing = Ethscription.find_by_content_sha(params[:sha]) - - render json: { - result: { - exists: existing.present?, - ethscription: existing - } - } - end - - def exists_multi - shas = Array.wrap(params[:shas]).sort.uniq - - if shas.size > 100 - render json: { error: "Too many SHAs" }, status: 400 - return - end - - result = Rails.cache.fetch(["ethscription-api-exists-multi", shas], expires_in: 12.seconds) do - existing_ethscriptions = Ethscription.where(content_sha: shas).pluck(:content_sha, :transaction_hash) - - sha_to_transaction_hash = existing_ethscriptions.to_h - - shas.each do |sha| - sha_to_transaction_hash[sha] ||= nil - end - - sha_to_transaction_hash - end - - render json: { result: result } - end - - def newer_ethscriptions - mimetypes = params[:mimetypes] || [] - initial_owner = params[:initial_owner] - requested_block_number = params[:block_number].to_i - client_past_ethscriptions_count = params[:past_ethscriptions_count] - past_ethscriptions_checksum = params[:past_ethscriptions_checksum] - - system_max_ethscriptions = ENV.fetch('MAX_ETHSCRIPTIONS_PER_VM_REQUEST', 25).to_i - system_max_blocks = ENV.fetch('MAX_BLOCKS_PER_VM_REQUEST', 50).to_i - - max_ethscriptions = [params[:max_ethscriptions]&.to_i || 50, system_max_ethscriptions].min - max_blocks = [params[:max_blocks]&.to_i || 500, system_max_blocks].min - - scope = Ethscription.all.oldest_first - scope = scope.where(mimetype: mimetypes) if mimetypes.present? - scope = scope.where(initial_owner: initial_owner) if initial_owner.present? - - unless scope.exists? - render json: { - error: { - message: "No matching ethscriptions found", - resolution: :retry_with_delay - } - }, status: :unprocessable_entity - return - end - - requested_block_number = [requested_block_number, scope.limit(1).pluck(:block_number).first].max - - we_are_up_to_date = EthBlock.where(block_number: requested_block_number). - where.not(imported_at: nil).exists? - - unless we_are_up_to_date - render json: { - error: { - message: "Block not yet imported. Please try again later", - resolution: :retry - } - }, status: :unprocessable_entity - return - end - - last_ethscription_block = scope.where('block_number >= ? AND block_number < ?', - requested_block_number, - requested_block_number + max_blocks) - .order(:block_number, :transaction_index) - .offset(max_ethscriptions - 1) - .pluck(:block_number) - .first - - last_block_in_range = last_ethscription_block || requested_block_number + max_blocks - 1 - - last_block_in_range -= 1 if last_block_in_range > requested_block_number - - block_range = (requested_block_number..last_block_in_range).to_a - - all_blocks_in_range = EthBlock.where(block_number: block_range).where.not(imported_at: nil).order(:block_number).index_by(&:block_number) - - if all_blocks_in_range[requested_block_number].nil? - render json: { - error: { - message: "Block not yet imported. Please try again later", - resolution: :retry - } - }, status: :unprocessable_entity - return - end - - ethscriptions_in_range = scope.where(block_number: block_range) - - ethscriptions_by_block = ethscriptions_in_range.group_by(&:block_number) - - block_data = all_blocks_in_range.map do |block_number, block| - current_block_ethscriptions = ethscriptions_by_block[block_number] || [] - { - blockhash: block.blockhash, - parent_blockhash: block.parent_blockhash, - block_number: block.block_number, - timestamp: block.timestamp.to_i, - ethscriptions: current_block_ethscriptions - } - end - - total_ethscriptions_in_future_blocks = scope.where('block_number > ?', block_range.last).count - - render json: { - total_future_ethscriptions: total_ethscriptions_in_future_blocks, - blocks: block_data - } - end -end diff --git a/app/controllers/status_controller.rb b/app/controllers/status_controller.rb deleted file mode 100644 index e32be55..0000000 --- a/app/controllers/status_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -class StatusController < ApplicationController - def indexer_status - set_cache_control_headers(max_age: 6) - - current_block_number = EthBlock.cached_global_block_number - last_imported_block = EthBlock.most_recently_imported_block_number.to_i - - blocks_behind = current_block_number - last_imported_block - - resp = { - current_block_number: current_block_number, - last_imported_block: last_imported_block, - blocks_behind: blocks_behind - } - - render json: resp - end -end diff --git a/app/controllers/tokens_controller.rb b/app/controllers/tokens_controller.rb deleted file mode 100644 index c6be424..0000000 --- a/app/controllers/tokens_controller.rb +++ /dev/null @@ -1,84 +0,0 @@ -class TokensController < ApplicationController - cache_actions_on_block - before_action :set_token, only: [:show, :historical_state, :balance_of, :validate_token_items] - - def index - scope = filter_by_params(Token.all, - :protocol, - :tick - ) - - results, pagination_response = paginate(scope) - - render json: { - result: numbers_to_strings(results), - pagination: pagination_response - } - end - - def show - json = @token.as_json(include_balances: true) - - render json: { - result: numbers_to_strings(json), - pagination: {} - } - end - - def historical_state - as_of_block = params[:as_of_block].to_i - - state = @token.token_states. - where("block_number <= ?", as_of_block). - newest_first.limit(1).first - - render json: { - result: numbers_to_strings(state), - pagination: {} - } - end - - def balance_of - balance = @token.balance_of(params[:address]) - - render json: { - result: numbers_to_strings(balance.to_s) - } - end - - def validate_token_items - tx_hashes = if request.post? - params.require(:transaction_hashes) - else - parse_param_array(params[:transaction_hashes]) - end - - valid_tx_hash_scope = @token.token_items.where( - ethscription_transaction_hash: tx_hashes - ) - - results, pagination_response = paginate(valid_tx_hash_scope) - - valid_tx_hashes = results.map(&:ethscription_transaction_hash) - - invalid_tx_hashes = tx_hashes.sort - valid_tx_hashes.sort - - res = { - valid: valid_tx_hashes, - invalid: invalid_tx_hashes, - token_items_checksum: @token.token_items_checksum - } - - render json: { - result: numbers_to_strings(res), - pagination: pagination_response - } - end - - private - - def set_token - @token = Token.find_by_protocol_and_tick(params[:protocol], params[:tick]) - raise RequestedRecordNotFound unless @token - end -end diff --git a/app/jobs/gap_detection_job.rb b/app/jobs/gap_detection_job.rb new file mode 100644 index 0000000..d43f1ae --- /dev/null +++ b/app/jobs/gap_detection_job.rb @@ -0,0 +1,112 @@ +class GapDetectionJob < ApplicationJob + queue_as :gap_detection + + def perform + return unless validation_enabled? + + Rails.logger.info "GapDetectionJob: Starting gap detection scan" + + # Get current import range + import_range = get_import_range + return unless import_range + + start_block, end_block = import_range + + # Find validation gaps + gaps = ValidationResult.validation_gaps(start_block, end_block) + + if gaps.empty? + Rails.logger.debug "GapDetectionJob: No validation gaps found in range #{start_block}..#{end_block}" + return + end + + Rails.logger.info "GapDetectionJob: Found #{gaps.length} validation gaps: #{gaps.first(5).join(', ')}#{gaps.length > 5 ? '...' : ''}" + + # Enqueue validation jobs for gaps + gaps.each do |block_number| + begin + # Get L2 block data for this L1 block + l2_blocks = get_l2_blocks_for_l1_block(block_number) + + if l2_blocks.any? + l2_block_hashes = l2_blocks.map { |block| block[:hash] } + ValidationJob.perform_later(block_number, l2_block_hashes) + Rails.logger.debug "GapDetectionJob: Enqueued validation for block #{block_number}" + else + Rails.logger.warn "GapDetectionJob: No L2 blocks found for L1 block #{block_number}" + end + rescue => e + Rails.logger.error "GapDetectionJob: Failed to enqueue validation for block #{block_number}: #{e.message}" + end + end + + Rails.logger.info "GapDetectionJob: Enqueued #{gaps.length} validation jobs for gaps" + end + + private + + def validation_enabled? + ENV.fetch('VALIDATION_ENABLED').casecmp?('true') + end + + def get_import_range + begin + # Get the range of blocks we should have validation for + # Use the current L2 blockchain state to determine what's been imported + latest_l2_block = GethDriver.client.call("eth_getBlockByNumber", ["latest", false]) + return nil if latest_l2_block.nil? + + latest_l2_block_number = latest_l2_block['number'].to_i(16) + return nil if latest_l2_block_number == 0 + + # Get L1 attributes to find the corresponding L1 block + l1_attributes = GethDriver.get_l1_attributes(latest_l2_block_number) + current_l1_block = l1_attributes[:number] + + # Check the last validated block + last_validated = ValidationResult.last_validated_block || 0 + + # We should validate from the oldest reasonable point to current + # Don't go back more than 1000 blocks to avoid overwhelming the system + start_block = [last_validated - 100, current_l1_block - 1000].max + + [start_block, current_l1_block] + rescue => e + Rails.logger.error "GapDetectionJob: Failed to determine import range: #{e.message}" + nil + end + end + + def get_l2_blocks_for_l1_block(l1_block_number) + # Query Geth to find L2 blocks that were created from this L1 block + # This is complex - we need to scan L2 blocks and check their L1 attributes + begin + l2_blocks = [] + + # Get current L2 tip to know the range to search + latest_l2_block = GethDriver.client.call("eth_getBlockByNumber", ["latest", false]) + return [] if latest_l2_block.nil? + + latest_l2_block_number = latest_l2_block['number'].to_i(16) + + # Search backwards from current L2 tip to find blocks from this L1 block + # This is expensive but necessary for gap filling + (0..latest_l2_block_number).reverse_each do |l2_block_num| + l1_attributes = GethDriver.get_l1_attributes(l2_block_num) + + if l1_attributes[:number] == l1_block_number + l2_block = GethDriver.client.call("eth_getBlockByNumber", ["0x#{l2_block_num.to_s(16)}", false]) + l2_blocks << { number: l2_block_num, hash: l2_block['hash'] } + elsif l1_attributes[:number] < l1_block_number + # We've gone too far back + break + end + end + + l2_blocks + rescue => e + Rails.logger.error "GapDetectionJob: Failed to get L2 blocks for L1 block #{l1_block_number}: #{e.message}" + [] + end + end +end \ No newline at end of file diff --git a/app/jobs/validation_job.rb b/app/jobs/validation_job.rb new file mode 100644 index 0000000..13add52 --- /dev/null +++ b/app/jobs/validation_job.rb @@ -0,0 +1,25 @@ +class ValidationJob < ApplicationJob + queue_as :validation + + # Retry all errors - any exception means we couldn't validate, not that validation failed + # StandardError catches all normal exceptions (network, RPC, API, etc.) + retry_on StandardError, + wait: ENV.fetch('VALIDATION_RETRY_WAIT_SECONDS', 5).to_i.seconds, + attempts: ENV.fetch('VALIDATION_TRANSIENT_RETRIES', 1000).to_i + + def perform(l1_block_number, l2_block_hashes) + start_time = Time.current + + # ValidationResult.validate_and_save will: + # 1. Create ValidationResult with success: true (job succeeds) + # 2. Create ValidationResult with success: false (job succeeds - real validation failure found) + # 3. Raise any exception (job retries via retry_on StandardError) + ValidationResult.validate_and_save(l1_block_number, l2_block_hashes) + + elapsed_time = Time.current - start_time + Rails.logger.debug "ValidationJob: Block #{l1_block_number} validation completed in #{elapsed_time.round(3)}s" + rescue => e + Rails.logger.error "ValidationJob failed for L1 #{l1_block_number}: #{e.class}: #{e.message}" + raise + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index f0ae1fb..f696145 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,9 +1,3 @@ class ApplicationRecord < ActiveRecord::Base primary_abstract_class - - if ENV['DATABASE_REPLICA_URL'].present? - connects_to database: { writing: :primary, reading: :primary_replica } - else - connects_to database: { writing: :primary } - end -end +end \ No newline at end of file diff --git a/app/models/erc20_fixed_denomination_parser.rb b/app/models/erc20_fixed_denomination_parser.rb new file mode 100644 index 0000000..5ea77f1 --- /dev/null +++ b/app/models/erc20_fixed_denomination_parser.rb @@ -0,0 +1,61 @@ +# Extracts fixed-denomination ERC-20 parameters from strict JSON inscriptions. +class Erc20FixedDenominationParser + # Constants + DEFAULT_PARAMS = [''.b, ''.b, ''.b].freeze + UINT256_MAX = 2**256 - 1 + PROTOCOL = 'erc-20-fixed-denomination'.b + + # Exact regex patterns for valid formats + # Protocol must be "erc-20" (legacy inscription) or the canonical identifier + # Tick must be lowercase letters/numbers, max 28 chars + # Numbers must be positive decimals without leading zeros + PROTOCOL_PATTERN = '(?:erc-20|erc-20-fixed-denomination)' + DEPLOY_REGEX = /\A\{"p":"#{PROTOCOL_PATTERN}","op":"deploy","tick":"([a-z0-9]{1,28})","max":"(0|[1-9][0-9]*)","lim":"(0|[1-9][0-9]*)"\}\z/ + MINT_REGEX = /\A\{"p":"#{PROTOCOL_PATTERN}","op":"mint","tick":"([a-z0-9]{1,28})","id":"(0|[1-9][0-9]*)","amt":"(0|[1-9][0-9]*)"\}\z/ + + # Validate and encode protocol params + # Unified interface - accepts all possible parameters, uses what it needs + def self.validate_and_encode(decoded_content:, operation:, params:, source:, ethscription_id: nil, **_extras) + new.validate_and_encode( + decoded_content: decoded_content, + operation: operation, + params: params, + source: source + ) + end + + def validate_and_encode(decoded_content:, operation:, params:, source:) + # Only support JSON source - no header parameters for ERC-20 + return DEFAULT_PARAMS unless source == :json + return DEFAULT_PARAMS unless decoded_content.is_a?(String) + + # Try deploy format first + if match = DEPLOY_REGEX.match(decoded_content) + tick = match[1] # Group 1: tick + max = match[2].to_i # Group 2: max + lim = match[3].to_i # Group 3: lim + + # Validate uint256 bounds + return DEFAULT_PARAMS if max > UINT256_MAX || lim > UINT256_MAX + + encoded = Eth::Abi.encode(['(string,uint256,uint256)'], [[tick.b, max, lim]]) + return [PROTOCOL, 'deploy'.b, encoded.b] + end + + # Try mint format + if match = MINT_REGEX.match(decoded_content) + tick = match[1] # Group 1: tick + id = match[2].to_i # Group 2: id + amt = match[3].to_i # Group 3: amt + + # Validate uint256 bounds + return DEFAULT_PARAMS if id > UINT256_MAX || amt > UINT256_MAX + + encoded = Eth::Abi.encode(['(string,uint256,uint256)'], [[tick.b, id, amt]]) + return [PROTOCOL, 'mint'.b, encoded.b] + end + + # No match - return default + DEFAULT_PARAMS + end +end \ No newline at end of file diff --git a/app/models/erc721_ethscriptions_collection_parser.rb b/app/models/erc721_ethscriptions_collection_parser.rb new file mode 100644 index 0000000..2d7919f --- /dev/null +++ b/app/models/erc721_ethscriptions_collection_parser.rb @@ -0,0 +1,834 @@ +require 'zlib' +require 'rubygems/package' + +# Strict parser for the ERC-721 Ethscriptions collection protocol with canonical JSON validation +class Erc721EthscriptionsCollectionParser + # Default return for invalid input + DEFAULT_PARAMS = [''.b, ''.b, ''.b].freeze + + # Maximum value for uint256 + UINT256_MAX = 2**256 - 1 + + # Operation schemas defining exact structure and ABI encoding + OPERATION_SCHEMAS = { + 'create_collection' => { + keys: %w[name symbol max_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link merkle_root initial_owner], + abi_type: '(string,string,uint256,string,string,string,string,string,string,string,bytes32,address)', + validators: { + 'name' => :string, + 'symbol' => :string, + 'max_supply' => :uint256, + 'description' => :string, + 'logo_image_uri' => :string, + 'banner_image_uri' => :string, + 'background_color' => :string, + 'website_link' => :string, + 'twitter_link' => :string, + 'discord_link' => :string, + 'merkle_root' => :bytes32, + 'initial_owner' => :optional_address + } + }, + # New combined create op name used by the contract; keep legacy alias below + 'create_collection_and_add_self' => { + keys: %w[metadata item], + # ((CollectionParams),(ItemData)) - CollectionParams now includes initialOwner + abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32,address),(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))', + validators: { + 'metadata' => :collection_metadata, + 'item' => :single_item + } + }, + # Legacy alias retained for backwards compatibility + 'create_and_add_self' => { + keys: %w[metadata item], + abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32,address),(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))', + validators: { + 'metadata' => :collection_metadata, + 'item' => :single_item + } + }, + # New single-item add op; keep legacy batch below for compatibility + 'add_self_to_collection' => { + keys: %w[collection_id item], + abi_type: '(bytes32,(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))', + validators: { + 'collection_id' => :bytes32, + 'item' => :single_item + } + }, + 'transfer_ownership' => { + keys: %w[collection_id new_owner], + abi_type: '(bytes32,address)', + validators: { + 'collection_id' => :bytes32, + 'new_owner' => :address + } + }, + 'renounce_ownership' => { + keys: %w[collection_id], + abi_type: 'bytes32', + validators: { + 'collection_id' => :bytes32 + } + }, + 'remove_items' => { + keys: %w[collection_id ethscription_ids], + abi_type: '(bytes32,bytes32[])', + validators: { + 'collection_id' => :bytes32, + 'ethscription_ids' => :bytes32_array + } + }, + 'edit_collection' => { + keys: %w[collection_id description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link merkle_root], + abi_type: '(bytes32,string,string,string,string,string,string,string,bytes32)', + validators: { + 'collection_id' => :bytes32, + 'description' => :string, + 'logo_image_uri' => :string, + 'banner_image_uri' => :string, + 'background_color' => :string, + 'website_link' => :string, + 'twitter_link' => :string, + 'discord_link' => :string, + 'merkle_root' => :bytes32 + } + }, + 'edit_collection_item' => { + keys: %w[collection_id item_index name background_color description attributes], + abi_type: '(bytes32,uint256,string,string,string,(string,string)[])', + validators: { + 'collection_id' => :bytes32, + 'item_index' => :uint256, + 'name' => :string, + 'background_color' => :string, + 'description' => :string, + 'attributes' => :attributes_array + } + }, + 'lock_collection' => { + keys: %w[collection_id], + abi_type: 'bytes32', # Not a tuple + validators: { + 'collection_id' => :bytes32 + } + } + }.freeze + + ZERO_BYTES32 = ["".ljust(64, '0')].pack('H*').freeze + ZERO_HEX_BYTES32 = '0x' + '0' * 64 + + # Item keys for validation (merkle_proof always present, can be empty array) + ITEM_KEYS = %w[content_hash item_index name background_color description attributes merkle_proof].freeze + + # Attribute keys for NFT metadata + ATTRIBUTE_KEYS = %w[trait_type value].freeze + + class ValidationError < StandardError; end + + DEFAULT_ITEMS_PATH = ENV['COLLECTIONS_ITEMS_PATH'] || Rails.root.join('items_by_ethscription.json') + DEFAULT_COLLECTIONS_PATH = ENV['COLLECTIONS_META_PATH'] || Rails.root.join('collections_by_name.json') + DEFAULT_ARCHIVE_PATH = ENV['COLLECTIONS_ARCHIVE_PATH'] || Rails.root.join('collections_data.tar.gz') + + # New API: validate and encode protocol params + # Unified interface - accepts all possible parameters, uses what it needs + def self.validate_and_encode(decoded_content:, operation:, params:, source:, ethscription_id: nil, eth_transaction: nil, **_extras) + new.validate_and_encode( + decoded_content: decoded_content, + operation: operation, + params: params, + source: source, + ethscription_id: ethscription_id, + eth_transaction: eth_transaction + ) + end + + def validate_and_encode(decoded_content:, operation:, params:, source:, ethscription_id: nil, eth_transaction: nil) + # Check import fallback first (if ethscription_id provided) + if ethscription_id + normalized_id = normalize_id(ethscription_id) + if normalized_id && (preplanned = build_import_encoded_params(normalized_id, decoded_content, eth_transaction)) + return preplanned + end + end + + return DEFAULT_PARAMS unless OPERATION_SCHEMAS.key?(operation) + + schema = OPERATION_SCHEMAS[operation] + + begin + if source == :json + # Strict JSON validation - enforce exact key order + validate_json_structure(params, operation, schema) + end + + # Extract encoding data (skip 'p' and 'op' for JSON source) + encoding_data = if source == :json + params.reject { |k, _| k == 'p' || k == 'op' } + else + params + end + + # Compute content hash ONLY for operations that need it + content_hash = nil + if ['create_collection_and_add_self', 'create_and_add_self', 'add_self_to_collection'].include?(operation) + # Calculate keccak256 of decoded content for item verification + hash = Eth::Util.keccak256(decoded_content).unpack1('H*') + content_hash = '0x' + hash + + # Inject content_hash into item data + item_data = encoding_data['item'] + if item_data.is_a?(Hash) + # Add content_hash as first key (OrderedHash maintains insertion order) + encoding_data['item'] = self.class::OrderedHash['content_hash', content_hash].merge(item_data) + end + end + + encoded_data = encode_operation(operation, encoding_data, schema, content_hash: content_hash) + ['erc-721-ethscriptions-collection'.b, operation.b, encoded_data.b] + rescue JSON::ParserError, ValidationError => e + Rails.logger.debug "Collections validation failed: #{e.message}" if defined?(Rails) + DEFAULT_PARAMS + end + end + + def validate_json_structure(params, operation, schema) + # For JSON source, enforce strict key ordering + expected_keys = ['p', 'op'] + schema[:keys] + unless params.keys == expected_keys + raise ValidationError, "Invalid key order for #{operation}" + end + end + + # Removed extract() method - use ProtocolParser.for_calldata() instead + # This avoids circular dependencies and keeps the architecture cleaner + # The import fallback logic is now handled in validate_and_encode() + + def normalize_id(value) + case value + when ByteString + value.to_hex.downcase + when String + value.downcase + else + nil + end + end + + # -------------------- Import fallback -------------------- + + # Returns [protocol, operation, encoded_data] or nil + def build_import_encoded_params(id, decoded_content, eth_transaction = nil) + data = self.class.load_import_data( + items_path: DEFAULT_ITEMS_PATH, + collections_path: DEFAULT_COLLECTIONS_PATH + ) + + item = data[:items_by_id][id] + return nil unless item + + coll_name = item['collection_name'] + return nil unless coll_name + + leader_id = data[:leader_by_collection][coll_name] + return nil unless leader_id + + item_index = data[:zero_index_by_id][id] || 0 + + # Always compute content hash from the actual decoded content + hash = Eth::Util.keccak256(decoded_content || ''.b).unpack1('H*') + content_hash = '0x' + hash + + if id == leader_id + raw_metadata = data[:collections_by_name][coll_name] + return nil unless raw_metadata + metadata = raw_metadata.merge( + 'merkle_root' => raw_metadata['merkle_root'] || ZERO_HEX_BYTES32 + ) + operation = 'create_collection_and_add_self' + schema = OPERATION_SCHEMAS[operation] + encoding_data = { + 'metadata' => build_metadata_object(metadata, eth_transaction: eth_transaction), + 'item' => build_item_object(item: item, item_index: item_index, content_hash: content_hash) + } + encoded_data = encode_operation(operation, encoding_data, schema, content_hash: content_hash) + ['erc-721-ethscriptions-collection'.b, operation.b, encoded_data.b] + else + operation = 'add_self_to_collection' + schema = OPERATION_SCHEMAS[operation] + encoding_data = { + 'collection_id' => to_bytes32_hex(leader_id), + 'item' => build_item_object(item: item, item_index: item_index, content_hash: content_hash) + } + encoded_data = encode_operation(operation, encoding_data, schema, content_hash: content_hash) + ['erc-721-ethscriptions-collection'.b, operation.b, encoded_data.b] + end + end + + class << self + include Memery + + def load_import_data(items_path:, collections_path:) + archive_path = DEFAULT_ARCHIVE_PATH + + # Check if we need to extract from the tar.gz archive + if File.exist?(archive_path) + # Check if JSON files don't exist or archive is newer + extract_needed = !File.exist?(items_path) || !File.exist?(collections_path) || + File.mtime(archive_path) > File.mtime(items_path) || + File.mtime(archive_path) > File.mtime(collections_path) + + if extract_needed + Rails.logger.info "Extracting collections data from #{archive_path}" if defined?(Rails) + + # Extract tar.gz archive + Zlib::GzipReader.open(archive_path) do |gz| + Gem::Package::TarReader.new(gz) do |tar| + tar.each do |entry| + if entry.file? + case entry.full_name + when 'items_by_ethscription.json' + File.open(items_path, 'wb') do |f| + f.write(entry.read) + end + Rails.logger.info "Extracted #{entry.full_name} to #{items_path}" if defined?(Rails) + when 'collections_by_name.json' + File.open(collections_path, 'wb') do |f| + f.write(entry.read) + end + Rails.logger.info "Extracted #{entry.full_name} to #{collections_path}" if defined?(Rails) + end + end + end + end + end + end + end + + # Ensure files exist before reading + unless File.exist?(items_path) && File.exist?(collections_path) + raise "Collections data files not found. Please ensure #{archive_path} exists or provide JSON files directly." + end + + items = JSON.parse(File.read(items_path)) + collections = JSON.parse(File.read(collections_path)) + + items_by_id = {} + items.each { |k, v| items_by_id[k.to_s.downcase] = v } + + # Group items by collection and derive leader (min ethscription_number) + groups = Hash.new { |h, k| h[k] = [] } + items_by_id.each do |iid, it| + cname = it['collection_name'] + next unless cname.is_a?(String) && !cname.empty? + num = it.fetch('ethscription_number').to_i + groups[cname] << [iid, num] + end + + leader_by_collection = {} + groups.each do |cname, pairs| + next if pairs.empty? + leader_by_collection[cname] = pairs.min_by { |_id, num| num }[0] + end + + # Normalize item indices to zero-based + zero_index_by_id = {} + groups.each do |_cname, pairs| + explicit = pairs.map { |(iid, _)| [iid, items_by_id[iid]['index']] } + explicit_indices = explicit.filter_map { |_iid, idx| idx if idx.is_a?(Integer) } + if explicit_indices.size == pairs.size + min_idx = explicit_indices.min + offset = (min_idx == 0) ? 0 : 1 + explicit.each { |iid, idx| zero_index_by_id[iid] = [idx - offset, 0].max } + else + pairs.sort_by { |_iid, num| num }.each_with_index { |(iid, _), i| zero_index_by_id[iid] = i } + end + end + + { + items_by_id: items_by_id, + collections_by_name: collections, + leader_by_collection: leader_by_collection, + zero_index_by_id: zero_index_by_id + } + end + memoize :load_import_data + end + + # Build ordered JSON objects to match strict parser expectations + def build_metadata_object(meta, eth_transaction: nil) + name = safe_string(meta['name']) + symbol = safe_string(meta['symbol'] || meta['slug'] || meta['name']) + max_supply = safe_uint_string(meta['max_supply'] || meta['total_supply'] || 0) + description = safe_string(meta['description']) + logo_image_uri = safe_string(meta['logo_image_uri']) + banner_image_uri = safe_string(meta['banner_image_uri']) + background_color = safe_string(meta['background_color']) + website_link = safe_string(meta['website_link']) + twitter_link = safe_string(meta['twitter_link']) + discord_link = safe_string(meta['discord_link']) + + result = OrderedHash[ + 'name', name, + 'symbol', symbol, + 'max_supply', max_supply, + 'description', description, + 'logo_image_uri', logo_image_uri, + 'banner_image_uri', banner_image_uri, + 'background_color', background_color, + 'website_link', website_link, + 'twitter_link', twitter_link, + 'discord_link', discord_link + ] + merkle_root = meta.fetch('merkle_root') + result['merkle_root'] = to_bytes32_hex(merkle_root) + + # Handle initial_owner based on should_renounce flag + if meta['should_renounce'] == true + # address(0) means renounce ownership + result['initial_owner'] = '0x0000000000000000000000000000000000000000' + elsif meta['initial_owner'] + # Use explicitly specified initial owner + result['initial_owner'] = to_address_hex(meta['initial_owner']) + elsif eth_transaction && eth_transaction.respond_to?(:from_address) + # Use the transaction sender as the actual owner + result['initial_owner'] = eth_transaction.from_address.to_hex + else + # No transaction context - this shouldn't happen in production + # For import, we always have the transaction + # Return nil to indicate we can't determine the owner + raise ValidationError, "Cannot determine initial owner without transaction context" + end + + result + end + + def build_item_object(item:, item_index:, content_hash:) + attrs = Array(item['attributes']).map do |a| + OrderedHash['trait_type', safe_string(a['trait_type']), 'value', safe_string(a['value'])] + end + + proofs = item.key?('merkle_proof') ? Array(item['merkle_proof']) : [] + + OrderedHash[ + 'content_hash', content_hash, + 'item_index', safe_uint_string(item_index), + 'name', safe_string(item['name']), + 'background_color', safe_string(item['background_color']), + 'description', safe_string(item['description']), + 'attributes', attrs, + 'merkle_proof', proofs + ] + end + + def to_bytes32_hex(val) + h = safe_string(val).downcase + raise ValidationError, "Invalid bytes32 hex: #{val}" unless h.match?(/\A0x[0-9a-f]{64}\z/) + h + end + + def to_address_hex(val) + h = safe_string(val).downcase + raise ValidationError, "Invalid address hex: #{val}" unless h.match?(/\A0x[0-9a-f]{40}\z/) + h + end + + # Integer coercion helper for import computations + def safe_uint(val) + case val + when Integer then val + when String then (val =~ /\A\d+\z/ ? val.to_i : 0) + else 0 + end + end + + def safe_uint_string(val) + n = case val + when Integer then val + when String then (val =~ /\A\d+\z/ ? val.to_i : 0) + else 0 + end + n = 0 if n.negative? + n.to_s + end + + def safe_string(val) + val.nil? ? '' : val.to_s + end + + def ordered_json(pairs) + JSON.generate(OrderedHash[pairs.to_a.flatten]) + end + + class OrderedHash < ::Hash + def self.[](*args) + h = new + args.each_slice(2) { |k, v| h[k] = v } + h + end + end + + def valid_data_uri?(uri) + DataUri.valid?(uri) + end + + def encode_operation(operation, data, schema, content_hash: nil) + # Validate and transform fields according to schema + validated_data = validate_fields(data, schema[:validators]) + + # Build values array based on operation + values = case operation + when 'create_collection' + build_create_collection_values(validated_data) + when 'create_collection_and_add_self', 'create_and_add_self' + build_create_and_add_self_values(validated_data, content_hash: content_hash) + when 'add_self_to_collection' + build_add_self_to_collection_values(validated_data, content_hash: content_hash) + when 'transfer_ownership' + build_transfer_ownership_values(validated_data) + when 'renounce_ownership' + build_renounce_ownership_values(validated_data) + when 'remove_items' + build_remove_items_values(validated_data) + when 'edit_collection' + build_edit_collection_values(validated_data) + when 'edit_collection_item' + build_edit_collection_item_values(validated_data) + when 'lock_collection' + build_lock_collection_values(validated_data) + else + raise ValidationError, "Unknown operation: #{operation}" + end + + # Use ABI type from schema for encoding + begin + Eth::Abi.encode([schema[:abi_type]], [values]) + rescue Encoding::CompatibilityError => e + Rails.logger.error "=== Collection ABI Encoding Error ===" + Rails.logger.error "Error: #{e.message}" + Rails.logger.error "operation: #{operation}" + Rails.logger.error "schema abi_type: #{schema[:abi_type]}" + Rails.logger.error "values inspection:" + log_encoding_details(values) + raise + end + end + + def log_encoding_details(obj, indent = 0) + prefix = " " * indent + case obj + when Array + Rails.logger.error "#{prefix}Array[#{obj.size}]:" + obj.each_with_index do |item, idx| + Rails.logger.error "#{prefix} [#{idx}]:" + log_encoding_details(item, indent + 2) + end + when String + Rails.logger.error "#{prefix}String: #{obj.inspect[0..100]}, encoding: #{obj.encoding.name}, bytesize: #{obj.bytesize}" + else + Rails.logger.error "#{prefix}#{obj.class}: #{obj.inspect[0..100]}" + end + end + + def validate_fields(data, validators) + validated = {} + + data.each do |key, value| + validator = validators[key] + + # All fields must have explicit validators - no silent coercion + unless validator + raise ValidationError, "No validator defined for field: #{key}" + end + + validated[key] = send("validate_#{validator}", value, key) + end + + validated + end + + # Validators + + def validate_string(value, field_name) + unless value.is_a?(String) + raise ValidationError, "Field #{field_name} must be a string, got #{value.class.name}" + end + value.b + end + + def validate_uint256(value, field_name) + unless value.is_a?(String) && value.match?(/\A(0|[1-9]\d*)\z/) + raise ValidationError, "Invalid uint256 for #{field_name}: #{value}" + end + + num = value.to_i + if num > UINT256_MAX + raise ValidationError, "Value exceeds uint256 maximum for #{field_name}: #{value}" + end + + num + end + + def validate_bytes32(value, field_name) + unless value.is_a?(String) && value.match?(/\A0x[0-9a-f]{64}\z/) + raise ValidationError, "Invalid bytes32 for #{field_name}: #{value}" + end + # Return as packed bytes for ABI encoding + [value[2..]].pack('H*') + end + + def validate_address(value, field_name) + unless value.is_a?(String) && value.match?(/\A0x[0-9a-f]{40}\z/i) + raise ValidationError, "Invalid address for #{field_name}: #{value}" + end + + if value == '0x0000000000000000000000000000000000000000' + raise ValidationError, "Address cannot be zero for #{field_name}" + end + + value.downcase + end + + def validate_optional_address(value, field_name) + unless value.is_a?(String) && value.match?(/\A0x[0-9a-f]{40}\z/i) + raise ValidationError, "Invalid address for #{field_name}: #{value}" + end + # Allow address(0) for renouncement + value.downcase + end + + def validate_bytes32_array(value, field_name) + unless value.is_a?(Array) + raise ValidationError, "Expected array for #{field_name}" + end + + value.map do |item| + unless item.is_a?(String) && item.match?(/\A0x[0-9a-f]{64}\z/) + raise ValidationError, "Invalid bytes32 in array: #{item}" + end + [item[2..]].pack('H*') + end + end + + def validate_items_array(value, field_name) + unless value.is_a?(Array) + raise ValidationError, "Expected array for #{field_name}" + end + + value.map do |item| + validate_item(item) + end + end + + def validate_single_item(value, field_name) + unless value.is_a?(Hash) + raise ValidationError, "Expected object for #{field_name}" + end + validate_item(value) + end + + def validate_collection_metadata(value, field_name) + unless value.is_a?(Hash) + raise ValidationError, "Expected object for #{field_name}" + end + # Expected keys for metadata (now includes initial_owner) + expected_keys = %w[name symbol max_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link merkle_root initial_owner] + unless value.keys == expected_keys + raise ValidationError, "Invalid metadata keys or order" + end + + { + name: validate_string(value['name'], 'name'), + symbol: validate_string(value['symbol'], 'symbol'), + maxSupply: validate_uint256(value['max_supply'], 'max_supply'), + description: validate_string(value['description'], 'description'), + logoImageUri: validate_string(value['logo_image_uri'], 'logo_image_uri'), + bannerImageUri: validate_string(value['banner_image_uri'], 'banner_image_uri'), + backgroundColor: validate_string(value['background_color'], 'background_color'), + websiteLink: validate_string(value['website_link'], 'website_link'), + twitterLink: validate_string(value['twitter_link'], 'twitter_link'), + discordLink: validate_string(value['discord_link'], 'discord_link'), + merkleRoot: validate_bytes32(value['merkle_root'], 'merkle_root'), + initialOwner: validate_optional_address(value['initial_owner'], 'initial_owner') + } + end + + def validate_item(item) + unless item.is_a?(Hash) + raise ValidationError, "Item must be an object" + end + + unless item.keys == ITEM_KEYS + expected = "[#{ITEM_KEYS.join(', ')}]" + raise ValidationError, "Invalid item keys or order. Expected: #{expected}, got: [#{item.keys.join(', ')}]" + end + + { + contentHash: validate_bytes32(item['content_hash'], 'content_hash'), + itemIndex: validate_uint256(item['item_index'], 'item_index'), + name: validate_string(item['name'], 'name'), + backgroundColor: validate_string(item['background_color'], 'background_color'), + description: validate_string(item['description'], 'description'), + attributes: validate_attributes_array(item['attributes'], 'attributes'), + merkleProof: validate_bytes32_array(item['merkle_proof'], 'merkle_proof') + } + end + + def validate_attributes_array(value, field_name) + unless value.is_a?(Array) + raise ValidationError, "Expected array for #{field_name}" + end + + value.map do |attr| + validate_attribute(attr) + end + end + + def validate_attribute(attr) + unless attr.is_a?(Hash) + raise ValidationError, "Attribute must be an object" + end + + # Check exact key order + unless attr.keys == ATTRIBUTE_KEYS + raise ValidationError, "Invalid attribute keys or order. Expected: #{ATTRIBUTE_KEYS.join(',')}, got: #{attr.keys.join(',')}" + end + + # Both must be strings - no coercion + [ + validate_string(attr['trait_type'], 'trait_type'), + validate_string(attr['value'], 'value') + ] + end + + # Encoders + + def build_create_collection_values(data) + [ + data['name'], + data['symbol'], + data['max_supply'], + data['description'], + data['logo_image_uri'], + data['banner_image_uri'], + data['background_color'], + data['website_link'], + data['twitter_link'], + data['discord_link'], + data['merkle_root'], + data['initial_owner'] + ] + end + + def build_create_and_add_self_values(data, content_hash:) + meta = data['metadata'] + item = data['item'] + + # Metadata tuple with merkleRoot and initialOwner + merkle_root = meta[:merkleRoot] || ["".ljust(64, '0')].pack('H*') + metadata_tuple = [ + meta[:name], + meta[:symbol], + meta[:maxSupply], + meta[:description], + meta[:logoImageUri], + meta[:bannerImageUri], + meta[:backgroundColor], + meta[:websiteLink], + meta[:twitterLink], + meta[:discordLink], + merkle_root, + meta[:initialOwner] + ] + + # Item tuple - contentHash comes first (keccak256 of ethscription content) + # Always use the computed content_hash if provided, otherwise use validated item contentHash + content_hash_bytes = if content_hash + [content_hash[2..]].pack('H*') + elsif item[:contentHash] + item[:contentHash] # Already packed bytes from validate_item + else + raise ValidationError, "Content hash missing" + end + item_tuple = [ + content_hash_bytes, # Already packed bytes, don't call to_bytes32_hex + item[:itemIndex], + item[:name], + item[:backgroundColor], + item[:description], + item[:attributes], + item[:merkleProof] + ] + + [metadata_tuple, item_tuple] + end + + def build_add_self_to_collection_values(data, content_hash:) + item = data['item'] + + # Item tuple - contentHash comes first (keccak256 of ethscription content) + # Always use the computed content_hash if provided, otherwise use validated item contentHash + content_hash_bytes = if content_hash + [content_hash[2..]].pack('H*') + elsif item[:contentHash] + item[:contentHash] # Already packed bytes from validate_item + else + raise ValidationError, "Content hash missing" + end + item_tuple = [ + content_hash_bytes, # Already packed bytes, don't call to_bytes32_hex + item[:itemIndex], + item[:name], + item[:backgroundColor], + item[:description], + item[:attributes], + item[:merkleProof] + ] + [data['collection_id'], item_tuple] + end + + def build_transfer_ownership_values(data) + [data['collection_id'], data['new_owner']] + end + + def build_renounce_ownership_values(data) + data['collection_id'] + end + + def build_remove_items_values(data) + [data['collection_id'], data['ethscription_ids']] + end + + def build_edit_collection_values(data) + values = [ + data['collection_id'], + data['description'], + data['logo_image_uri'], + data['banner_image_uri'], + data['background_color'], + data['website_link'], + data['twitter_link'], + data['discord_link'] + ] + + values << data['merkle_root'] + values + end + + def build_edit_collection_item_values(data) + [ + data['collection_id'], + data['item_index'], + data['name'], + data['background_color'], + data['description'], + data['attributes'] + ] + end + + def build_lock_collection_values(data) + # Single bytes32, not a tuple - but we need to return just the value + data['collection_id'] + end +end diff --git a/app/models/eth_block.rb b/app/models/eth_block.rb index 3028b3b..e965ffa 100644 --- a/app/models/eth_block.rb +++ b/app/models/eth_block.rb @@ -1,370 +1,34 @@ -class EthBlock < ApplicationRecord - include FacetRailsCommon::OrderQuery - class BlockNotReadyToImportError < StandardError; end - - initialize_order_query({ - newest_first: [[:block_number, :desc, unique: true]], - oldest_first: [[:block_number, :asc, unique: true]] - }, page_key_attributes: [:block_number]) - - %i[ - eth_transactions - ethscriptions - ethscription_transfers - ethscription_ownership_versions - token_states - ].each do |association| - has_many association, - foreign_key: :block_number, - primary_key: :block_number, - inverse_of: :eth_block - end - - before_validation :generate_attestation_hash, if: -> { imported_at.present? } - - def self.ethereum_client - @_ethereum_client ||= begin - client_class = ENV.fetch('ETHEREUM_CLIENT_CLASS', 'AlchemyClient').constantize - - client_class.new( - api_key: ENV['ETHEREUM_CLIENT_API_KEY'], - base_url: ENV.fetch('ETHEREUM_CLIENT_BASE_URL') - ) - end - end - - def self.beacon_client - @_beacon_client ||= begin - EthereumBeaconNodeClient.new( - api_key: ENV['ETHEREUM_BEACON_NODE_API_KEY'], - base_url: ENV.fetch('ETHEREUM_BEACON_NODE_API_BASE_URL') - ) - end - end - - def self.genesis_blocks - blocks = if ENV.fetch('ETHEREUM_NETWORK') == "eth-mainnet" - [1608625, 3369985, 3981254, 5873780, 8205613, 9046950, - 9046974, 9239285, 9430552, 10548855, 10711341, 15437996, 17478950] - else - [[ENV.fetch('TESTNET_START_BLOCK').to_i, 4370001].max] - end - - @_genesis_blocks ||= blocks.sort.freeze - end - - def self.most_recently_imported_block_number - EthBlock.where.not(imported_at: nil).order(block_number: :desc).limit(1).pluck(:block_number).first - end - - def self.most_recently_imported_blockhash - EthBlock.where.not(imported_at: nil).order(block_number: :desc).limit(1).pluck(:blockhash).first - end - - def self.blocks_behind - (cached_global_block_number - next_block_to_import) + 1 - end - - def self.import_batch_size - [blocks_behind, ENV.fetch('BLOCK_IMPORT_BATCH_SIZE', 2).to_i].min - end - - def self.import_blocks_until_done - loop do - begin - block_numbers = EthBlock.next_blocks_to_import(import_batch_size) - - if block_numbers.blank? - raise BlockNotReadyToImportError.new("Block not ready") - end - - EthBlock.import_blocks(block_numbers) - rescue BlockNotReadyToImportError => e - puts "#{e.message}. Stopping import." - break - end - end - end - - def self.import_next_block - next_block_to_import.tap do |block| - import_blocks([block]) - end - end - - def self.import_blocks(block_numbers) - logger.info "Block Importer: importing blocks #{block_numbers.join(', ')}" - start = Time.current - _blocks_behind = blocks_behind - - block_by_number_promises = block_numbers.map do |block_number| - Concurrent::Promise.execute do - [block_number, ethereum_client.get_block(block_number)] - end - end - - receipts_promises = block_numbers.map do |block_number| - Concurrent::Promise.execute do - [ - block_number, - ethereum_client.get_transaction_receipts( - block_number, - blocks_behind: _blocks_behind - ) - ] - end - end - - block_by_number_responses = block_by_number_promises.map(&:value!).sort_by(&:first) - receipts_responses = receipts_promises.map(&:value!).sort_by(&:first) - - res = [] - - block_by_number_responses.zip(receipts_responses).each do |(block_number1, block_by_number_response), (block_number2, receipts_response)| - raise "Mismatched block numbers: #{block_number1} and #{block_number2}" unless block_number1 == block_number2 - res << import_block(block_number1, block_by_number_response, receipts_response) - end - - blocks_per_second = (block_numbers.length / (Time.current - start)).round(2) - puts "Imported #{res.map(&:ethscriptions_imported).sum} ethscriptions" - puts "Imported #{block_numbers.length} blocks. #{blocks_per_second} blocks / s" - - block_numbers - end - - def self.import_block(block_number, block_by_number_response, receipts_response) - ActiveRecord::Base.transaction do - validate_ready_to_import!(block_by_number_response, receipts_response) - - result = block_by_number_response['result'] - - parent_block = EthBlock.find_by(block_number: block_number - 1) - - if (block_number > genesis_blocks.max) && parent_block.blockhash != result['parentHash'] - Airbrake.notify(" - Reorg detected: #{block_number}, - #{parent_block.blockhash}, - #{result['parentHash']}, - Deleting block(s): #{EthBlock.where("block_number >= ?", parent_block.block_number).pluck(:block_number).join(', ')} - ") - - EthBlock.where("block_number >= ?", parent_block.block_number).delete_all - - return OpenStruct.new(ethscriptions_imported: 0) - end - - block_record = create!( - block_number: block_number, - blockhash: result['hash'], - parent_blockhash: result['parentHash'], - parent_beacon_block_root: result['parentBeaconBlockRoot'], - timestamp: result['timestamp'].to_i(16), - is_genesis_block: genesis_blocks.include?(block_number) - ) - - receipts = receipts_response['result']['receipts'] - - tx_record_instances = result['transactions'].map do |tx| - current_receipt = receipts.detect { |receipt| receipt['transactionHash'] == tx['hash'] } - - gas_price = current_receipt['effectiveGasPrice'].to_i(16).to_d - gas_used = current_receipt['gasUsed'].to_i(16).to_d - transaction_fee = gas_price * gas_used - - EthTransaction.new( - block_number: block_record.block_number, - block_timestamp: block_record.timestamp, - block_blockhash: block_record.blockhash, - transaction_hash: tx['hash'], - from_address: tx['from'], - to_address: tx['to'], - created_contract_address: current_receipt['contractAddress'], - transaction_index: tx['transactionIndex'].to_i(16), - input: tx['input'], - status: current_receipt['status']&.to_i(16), - logs: current_receipt['logs'], - gas_price: gas_price, - gas_used: gas_used, - transaction_fee: transaction_fee, - value: tx['value'].to_i(16).to_d, - blob_versioned_hashes: tx['blobVersionedHashes'].presence || [] - ) - end - - possibly_relevant = tx_record_instances.select(&:possibly_relevant?) - - if possibly_relevant.present? - EthTransaction.import!(possibly_relevant) - - eth_transactions = EthTransaction.where(block_number: block_number).order(transaction_index: :asc) - - eth_transactions.each(&:process!) - - ethscriptions_imported = eth_transactions.map(&:ethscription).compact.size - end - - EthTransaction.prune_transactions(block_number) - - Token.process_block(block_record) - - block_record.create_attachments_for_previous_block - - block_record.update!(imported_at: Time.current) - - puts "Block Importer: imported block #{block_number}" - - OpenStruct.new(ethscriptions_imported: ethscriptions_imported.to_i) - end - rescue ActiveRecord::RecordNotUnique => e - if e.message.include?("eth_blocks") && e.message.include?("block_number") - logger.info "Block Importer: Block #{block_number} already exists" - raise ActiveRecord::Rollback - else - raise - end - end - - def ensure_blob_sidecars(beacon_block_root = nil) - if blob_sidecars.present? && blob_sidecars.first['blob'].present? - return blob_sidecars - end - - beacon_block_root ||= EthBlock.where(block_number: block_number + 1).pick(:parent_beacon_block_root) - - raise "Need beacon root" unless beacon_block_root.present? - - self.blob_sidecars = EthBlock.beacon_client.get_blob_sidecars(beacon_block_root) - end - - def create_attachments_for_previous_block - return unless EthTransaction.esip8_enabled?(block_number - 1) - - scope = EthTransaction.with_blobs.joins(:ethscription).where(block_number: block_number - 1) - - return unless scope.exists? - - prev_block = EthBlock.find_by(block_number: block_number - 1) - - prev_block.ensure_blob_sidecars(parent_beacon_block_root) - - scope.find_each do |tx| - tx.block_blob_sidecars = prev_block.blob_sidecars - tx.create_ethscription_attachment_if_needed! - end - - prev_block.blob_sidecars = prev_block.blob_sidecars.map do |sidecar| - sidecar.except('blob') - end - - # TODO: Update state attestation hash - prev_block.save! - end - - def self.uncached_global_block_number - ethereum_client.get_block_number.tap do |block_number| - Rails.cache.write('global_block_number', block_number, expires_in: 1.second) - end - end - - def self.cached_global_block_number - Rails.cache.read('global_block_number') || uncached_global_block_number - end - - def self.validate_ready_to_import!(block_by_number_response, receipts_response) - is_ready = block_by_number_response.present? && - block_by_number_response.dig('result', 'hash').present? && - receipts_response.present? && - receipts_response.dig('error', 'code') != -32600 && - receipts_response.dig('error', 'message') != "Block being processed - please try again later" - - unless is_ready - raise BlockNotReadyToImportError.new("Block not ready") - end - end - - def self.next_block_to_import - next_blocks_to_import(1).first - end - - def self.next_blocks_to_import(n) - max_db_block = EthBlock.maximum(:block_number) - - return genesis_blocks.sort.first(n) unless max_db_block - - if max_db_block < genesis_blocks.max - imported_genesis_blocks = EthBlock.where.not(imported_at: nil).where(block_number: genesis_blocks).pluck(:block_number).to_set - remaining_genesis_blocks = (genesis_blocks.to_set - imported_genesis_blocks).sort - return remaining_genesis_blocks.first(n) - end - - (max_db_block + 1..max_db_block + n).to_a - end - - def generate_attestation_hash - hash = Digest::SHA256.new - - self.parent_state_hash = EthBlock.where(block_number: block_number - 1). - limit(1).pluck(:state_hash).first - - hash << parent_state_hash.to_s - - hash << hashable_attributes.map do |attr| - send(attr) - end.to_json - - associations_to_hash.each do |association| - hashable_attributes = quoted_hashable_attributes(association.klass) - records = association_scope(association).pluck(*hashable_attributes) - - hash << records.to_json - end - - self.state_hash = "0x" + hash.hexdigest - end - - delegate :quoted_hashable_attributes, :associations_to_hash, to: :class - - def hashable_attributes - self.class.hashable_attributes(self.class) - end - - def check_attestation_hash - current_hash = state_hash - - current_hash == generate_attestation_hash && - parent_state_hash == EthBlock.find_by(block_number: block_number - 1)&.generate_attestation_hash - ensure - self.state_hash = current_hash - end - - def association_scope(association) - association.klass.oldest_first.where(block_number: block_number) - end - - def self.associations_to_hash - reflect_on_all_associations(:has_many).sort_by(&:name) - end - - def self.all_hashable_attrs - classes = [self, associations_to_hash.map(&:klass)].flatten - - classes.map(&:column_names).flatten.uniq.sort - [ - 'state_hash', - 'parent_state_hash', - 'id', - 'created_at', - 'updated_at', - 'imported_at' - ] - end - - def self.hashable_attributes(klass) - (all_hashable_attrs & klass.column_names).sort - end - - def self.quoted_hashable_attributes(klass) - hashable_attributes(klass).map do |attr| - Arel.sql("encode(digest(#{klass.connection.quote_column_name(attr)}::text, 'sha256'), 'hex')") - end +class EthBlock < T::Struct + include AttrAssignable + + # Primary schema fields + prop :number, Integer + prop :block_hash, Hash32 + prop :base_fee_per_gas, Integer + prop :parent_beacon_block_root, T.nilable(Hash32) + prop :mix_hash, Hash32 + prop :parent_hash, Hash32 + prop :timestamp, Integer + + # Association-like field + prop :ethscriptions_block, T.nilable(EthscriptionsBlock) + + sig { params(block_result: T.untyped).returns(EthBlock) } + def self.from_rpc_result(block_result) + attrs = { + number: block_result['number'].to_i(16), + block_hash: Hash32.from_hex(block_result['hash']), + base_fee_per_gas: block_result['baseFeePerGas'].to_i(16), + mix_hash: Hash32.from_hex(block_result['mixHash']), + parent_hash: Hash32.from_hex(block_result['parentHash']), + timestamp: block_result['timestamp'].to_i(16) + } + + # parentBeaconBlockRoot only exists after Cancun + if block_result['parentBeaconBlockRoot'] + attrs[:parent_beacon_block_root] = Hash32.from_hex(block_result['parentBeaconBlockRoot']) + end + + EthBlock.new(attrs) end end diff --git a/app/models/eth_transaction.rb b/app/models/eth_transaction.rb index 4b848c1..95c26c3 100644 --- a/app/models/eth_transaction.rb +++ b/app/models/eth_transaction.rb @@ -1,348 +1,263 @@ -class EthTransaction < ApplicationRecord - class HowDidWeGetHereError < StandardError; end - - belongs_to :eth_block, foreign_key: :block_number, primary_key: :block_number, optional: true, - inverse_of: :eth_transactions - has_one :ethscription, foreign_key: :transaction_hash, primary_key: :transaction_hash, - inverse_of: :eth_transaction - has_many :ethscription_transfers, foreign_key: :transaction_hash, - primary_key: :transaction_hash, inverse_of: :eth_transaction - has_many :ethscription_ownership_versions, foreign_key: :transaction_hash, - primary_key: :transaction_hash, inverse_of: :eth_transaction - - attr_accessor :transfer_index, :block_blob_sidecars - def block_blob_sidecars - @block_blob_sidecars ||= eth_block.ensure_blob_sidecars - end - - scope :newest_first, -> { order(block_number: :desc, transaction_index: :desc) } - scope :oldest_first, -> { order(block_number: :asc, transaction_index: :asc) } - - scope :with_blobs, -> { where("blob_versioned_hashes != '[]'::jsonb") } - scope :without_blobs, -> { where("blob_versioned_hashes = '[]'::jsonb") } - - def has_blob? - blob_versioned_hashes.present? - end +class EthTransaction < T::Struct + include SysConfig + # ESIP event signatures for detecting Ethscription events def self.event_signature(event_name) - "0x" + Digest::Keccak256.hexdigest(event_name) + '0x' + Eth::Util.keccak256(event_name).unpack1('H*') end CreateEthscriptionEventSig = event_signature("ethscriptions_protocol_CreateEthscription(address,string)") - Esip2EventSig = event_signature("ethscriptions_protocol_TransferEthscriptionForPreviousOwner(address,address,bytes32)") Esip1EventSig = event_signature("ethscriptions_protocol_TransferEthscription(address,bytes32)") + Esip2EventSig = event_signature("ethscriptions_protocol_TransferEthscriptionForPreviousOwner(address,address,bytes32)") - def possibly_relevant? - status != 0 && - (possibly_creates_ethscription? || possibly_transfers_ethscription?) + const :block_hash, Hash32 + const :block_number, Integer + const :block_timestamp, Integer + const :tx_hash, Hash32 + const :transaction_index, Integer + const :input, ByteString + const :chain_id, T.nilable(Integer) + const :from_address, Address20 + const :to_address, T.nilable(Address20) + const :status, Integer + const :logs, T::Array[T.untyped], default: [] + const :eth_block, T.nilable(EthBlock) + const :ethscription_transactions, T::Array[EthscriptionTransaction], default: [] + + # Alias for consistency with ethscription_detector + sig { returns(Hash32) } + def transaction_hash + tx_hash end - - def possibly_creates_ethscription? - (DataUri.valid?(utf8_input) && to_address.present?) || - ethscription_creation_events.present? + + sig { params(block_result: T.untyped, receipt_result: T.untyped).returns(T::Array[EthTransaction]) } + def self.from_rpc_result(block_result, receipt_result) + block_hash = block_result['hash'] + block_number = block_result['number'].to_i(16) + + indexed_receipts = receipt_result.index_by{|el| el['transactionHash']} + + block_result['transactions'].map do |tx| + current_receipt = indexed_receipts[tx['hash']] + + EthTransaction.new( + block_hash: Hash32.from_hex(block_hash), + block_number: block_number, + block_timestamp: block_result['timestamp'].to_i(16), + tx_hash: Hash32.from_hex(tx['hash']), + transaction_index: tx['transactionIndex'].to_i(16), + input: ByteString.from_hex(tx['input']), + chain_id: tx['chainId']&.to_i(16), + from_address: Address20.from_hex(tx['from']), + to_address: tx['to'] ? Address20.from_hex(tx['to']) : nil, + status: current_receipt['status'].to_i(16), + logs: current_receipt['logs'], + ) + end end - def possibly_transfers_ethscription? - transfers_ethscription_via_input? || - ethscription_transfer_events.present? + sig { params(block_results: T.untyped, receipt_results: T.untyped, ethscriptions_block: EthscriptionsBlock).returns(T::Array[EthscriptionTransaction]) } + def self.ethscription_txs_from_rpc_results(block_results, receipt_results, ethscriptions_block) + eth_txs = from_rpc_result(block_results, receipt_results) + + # Collect all deposits from all transactions + all_deposits = [] + eth_txs.sort_by(&:transaction_index).each do |eth_tx| + next unless eth_tx.is_success? + + # Build deposits directly from this EthTransaction instance + deposits = eth_tx.build_ethscription_deposits(ethscriptions_block) + all_deposits.concat(deposits) + end + + all_deposits end - def utf8_input - HexDataProcessor.hex_to_utf8( - input, - support_gzip: EthTransaction.esip7_enabled?(block_number) - ) + sig { returns(T::Boolean) } + def is_success? + status == 1 end - def ethscription_attrs - { - transaction_hash: transaction_hash, - block_number: block_number, - block_timestamp: block_timestamp, - block_blockhash: block_blockhash, - transaction_index: transaction_index, - gas_price: gas_price, - gas_used: gas_used, - transaction_fee: transaction_fee, - value: value, - } + sig { returns(Hash32) } + def ethscription_source_hash + tx_hash end - def process! - self.transfer_index = 0 + # Build deposit transactions (EthscriptionTransaction objects) from this L1 transaction + sig { params(ethscriptions_block: EthscriptionsBlock).returns(T::Array[EthscriptionTransaction]) } + def build_ethscription_deposits(ethscriptions_block) + @transactions = [] - create_ethscription_from_input! - create_ethscription_from_events! - create_ethscription_transfers_from_input! - create_ethscription_transfers_from_events! - end - - def blob_from_version_hash(version_hash) - block_blob_sidecars.find do |blob| - kzg_commitment = blob["kzg_commitment"].sub(/\A0x/, '') - binary_kzg_commitment = [kzg_commitment].pack("H*") - sha256_hash = Digest::SHA256.hexdigest(binary_kzg_commitment) - modified_hash = "0x01" + sha256_hash[2..-1] - - version_hash == modified_hash - end + # 1. Process calldata (try as creation, then as transfer) + process_calldata + + # 2. Process events (creations and transfers) + process_events + + @transactions.compact end - def blobs - blob_versioned_hashes.map do |version_hash| - blob_from_version_hash(version_hash) - end + private + + def process_calldata + return unless to_address.present? + + try_calldata_creation + try_calldata_transfer end - def create_ethscription_attachment_if_needed! - return unless EthTransaction.esip8_enabled?(block_number) - - if ethscription.blank? || !has_blob? - raise HowDidWeGetHereError, "Invalid state to create attachment: #{transaction_hash}" + def try_calldata_creation + transaction = EthscriptionTransaction.build_create_ethscription( + eth_transaction: self, + creator: normalize_address(from_address), + initial_owner: normalize_address(to_address), + content_uri: utf8_input, + source_type: :input, + source_index: transaction_index + ) + + @transactions << transaction + end + + def try_calldata_transfer + valid_length = if SysConfig.esip5_enabled?(block_number) + input.bytesize > 0 && input.bytesize % 32 == 0 + else + input.bytesize == 32 end - return if ethscription.event_log_index.present? - - attachment = EthscriptionAttachment.from_eth_transaction(self) + return unless valid_length - attachment.create_unless_exists! - - ethscription.update!( - attachment_sha: attachment.sha, - attachment_content_type: attachment.content_type, - ) - rescue EthscriptionAttachment::InvalidInputError => e - puts "Invalid attachment: #{e.message}, transaction_hash: #{transaction_hash}, block_number: #{block_number}" - end + input_hex = input.to_hex.delete_prefix('0x') + + ids = input_hex.scan(/.{64}/).map { |hash_hex| normalize_hash("0x#{hash_hex}") } - def create_ethscription_from_input! - potentially_valid = Ethscription.new( - { - creator: from_address, - previous_owner: from_address, - current_owner: to_address, - initial_owner: to_address, - content_uri: utf8_input, - }.merge(ethscription_attrs) + # Create transfer transaction + transaction = EthscriptionTransaction.build_transfer( + eth_transaction: self, + from_address: normalize_address(from_address), + to_address: normalize_address(to_address), + ethscription_ids: ids, + source_type: :input, + source_index: transaction_index ) - - save_if_valid_and_no_ethscription_created!(potentially_valid) + + @transactions << transaction end - - def create_ethscription_from_events! - ethscription_creation_events.each do |creation_event| - next if creation_event['topics'].length != 2 - + + def process_events + ordered_events.each do |log| begin - initial_owner = Eth::Abi.decode(['address'], creation_event['topics'].second).first - - content_uri_data = Eth::Abi.decode(['string'], creation_event['data']).first - content_uri = HexDataProcessor.clean_utf8(content_uri_data) - rescue Eth::Abi::DecodingError + case log['topics']&.first + when CreateEthscriptionEventSig + process_create_event(log) + when Esip1EventSig + process_esip1_transfer_event(log) + when Esip2EventSig + process_esip2_transfer_event(log) + end + rescue Eth::Abi::DecodingError, RangeError => e + Rails.logger.error "Failed to decode event: #{e.message}" next end - - potentially_valid = Ethscription.new( - { - creator: creation_event['address'], - previous_owner: creation_event['address'], - current_owner: initial_owner, - initial_owner: initial_owner, - content_uri: content_uri, - event_log_index: creation_event['logIndex'].to_i(16), - }.merge(ethscription_attrs) - ) - - save_if_valid_and_no_ethscription_created!(potentially_valid) - end - end - - def save_if_valid_and_no_ethscription_created!(potentially_valid) - return if ethscription.present? - - if potentially_valid.valid_ethscription? - potentially_valid.eth_transaction = self - potentially_valid.save! - end - end - - def ethscription_creation_events - return [] unless EthTransaction.esip3_enabled?(block_number) - - ordered_events.select do |log| - CreateEthscriptionEventSig == log['topics'].first end end - - def transfer_attrs - { + + def process_create_event(log) + return unless SysConfig.esip3_enabled?(block_number) + return unless log['topics'].length == 2 + + # Decode event data + initial_owner = Eth::Abi.decode(['address'], log['topics'].second).first + content_uri_data = Eth::Abi.decode(['string'], log['data']).first + content_uri = HexDataProcessor.clean_utf8(content_uri_data) + + transaction = EthscriptionTransaction.build_create_ethscription( eth_transaction: self, - block_number: block_number, - block_timestamp: block_timestamp, - block_blockhash: block_blockhash, - transaction_index: transaction_index, - } - end - - def create_ethscription_transfers_from_input! - return unless transfers_ethscription_via_input? - - concatenated_hashes = input_no_prefix.scan(/.{64}/).map { |hash| "0x#{hash}" } - matching_ethscriptions = Ethscription.where(transaction_hash: concatenated_hashes) + creator: normalize_address(log['address']), + initial_owner: normalize_address(initial_owner), + content_uri: content_uri, + source_type: :event, + source_index: log['logIndex'].to_i(16) + ) - sorted_ethscriptions = concatenated_hashes.map do |hash| - matching_ethscriptions.detect { |e| e.transaction_hash == hash } - end.compact - - sorted_ethscriptions.each do |ethscription| - potentially_valid = EthscriptionTransfer.new({ - ethscription: ethscription, - from_address: from_address, - to_address: to_address, - transfer_index: transfer_index, - }.merge(transfer_attrs)) - - potentially_valid.create_if_valid! - end + @transactions << transaction end - - def create_ethscription_transfers_from_events! - ethscription_transfer_events.each do |log| - topics = log['topics'] - event_type = topics.first - - if event_type == Esip1EventSig - next if topics.length != 3 - - begin - event_to = Eth::Abi.decode(['address'], topics.second).first - tx_hash = Eth::Util.bin_to_prefixed_hex( - Eth::Abi.decode(['bytes32'], topics.third).first - ) - rescue Eth::Abi::DecodingError - next - end - - target_ethscription = Ethscription.find_by(transaction_hash: tx_hash) - - if target_ethscription.present? - potentially_valid = EthscriptionTransfer.new({ - ethscription: target_ethscription, - from_address: log['address'], - to_address: event_to, - event_log_index: log['logIndex'].to_i(16), - transfer_index: transfer_index, - }.merge(transfer_attrs)) - - potentially_valid.create_if_valid! - end - elsif event_type == Esip2EventSig - next if topics.length != 4 - - begin - event_previous_owner = Eth::Abi.decode(['address'], topics.second).first - event_to = Eth::Abi.decode(['address'], topics.third).first - tx_hash = Eth::Util.bin_to_prefixed_hex( - Eth::Abi.decode(['bytes32'], topics.fourth).first - ) - rescue Eth::Abi::DecodingError - next - end - - target_ethscription = Ethscription.find_by(transaction_hash: tx_hash) - - if target_ethscription.present? - potentially_valid = EthscriptionTransfer.new({ - ethscription: target_ethscription, - from_address: log['address'], - to_address: event_to, - event_log_index: log['logIndex'].to_i(16), - transfer_index: transfer_index, - enforced_previous_owner: event_previous_owner, - }.merge(transfer_attrs)) - - potentially_valid.create_if_valid! - end - end - end - end - - def transfers_ethscription_via_input? - valid_length = if EthTransaction.esip5_enabled?(block_number) - input_no_prefix.length > 0 && input_no_prefix.length % 64 == 0 - else - input_no_prefix.length == 64 - end - - to_address.present? && valid_length - end - - def transfers_ethscription_via_event? - ethscription_transfer_events.present? + + def process_esip1_transfer_event(log) + return unless SysConfig.esip1_enabled?(block_number) + return unless log['topics'].length == 3 + + # Decode event data + event_to = Eth::Abi.decode(['address'], log['topics'].second).first + tx_hash_hex = Eth::Util.bin_to_prefixed_hex( + Eth::Abi.decode(['bytes32'], log['topics'].third).first + ) + + ethscription_id = normalize_hash(tx_hash_hex) + + transaction = EthscriptionTransaction.build_transfer( + eth_transaction: self, + from_address: normalize_address(log['address']), + to_address: normalize_address(event_to), + ethscription_ids: ethscription_id, # Single ID, will be wrapped in array + source_type: :event, + source_index: log['logIndex'].to_i(16) + ) + + @transactions << transaction end - - def ethscription_transfer_events - ordered_events.select do |log| - EthTransaction.contract_transfer_event_signatures(block_number).include?(log['topics'].first) - end + + def process_esip2_transfer_event(log) + return unless SysConfig.esip2_enabled?(block_number) + return unless log['topics'].length == 4 + + event_previous_owner = Eth::Abi.decode(['address'], log['topics'].second).first + event_to = Eth::Abi.decode(['address'], log['topics'].third).first + tx_hash_hex = Eth::Util.bin_to_prefixed_hex( + Eth::Abi.decode(['bytes32'], log['topics'].fourth).first + ) + + ethscription_id = normalize_hash(tx_hash_hex) + + transaction = EthscriptionTransaction.build_transfer( + eth_transaction: self, + from_address: normalize_address(log['address']), + to_address: normalize_address(event_to), + ethscription_ids: ethscription_id, # Single ID, will be wrapped in array + enforced_previous_owner: normalize_address(event_previous_owner), + source_type: :event, + source_index: log['logIndex'].to_i(16) + ) + + @transactions << transaction end - + def ordered_events - logs.select do |log| - !log['removed'] - end.sort_by do |log| - log['logIndex'].to_i(16) - end - end - - def input_no_prefix - input.gsub(/\A0x/, '') - end - - def self.esip3_enabled?(block_number) - on_testnet? || block_number >= 18130000 - end - - def self.esip5_enabled?(block_number) - on_testnet? || block_number >= 18330000 - end - - def self.esip2_enabled?(block_number) - on_testnet? || block_number >= 17764910 - end - - def self.esip1_enabled?(block_number) - on_testnet? || block_number >= 17672762 - end - - def self.esip7_enabled?(block_number) - on_testnet? || block_number >= 19376500 - end - - def self.esip8_enabled?(block_number) - on_testnet? || block_number >= 19526000 + return [] unless logs + + logs.reject { |log| log['removed'] } + .sort_by { |log| log['logIndex'].to_i(16) } end - - def self.contract_transfer_event_signatures(block_number) - [].tap do |res| - res << Esip1EventSig if esip1_enabled?(block_number) - res << Esip2EventSig if esip2_enabled?(block_number) - end + + def utf8_input + HexDataProcessor.hex_to_utf8( + input.to_hex, + support_gzip: SysConfig.esip7_enabled?(block_number) + ) end - - def self.prune_transactions(block_number) - EthTransaction.where(block_number: block_number) - .where.not( - transaction_hash: Ethscription.where(block_number: block_number).select(:transaction_hash) - ) - .where.not( - transaction_hash: EthscriptionTransfer.where(block_number: block_number).select(:transaction_hash) - ) - .delete_all + + def normalize_address(addr) + return nil unless addr + # Handle both Address20 objects and strings + addr_str = addr.respond_to?(:to_hex) ? addr.to_hex : addr.to_s + addr_str.downcase end - - def self.on_testnet? - ENV['ETHEREUM_NETWORK'] != "eth-mainnet" + + def normalize_hash(hash) + return nil unless hash + # Handle both Hash32 objects and strings + hash_str = hash.respond_to?(:to_hex) ? hash.to_hex : hash.to_s + hash_str.downcase end end diff --git a/app/models/ethscription.rb b/app/models/ethscription.rb deleted file mode 100644 index 7b6965b..0000000 --- a/app/models/ethscription.rb +++ /dev/null @@ -1,147 +0,0 @@ -class Ethscription < ApplicationRecord - include FacetRailsCommon::OrderQuery - - initialize_order_query({ - newest_first: [[:block_number, :desc], [:transaction_index, :desc, unique: true]], - oldest_first: [[:block_number, :asc], [:transaction_index, :asc, unique: true]] - }, page_key_attributes: [:transaction_hash]) - - belongs_to :eth_block, foreign_key: :block_number, primary_key: :block_number, optional: true, - inverse_of: :ethscriptions - belongs_to :eth_transaction, foreign_key: :transaction_hash, primary_key: :transaction_hash, optional: true, inverse_of: :ethscription - - has_many :ethscription_transfers, foreign_key: :ethscription_transaction_hash, primary_key: :transaction_hash, inverse_of: :ethscription - - has_many :ethscription_ownership_versions, foreign_key: :ethscription_transaction_hash, primary_key: :transaction_hash, inverse_of: :ethscription - - has_one :token_item, - foreign_key: :ethscription_transaction_hash, - primary_key: :transaction_hash, - inverse_of: :ethscription - - has_one :token, - foreign_key: :deploy_ethscription_transaction_hash, - primary_key: :transaction_hash, - inverse_of: :deploy_ethscription - - has_one :attachment, - class_name: 'EthscriptionAttachment', - foreign_key: :sha, - primary_key: :attachment_sha, - inverse_of: :ethscriptions - - scope :with_token_tick_and_protocol, -> (token_tick, token_protocol) { - joins(token_item: :token) - .where(tokens: {tick: token_tick, protocol: token_protocol}) - .order('token_items.block_number DESC, token_items.transaction_index DESC') - } - - before_validation :set_derived_attributes, on: :create - after_create :create_initial_transfer! - - MAX_MIMETYPE_LENGTH = 1000 - - def latest_transfer - ethscription_transfers.sort_by do |transfer| - [transfer.block_number, transfer.transaction_index, transfer.transfer_index] - end.last - end - - def create_initial_transfer! - ethscription_transfers.create!( - { - from_address: creator, - to_address: initial_owner, - transfer_index: eth_transaction.transfer_index, - }.merge(eth_transaction.transfer_attrs) - ) - end - - def current_ownership_version - ethscription_ownership_versions.newest_first.first - end - - def content - parsed_data_uri&.decoded_data - end - - def valid_data_uri? - DataUri.valid?(content_uri) - end - - def parsed_data_uri - return unless valid_data_uri? - DataUri.new(content_uri) - end - - def content_sha - "0x" + Digest::SHA256.hexdigest(content_uri) - end - - def esip6 - DataUri.esip6?(content_uri) - end - - def mimetype - parsed_data_uri&.mimetype&.first(MAX_MIMETYPE_LENGTH) - end - - def media_type - mimetype&.split('/')&.first - end - - def mime_subtype - mimetype&.split('/')&.last - end - - def valid_ethscription? - initial_owner.present? && - valid_data_uri? && - (esip6 || content_is_unique?) - end - - def content_is_unique? - !Ethscription.exists?(content_sha: content_sha) - end - - def self.scope_checksum(scope) - subquery = scope.select(:transaction_hash) - hash_value = Ethscription.from(subquery, :ethscriptions) - .select("encode(digest(array_to_string(array_agg(transaction_hash), ''), 'sha256'), 'hex') - as hash_value") - .take - .hash_value - end - - def as_json(options = {}) - super(options.merge( - except: [ - :id, - :created_at, - :updated_at - ] - ) - ).tap do |json| - if options[:include_transfers] - json[:ethscription_transfers] = ethscription_transfers.as_json - end - if options[:include_latest_transfer] - json[:latest_transfer] = latest_transfer.as_json - end - - if json['attachment_sha'] - json['attachment_path'] = Rails.application.routes.url_helpers.attachment_ethscription_path(id: transaction_hash) - end - end - end - - private - - def set_derived_attributes - self[:content_sha] = content_sha - self[:esip6] = esip6 - self[:mimetype] = mimetype - self[:media_type] = media_type - self[:mime_subtype] = mime_subtype - end -end diff --git a/app/models/ethscription_attachment.rb b/app/models/ethscription_attachment.rb deleted file mode 100644 index 329bda7..0000000 --- a/app/models/ethscription_attachment.rb +++ /dev/null @@ -1,105 +0,0 @@ -class EthscriptionAttachment < ApplicationRecord - class InvalidInputError < StandardError; end - MAX_CONTENT_TYPE_LENGTH = 1000 - - has_many :ethscriptions, - foreign_key: :attachment_sha, - primary_key: :sha, - inverse_of: :attachment - - delegate :ungzip_if_necessary!, to: :class - attr_accessor :decoded_data - - def self.from_eth_transaction(tx) - blobs = tx.blobs.map{|i| i['blob']} - - cbor = BlobUtils.from_blobs(blobs: blobs) - - from_cbor(cbor) - rescue BlobUtils::IncorrectBlobEncoding => e - raise InvalidInputError, "Failed to decode CBOR: #{e.message}" - end - - def self.from_cbor(cbor_encoded_data) - cbor_encoded_data = ungzip_if_necessary!(cbor_encoded_data) - - decoded_data = CBOR.decode(cbor_encoded_data) - - new(decoded_data: decoded_data) - rescue EOFError, *cbor_errors => e - raise InvalidInputError, "Failed to decode CBOR: #{e.message}" - end - - def decoded_data=(new_decoded_data) - @decoded_data = new_decoded_data - - validate_input! - - self.content = ungzip_if_necessary!(decoded_data['content']) - - self.content_type = ungzip_if_necessary!( - decoded_data['contentType'] - ).first(MAX_CONTENT_TYPE_LENGTH) - - self.size = content.bytesize - self.sha = calculate_sha - - decoded_data - end - - def calculate_sha - combined = [ - Digest::SHA256.hexdigest(content_type), - Digest::SHA256.hexdigest(content), - ].join - - "0x" + Digest::SHA256.hexdigest(combined) - end - - def create_unless_exists! - save! unless self.class.exists?(sha: sha) - end - - def self.ungzip_if_necessary!(binary) - HexDataProcessor.ungzip_if_necessary(binary) - rescue Zlib::Error, CompressionLimitExceededError => e - raise InvalidInputError, "Failed to decompress content: #{e.message}" - end - - def content_type_with_encoding - parts = content_type.split(';').map(&:strip) - mime_type = parts[0] - - return content_type if mime_type.blank? - - has_charset = parts.any? { |part| part.downcase.start_with?('charset=') } - - text_or_json_types = ['text/', 'application/json', 'application/javascript'] - - if text_or_json_types.any? { |type| mime_type.downcase.start_with?(type) } && !has_charset - "#{mime_type}; charset=UTF-8" - else - content_type - end - end - - private - - def validate_input! - unless decoded_data.is_a?(Hash) - raise InvalidInputError, "Expected data to be a hash, got #{decoded_data.class} instead." - end - - unless decoded_data.keys.to_set == ['content', 'contentType'].to_set - raise InvalidInputError, "Expected keys to be 'content' and 'contentType', got #{decoded_data.keys} instead." - end - - unless decoded_data.values.all?{|i| i.is_a?(String)} - raise InvalidInputError, "Invalid value type: #{decoded_data.values.map(&:class).join(', ')}" - end - end - - def self.cbor_errors - [CBOR::MalformedFormatError, CBOR::UnpackError, CBOR::StackError, CBOR::TypeError] - end -end diff --git a/app/models/ethscription_ownership_version.rb b/app/models/ethscription_ownership_version.rb deleted file mode 100644 index d01a93f..0000000 --- a/app/models/ethscription_ownership_version.rb +++ /dev/null @@ -1,24 +0,0 @@ -class EthscriptionOwnershipVersion < ApplicationRecord - belongs_to :eth_block, foreign_key: :block_number, primary_key: :block_number, - optional: true, inverse_of: :ethscription_ownership_versions - belongs_to :eth_transaction, - foreign_key: :transaction_hash, - primary_key: :transaction_hash, optional: true, - inverse_of: :ethscription_ownership_versions - belongs_to :ethscription, - foreign_key: :ethscription_transaction_hash, - primary_key: :transaction_hash, optional: true, - inverse_of: :ethscription_ownership_versions - - scope :newest_first, -> { order( - block_number: :desc, - transaction_index: :desc, - transfer_index: :desc - )} - - scope :oldest_first, -> { order( - block_number: :asc, - transaction_index: :asc, - transfer_index: :asc - )} -end \ No newline at end of file diff --git a/app/models/ethscription_transaction.rb b/app/models/ethscription_transaction.rb new file mode 100644 index 0000000..55b2e85 --- /dev/null +++ b/app/models/ethscription_transaction.rb @@ -0,0 +1,297 @@ +class EthscriptionTransaction < T::Struct + include SysConfig + include AttrAssignable + + # Only what's needed for to_deposit_payload + prop :from_address, T.nilable(Address20) + + # Block reference (used by importer) + prop :ethscriptions_block, T.nilable(EthscriptionsBlock) + + # Operation data (for building calldata and validation) + prop :eth_transaction, T.nilable(Object) + + # Create operation fields + prop :creator, T.nilable(String) + prop :initial_owner, T.nilable(String) + prop :content_uri, T.nilable(String) + + # Transfer operation fields + prop :transfer_ids, T.nilable(T::Array[String]) # Always an array, even for single transfers + prop :transfer_from_address, T.nilable(String) + prop :transfer_to_address, T.nilable(String) + prop :enforced_previous_owner, T.nilable(String) + + # Unified source tracking + prop :source_type, T.nilable(Symbol) # :input or :event + prop :source_index, T.nilable(Integer) + + # Debug info (can be removed if not needed) + prop :ethscription_operation, T.nilable(String) # 'create', 'transfer', 'transfer_with_previous_owner' + + MAX_MIMETYPE_LENGTH = 1000 + DEPOSIT_TX_TYPE = 0x7D + MINT = 0 + VALUE = 0 + GAS_LIMIT = 1_000_000_000 + TO_ADDRESS = SysConfig::ETHSCRIPTIONS_ADDRESS + + # Factory method for create operations + def self.build_create_ethscription( + eth_transaction:, + creator:, + initial_owner:, + content_uri:, + source_type:, + source_index: + ) + return unless DataUri.valid?(content_uri) + + new( + from_address: Address20.from_hex(creator.is_a?(String) ? creator : creator.to_hex), + eth_transaction: eth_transaction, + creator: creator, + initial_owner: initial_owner, + content_uri: content_uri, + source_type: source_type&.to_sym, + source_index: source_index, + ethscription_operation: 'create' + ) + end + + # Transfer factory - handles single, multiple, and previous owner cases + def self.build_transfer( + eth_transaction:, + from_address:, + to_address:, + source_type:, + source_index:, + ethscription_ids:, # Can be a single ID or an array of IDs + enforced_previous_owner: nil + ) + # Normalize to array - accept either single ID or array of IDs + ids = Array.wrap(ethscription_ids) + + # Determine operation type + operation_type = enforced_previous_owner ? 'transfer_with_previous_owner' : 'transfer' + + new( + from_address: Address20.from_hex(from_address.is_a?(String) ? from_address : from_address.to_hex), + eth_transaction: eth_transaction, + transfer_ids: ids, # Always use array + transfer_from_address: from_address, + transfer_to_address: to_address, + enforced_previous_owner: enforced_previous_owner, + source_type: source_type&.to_sym, + source_index: source_index, + ethscription_operation: operation_type + ) + end + + # Get function selector for this operation + def function_selector + function_signature = case ethscription_operation + when 'create' + 'createEthscription((bytes32,bytes32,address,bytes,string,bool,(string,string,bytes)))' + when 'transfer' + if transfer_ids.length > 1 + 'transferEthscriptions(address,bytes32[])' + else + 'transferEthscription(address,bytes32)' + end + when 'transfer_with_previous_owner' + 'transferEthscriptionForPreviousOwner(address,bytes32,address)' + else + raise "Unknown ethscription operation: #{ethscription_operation}" + end + + Eth::Util.keccak256(function_signature)[0...4] + end + + # Unified source hash computation following Optimism pattern + def source_hash + raise "Operation must have source metadata" if source_type.nil? || source_index.nil? + + source_tag = source_type.to_s # "input" or "event" + source_tag_hash = Eth::Util.keccak256(source_tag.bytes.pack('C*')) # Hash for constant width + + payload = ByteString.from_bin( + eth_transaction.block_hash.to_bin + + source_tag_hash + # 32 bytes (hashed source tag) + function_selector + # 4 bytes (function selector) + Eth::Util.zpad_int(source_index, 32) # 32 bytes (source_index) + ) + + bin_val = Eth::Util.keccak256( + Eth::Util.zpad_int(0, 32) + Eth::Util.keccak256(payload.to_bin) # Domain 0 like Optimism + ) + + Hash32.from_bin(bin_val) + end + + public + + # Dynamic input method - builds calldata on demand + def input + case ethscription_operation + when 'create' + ByteString.from_bin(build_create_calldata) + when 'transfer' + if transfer_ids.length > 1 + ByteString.from_bin(build_transfer_multiple_calldata) + else + ByteString.from_bin(build_transfer_calldata) + end + when 'transfer_with_previous_owner' + ByteString.from_bin(build_transfer_with_previous_owner_calldata) + else + raise "Unknown ethscription operation: #{ethscription_operation}" + end + end + + # Method for deposit payload generation (used by GethDriver) + sig { returns(ByteString) } + def to_deposit_payload + tx_data = [] + tx_data.push(source_hash.to_bin) + tx_data.push(from_address.to_bin) + tx_data.push(TO_ADDRESS.to_bin) + tx_data.push(Eth::Util.serialize_int_to_big_endian(MINT)) + tx_data.push(Eth::Util.serialize_int_to_big_endian(VALUE)) + tx_data.push(Eth::Util.serialize_int_to_big_endian(GAS_LIMIT)) + tx_data.push('') + tx_data.push(input.to_bin) + tx_encoded = Eth::Rlp.encode(tx_data) + + tx_type = Eth::Util.serialize_int_to_big_endian(DEPOSIT_TX_TYPE) + ByteString.from_bin("#{tx_type}#{tx_encoded}") + end + + # Build calldata for create operations (same for both input and event-based) + def build_create_calldata + # Get function selector as binary + function_sig = function_selector.b + + # Both input and event-based creates use data URI format + # Events are "equivalent of an EOA hex-encoding contentURI and putting it in the calldata" + data_uri = DataUri.new(content_uri) + mimetype = data_uri.mimetype.to_s.first(MAX_MIMETYPE_LENGTH) + raw_content = data_uri.decoded_data.b + esip6 = DataUri.esip6?(content_uri) || false + + # Extract protocol params - returns [protocol, operation, encoded_data] + # Pass the eth_transaction for context (includes from_address and transaction_hash) + protocol, operation, encoded_data = ProtocolParser.for_calldata( + content_uri, + eth_transaction: eth_transaction + ) + + # Hash the content for protocol uniqueness + content_uri_sha_hex = Digest::SHA256.hexdigest(content_uri) + content_uri_sha = [content_uri_sha_hex].pack('H*') + + # Convert hex strings to binary for ABI encoding + tx_hash_bin = hex_to_bin(eth_transaction.transaction_hash) + owner_bin = address_to_bin(initial_owner) + + # Build protocol params tuple + protocol_params = [ + protocol, # string protocol + operation, # string operation + encoded_data # bytes data + ] + + # Encode parameters + params = [ + tx_hash_bin, # bytes32 ethscriptionId (L1 tx hash) + content_uri_sha, # bytes32 contentUriHash + owner_bin, # address + raw_content, # bytes content + mimetype.b, # string + esip6, # bool esip6 + protocol_params # ProtocolParams tuple + ] + + begin + encoded = Eth::Abi.encode( + ['(bytes32,bytes32,address,bytes,string,bool,(string,string,bytes))'], + [params] + ) + rescue Encoding::CompatibilityError => e + Rails.logger.error "=== ABI Encoding Error (build_create_calldata) ===" + Rails.logger.error "Error: #{e.message}" + Rails.logger.error "content_uri: #{content_uri[0..100]}" + Rails.logger.error "protocol: #{protocol.inspect[0..100]}, encoding: #{protocol.encoding.name}" + Rails.logger.error "operation: #{operation.inspect[0..100]}, encoding: #{operation.encoding.name}" + Rails.logger.error "encoded_data: #{encoded_data.inspect[0..100]}, encoding: #{encoded_data.encoding.name}, bytesize: #{encoded_data.bytesize}" + Rails.logger.error "mimetype: #{mimetype.inspect}, encoding: #{mimetype.encoding.name}" + Rails.logger.error "raw_content encoding: #{raw_content.encoding.name}, bytesize: #{raw_content.bytesize}" + raise + end + + # Ensure binary encoding + (function_sig + encoded).b + end + + def build_transfer_calldata + # Get function selector as binary + function_sig = function_selector.b + + # Convert to binary for ABI + to_bin = address_to_bin(transfer_to_address) + id_bin = hex_to_bin(transfer_ids.first) + + encoded = Eth::Abi.encode(['address', 'bytes32'], [to_bin, id_bin]) + + # Ensure binary encoding + (function_sig + encoded).b + end + + def build_transfer_with_previous_owner_calldata + # Get function selector as binary + function_sig = function_selector.b + + # Convert to binary for ABI + to_bin = address_to_bin(transfer_to_address) + id_bin = hex_to_bin(transfer_ids.first) + prev_bin = address_to_bin(enforced_previous_owner) + + encoded = Eth::Abi.encode(['address', 'bytes32', 'address'], [to_bin, id_bin, prev_bin]) + + # Ensure binary encoding + (function_sig + encoded).b + end + + def build_transfer_multiple_calldata + # Get function selector as binary + function_sig = function_selector.b + + to_bin = address_to_bin(transfer_to_address) + ids_bin = transfer_ids.map { |id| hex_to_bin(id) } + + encoded = Eth::Abi.encode(['address', 'bytes32[]'], [to_bin, ids_bin]) + + (function_sig + encoded).b + end + + # Helper to convert hex string to binary + def hex_to_bin(hex_str) + return nil unless hex_str + # Hash32 objects have .to_bin, strings need conversion + hex_str.respond_to?(:to_bin) ? hex_str.to_bin : [hex_str.delete_prefix('0x')].pack('H*') + end + + # Helper to convert address to binary (20 bytes) + def address_to_bin(addr_str) + return nil unless addr_str + # Handle Address20 objects that have .to_bin method + if addr_str.respond_to?(:to_bin) + return addr_str.to_bin + end + + clean_hex = addr_str.to_s.delete_prefix('0x') + # Ensure 20 bytes (40 hex chars) + clean_hex = clean_hex.rjust(40, '0')[-40..] + [clean_hex].pack('H*') + end +end diff --git a/app/models/ethscription_transfer.rb b/app/models/ethscription_transfer.rb deleted file mode 100644 index a525f0e..0000000 --- a/app/models/ethscription_transfer.rb +++ /dev/null @@ -1,91 +0,0 @@ -class EthscriptionTransfer < ApplicationRecord - include FacetRailsCommon::OrderQuery - - initialize_order_query({ - newest_first: [ - [:block_number, :desc], - [:transaction_index, :desc], - [:transfer_index, :desc, unique: true] - ], - oldest_first: [ - [:block_number, :asc], - [:transaction_index, :asc], - [:transfer_index, :asc, unique: true] - ] - }, page_key_attributes: [:block_number, :transaction_index, :transfer_index]) - - belongs_to :eth_block, foreign_key: :block_number, primary_key: :block_number, optional: true, - inverse_of: :ethscription_transfers - belongs_to :eth_transaction, foreign_key: :transaction_hash, primary_key: :transaction_hash, optional: true, - inverse_of: :ethscription_transfers - belongs_to :ethscription, foreign_key: :ethscription_transaction_hash, primary_key: :transaction_hash, optional: true, - inverse_of: :ethscription_transfers - - after_create :create_ownership_version!, :notify_eth_transaction - - def is_only_transfer? - !EthscriptionTransfer.where.not(id: id).exists?(ethscription_transaction_hash: ethscription_transaction_hash) - end - - def notify_eth_transaction - if eth_transaction.transfer_index.nil? - raise "Need eth_transaction.transfer_index" - end - - eth_transaction.transfer_index += 1 - end - - def create_if_valid! - raise "Already created" if persisted? - save! if is_valid_transfer? - end - - def create_ownership_version! - EthscriptionOwnershipVersion.create!( - transaction_hash: transaction_hash, - ethscription_transaction_hash: ethscription_transaction_hash, - transfer_index: transfer_index, - block_number: block_number, - transaction_index: transaction_index, - block_timestamp: block_timestamp, - block_blockhash: block_blockhash, - current_owner: to_address, - previous_owner: from_address, - ) - end - - def is_valid_transfer? - current_version = ethscription.current_ownership_version - - if current_version.nil? - unless from_address == ethscription.creator && - to_address == ethscription.initial_owner - raise "First transfer must be from creator to initial owner" - end - - return true - end - - current_owner = current_version.current_owner - current_previous_owner = current_version.previous_owner - - return false unless current_owner == from_address - - if enforced_previous_owner - return false unless current_previous_owner == enforced_previous_owner - end - - true - end - - def as_json(options = {}) - super(options.merge( - except: [ - :id, - :created_at, - :updated_at - ] - ) - ) - end -end diff --git a/app/models/ethscriptions_block.rb b/app/models/ethscriptions_block.rb new file mode 100644 index 0000000..b8057f2 --- /dev/null +++ b/app/models/ethscriptions_block.rb @@ -0,0 +1,103 @@ +class EthscriptionsBlock < T::Struct + include Memery + include AttrAssignable + + # Primary fields derived from schema + prop :number, T.nilable(Integer) + prop :block_hash, T.nilable(Hash32) + prop :eth_block_hash, T.nilable(Hash32) + prop :eth_block_number, T.nilable(Integer) + prop :base_fee_per_gas, T.nilable(Integer) + prop :extra_data, T.nilable(String) + prop :gas_limit, T.nilable(Integer) + prop :gas_used, T.nilable(Integer) + prop :logs_bloom, T.nilable(String) + prop :parent_beacon_block_root, T.nilable(Hash32) + prop :parent_hash, T.nilable(Hash32) + prop :receipts_root, T.nilable(Hash32) + prop :size, T.nilable(Integer) + prop :state_root, T.nilable(Hash32) + prop :timestamp, T.nilable(Integer) + prop :transactions_root, T.nilable(String) + prop :prev_randao, T.nilable(Hash32) + prop :eth_block_timestamp, T.nilable(Integer) + prop :eth_block_base_fee_per_gas, T.nilable(Integer) + prop :sequence_number, T.nilable(Integer) + # Association-like fields + prop :eth_block, T.nilable(EthBlock) + prop :ethscription_transactions, T::Array[T.untyped], default: [] + + def assign_l1_attributes(l1_attributes) + assign_attributes( + sequence_number: l1_attributes.fetch(:sequence_number), + eth_block_hash: l1_attributes.fetch(:hash), + eth_block_number: l1_attributes.fetch(:number), + eth_block_timestamp: l1_attributes.fetch(:timestamp), + eth_block_base_fee_per_gas: l1_attributes.fetch(:base_fee) + ) + end + + def self.from_eth_block(eth_block) + EthscriptionsBlock.new( + eth_block_hash: eth_block.block_hash, + eth_block_number: eth_block.number, + prev_randao: eth_block.mix_hash, + eth_block_timestamp: eth_block.timestamp, + eth_block_base_fee_per_gas: eth_block.base_fee_per_gas, + parent_beacon_block_root: eth_block.parent_beacon_block_root, + timestamp: eth_block.timestamp, + sequence_number: 0, + eth_block: eth_block, + ) + end + + def self.next_in_sequence_from_ethscriptions_block(ethscriptions_block) + EthscriptionsBlock.new( + eth_block_hash: ethscriptions_block.eth_block_hash, + eth_block_number: ethscriptions_block.eth_block_number, + eth_block_timestamp: ethscriptions_block.eth_block_timestamp, + prev_randao: ethscriptions_block.prev_randao, + eth_block_base_fee_per_gas: ethscriptions_block.eth_block_base_fee_per_gas, + parent_beacon_block_root: ethscriptions_block.parent_beacon_block_root, + number: ethscriptions_block.number + 1, + timestamp: ethscriptions_block.timestamp + 12, + sequence_number: ethscriptions_block.sequence_number + 1 + ) + end + + def attributes_tx + L1AttributesTransaction.from_ethscriptions_block(self) + end + + def self.from_rpc_result(res) + new(attributes_from_rpc(res)) + end + + def from_rpc_response(res) + assign_attributes(self.class.attributes_from_rpc(res)) + end + + def self.attributes_from_rpc(resp) + attrs = { + number: (resp['blockNumber'] || resp['number']).to_i(16), + block_hash: Hash32.from_hex(resp['hash'] || resp['blockHash']), + parent_hash: Hash32.from_hex(resp['parentHash']), + state_root: Hash32.from_hex(resp['stateRoot']), + receipts_root: Hash32.from_hex(resp['receiptsRoot']), + logs_bloom: resp['logsBloom'], + gas_limit: resp['gasLimit'].to_i(16), + gas_used: resp['gasUsed'].to_i(16), + timestamp: resp['timestamp'].to_i(16), + base_fee_per_gas: resp['baseFeePerGas'].to_i(16), + prev_randao: Hash32.from_hex(resp['prevRandao'] || resp['mixHash']), + extra_data: resp['extraData'], + ethscription_transactions: resp['transactions'] + } + + if resp['parentBeaconBlockRoot'] + attrs[:parent_beacon_block_root] = Hash32.from_hex(resp['parentBeaconBlockRoot']) + end + + attrs + end +end diff --git a/app/models/l1_attributes_transaction.rb b/app/models/l1_attributes_transaction.rb new file mode 100644 index 0000000..d710d0d --- /dev/null +++ b/app/models/l1_attributes_transaction.rb @@ -0,0 +1,69 @@ +class L1AttributesTransaction < T::Struct + include SysConfig + include AttrAssignable + + # Only what's needed for to_deposit_payload + prop :source_hash, T.nilable(Hash32) + prop :from_address, T.nilable(Address20) + prop :input, T.nilable(ByteString) + + # Constants + DEPOSIT_TX_TYPE = 0x7D + MINT = 0 + VALUE = 0 + GAS_LIMIT = 1_000_000_000 + + # L1 attributes transactions always go to L1_INFO_ADDRESS + def to_address + L1_INFO_ADDRESS + end + + # Factory method for L1 attributes transactions + def self.from_ethscriptions_block(ethscriptions_block) + calldata = L1AttributesTxCalldata.build(ethscriptions_block) + + payload = [ + ethscriptions_block.eth_block_hash.to_bin, + Eth::Util.zpad_int(ethscriptions_block.sequence_number, 32) + ].join + + source_hash = compute_source_hash( + ByteString.from_bin(payload), + 1 + ) + + new( + source_hash: source_hash, + from_address: SYSTEM_ADDRESS, + input: calldata + ) + end + + sig { params(payload: ByteString, source_domain: Integer).returns(Hash32) } + def self.compute_source_hash(payload, source_domain) + bin_val = Eth::Util.keccak256( + Eth::Util.zpad_int(source_domain, 32) + + Eth::Util.keccak256(payload.to_bin) + ) + + Hash32.from_bin(bin_val) + end + + # Method for deposit payload generation (used by GethDriver) + sig { returns(ByteString) } + def to_deposit_payload + tx_data = [] + tx_data.push(source_hash.to_bin) + tx_data.push(from_address.to_bin) + tx_data.push(to_address.to_bin) + tx_data.push(Eth::Util.serialize_int_to_big_endian(MINT)) + tx_data.push(Eth::Util.serialize_int_to_big_endian(VALUE)) + tx_data.push(Eth::Util.serialize_int_to_big_endian(GAS_LIMIT)) + tx_data.push('') + tx_data.push(input.to_bin) + tx_encoded = Eth::Rlp.encode(tx_data) + + tx_type = Eth::Util.serialize_int_to_big_endian(DEPOSIT_TX_TYPE) + ByteString.from_bin("#{tx_type}#{tx_encoded}") + end +end \ No newline at end of file diff --git a/app/models/protocol_parser.rb b/app/models/protocol_parser.rb new file mode 100644 index 0000000..c72a7ca --- /dev/null +++ b/app/models/protocol_parser.rb @@ -0,0 +1,216 @@ +# Unified protocol parser that delegates to specific protocol parsers +class ProtocolParser + # Default return value for all parsers - unified 3-element format + DEFAULT_PARAMS = [''.b, ''.b, ''.b].freeze + + # Protocol name to parser class mapping + PROTOCOL_PARSERS = { + 'erc-20' => Erc20FixedDenominationParser, + 'erc-20-fixed-denomination' => Erc20FixedDenominationParser, + 'erc-721-ethscriptions-collection' => Erc721EthscriptionsCollectionParser + }.freeze + + def self.extract(content_uri, eth_transaction: nil, ethscription_id: nil) + # Parse data URI and extract protocol info + parsed = parse_data_uri_and_protocol(content_uri) + + if parsed.nil? + # If we have an ethscription_id, try import fallback for collections regardless of content + if ethscription_id + # Get decoded content for import fallback + decoded_content = nil + if content_uri.is_a?(String) && DataUri.valid?(content_uri) + data_uri = DataUri.new(content_uri) + decoded_content = data_uri.decoded_data + end + + # Try collections parser for import fallback with any content + # Ensure decoded_content is binary to avoid encoding issues + encoded = Erc721EthscriptionsCollectionParser.validate_and_encode( + decoded_content: (decoded_content || '').b, + operation: nil, + params: {}, + source: :json, + ethscription_id: ethscription_id, + eth_transaction: eth_transaction + ) + + if encoded != DEFAULT_PARAMS + protocol, operation, encoded_data = encoded + return { + type: :erc721_ethscriptions_collection, + protocol: protocol, + operation: operation, + params: nil, + encoded_params: encoded_data + } + end + end + + return nil + end + + # Direct routing - no "try" needed since we know the protocol + parser_class = PROTOCOL_PARSERS[parsed[:protocol_name]] + return nil unless parser_class + + # Call the same method on all parsers with unified interface + encoded = parser_class.validate_and_encode( + decoded_content: parsed[:decoded_content], + operation: parsed[:operation], + params: parsed[:params], + source: parsed[:source], + ethscription_id: ethscription_id, + eth_transaction: eth_transaction + ) + + # Check if parsing succeeded + return nil if encoded == DEFAULT_PARAMS + + protocol, operation, encoded_data = encoded + + # Derive type from parser class name + type = parser_class.name.underscore.sub(/_parser$/, '').to_sym + + { + type: type, + protocol: protocol, + operation: operation, + params: nil, + encoded_params: encoded_data + } + end + + # Get protocol data formatted for L2 calldata + # Returns [protocol, operation, encoded_data] for contract consumption + def self.for_calldata(content_uri, eth_transaction: nil, ethscription_id: nil) + # Support both for backward compatibility + ethscription_id ||= eth_transaction&.transaction_hash + result = extract(content_uri, eth_transaction: eth_transaction, ethscription_id: ethscription_id) + + if result.nil? + # No protocol detected - return empty protocol params + DEFAULT_PARAMS + else + # All parsers return the same format, so we can just extract directly + [result[:protocol], result[:operation], result[:encoded_params]] + end + end + + private + + # Parse data URI and extract protocol information from JSON body or headers + # Returns hash with: decoded_content, protocol_name, operation, params, source + # Note: content_hash removed - parsers compute their own if needed + def self.parse_data_uri_and_protocol(content_uri) + return nil unless content_uri.is_a?(String) + return nil unless DataUri.valid?(content_uri) + + data_uri = DataUri.new(content_uri) + decoded_content = data_uri.decoded_data + return nil unless decoded_content.is_a?(String) + + # Try to extract protocol from JSON body + json_protocol = extract_json_protocol(decoded_content) + + # Try to extract protocol from headers + header_protocol = extract_header_protocol(data_uri) + + # Fail if both present (ambiguous) + return nil if json_protocol && header_protocol + + # Get protocol info from whichever source exists + protocol_info = json_protocol || header_protocol + return nil unless protocol_info + + { + decoded_content: decoded_content, + protocol_name: protocol_info[:protocol], + operation: protocol_info[:operation], + params: protocol_info[:params], + source: protocol_info[:source] + } + end + + # Extract protocol info from JSON body + def self.extract_json_protocol(decoded_content) + return nil unless decoded_content.lstrip.start_with?('{') + + data = JSON.parse(decoded_content) + return nil unless data.is_a?(Hash) + + protocol = data['p'] || data['protocol'] + operation = data['op'] || data['operation'] || data['type'] + + return nil unless protocol.is_a?(String) && operation.is_a?(String) + + # Return protocol info with full JSON data as params + { + protocol: protocol, + operation: operation, + params: data, + source: :json + } + rescue JSON::ParserError + nil + end + + # Extract protocol info from data URI headers (;p=...;op=...;d=...) + def self.extract_header_protocol(data_uri) + params_map = parse_parameters(data_uri.parameters) + + # Must have exactly one 'p' and exactly one 'op' + p_values = params_map['p'] + op_values = params_map['op'] + + return nil unless p_values&.length == 1 && op_values&.length == 1 + + protocol = p_values.first + operation = op_values.first + + # Validate protocol and operation format (lowercase, alphanumeric + dash/underscore, 1-50 chars) + return nil unless protocol.match?(/\A[a-z0-9\-_]{1,50}\z/) + return nil unless operation.match?(/\A[a-z0-9\-_]{1,50}\z/) + + # Optional data parameter (d= or data=) with base64-encoded JSON + d_values = (params_map['d'] || []) + (params_map['data'] || []) + return nil if d_values.length > 1 # Only zero or one allowed + + params_hash = {} + if d_values.length == 1 + begin + raw = Base64.strict_decode64(d_values.first) + parsed = JSON.parse(raw) + params_hash = parsed if parsed.is_a?(Hash) + rescue ArgumentError, JSON::ParserError + return nil # Invalid base64 or JSON + end + end + + { + protocol: protocol, + operation: operation, + params: params_hash, + source: :header + } + end + + # Parse data URI parameters into a hash of arrays (supporting multiple values per key) + def self.parse_parameters(parameters) + map = Hash.new { |h, k| h[k] = [] } + + parameters.each do |seg| + next if seg.to_s.empty? + + if (eq = seg.index('=')) + key = seg[0...eq].strip.downcase + val = seg[(eq + 1)..].to_s.strip + map[key] << val + end + # Ignore bare flags (e.g., base64, rule=esip6) + end + + map + end + +end diff --git a/app/models/token.rb b/app/models/token.rb deleted file mode 100644 index f0174cc..0000000 --- a/app/models/token.rb +++ /dev/null @@ -1,284 +0,0 @@ -class Token < ApplicationRecord - include FacetRailsCommon::OrderQuery - - initialize_order_query({ - newest_first: [ - [:deploy_block_number, :desc], - [:deploy_transaction_index, :desc, unique: true] - ], - oldest_first: [ - [:deploy_block_number, :asc], - [:deploy_transaction_index, :asc, unique: true] - ] - }, page_key_attributes: [:deploy_ethscription_transaction_hash]) - - has_many :token_items, - foreign_key: :deploy_ethscription_transaction_hash, - primary_key: :deploy_ethscription_transaction_hash, - inverse_of: :token - - belongs_to :deploy_ethscription, - foreign_key: :deploy_ethscription_transaction_hash, - primary_key: :transaction_hash, - class_name: 'Ethscription', - inverse_of: :token, - optional: true - - has_many :token_states, foreign_key: :deploy_ethscription_transaction_hash, primary_key: :deploy_ethscription_transaction_hash, inverse_of: :token - - scope :minted_out, -> { where("total_supply = max_supply") } - scope :not_minted_out, -> { where("total_supply < max_supply") } - - def minted_out? - total_supply == max_supply - end - - def self.create_from_token_details!(tick:, p:, max:, lim:) - deploy_tx = find_deploy_transaction(tick: tick, p: p, max: max, lim: lim) - - existing = find_by(deploy_ethscription_transaction_hash: deploy_tx.transaction_hash) - - return existing if existing - - content = OpenStruct.new(JSON.parse(deploy_tx.content)) - - token = nil - - Token.transaction do - token = create!( - deploy_ethscription_transaction_hash: deploy_tx.transaction_hash, - deploy_block_number: deploy_tx.block_number, - deploy_transaction_index: deploy_tx.transaction_index, - protocol: content.p, - tick: content.tick, - max_supply: content.max.to_i, - mint_amount: content.lim.to_i, - total_supply: 0 - ) - - token.sync_past_token_items! - token.save_state_checkpoint! - end - - token - end - - def self.process_block(block) - all_tokens = Token.all.to_a - - return unless all_tokens.present? - - transfers = EthscriptionTransfer.where(block_number: block.block_number).includes(:ethscription) - - transfers_by_token = transfers.group_by do |transfer| - all_tokens.detect { |token| token.ethscription_is_token_item?(transfer.ethscription) } - end - - new_token_items = [] - - # Process each token's transfers as a batch - transfers_by_token.each do |token, transfers| - next unless token.present? - - # Start with the current state - total_supply = token.total_supply.to_i - balances = Hash.new(0).merge(token.balances.deep_dup) - - # Apply all transfers to the state - transfers.each do |transfer| - balances[transfer.to_address] += token.mint_amount - - if transfer.is_only_transfer? - total_supply += token.mint_amount - # Prepare token item for bulk import - new_token_items << TokenItem.new( - deploy_ethscription_transaction_hash: token.deploy_ethscription_transaction_hash, - ethscription_transaction_hash: transfer.ethscription_transaction_hash, - token_item_id: token.token_id_from_ethscription(transfer.ethscription), - block_number: transfer.block_number, - transaction_index: transfer.transaction_index - ) - else - balances[transfer.from_address] -= token.mint_amount - end - end - - balances.delete_if { |address, amount| amount == 0 } - - if balances.values.any?(&:negative?) - raise "Negative balance detected in block: #{block.block_number}" - end - - # Create a single state change for the block - token.token_states.create!( - total_supply: total_supply, - balances: balances, - block_number: block.block_number, - block_timestamp: block.timestamp, - block_blockhash: block.blockhash, - ) - end - - TokenItem.import!(new_token_items) if new_token_items.present? - end - - def token_id_from_ethscription(ethscription) - regex = /\Adata:,\{"p":"#{Regexp.escape(protocol)}","op":"mint","tick":"#{Regexp.escape(tick)}","id":"([1-9][0-9]{0,#{trailing_digit_count}})","amt":"#{mint_amount.to_i}"\}\z/ - - id = ethscription.content_uri[regex, 1] - - id_valid = id.to_i.between?(1, max_id) - - creation_sequence_valid = ethscription.block_number > deploy_block_number || - (ethscription.block_number == deploy_block_number && - ethscription.transaction_index > deploy_transaction_index) - - (id_valid && creation_sequence_valid) ? id.to_i : nil - end - - def ethscription_is_token_item?(ethscription) - token_id_from_ethscription(ethscription).present? - end - - def trailing_digit_count - max_id.to_i.to_s.length - 1 - end - - def sync_past_token_items! - return if minted_out? - - unless tick =~ /\A[[:alnum:]\p{Emoji_Presentation}]+\z/ - raise "Invalid tick format: #{tick.inspect}" - end - quoted_tick = ActiveRecord::Base.connection.quote_string(tick) - - unless protocol =~ /\A[a-z0-9\-]+\z/ - raise "Invalid protocol format: #{protocol.inspect}" - end - quoted_protocol = ActiveRecord::Base.connection.quote_string(protocol) - - regex = %Q{^data:,{"p":"#{quoted_protocol}","op":"mint","tick":"#{quoted_tick}","id":"([1-9][0-9]{0,#{trailing_digit_count}})","amt":"#{mint_amount.to_i}"}$} - - deploy_ethscription = Ethscription.find_by( - transaction_hash: deploy_ethscription_transaction_hash - ) - - sql = <<-SQL - INSERT INTO token_items ( - ethscription_transaction_hash, - deploy_ethscription_transaction_hash, - token_item_id, - block_number, - transaction_index, - created_at, - updated_at - ) - SELECT - e.transaction_hash, - '#{deploy_ethscription_transaction_hash}', - (substring(e.content_uri from '#{regex}')::integer), - e.block_number, - e.transaction_index, - NOW(), - NOW() - FROM - ethscriptions e - WHERE - e.content_uri ~ '#{regex}' AND - substring(e.content_uri from '#{regex}')::integer BETWEEN 1 AND #{max_id} AND - ( - e.block_number > #{deploy_ethscription.block_number} OR - ( - e.block_number = #{deploy_ethscription.block_number} AND - e.transaction_index > #{deploy_ethscription.transaction_index} - ) - ) - ON CONFLICT (ethscription_transaction_hash, deploy_ethscription_transaction_hash, token_item_id) - DO NOTHING - SQL - - ActiveRecord::Base.connection.execute(sql) - end - - def max_id - max_supply.div(mint_amount) - end - - def token_items_checksum - Rails.cache.fetch(["token-items-checksum", token_items]) do - item_hashes = token_items.select(:ethscription_transaction_hash) - scope = Ethscription.oldest_first.where(transaction_hash: item_hashes) - Ethscription.scope_checksum(scope) - end - end - - def balance_of(address) - balances.fetch(address&.downcase, 0) - end - - def save_state_checkpoint! - item_hashes = token_items.select(:ethscription_transaction_hash) - - last_transfer = EthscriptionTransfer. - where(ethscription_transaction_hash: item_hashes). - newest_first.first - - return unless last_transfer.present? - - balances = Ethscription.where(transaction_hash: item_hashes). - select( - :current_owner, - Arel.sql("SUM(#{mint_amount}) AS balance"), - Arel.sql("(SELECT block_number FROM eth_blocks WHERE imported_at IS NOT NULL ORDER BY block_number DESC LIMIT 1) AS latest_block_number"), - Arel.sql("(SELECT blockhash FROM eth_blocks WHERE imported_at IS NOT NULL ORDER BY block_number DESC LIMIT 1) AS latest_block_hash") - ). - group(:current_owner) - - balance_map = balances.each_with_object({}) do |balance, map| - map[balance.current_owner] = balance.balance - end - - latest_block_number = balances.first&.latest_block_number - latest_block_hash = balances.first&.latest_block_hash - - if latest_block_number > last_transfer.block_number - token_states.create!( - total_supply: balance_map.values.sum, - balances: balance_map, - block_number: latest_block_number, - block_blockhash: latest_block_hash, - block_timestamp: EthBlock.where(block_number: latest_block_number).pick(:timestamp), - ) - end - end - - def self.batch_import(tokens) - tokens.each do |token| - tick = token.fetch('tick') - protocol = token.fetch('p') - max = token.fetch('max') - lim = token.fetch('lim') - - create_from_token_details!(tick: tick, p: protocol, max: max, lim: lim) - end - end - - def self.find_deploy_transaction(tick:, p:, max:, lim:) - uri = % - - Ethscription.find_by_content_uri(uri) - end - - def as_json(options = {}) - super(options.merge(except: [ - :balances, - :id, - :created_at, - :updated_at - ])).tap do |json| - if options[:include_balances] - json[:balances] = balances - end - end - end -end diff --git a/app/models/token_item.rb b/app/models/token_item.rb deleted file mode 100644 index f3c126f..0000000 --- a/app/models/token_item.rb +++ /dev/null @@ -1,26 +0,0 @@ -class TokenItem < ApplicationRecord - include FacetRailsCommon::OrderQuery - - initialize_order_query({ - newest_first: [ - [:block_number, :desc], - [:transaction_index, :desc, unique: true] - ], - oldest_first: [ - [:block_number, :asc], - [:transaction_index, :asc, unique: true] - ] - }, page_key_attributes: [:ethscription_transaction_hash]) - - belongs_to :ethscription, - foreign_key: :ethscription_transaction_hash, - primary_key: :transaction_hash, - inverse_of: :token_item, - optional: true - - belongs_to :token, - foreign_key: :deploy_ethscription_transaction_hash, - primary_key: :deploy_ethscription_transaction_hash, - inverse_of: :token_items, - optional: true -end diff --git a/app/models/token_state.rb b/app/models/token_state.rb deleted file mode 100644 index 946d3c7..0000000 --- a/app/models/token_state.rb +++ /dev/null @@ -1,10 +0,0 @@ -class TokenState < ApplicationRecord - belongs_to :eth_block, foreign_key: :block_number, primary_key: :block_number, optional: true, - inverse_of: :token_states - - belongs_to :token, foreign_key: :deploy_ethscription_transaction_hash, - primary_key: :deploy_ethscription_transaction_hash, inverse_of: :token_states, optional: true - - scope :newest_first, -> { order(block_number: :desc) } - scope :oldest_first, -> { order(block_number: :asc) } -end diff --git a/app/models/validation_result.rb b/app/models/validation_result.rb new file mode 100644 index 0000000..998f7b2 --- /dev/null +++ b/app/models/validation_result.rb @@ -0,0 +1,155 @@ +class ValidationResult < ApplicationRecord + self.primary_key = 'l1_block' + + scope :successful, -> { where(success: true) } + scope :failed, -> { where(success: false) } + scope :recent, -> { order(validated_at: :desc) } + scope :in_range, ->(start_block, end_block) { where(l1_block: start_block..end_block) } + scope :with_activity, -> { + where("JSON_EXTRACT(validation_stats, '$.validation_details.expected_creations') > 0 OR JSON_EXTRACT(validation_stats, '$.validation_details.expected_transfers') > 0 OR JSON_EXTRACT(validation_stats, '$.validation_details.storage_checks') > 0") + } + + def self.delete_successes_older_than(older_than: 6.hours, batch_size: 2_000, sleep_between_batches: 0.3) + cutoff = + case older_than + when ActiveSupport::Duration + older_than.ago + when Numeric + Time.current - older_than + when Time + older_than + else + raise ArgumentError, "Unsupported older_than: #{older_than.class}" + end + + scope = successful.where('validated_at < ?', cutoff) + total_deleted = 0 + + scope.in_batches(of: batch_size) do |relation| + deleted = relation.delete_all + break if deleted.zero? + + total_deleted += deleted + sleep(sleep_between_batches) if sleep_between_batches.positive? + end + + total_deleted + end + + # Class methods for validation management + def self.last_validated_block + maximum(:l1_block) + end + + def self.validation_gaps(start_block, end_block) + # Use SQL recursive CTE to find gaps efficiently + sql = <<~SQL + WITH RECURSIVE expected(n) AS ( + SELECT ? AS n + UNION ALL + SELECT n + 1 FROM expected WHERE n < ? + ) + SELECT n AS missing_block + FROM expected + LEFT JOIN validation_results vr ON vr.l1_block = expected.n + WHERE vr.l1_block IS NULL + ORDER BY n + SQL + + connection.execute(sql, [start_block, end_block]).map { |row| row['missing_block'] } + end + + # Faster method that just counts gaps without listing them all + def self.validation_gap_count(start_block, end_block) + # Count how many blocks are missing in the range + expected_count = end_block - start_block + 1 + validated_count = where(l1_block: start_block..end_block).count + expected_count - validated_count + end + + def self.validation_stats(since: 1.hour.ago) + results = where('validated_at >= ?', since) + total = results.count + passed = results.successful.count + failed = results.failed.count + + { + total: total, + passed: passed, + failed: failed, + pass_rate: total > 0 ? (passed.to_f / total * 100).round(2) : 0 + } + end + + def self.recent_failures(limit: 10) + failed.recent.limit(limit) + end + + # Class method to perform validation and save result + def self.validate_and_save(l1_block_number, l2_block_hashes) + Rails.logger.debug "ValidationResult: Validating L1 block #{l1_block_number}" + + # Create validator and validate (validator fetches its own API data) + validator = BlockValidator.new + start_time = Time.current + block_result = validator.validate_l1_block(l1_block_number, l2_block_hashes) + + # Find or initialize - idempotent for re-runs + validation_result = find_or_initialize_by(l1_block: l1_block_number) + + validation_result.assign_attributes( + success: block_result.success, + error_details: block_result.errors, + validation_stats: { + # Basic stats + success: block_result.success, + l1_block: l1_block_number, + l2_blocks: l2_block_hashes, + + # Detailed comparison data + validation_details: block_result.stats, + + # Store the raw data for debugging + raw_api_data: block_result.respond_to?(:api_data) ? block_result.api_data : nil, + raw_l2_events: block_result.respond_to?(:l2_events) ? block_result.l2_events : nil, + + # Timing info + validation_duration_ms: ((Time.current - start_time) * 1000).round(2) + }, + validated_at: Time.current + ) + + validation_result.save! + + # Log the result + validation_result.log_summary + + validation_result + end + + # Instance methods + def failure_summary + return nil if success? + return "No error details" if error_details.blank? + + # error_details is automatically parsed as Array + error_details.first(3).join('; ') + end + + def log_summary(logger = Rails.logger) + if success? + stats_data = validation_stats || {} + if stats_data['actual_creations'].to_i > 0 || stats_data['actual_transfers'].to_i > 0 || stats_data['storage_checks'].to_i > 0 + logger.debug "✅ Block #{l1_block} validated successfully: " \ + "#{stats_data['actual_creations']} creations, " \ + "#{stats_data['actual_transfers']} transfers, " \ + "#{stats_data['storage_checks']} storage checks" + end + else + errors = error_details || [] + logger.error "❌ Block #{l1_block} validation failed with #{errors.size} errors:" + errors.first(5).each { |e| logger.error " - #{e}" } + logger.error " ... and #{errors.size - 5} more errors" if errors.size > 5 + end + end +end diff --git a/app/services/eth_block_importer.rb b/app/services/eth_block_importer.rb new file mode 100644 index 0000000..c7944fc --- /dev/null +++ b/app/services/eth_block_importer.rb @@ -0,0 +1,578 @@ +class EthBlockImporter + include SysConfig + include Memery + + # Raised when the next block to import is not yet available on L1 + class BlockNotReadyToImportError < StandardError; end + # Raised when a re-org is detected (parent hash mismatch) + class ReorgDetectedError < StandardError; end + # Raised when validation failure is detected (should stop system permanently) + class ValidationFailureError < StandardError; end + # Raised when validation is too far behind (should wait and retry) + class ValidationStalledError < StandardError; end + + attr_accessor :ethscriptions_block_cache, :ethereum_client, :eth_block_cache, :geth_driver, :prefetcher + + def initialize + @ethscriptions_block_cache = {} + @eth_block_cache = {} + + @ethereum_client ||= EthRpcClient.l1 + + @geth_driver = GethDriver + + # L1 prefetcher for blocks/receipts/API data + @prefetcher = L1RpcPrefetcher.new( + ethereum_client: @ethereum_client, + ahead: ENV.fetch('L1_PREFETCH_FORWARD', Rails.env.test? ? 5 : 20).to_i, + threads: ENV.fetch('L1_PREFETCH_THREADS', Rails.env.test? ? 2 : 2).to_i + ) + + logger.info "EthBlockImporter initialized - Validation: #{ENV.fetch('VALIDATION_ENABLED').casecmp?('true') ? 'ENABLED' : 'disabled'}" + + MemeryExtensions.clear_all_caches! + + set_eth_block_starting_points + populate_ethscriptions_block_cache + + # Clean up any stale validation records ahead of our starting position + if ENV.fetch('VALIDATION_ENABLED').casecmp?('true') + cleanup_stale_validation_records + end + + unless Rails.env.test? + max_block = current_max_eth_block_number + if max_block && max_block > 0 + ImportProfiler.start('prefetch_warmup') + @prefetcher.ensure_prefetched(max_block + 1) + ImportProfiler.stop('prefetch_warmup') + end + end + end + + def current_max_ethscriptions_block_number + ethscriptions_block_cache.keys.max + end + + def current_max_eth_block_number + eth_block_cache.keys.max + end + + def current_max_eth_block + eth_block_cache[current_max_eth_block_number] + end + + def populate_ethscriptions_block_cache + epochs_found = 0 + current_block_number = current_max_ethscriptions_block_number - 1 + + while epochs_found < 64 && current_block_number >= 0 + hex_block_number = "0x#{current_block_number.to_s(16)}" + ImportProfiler.start("l2_block_fetch") + block_data = geth_driver.client.call("eth_getBlockByNumber", [hex_block_number, false]) + ImportProfiler.stop("l2_block_fetch") + current_block = EthscriptionsBlock.from_rpc_result(block_data) + + ImportProfiler.start("l1_attributes_fetch") + l1_attributes = GethDriver.get_l1_attributes(current_block.number) + ImportProfiler.stop("l1_attributes_fetch") + current_block.assign_l1_attributes(l1_attributes) + + ethscriptions_block_cache[current_block.number] = current_block + + if current_block.sequence_number == 0 || current_block_number == 0 + epochs_found += 1 + logger.info "Found epoch #{epochs_found} at block #{current_block_number}" + end + + current_block_number -= 1 + end + + logger.info "Populated facet block cache with #{ethscriptions_block_cache.size} blocks from #{epochs_found} epochs" + end + + def logger + Rails.logger + end + + def blocks_behind + (current_block_number - next_block_to_import) + 1 + end + + def current_block_number + ethereum_client.get_block_number + end + memoize :current_block_number, ttl: 12.seconds + + # Removed batch processing - now imports one block at a time + + def find_first_l2_block_in_epoch(l2_block_number_candidate) + l1_attributes = GethDriver.get_l1_attributes(l2_block_number_candidate) + + if l1_attributes[:sequence_number] == 0 + return l2_block_number_candidate + end + + return find_first_l2_block_in_epoch(l2_block_number_candidate - 1) + end + + def set_eth_block_starting_points + latest_l2_block = GethDriver.client.call("eth_getBlockByNumber", ["latest", false]) + latest_l2_block_number = latest_l2_block['number'].to_i(16) + + if latest_l2_block_number == 0 + l1_block = EthRpcClient.l1.get_block(SysConfig.l1_genesis_block_number) + eth_block = EthBlock.from_rpc_result(l1_block) + ethscriptions_block = EthscriptionsBlock.from_rpc_result(latest_l2_block) + l1_attributes = GethDriver.get_l1_attributes(latest_l2_block_number) + + ethscriptions_block.assign_l1_attributes(l1_attributes) + + ethscriptions_block_cache[0] = ethscriptions_block + eth_block_cache[eth_block.number] = eth_block + + return [eth_block.number, 0] + end + + l1_attributes = GethDriver.get_l1_attributes(latest_l2_block_number) + + l1_candidate = l1_attributes[:number] + l2_candidate = latest_l2_block_number + + max_iterations = 1000 + iterations = 0 + + while iterations < max_iterations + l2_candidate = find_first_l2_block_in_epoch(l2_candidate) + + l1_result = ethereum_client.get_block(l1_candidate) + l1_hash = Hash32.from_hex(l1_result['hash']) + + l1_attributes = GethDriver.get_l1_attributes(l2_candidate) + + l2_block = GethDriver.client.call("eth_getBlockByNumber", ["0x#{l2_candidate.to_s(16)}", false]) + + # Start from finalization block (use smaller offset for tests) + retry_offset = Rails.env.test? ? 0 : 63 + blocks_behind = latest_l2_block_number - l2_candidate + + if l1_hash == l1_attributes[:hash] && l1_attributes[:number] == l1_candidate && blocks_behind >= retry_offset + eth_block_cache[l1_candidate] = EthBlock.from_rpc_result(l1_result) + + ethscriptions_block = EthscriptionsBlock.from_rpc_result(l2_block) + ethscriptions_block.assign_l1_attributes(l1_attributes) + + ethscriptions_block_cache[l2_candidate] = ethscriptions_block + logger.info "Found matching block at #{l1_candidate}, #{blocks_behind} blocks behind (minimum #{retry_offset})" + return [l1_candidate, l2_candidate] + else + if l1_hash == l1_attributes[:hash] && l1_attributes[:number] == l1_candidate + logger.info "Block #{l2_candidate} matches but only #{blocks_behind} blocks behind (need #{retry_offset}), continuing back" + else + logger.info "Mismatch on block #{l2_candidate}: #{l1_hash.to_hex} != #{l1_attributes[:hash].to_hex}, decrementing" + end + + l2_candidate -= 1 + l1_candidate -= 1 + end + + iterations += 1 + end + + raise "No starting block found after #{max_iterations} iterations" + end + + def import_blocks_until_done + MemeryExtensions.clear_all_caches! + + # Initialize stats tracking + stats_start_time = Time.current + stats_start_block = current_max_eth_block_number + blocks_imported_count = 0 + total_gas_used = 0 + total_transactions = 0 + imported_l2_blocks = [] + + # Track timing for recent batch calculations + recent_batch_start_time = Time.current + + begin + loop do + ImportProfiler.start("import_blocks_until_done_loop") + # Check for validation failures + ImportProfiler.start("real_validation_failure_detected") + if real_validation_failure_detected? + failed_block = get_validation_failure_block + logger.error "Import stopped due to validation failure at block #{failed_block}" + raise ValidationFailureError.new("Validation failure detected at block #{failed_block}") + end + ImportProfiler.stop("real_validation_failure_detected") + + # Check if validation is stalled + ImportProfiler.start("validation_stalled") + if validation_stalled? + current_position = current_max_eth_block_number + logger.warn "Import paused - validation is behind (current: #{current_position})" + raise ValidationStalledError.new("Validation stalled - waiting for validation to catch up") + end + ImportProfiler.stop("validation_stalled") + + block_number = next_block_to_import + + if block_number.nil? + raise BlockNotReadyToImportError.new("Block not ready") + end + + l2_blocks, l1_blocks = import_single_block(block_number) + blocks_imported_count += 1 + + # Collect stats from imported L2 blocks + if l2_blocks.any? + imported_l2_blocks.concat(l2_blocks) + l2_blocks.each do |l2_block| + total_gas_used += l2_block.gas_used if l2_block.gas_used + total_transactions += l2_block.ethscription_transactions.length if l2_block.ethscription_transactions + end + end + + # Report stats every 25 blocks + if blocks_imported_count % 25 == 0 + recent_batch_time = Time.current - recent_batch_start_time + report_import_stats( + blocks_imported_count: blocks_imported_count, + stats_start_time: stats_start_time, + stats_start_block: stats_start_block, + total_gas_used: total_gas_used, + total_transactions: total_transactions, + imported_l2_blocks: imported_l2_blocks, + recent_batch_time: recent_batch_time + ) + # Reset recent batch timer + recent_batch_start_time = Time.current + end + + rescue ReorgDetectedError => e + logger.error "Reorg detected: #{e.message}" + raise e + rescue => e + logger.error "Import error: #{e.message}" + raise e + ensure + ImportProfiler.stop("import_blocks_until_done_loop") + end + end + end + + def fetch_block_from_cache(block_number) + block_number = [block_number, 0].max + + ethscriptions_block_cache.fetch(block_number) + end + + def prune_caches + eth_block_threshold = current_max_eth_block_number - 65 + + # Remove old entries from eth_block_cache + eth_block_cache.delete_if { |number, _| number < eth_block_threshold } + + # Find the oldest Ethereum block number we want to keep + oldest_eth_block_to_keep = eth_block_cache.keys.min + + # Remove old entries from ethscriptions_block_cache based on Ethereum block number + ethscriptions_block_cache.delete_if do |_, ethscriptions_block| + ethscriptions_block.eth_block_number < oldest_eth_block_to_keep + end + + # Clean up prefetcher cache + if oldest_eth_block_to_keep + @prefetcher.clear_older_than(oldest_eth_block_to_keep) + end + end + + def current_ethscriptions_block(type) + case type + when :head + fetch_block_from_cache(current_max_ethscriptions_block_number) + when :safe + find_block_by_epoch_offset(32) + when :finalized + find_block_by_epoch_offset(64) + else + raise ArgumentError, "Invalid block type: #{type}" + end + end + + def find_block_by_epoch_offset(offset) + current_eth_block_number = current_facet_head_block.eth_block_number + target_eth_block_number = current_eth_block_number - (offset - 1) + + matching_block = ethscriptions_block_cache.values + .select { |block| block.eth_block_number <= target_eth_block_number } + .max_by(&:number) + + matching_block || oldest_known_ethscriptions_block + end + + def oldest_known_ethscriptions_block + ethscriptions_block_cache.values.min_by(&:number) + end + + def current_facet_head_block + current_ethscriptions_block(:head) + end + + def current_facet_safe_block + current_ethscriptions_block(:safe) + end + + def current_facet_finalized_block + current_ethscriptions_block(:finalized) + end + + def import_single_block(block_number) + ImportProfiler.start("import_single_block") + + # Removed noisy per-block logging + start = Time.current + + # Fetch block data from prefetcher + begin + ImportProfiler.start('prefetcher_fetch') + response = prefetcher.fetch(block_number) + rescue L1RpcPrefetcher::BlockFetchError => e + raise BlockNotReadyToImportError.new(e.message) + ensure + ImportProfiler.stop('prefetcher_fetch') + end + + # Handle cancellation, fetch failure, or block not ready + if response.nil? + raise BlockNotReadyToImportError.new("Block #{block_number} fetch was cancelled or failed") + end + + if response[:error] == :not_ready + raise BlockNotReadyToImportError.new("Block #{block_number} not yet available on L1") + end + + eth_block = response[:eth_block] + ethscriptions_block = response[:ethscriptions_block] + ethscription_txs = response[:ethscription_txs] + + ethscription_txs.each { |tx| tx.ethscriptions_block = ethscriptions_block } + + # Check for reorg by validating parent hash + parent_eth_block = eth_block_cache[block_number - 1] + if parent_eth_block && parent_eth_block.block_hash != eth_block.parent_hash + logger.error "Reorg detected at block #{block_number}" + raise ReorgDetectedError.new("Parent hash mismatch at block #{block_number}") + end + + # Import the L2 block(s) + ImportProfiler.start("propose_ethscriptions_block") + imported_ethscriptions_blocks = propose_ethscriptions_block( + ethscriptions_block: ethscriptions_block, + ethscription_txs: ethscription_txs + ) + ImportProfiler.stop("propose_ethscriptions_block") + + logger.debug "Block #{block_number}: Found #{ethscription_txs.length} ethscription txs, created #{imported_ethscriptions_blocks.length} L2 blocks" + + # Update caches + imported_ethscriptions_blocks.each do |ethscriptions_block| + ethscriptions_block_cache[ethscriptions_block.number] = ethscriptions_block + end + eth_block_cache[eth_block.number] = eth_block + prune_caches + + # Queue validation job if validation is enabled + if ENV.fetch('VALIDATION_ENABLED').casecmp?('true') + l2_block_hashes = imported_ethscriptions_blocks.map { |block| block.block_hash.to_hex } + ValidationJob.perform_later(block_number, l2_block_hashes) + end + + [imported_ethscriptions_blocks, [eth_block]] + ensure + ImportProfiler.stop("import_single_block") + end + + def import_next_block + block_number = next_block_to_import + import_single_block(block_number) + end + + def next_block_to_import + next_blocks_to_import(1).first + end + + def next_blocks_to_import(n) + max_imported_block = current_max_eth_block_number + + start_block = max_imported_block + 1 + + (start_block...(start_block + n)).to_a + end + + def propose_ethscriptions_block(ethscriptions_block:, ethscription_txs:) + geth_driver.propose_block( + transactions: ethscription_txs, + new_ethscriptions_block: ethscriptions_block, + head_block: current_facet_head_block, + safe_block: current_facet_safe_block, + finalized_block: current_facet_finalized_block + ) + end + + def geth_driver + @geth_driver + end + + def get_validation_failure_block + # Get the earliest failed block that's behind current import (for real failures) + current_position = current_max_eth_block_number + ValidationResult.failed.where('l1_block <= ?', current_position).order(:l1_block).first&.l1_block + end + + def real_validation_failure_detected? + # Only consider real validation failures BEHIND current import position as critical + current_position = current_max_eth_block_number + ValidationResult.failed.where('l1_block <= ?', current_position).exists? + end + + def validation_stalled? + return false unless ENV.fetch('VALIDATION_ENABLED').casecmp?('true') + + current_position = current_max_eth_block_number + + # Only check every 5 blocks to reduce DB queries + return false unless current_position % 5 == 0 + + genesis_block = SysConfig.l1_genesis_block_number + hard_limit = ENV.fetch('VALIDATION_LAG_HARD_LIMIT', 30).to_i + + # Check for validation gaps in recent blocks + # Start from the later of: genesis+1 or (current - limit + 1) + # This ensures we never try to validate genesis itself or before it + check_range_start = [current_position - hard_limit + 1, genesis_block + 1].max + check_range_end = current_position + + # Count ALL missing validations in critical range (includes both gaps and lag) + ImportProfiler.start("validation_gap_count") + gap_count = ValidationResult.validation_gap_count(check_range_start, check_range_end) + ImportProfiler.stop("validation_gap_count") + + if gap_count >= hard_limit + Rails.logger.error "Too many validation gaps: #{gap_count} unvalidated blocks in range #{check_range_start}-#{check_range_end} (limit: #{hard_limit})" + return true + end + + false + end + + public + + def cleanup_stale_validation_records + # Remove validation records AND pending jobs ahead of our starting position + # These are from previous runs and may be stale due to reorgs + starting_position = current_max_eth_block_number + stale_count = ValidationResult.where('l1_block > ?', starting_position).count + + if stale_count > 0 + logger.info "Cleaning up #{stale_count} stale validation records ahead of block #{starting_position}" + ValidationResult.where('l1_block > ?', starting_position).delete_all + end + + # Cancel all pending validation jobs on startup (fresh start) + pending_jobs = SolidQueue::Job.where(queue_name: 'validation', finished_at: nil) + + if pending_jobs.exists? + cancelled_count = pending_jobs.count + logger.info "Cancelling #{cancelled_count} pending validation jobs from previous run" + pending_jobs.delete_all + end + end + + def report_import_stats(blocks_imported_count:, stats_start_time:, stats_start_block:, + total_gas_used:, total_transactions:, imported_l2_blocks:, recent_batch_time:) + ImportProfiler.start("report_import_stats") + + elapsed_time = Time.current - stats_start_time + current_block = current_max_eth_block_number + + # Calculate cumulative metrics (entire session) + cumulative_blocks_per_second = blocks_imported_count / elapsed_time + cumulative_transactions_per_second = total_transactions / elapsed_time + total_gas_millions = (total_gas_used / 1_000_000.0).round(2) + cumulative_gas_per_second_millions = (total_gas_used / elapsed_time / 1_000_000.0).round(2) + + # Calculate recent batch metrics (last 25 blocks using actual timing) + recent_l2_blocks = imported_l2_blocks.last(25) + recent_gas = recent_l2_blocks.sum { |block| block.gas_used || 0 } + recent_transactions = recent_l2_blocks.sum { |block| block.ethscription_transactions&.length || 0 } + + recent_blocks_per_second = 25 / recent_batch_time + recent_transactions_per_second = recent_transactions / recent_batch_time + recent_gas_millions = (recent_gas / 1_000_000.0).round(2) + recent_gas_per_second_millions = (recent_gas / recent_batch_time / 1_000_000.0).round(2) + + # Build single comprehensive stats message + stats_message = <<~MSG + #{"=" * 70} + 📊 IMPORT STATS + 🏁 Blocks: #{stats_start_block + 1} → #{current_block} (#{blocks_imported_count} total) + + ⚡ Speed: #{recent_blocks_per_second.round(1)} bl/s (#{cumulative_blocks_per_second.round(1)} session) + 📝 Transactions: #{recent_transactions} (#{total_transactions} total) | #{recent_transactions_per_second.round(1)}/s (#{cumulative_transactions_per_second.round(1)}/s session) + ⛽ Gas: #{recent_gas_millions}M (#{total_gas_millions}M total) | #{recent_gas_per_second_millions.round(1)}M/s (#{cumulative_gas_per_second_millions.round(1)}M/s session) + ⏱️ Time: #{recent_batch_time.round(1)}s recent | #{elapsed_time.round(1)}s total session + MSG + + # Add validation stats to message + if ENV.fetch('VALIDATION_ENABLED').casecmp?('true') + last_validated = ValidationResult.last_validated_block || 0 + validation_lag = current_block - last_validated + + lag_status = case validation_lag + when 0..5 then "✅ CURRENT" + when 6..25 then "⚠️ BEHIND" + when 26..100 then "🟡 LAGGING" + else "🔴 VERY BEHIND" + end + + validation_line = "🔍 VALIDATION: #{lag_status} (#{validation_lag} behind)" + else + validation_line = "🔍 Validation: DISABLED" + end + + # Add prefetcher stats if available + if blocks_imported_count >= 10 + stats = @prefetcher.stats + prefetcher_line = "🔄 Prefetcher: #{stats[:promises_fulfilled]}/#{stats[:promises_total]} fulfilled (#{stats[:threads_active]} active, #{stats[:threads_queued]} queued)" + else + prefetcher_line = "" + end + + # Combine validation and prefetcher stats into main message + stats_message += "\n#{validation_line}" + stats_message += "\n#{prefetcher_line}" if prefetcher_line.present? + stats_message += "\n#{"=" * 70}" + + # Output single message to reduce flicker + logger.info stats_message + + if ImportProfiler.enabled? + logger.info "" + logger.info "🔍 DETAILED PROFILER STATS:" + ImportProfiler.stop("report_import_stats") + ImportProfiler.report + ImportProfiler.reset + end + + logger.info "=" * 70 + end + + public + + def shutdown + @prefetcher&.shutdown + end +end diff --git a/app/services/geth_driver.rb b/app/services/geth_driver.rb new file mode 100644 index 0000000..01c0030 --- /dev/null +++ b/app/services/geth_driver.rb @@ -0,0 +1,326 @@ +module GethDriver + extend self + attr_reader :password + include Memery + + def client + @_client ||= EthRpcClient.l2_engine + end + + def non_auth_client + @_non_auth_client ||= EthRpcClient.l2 + end + + def get_l1_attributes(l2_block_number) + if l2_block_number > 0 + l2_block = EthRpcClient.l2.call("eth_getBlockByNumber", ["0x#{l2_block_number.to_s(16)}", true]) + l2_attributes_tx = l2_block['transactions'].first + L1AttributesTxCalldata.decode( + ByteString.from_hex(l2_attributes_tx['input']), + l2_block_number + ) + else + l1_block = EthRpcClient.l1.get_block(SysConfig.l1_genesis_block_number) + eth_block = EthBlock.from_rpc_result(l1_block) + { + timestamp: eth_block.timestamp, + number: eth_block.number, + base_fee: eth_block.base_fee_per_gas, + blob_base_fee: 1, + hash: eth_block.block_hash, + batcher_hash: Hash32.from_bin("\x00".b * 32), + sequence_number: 0, + base_fee_scalar: 0, + blob_base_fee_scalar: 1 + }.with_indifferent_access + end + end + memoize :get_l1_attributes + + def non_authed_rpc_url + ENV.fetch('NON_AUTH_GETH_RPC_URL') + end + + def propose_block( + transactions:, + new_ethscriptions_block:, + head_block:, + safe_block:, + finalized_block: + ) + # Create filler blocks if necessary and update head_block + ImportProfiler.start("create_filler_blocks") + filler_blocks = create_filler_blocks( + head_block: head_block, + new_ethscriptions_block: new_ethscriptions_block, + safe_block: safe_block, + finalized_block: finalized_block + ) + ImportProfiler.stop("create_filler_blocks") + + head_block = filler_blocks.last || head_block + + new_ethscriptions_block.number = head_block.number + 1 + + # Update block hashes after filler blocks have been added + head_block_hash = head_block.block_hash + safe_block_hash = safe_block.block_hash + finalized_block_hash = finalized_block.block_hash + + fork_choice_state = { + headBlockHash: head_block_hash, + safeBlockHash: safe_block_hash, + finalizedBlockHash: finalized_block_hash, + } + + # No mint calculations needed for Ethscriptions (mint is always 0) + + system_txs = [new_ethscriptions_block.attributes_tx] + + # No migration transactions needed for Ethscriptions + # No L1Block upgrades needed (always post-Bluebird) + + transactions_with_attributes = system_txs + transactions + transaction_payloads = transactions_with_attributes.map(&:to_deposit_payload) + + payload_attributes = { + timestamp: "0x" + new_ethscriptions_block.timestamp.to_s(16), + prevRandao: new_ethscriptions_block.prev_randao, + suggestedFeeRecipient: "0x0000000000000000000000000000000000000000", + withdrawals: [], + noTxPool: true, + transactions: transaction_payloads, + gasLimit: "0x" + SysConfig.block_gas_limit(new_ethscriptions_block).to_s(16), + } + + if new_ethscriptions_block.parent_beacon_block_root + version = 3 + payload_attributes[:parentBeaconBlockRoot] = new_ethscriptions_block.parent_beacon_block_root + else + version = 2 + end + + payload_attributes = ByteString.deep_hexify(payload_attributes) + fork_choice_state = ByteString.deep_hexify(fork_choice_state) + + ImportProfiler.start("engine_forkchoiceUpdated_1") + fork_choice_response = client.call("engine_forkchoiceUpdatedV#{version}", [fork_choice_state, payload_attributes]) + ImportProfiler.stop("engine_forkchoiceUpdated_1") + + if fork_choice_response['error'] + raise "Fork choice update failed: #{fork_choice_response['error']}" + end + + payload_id = fork_choice_response['payloadId'] + unless payload_id + raise "Fork choice update did not return a payload ID" + end + + ImportProfiler.start("engine_getPayload") + get_payload_response = client.call("engine_getPayloadV#{version}", [payload_id]) + ImportProfiler.stop("engine_getPayload") + + if get_payload_response['error'] + raise "Get payload failed: #{get_payload_response['error']}" + end + + payload = get_payload_response['executionPayload'] + + if payload['transactions'].empty? + raise "No transactions in returned payload" + end + + new_payload_request = [payload] + + if version == 3 + new_payload_request << [] + new_payload_request << new_ethscriptions_block.parent_beacon_block_root + end + + new_payload_request = ByteString.deep_hexify(new_payload_request) + + ImportProfiler.start("engine_newPayload") + new_payload_response = client.call("engine_newPayloadV#{version}", new_payload_request) + ImportProfiler.stop("engine_newPayload") + + status = new_payload_response['status'] + unless status == 'VALID' + raise "New payload was not valid: #{status}" + end + + unless new_payload_response['latestValidHash'] == payload['blockHash'] + raise "New payload latestValidHash mismatch: #{new_payload_response['latestValidHash']}" + end + + new_safe_block = safe_block + new_finalized_block = finalized_block + + fork_choice_state = { + headBlockHash: payload['blockHash'], + safeBlockHash: new_safe_block.block_hash, + finalizedBlockHash: new_finalized_block.block_hash + } + + fork_choice_state = ByteString.deep_hexify(fork_choice_state) + + ImportProfiler.start("engine_forkchoiceUpdated_2") + fork_choice_response = client.call("engine_forkchoiceUpdatedV#{version}", [fork_choice_state, nil]) + ImportProfiler.stop("engine_forkchoiceUpdated_2") + + status = fork_choice_response['payloadStatus']['status'] + unless status == 'VALID' + raise "Fork choice update was not valid: #{status}" + end + + unless fork_choice_response['payloadStatus']['latestValidHash'] == payload['blockHash'] + raise "Fork choice update latestValidHash mismatch: #{fork_choice_response['payloadStatus']['latestValidHash']}" + end + + new_ethscriptions_block.from_rpc_response(payload) + filler_blocks + [new_ethscriptions_block] + end + + def create_filler_blocks( + head_block:, + new_ethscriptions_block:, + safe_block:, + finalized_block: + ) + max_filler_blocks = 100 + block_interval = 12 + last_block = head_block + filler_blocks = [] + + diff = new_ethscriptions_block.timestamp - last_block.timestamp + + if diff > block_interval + num_intervals = (diff / block_interval).to_i + aligns_exactly = (diff % block_interval).zero? + num_filler_blocks = aligns_exactly ? num_intervals - 1 : num_intervals + + if num_filler_blocks > max_filler_blocks + raise "Too many filler blocks" + end + + num_filler_blocks.times do + filler_block = EthscriptionsBlock.next_in_sequence_from_ethscriptions_block(last_block) + + proposed_blocks = GethDriver.propose_block( + transactions: [], + new_ethscriptions_block: filler_block, + head_block: last_block, + safe_block: safe_block, + finalized_block: finalized_block, + ).sort_by(&:number) + + filler_blocks.concat(proposed_blocks) + last_block = proposed_blocks.last + end + end + + filler_blocks.sort_by(&:number) + end + + def init_command + http_port = ENV.fetch('NON_AUTH_GETH_RPC_URL').split(':').last + authrpc_port = ENV.fetch('GETH_RPC_URL').split(':').last + discovery_port = ENV.fetch('GETH_DISCOVERY_PORT') + + genesis_filename = ChainIdManager.on_mainnet? ? "ethscriptions-mainnet.json" : "ethscriptions-sepolia.json" + + command = [ + "make geth &&", + "mkdir -p ./datadir &&", + "rm -rf ./datadir/* &&", + "./build/bin/geth init --cache.preimages --state.scheme=hash --datadir ./datadir genesis-files/#{genesis_filename} &&", + "./build/bin/geth --datadir ./datadir", + "--http", + "--http.api 'eth,net,web3,debug'", + "--http.vhosts=\"*\"", + "--authrpc.jwtsecret /tmp/jwtsecret", + "--http.port #{http_port}", + '--http.corsdomain="*"', + "--authrpc.port #{authrpc_port}", + "--discovery.port #{discovery_port}", + "--port #{discovery_port}", + "--authrpc.addr localhost", + "--authrpc.vhosts=\"*\"", + "--nodiscover", + "--cache 32000", + "--rpc.gascap 5000000000", + "--rpc.batch-request-limit=10000", + "--rpc.batch-response-max-size=100000000", + "--cache.preimages", + "--maxpeers 0", + # "--verbosity 2", + "--syncmode full", + "--gcmode archive", + "--history.state 0", + "--history.transactions 0", + "--rollup.enabletxpooladmission=false", + "--rollup.disabletxpoolgossip", + "--override.canyon", "0", + "console" + ].join(' ') + + puts command + end + + def get_state_dump(geth_dir = ENV.fetch('LOCAL_GETH_DIR')) + command = [ + "#{geth_dir}/build/bin/geth", + 'dump', + "--datadir #{geth_dir}/datadir" + ] + + full_command = command.join(' ') + + data = `#{full_command}` + + alloc = {} + + data.each_line do |line| + entry = JSON.parse(line) + address = entry['address'] + + next unless address + + alloc[address] = { + 'balance' => entry['balance'].to_i(16), + 'nonce' => entry['nonce'], + 'code' => entry['code'].presence || "0x", + 'storage' => entry['storage'].presence || {} + } + end + + alloc + end + + def trace_transaction(tx_hash) + non_auth_client.call("debug_traceTransaction", [tx_hash, { + enableMemory: true, + disableStack: false, + disableStorage: false, + enableReturnData: true, + debug: true, + tracer: "callTracer" + }]) + end + + def check_failed_system_txs(block_to_check, context) + receipts = EthRpcClient.l2.get_block_receipts(block_to_check) + + failed_system_txs = receipts.select do |receipt| + SysConfig::SYSTEM_ADDRESS == Address20.from_hex(receipt['from']) && + receipt['status'] != '0x1' + end + + unless failed_system_txs.empty? + failed_system_txs.each do |tx| + trace = EthRpcClient.l2.trace_transaction(tx['transactionHash']) + puts trace # Use puts instead of ap which might not be available + end + raise "#{context} system transactions did not execute successfully" + end + end +end diff --git a/app/services/l1_attributes_tx_calldata.rb b/app/services/l1_attributes_tx_calldata.rb new file mode 100644 index 0000000..85f52f3 --- /dev/null +++ b/app/services/l1_attributes_tx_calldata.rb @@ -0,0 +1,59 @@ +module L1AttributesTxCalldata + extend self + + FUNCTION_SELECTOR = Eth::Util.keccak256('setL1BlockValuesEcotone()').first(4) + + sig { params(ethscriptions_block: EthscriptionsBlock).returns(ByteString) } + def build(ethscriptions_block) + base_fee_scalar = 0 + blob_base_fee_scalar = 1 # TODO: use real values + blob_base_fee = 1 + batcher_hash = "\x00" * 32 + + packed_data = [ + FUNCTION_SELECTOR, + Eth::Util.zpad_int(base_fee_scalar, 4), + Eth::Util.zpad_int(blob_base_fee_scalar, 4), + Eth::Util.zpad_int(ethscriptions_block.sequence_number, 8), + Eth::Util.zpad_int(ethscriptions_block.eth_block_timestamp, 8), + Eth::Util.zpad_int(ethscriptions_block.eth_block_number, 8), + Eth::Util.zpad_int(ethscriptions_block.eth_block_base_fee_per_gas, 32), + Eth::Util.zpad_int(blob_base_fee, 32), + ethscriptions_block.eth_block_hash.to_bin, + batcher_hash + ] + + ByteString.from_bin(packed_data.join) + end + + sig { params(calldata: ByteString, block_number: Integer).returns(T::Hash[Symbol, T.untyped]) } + def decode(calldata, block_number) + data = calldata.to_bin + + # Remove the function selector + data = data[4..-1] + + # Unpack the data + base_fee_scalar = data[0...4].unpack1('N') + blob_base_fee_scalar = data[4...8].unpack1('N') + sequence_number = data[8...16].unpack1('Q>') + timestamp = data[16...24].unpack1('Q>') + number = data[24...32].unpack1('Q>') + base_fee = data[32...64].unpack1('H*').to_i(16) + blob_base_fee = data[64...96].unpack1('H*').to_i(16) + hash = data[96...128].unpack1('H*') + batcher_hash = data[128...160].unpack1('H*') + + { + timestamp: timestamp, + number: number, + base_fee: base_fee, + blob_base_fee: blob_base_fee, + hash: Hash32.from_hex("0x#{hash}"), + batcher_hash: Hash32.from_hex("0x#{batcher_hash}"), + sequence_number: sequence_number, + blob_base_fee_scalar: blob_base_fee_scalar, + base_fee_scalar: base_fee_scalar + }.with_indifferent_access + end +end \ No newline at end of file diff --git a/bin/dev b/bin/dev new file mode 100755 index 0000000..5f91c20 --- /dev/null +++ b/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 0000000..dcf59f3 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/setup b/bin/setup index 3cd5a9d..be3db3c 100755 --- a/bin/setup +++ b/bin/setup @@ -1,7 +1,6 @@ #!/usr/bin/env ruby require "fileutils" -# path to your application root. APP_ROOT = File.expand_path("..", __dir__) def system!(*args) @@ -14,7 +13,6 @@ FileUtils.chdir APP_ROOT do # Add necessary setup steps to this file. puts "== Installing dependencies ==" - system! "gem install bundler --conservative" system("bundle check") || system!("bundle install") # puts "\n== Copying sample files ==" @@ -28,6 +26,9 @@ FileUtils.chdir APP_ROOT do puts "\n== Removing old logs and tempfiles ==" system! "bin/rails log:clear tmp:clear" - puts "\n== Restarting application server ==" - system! "bin/rails restart" + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end end diff --git a/collections_data.tar.gz b/collections_data.tar.gz new file mode 100644 index 0000000..2605bfb Binary files /dev/null and b/collections_data.tar.gz differ diff --git a/compress_collections.sh b/compress_collections.sh new file mode 100755 index 0000000..7ce41a4 --- /dev/null +++ b/compress_collections.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# Script to compress collection JSON files into a single tar.gz archive + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +ITEMS_FILE="items_by_ethscription.json" +COLLECTIONS_FILE="collections_by_name.json" +ARCHIVE_FILE="collections_data.tar.gz" + +# Check if JSON files exist +if [ ! -f "$ITEMS_FILE" ]; then + echo "Error: $ITEMS_FILE not found!" + exit 1 +fi + +if [ ! -f "$COLLECTIONS_FILE" ]; then + echo "Error: $COLLECTIONS_FILE not found!" + exit 1 +fi + +# Create tar.gz archive +echo "Creating $ARCHIVE_FILE..." +tar -czf "$ARCHIVE_FILE" "$ITEMS_FILE" "$COLLECTIONS_FILE" + +if [ $? -eq 0 ]; then + echo "Successfully created $ARCHIVE_FILE" + + # Show file sizes for comparison + echo "" + echo "File sizes:" + ls -lh "$ITEMS_FILE" "$COLLECTIONS_FILE" "$ARCHIVE_FILE" | awk '{print $9 ": " $5}' + + # Calculate compression ratio (macOS compatible) + ORIGINAL_SIZE=$(stat -f %z "$ITEMS_FILE" "$COLLECTIONS_FILE" 2>/dev/null | awk '{sum += $1} END {print sum}') + if [ -z "$ORIGINAL_SIZE" ]; then + # Linux fallback + ORIGINAL_SIZE=$(stat -c %s "$ITEMS_FILE" "$COLLECTIONS_FILE" 2>/dev/null | awk '{sum += $1} END {print sum}') + fi + COMPRESSED_SIZE=$(stat -f %z "$ARCHIVE_FILE" 2>/dev/null || stat -c %s "$ARCHIVE_FILE" 2>/dev/null) + if [ -n "$ORIGINAL_SIZE" ] && [ -n "$COMPRESSED_SIZE" ]; then + RATIO=$(echo "scale=2; (1 - $COMPRESSED_SIZE / $ORIGINAL_SIZE) * 100" | bc) + else + RATIO="N/A" + fi + + echo "" + echo "Compression ratio: ${RATIO}% size reduction" +else + echo "Error: Failed to create archive" + exit 1 +fi \ No newline at end of file diff --git a/config/application.rb b/config/application.rb index abfee58..64e1932 100644 --- a/config/application.rb +++ b/config/application.rb @@ -5,14 +5,6 @@ require "active_model/railtie" require "active_job/railtie" require "active_record/railtie" -# require "active_storage/engine" -require "action_controller/railtie" -# require "action_mailer/railtie" -# require "action_mailbox/engine" -# require "action_text/engine" -require "action_view/railtie" -# require "action_cable/engine" -# require "rails/test_unit/railtie" # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. @@ -37,7 +29,13 @@ class Application < Rails::Application config.autoload_paths += additional_paths config.eager_load_paths += additional_paths - config.active_record.schema_format = :sql + # config.active_record.schema_format = :sql + + # Configure SolidQueue as the job adapter + config.active_job.queue_adapter = :solid_queue + + # Fix timezone deprecation warning + config.active_support.to_time_preserves_timezone = :zone # Configuration for the application, engines, and railties goes here. # diff --git a/config/database.yml b/config/database.yml index cba0130..ee439f0 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,31 +1,31 @@ default: &default - adapter: postgresql - encoding: unicode - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - -primary: &primary - <<: *default - url: <%= ENV.fetch("DATABASE_URL") %> - -primary_replica: &primary_replica - <<: *default - url: <%= ENV['DATABASE_REPLICA_URL'] %> - replica: true + adapter: sqlite3 + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { ENV.fetch("JOB_THREADS", 3).to_i + 2 }.to_i %> + timeout: 5000 development: primary: - <<: *primary - primary_replica: - <<: *primary_replica + <<: *default + database: storage/development.sqlite3 + queue: + <<: *default + database: storage/development_queue.sqlite3 + migrations_paths: db/queue_migrate test: primary: - <<: *primary - primary_replica: - <<: *primary_replica + <<: *default + database: storage/test.sqlite3 + queue: + <<: *default + database: storage/test_queue.sqlite3 + migrations_paths: db/queue_migrate production: primary: - <<: *primary - primary_replica: - <<: *primary_replica + <<: *default + database: storage/production.sqlite3 + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate diff --git a/config/derive_ethscriptions_blocks.rb b/config/derive_ethscriptions_blocks.rb new file mode 100644 index 0000000..8ecb281 --- /dev/null +++ b/config/derive_ethscriptions_blocks.rb @@ -0,0 +1,193 @@ +require 'clockwork' +require './config/boot' +require './config/environment' +require 'active_support/time' +require 'optparse' + +# Define required arguments, descriptions, and defaults +REQUIRED_CONFIG = { + 'L1_NETWORK' => { description: 'L1 network (e.g., mainnet, sepolia)', required: true }, + 'GETH_RPC_URL' => { description: 'Geth Engine API RPC URL (with JWT auth)', required: true }, + 'NON_AUTH_GETH_RPC_URL' => { description: 'Geth HTTP RPC URL (no auth)', required: true }, + 'L1_RPC_URL' => { description: 'L1 RPC URL for fetching blocks', required: true }, + 'JWT_SECRET' => { description: 'JWT Secret for Engine API', required: true }, + 'L1_GENESIS_BLOCK' => { description: 'L1 Genesis Block number', required: true }, + 'L1_PREFETCH_THREADS' => { description: 'L1 prefetch thread count', default: '2' }, + 'VALIDATION_ENABLED' => { description: 'Enable validation (true/false)', default: 'false' }, + 'JOB_CONCURRENCY' => { description: 'SolidQueue worker processes', default: '2' }, + 'IMPORT_INTERVAL' => { description: 'Seconds between import attempts', default: '6' } +} + +# Parse command line options +options = {} +parser = OptionParser.new do |opts| + opts.banner = "Usage: clockwork derive_ethscriptions_blocks.rb [options]" + + REQUIRED_CONFIG.each do |key, config| + flag = "--#{key.downcase.tr('_', '-')}" + opts.on("#{flag} VALUE", config[:description]) do |v| + options[key] = v + end + end + + opts.on("-h", "--help", "Show this help message") do + puts opts + puts "\nEnvironment variables can also be used for any option." + puts "\nExample:" + puts " L1_NETWORK=mainnet L1_RPC_URL=https://eth.llamarpc.com GETH_RPC_URL=http://localhost:9545 \\" + puts " NON_AUTH_GETH_RPC_URL=http://localhost:8545 JWT_SECRET=/tmp/jwtsecret \\" + puts " L1_GENESIS_BLOCK=17478951 VALIDATION_ENABLED=true \\" + puts " bundle exec clockwork config/derive_ethscriptions_blocks.rb" + exit + end +end + +parser.parse! + +# Merge ENV vars with command line options and defaults +config = REQUIRED_CONFIG.each_with_object({}) do |(key, config_opts), hash| + hash[key] = options[key] || ENV[key] || config_opts[:default] +end + +# Check for missing required values +missing = config.select do |key, value| + REQUIRED_CONFIG[key][:required] && (value.nil? || value.empty?) +end + +if missing.any? + puts "Missing required configuration:" + missing.each do |key, _| + puts " #{key}: #{REQUIRED_CONFIG[key][:description]}" + puts " Set via environment variable: #{key}=value" + puts " Or via command line: --#{key.downcase.tr('_', '-')} value" + end + + puts "\nExample usage:" + puts " L1_NETWORK=mainnet L1_RPC_URL=https://eth.llamarpc.com GETH_RPC_URL=http://localhost:9545 \\" + puts " NON_AUTH_GETH_RPC_URL=http://localhost:8545 JWT_SECRET=/tmp/jwtsecret \\" + puts " L1_GENESIS_BLOCK=17478951 \\" + puts " bundle exec clockwork config/derive_ethscriptions_blocks.rb" + exit 1 +end + +# Set final values in ENV +config.each { |key, value| ENV[key] = value } + +# Display configuration +puts "="*80 +puts "Starting Ethscriptions Block Importer" +puts "="*80 +puts "Configuration:" +puts " L1 Network: #{ENV['L1_NETWORK']}" +puts " L1 Genesis Block: #{ENV['L1_GENESIS_BLOCK']}" +puts " L1 RPC: #{ENV['L1_RPC_URL'][0..30]}..." +puts " Geth RPC: #{ENV['NON_AUTH_GETH_RPC_URL']}" +puts " L1 Prefetch Threads: #{ENV['L1_PREFETCH_THREADS']}" +puts " Job Concurrency: #{ENV['JOB_CONCURRENCY']}" +puts " Validation: #{ENV.fetch('VALIDATION_ENABLED').casecmp?('true') ? 'ENABLED' : 'disabled'}" +puts " Import Interval: #{ENV['IMPORT_INTERVAL']}s" +puts "="*80 + +module Clockwork + handler do |job| + puts "\n[#{Time.now}] Running #{job}" + end + + error_handler do |error| + report_exception_every = 15.minutes + + exception_key = ["clockwork-ethscriptions", error.class, error.message, error.backtrace[0]] + + last_reported_at = Rails.cache.read(exception_key) + + if last_reported_at.blank? || (Time.zone.now - last_reported_at > report_exception_every) + Rails.logger.error "Clockwork error: #{error.class} - #{error.message}" + Rails.logger.error error.backtrace.first(10).join("\n") + + # Report to Airbrake if configured + Airbrake.notify(error) if defined?(Airbrake) + + Rails.cache.write(exception_key, Time.zone.now) + end + end + + import_interval = ENV.fetch('IMPORT_INTERVAL', '6').to_i + + every(import_interval.seconds, 'import_ethscriptions_blocks') do + importer = EthBlockImporter.new + + # Track statistics + total_blocks_imported = 0 + start_time = Time.now + + begin + loop do + begin + initial_block = importer.current_max_eth_block_number + + # Import blocks + importer.import_blocks_until_done + + final_block = importer.current_max_eth_block_number + blocks_imported = final_block - initial_block + + if blocks_imported > 0 + total_blocks_imported += blocks_imported + + puts "[#{Time.now}] Imported #{blocks_imported} blocks (#{initial_block + 1} to #{final_block})" + else + # We're caught up + elapsed = (Time.now - start_time).round(2) + + if total_blocks_imported > 0 + puts "[#{Time.now}] Session summary: Imported #{total_blocks_imported} blocks in #{elapsed}s" + + # Reset counters + total_blocks_imported = 0 + start_time = Time.now + end + + puts "[#{Time.now}] Caught up at block #{final_block}. Waiting #{import_interval}s..." + end + + rescue EthBlockImporter::BlockNotReadyToImportError => e + # This is normal when caught up + current = importer.current_max_eth_block_number + puts "[#{Time.now}] Waiting for new blocks (current: #{current})..." + + rescue EthBlockImporter::ReorgDetectedError => e + Rails.logger.warn "[#{Time.now}] ⚠️ Reorg detected! Reinitializing importer..." + puts "[#{Time.now}] ⚠️ Reorg detected at block #{importer.current_max_eth_block_number}" + + # Reinitialize importer to handle reorg + importer.shutdown + importer = EthBlockImporter.new + puts "[#{Time.now}] Importer reinitialized. Continuing from block #{importer.current_max_eth_block_number}" + + rescue EthBlockImporter::ValidationFailureError => e + Rails.logger.fatal "[#{Time.now}] 🛑 VALIDATION FAILURE: #{e.message}" + puts "[#{Time.now}] 🛑 VALIDATION FAILURE - System stopping for investigation" + puts "[#{Time.now}] Fix the validation issue and restart manually" + exit 1 + + rescue EthBlockImporter::ValidationStalledError => e + Rails.logger.info "[#{Time.now}] ⏸️ VALIDATION BEHIND: #{e.message}" + puts "[#{Time.now}] ⏸️ Validation is behind - waiting #{import_interval}s for validation to catch up..." + # Don't sleep here - the loop's sleep at line 192 will handle it + rescue => e + Rails.logger.error "Import error: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + puts "[#{Time.now}] ❌ Error: #{e.message}" + + # For other errors, wait and retry + puts "[#{Time.now}] Retrying in #{import_interval}s..." + end + + sleep import_interval + end + ensure + importer&.shutdown + end + end +end diff --git a/config/environments/development.rb b/config/environments/development.rb index bd740a7..4c00a8a 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -12,33 +12,29 @@ config.eager_load = true # Show full error reports. - config.consider_all_requests_local = true + config.consider_all_requests_local = false # Enable server timing - config.server_timing = true - - # Enable/disable caching. By default caching is disabled. - # Run rails dev:cache to toggle caching. - if Rails.root.join("tmp/caching-dev.txt").exist? - config.cache_store = :mem_cache_store, - 'localhost', - { - failover: true, - socket_timeout: 1.5, - socket_failure_delay: 0.2, - down_retry_delay: 60, - compress: true - } - config.public_file_server.headers = { - "Cache-Control" => "public, max-age=#{2.days.to_i}" - } - else - config.action_controller.perform_caching = false - - config.cache_store = :null_store - end + config.server_timing = false - config.logger = ActiveSupport::Logger.new('/dev/null') + # Enable file-based caching for persistent checkpoints + config.action_controller.perform_caching = true + config.cache_store = :file_store, Rails.root.join("tmp", "cache", "checkpoints") + + # Output logger to STDOUT for development + config.logger = ActiveSupport::Logger.new(STDOUT) + config.logger.formatter = Logger::Formatter.new + config.log_level = :info + + # Reduce ActiveJob/SolidQueue log noise + config.active_job.logger = Logger.new(STDOUT) + config.active_job.logger.level = Logger::WARN + config.solid_queue.logger = Logger.new(STDOUT) + config.solid_queue.logger.level = Logger::WARN + + # Use Solid Queue in Development. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log @@ -50,15 +46,15 @@ config.active_support.disallowed_deprecation_warnings = [] # Raise an error on page load if there are pending migrations. - config.active_record.migration_error = :page_load + # config.active_record.migration_error = :page_load # Highlight code that triggered database queries in logs. - config.active_record.verbose_query_logs = true + # config.active_record.verbose_query_logs = true # Highlight code that enqueued background job in logs. config.active_job.verbose_enqueue_logs = true - config.active_record.async_query_executor = :global_thread_pool + # config.active_record.async_query_executor = :global_thread_pool # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true diff --git a/config/environments/production.rb b/config/environments/production.rb index 93fb523..59e0fb7 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -49,20 +49,12 @@ # want to log everything, set the level to "debug". config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") - # Use a different cache store in production. - config.cache_store = :mem_cache_store, - (ENV["MEMCACHIER_SERVERS"] || "").split(","), - {:username => ENV["MEMCACHIER_USERNAME"], - :password => ENV["MEMCACHIER_PASSWORD"], - :failover => true, - :socket_timeout => 1.5, - :socket_failure_delay => 0.2, - :down_retry_delay => 60, - compress: true - } + # Use file-based cache store for persistent checkpoints + config.cache_store = :file_store, Rails.root.join("tmp", "cache", "checkpoints") # Use a real queuing backend for Active Job (and separate queues per environment). - # config.active_job.queue_adapter = :resque + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } # config.active_job.queue_name_prefix = "eths_indexer_production" # Enable locale fallbacks for I18n (makes lookups for any locale fall back to @@ -73,9 +65,9 @@ config.active_support.report_deprecations = false # Do not dump schema after migrations. - config.active_record.dump_schema_after_migration = false - - config.active_record.async_query_executor = :global_thread_pool + # config.active_record.dump_schema_after_migration = false + + # config.active_record.async_query_executor = :global_thread_pool # Enable DNS rebinding protection and other `Host` header attacks. # config.hosts = [ diff --git a/config/initializers/active_record_query_trace.rb b/config/initializers/active_record_query_trace.rb deleted file mode 100644 index 60629a3..0000000 --- a/config/initializers/active_record_query_trace.rb +++ /dev/null @@ -1,5 +0,0 @@ -if Rails.env.development? - ActiveRecordQueryTrace.enabled = false - ActiveRecordQueryTrace.ignore_cached_queries = true # Default is false. - ActiveRecordQueryTrace.colorize = :light_purple # Colorize in specific color -end diff --git a/config/initializers/airbrake.rb b/config/initializers/airbrake.rb index e214c47..1add343 100644 --- a/config/initializers/airbrake.rb +++ b/config/initializers/airbrake.rb @@ -9,7 +9,8 @@ # # Configuration details: # https://github.com/airbrake/airbrake-ruby#configuration -if (project_id = ENV['AIRBRAKE_PROJECT_ID']) && +if Rails.env.production? && + (project_id = ENV['AIRBRAKE_PROJECT_ID']) && project_key = (ENV['AIRBRAKE_PROJECT_KEY'] || ENV['AIRBRAKE_API_KEY']) Airbrake.configure do |c| # You must set both project_id & project_key. To find your project_id and diff --git a/config/initializers/array_extensions.rb b/config/initializers/array_extensions.rb deleted file mode 100644 index ce4b770..0000000 --- a/config/initializers/array_extensions.rb +++ /dev/null @@ -1,5 +0,0 @@ -class Array - def to_cache_key(namespace = nil) - ActiveSupport::Cache.expand_cache_key(self, namespace) - end -end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb deleted file mode 100644 index b0422c8..0000000 --- a/config/initializers/cors.rb +++ /dev/null @@ -1,8 +0,0 @@ -Rails.application.config.middleware.insert_before 0, Rack::Cors do - allow do - origins '*' - resource '*', - headers: :any, - methods: [:get, :post, :put, :patch, :delete, :options, :head] - end -end diff --git a/config/initializers/dotenv.rb b/config/initializers/dotenv.rb new file mode 100644 index 0000000..019029b --- /dev/null +++ b/config/initializers/dotenv.rb @@ -0,0 +1,15 @@ +unless Rails.env.production? + require 'dotenv' + + Dotenv.load + + if ENV['L1_NETWORK'] == 'sepolia' + sepolia_env = Rails.root.join('.env.sepolia') + Dotenv.load(sepolia_env) if File.exist?(sepolia_env) + elsif ENV['L1_NETWORK'] == 'mainnet' + mainnet_env = Rails.root.join('.env.mainnet') + Dotenv.load(mainnet_env) if File.exist?(mainnet_env) + else + raise "Unknown L1_NETWORK: #{ENV['L1_NETWORK']}" + end +end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb deleted file mode 100644 index 7ce9cc6..0000000 --- a/config/initializers/filter_parameter_logging.rb +++ /dev/null @@ -1,12 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Configure parameters to be filtered from the log file. Use this to limit dissemination of -# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported -# notations and behaviors. -Rails.application.config.filter_parameters += [ - :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn -] - -if defined?(Rails::Console) || Rails.env.test? - Rails.application.config.filter_parameters = [] -end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb deleted file mode 100644 index 3860f65..0000000 --- a/config/initializers/inflections.rb +++ /dev/null @@ -1,16 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Add new inflection rules using the following format. Inflections -# are locale specific, and you may define rules for as many different -# locales as you wish. All of these examples are active by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.plural /^(ox)$/i, "\\1en" -# inflect.singular /^(ox)en/i, "\\1" -# inflect.irregular "person", "people" -# inflect.uncountable %w( fish sheep ) -# end - -# These inflection rules are supported but not enabled by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym "RESTful" -# end diff --git a/config/initializers/memery_extensions.rb b/config/initializers/memery_extensions.rb new file mode 100644 index 0000000..0dbebf6 --- /dev/null +++ b/config/initializers/memery_extensions.rb @@ -0,0 +1,34 @@ +# Simplified memery extensions for Ethscriptions +module MemeryExtensions + class << self + attr_accessor :included_classes_and_modules + end + + self.included_classes_and_modules = [].to_set + + def included(base = nil, &block) + # Call the original included method + super + + # Track the class or module that includes Memery + MemeryExtensions.included_classes_and_modules << base + end + + def self.clear_all_caches! + MemeryExtensions.included_classes_and_modules.each do |mod| + if mod.respond_to?(:clear_memery_cache!) + mod.clear_memery_cache! + end + + # Check if the singleton class responds to clear_memery_cache! + if mod.singleton_class.respond_to?(:clear_memery_cache!) + mod.singleton_class.clear_memery_cache! + end + end + end +end + +# Only prepend if Memery is loaded +if defined?(Memery) + Memery::ModuleMethods.prepend(MemeryExtensions) +end \ No newline at end of file diff --git a/config/initializers/rswag_api.rb b/config/initializers/rswag_api.rb deleted file mode 100644 index 2b9cbb9..0000000 --- a/config/initializers/rswag_api.rb +++ /dev/null @@ -1,15 +0,0 @@ -Rswag::Api.configure do |c| - - # RSpec.configure(&:disable_monkey_patching!) - # Specify a root folder where Swagger JSON files are located - # This is used by the Swagger middleware to serve requests for API descriptions - # NOTE: If you're using rswag-specs to generate Swagger, you'll need to ensure - # that it's configured to generate files in the same folder - c.openapi_root = Rails.root.join('openapi').to_s - - # Inject a lambda function to alter the returned OpenAPI prior to serialization - # The function will have access to the rack env for the current request - # For example, you could leverage this to dynamically assign the "host" property - # - #c.openapi_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] } -end diff --git a/config/initializers/rswag_ui.rb b/config/initializers/rswag_ui.rb deleted file mode 100644 index 2c4d438..0000000 --- a/config/initializers/rswag_ui.rb +++ /dev/null @@ -1,16 +0,0 @@ -Rswag::Ui.configure do |c| - - # List the Swagger endpoints that you want to be documented through the - # swagger-ui. The first parameter is the path (absolute or relative to the UI - # host) to the corresponding endpoint and the second is a title that will be - # displayed in the document selector. - # NOTE: If you're using rspec-api to expose Swagger files - # (under openapi_root) as JSON or YAML endpoints, then the list below should - # correspond to the relative paths for those endpoints. - - c.openapi_endpoint '/api-docs/v1/openapi.yaml', 'API V1 Docs' - - # Add Basic Auth in case your API is private - # c.basic_auth_enabled = true - # c.basic_auth_credentials 'username', 'password' -end diff --git a/config/initializers/sorbet.rb b/config/initializers/sorbet.rb new file mode 100644 index 0000000..98ddd03 --- /dev/null +++ b/config/initializers/sorbet.rb @@ -0,0 +1,7 @@ +# Set up Sorbet runtime +require 'sorbet-runtime' + +# Make T::Sig available globally +class Module + include T::Sig +end \ No newline at end of file diff --git a/config/initializers/type_extensions.rb b/config/initializers/type_extensions.rb new file mode 100644 index 0000000..8942c1a --- /dev/null +++ b/config/initializers/type_extensions.rb @@ -0,0 +1,27 @@ +class Integer + def ether + (self.to_d * 1e18.to_d).to_i + end + + def gwei + (self.to_d * 1e9.to_d).to_i + end +end + +class Float + def ether + (self.to_d * 1e18.to_d).to_i + end + + def gwei + (self.to_d * 1e9.to_d).to_i + end +end + +class String + def pbcopy(strip: true) + to_copy = strip ? self.strip : self + Clipboard.copy(to_copy) + nil + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml deleted file mode 100644 index 6c349ae..0000000 --- a/config/locales/en.yml +++ /dev/null @@ -1,31 +0,0 @@ -# Files in the config/locales directory are used for internationalization and -# are automatically loaded by Rails. If you want to use locales other than -# English, add the necessary files in this directory. -# -# To use the locales, use `I18n.t`: -# -# I18n.t "hello" -# -# In views, this is aliased to just `t`: -# -# <%= t("hello") %> -# -# To use a different locale, set it with `I18n.locale`: -# -# I18n.locale = :es -# -# This would use the information in config/locales/es.yml. -# -# To learn more about the API, please read the Rails Internationalization guide -# at https://guides.rubyonrails.org/i18n.html. -# -# Be aware that YAML interprets the following case-insensitive strings as -# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings -# must be quoted to be interpreted as strings. For example: -# -# en: -# "yes": yup -# enabled: "ON" - -en: - hello: "Hello world" diff --git a/config/main_importer_clock.rb b/config/main_importer_clock.rb deleted file mode 100644 index b1a010d..0000000 --- a/config/main_importer_clock.rb +++ /dev/null @@ -1,27 +0,0 @@ -require 'clockwork' -require './config/boot' -require './config/environment' -require 'active_support/time' - -module Clockwork - handler do |job| - puts "Running #{job}" - end - - error_handler do |error| - report_exception_every = 15.minutes - - exception_key = ["clockwork-airbrake", error.class, error.message, error.backtrace[0]].to_cache_key - - last_reported_at = Rails.cache.read(exception_key) - - if last_reported_at.blank? || (Time.zone.now - last_reported_at > report_exception_every) - Airbrake.notify(error) - Rails.cache.write(exception_key, Time.zone.now) - end - end - - every(6.seconds, 'import_blocks_until_done') do - EthBlock.import_blocks_until_done - end -end diff --git a/config/puma.rb b/config/puma.rb deleted file mode 100644 index afa809b..0000000 --- a/config/puma.rb +++ /dev/null @@ -1,35 +0,0 @@ -# This configuration file will be evaluated by Puma. The top-level methods that -# are invoked here are part of Puma's configuration DSL. For more information -# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. - -# Puma can serve each request in a thread from an internal thread pool. -# The `threads` method setting takes two numbers: a minimum and maximum. -# Any libraries that use thread pools should be configured to match -# the maximum value specified for Puma. Default is set to 5 threads for minimum -# and maximum; this matches the default thread size of Active Record. -max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } -min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } -threads min_threads_count, max_threads_count - -# Specifies that the worker count should equal the number of processors in production. -if ENV["RAILS_ENV"] == "production" - require "concurrent-ruby" - worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) - workers worker_count if worker_count > 1 -end - -# Specifies the `worker_timeout` threshold that Puma will use to wait before -# terminating a worker in development environments. -worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" - -# Specifies the `port` that Puma will listen on to receive requests; default is 3000. -port ENV.fetch("PORT") { 3000 } - -# Specifies the `environment` that Puma will run in. -environment ENV.fetch("RAILS_ENV") { "development" } - -# Specifies the `pidfile` that Puma will use. -pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } - -# Allow puma to be restarted by `bin/rails restart` command. -plugin :tmp_restart diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 0000000..9f6ae7e --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: <%= ENV.fetch("JOB_THREADS", 3).to_i %> + processes: <%= ENV.fetch("JOB_CONCURRENCY", 2).to_i %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 0000000..6c2f1b5 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,32 @@ +# examples: +# periodic_cleanup: +# class: CleanSoftDeletedRecordsJob +# queue: background +# args: [ 1000, { batch_size: 500 } ] +# schedule: every hour +# periodic_cleanup_with_command: +# command: "SoftDeletedRecord.due.delete_all" +# priority: 2 +# schedule: at 5am every day + +development: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 + purge_old_validation_successes: + command: "ValidationResult.delete_successes_older_than" + schedule: every hour at minute 43 + +production: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 + purge_old_validation_successes: + command: "ValidationResult.delete_successes_older_than" + schedule: every hour at minute 43 + +# development: +# validation_gap_detection: +# class: GapDetectionJob +# queue: gap_detection +# schedule: every 2 minutes diff --git a/config/routes.rb b/config/routes.rb deleted file mode 100644 index 0aafc24..0000000 --- a/config/routes.rb +++ /dev/null @@ -1,56 +0,0 @@ -Rails.application.routes.draw do - def draw_routes - resources :ethscriptions, only: [:index, :show] do - collection do - get "/:id/data", to: "ethscriptions#data" - # get "/newer_ethscriptions", to: "ethscriptions#newer_ethscriptions" - # get "/newer", to: "ethscriptions#newer_ethscriptions" - get '/owned_by/:owned_by_address', to: 'ethscriptions#index' - get "/exists/:sha", to: "ethscriptions#exists" - post "/exists_multi", to: "ethscriptions#exists_multi" - end - - member do - get 'attachment', to: 'ethscriptions#attachment' - end - end - - resources :ethscription_transfers, only: [:index] do - end - - resources :blocks, only: [:index, :show] do - collection do - get "/newer_blocks", to: "blocks#newer_blocks" - end - end - - resources :tokens, only: [:index] do - collection do - get "/:protocol/:tick", to: "tokens#show" - get "/:protocol/:tick/historical_state", to: "tokens#historical_state" - - get "/balance_of", to: "tokens#balance_of" - - get "/validate_token_items", to: "tokens#validate_token_items" - post "/validate_token_items", to: "tokens#validate_token_items" - end - end - - get "/status", to: "status#indexer_status" - end - - draw_routes - - # Support legacy indexer namespace - scope :api do - draw_routes - end - - scope :v2 do - draw_routes - end - - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. - get "up" => "rails/health#show", as: :rails_health_check -end diff --git a/contracts/.gitignore b/contracts/.gitignore new file mode 100644 index 0000000..ed4f730 --- /dev/null +++ b/contracts/.gitignore @@ -0,0 +1,19 @@ +# Compiler files +cache/ +out/ +forge-artifacts/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env + + +# Soldeer +/dependencies diff --git a/contracts/foundry.toml b/contracts/foundry.toml new file mode 100644 index 0000000..28ab724 --- /dev/null +++ b/contracts/foundry.toml @@ -0,0 +1,41 @@ +[profile.default] +src = "src" +out = "forge-artifacts" +libs = ["dependencies"] +solc = "0.8.24" +optimizer = true +optimizer_runs = 200 +gas_limit = "18446744073709551615" +# via_ir = true +remappings = [ + "@openzeppelin/contracts/=dependencies/@openzeppelin-contracts-5.3.0/", + "@openzeppelin/contracts-upgradeable/=dependencies/@openzeppelin-contracts-upgradeable-5.3.0/", + "solady/=dependencies/solady-0.1.26/src/", + "forge-std/=dependencies/forge-std-1.10.0/src/", + "@eth-optimism/contracts-bedrock/=dependencies/@eth-optimism-contracts-bedrock-0.17.3/" +] + +# Allow tests to read JSON fixtures from the test directory +fs_permissions = [ + { access = "read", path = "./test" }, + { access = "read", path = "./script" } +] +ffi = true + +[dependencies] +solady = "0.1.26" +forge-std = "1.10.0" +"@openzeppelin-contracts" = "5.3.0" +"@openzeppelin-contracts-upgradeable" = "5.3.0" +"@eth-optimism-contracts-bedrock" = "0.17.3" + +[lint] +exclude_lints = [ + "unaliased-plain-import", + "pascal-case-struct", + "mixed-case-variable", + "mixed-case-function", + "screaming-snake-case-const", + "erc20-unchecked-transfer", + "asm-keccak256" +] diff --git a/contracts/script/L2Genesis.s.sol b/contracts/script/L2Genesis.s.sol new file mode 100644 index 0000000..69baa75 --- /dev/null +++ b/contracts/script/L2Genesis.s.sol @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Script} from "forge-std/Script.sol"; +import {L2GenesisConfig} from "./L2GenesisConfig.sol"; +import {Predeploys} from "../src/libraries/Predeploys.sol"; +import {Constants} from "../src/libraries/Constants.sol"; +import {Ethscriptions} from "../src/Ethscriptions.sol"; +import {MetaStoreLib} from "../src/libraries/MetaStoreLib.sol"; +import "forge-std/console.sol"; + +/// @title GenesisEthscriptions +/// @notice Temporary contract that extends Ethscriptions with genesis-specific creation +/// @dev Used only during genesis, then replaced with the real Ethscriptions contract +contract GenesisEthscriptions is Ethscriptions { + + /// @notice Store a genesis ethscription ID for later event emission + /// @dev Internal function only used during genesis setup + /// @param ethscriptionId The ethscription ID (L1 tx hash) to store + function _storePendingGenesisEvent(bytes32 ethscriptionId) internal { + pendingGenesisEvents.push(ethscriptionId); + } + + /// @notice Create an ethscription with all values explicitly set for genesis + function createGenesisEthscription( + CreateEthscriptionParams calldata params, + address creator, + uint256 createdAt, + uint64 l1BlockNumber, + bytes32 l1BlockHash + ) public returns (uint256 tokenId) { + require(creator != address(0), "Invalid creator"); + require(ethscriptions[params.ethscriptionId].creator == address(0), "Ethscription already exists"); + + // Check protocol uniqueness using content URI hash + if (firstEthscriptionByContentUri[params.contentUriSha] != bytes32(0)) { + if (!params.esip6) revert DuplicateContentUri(); + } + + // Store content and get content hash (reusing parent's helper) + bytes32 contentHash = _storeContent(params.content); + + // Mark content URI as used by storing this ethscription's tx hash + firstEthscriptionByContentUri[params.contentUriSha] = params.ethscriptionId; + + // Store metadata (mimetype, protocol, operation) + // Genesis ethscriptions have no protocol parameters + bytes32 metaRef = MetaStoreLib.store( + params.mimetype, + params.protocolParams.protocolName, + params.protocolParams.operation, + metadataStorage + ); + + // Set all values including genesis-specific ones + ethscriptions[params.ethscriptionId] = EthscriptionStorage({ + // Fixed-size fields + contentUriSha: params.contentUriSha, + contentHash: contentHash, + l1BlockHash: l1BlockHash, + // Packed slot 3 + creator: creator, + createdAt: uint48(createdAt), + l1BlockNumber: uint48(l1BlockNumber), + // Metadata reference + metaRef: metaRef, + // Packed slot N + initialOwner: params.initialOwner, + ethscriptionNumber: uint48(totalSupply()), + esip6: params.esip6, + // Packed slot N+1 + previousOwner: creator, + l2BlockNumber: 0 // Genesis ethscriptions have no L2 block + }); + + // Use ethscription number as token ID + tokenId = totalSupply(); + + // Store the mapping from token ID to ethscription ID + tokenIdToEthscriptionId[tokenId] = params.ethscriptionId; + + // Token count is automatically tracked by enumerable's _update + + // If initial owner is zero (burned), mint to creator then burn + if (params.initialOwner == address(0)) { + _mint(creator, tokenId); + _transfer(creator, address(0), tokenId); + } else { + _mint(params.initialOwner, tokenId); + } + + // Store the transaction hash so all events can be emitted later + // The emission logic in _emitPendingGenesisEvents will figure out + // what events to emit based on the ethscription data + _storePendingGenesisEvent(params.ethscriptionId); + + // Skip token handling for genesis + } +} + +/// @title L2Genesis +/// @notice Generates the minimal genesis state for the L2 network. +/// Sets up L1Block contract, L2ToL1MessagePasser, ProxyAdmin, and proxy infrastructure. + +contract L2Genesis is Script { + uint256 constant PRECOMPILE_COUNT = 256; + uint256 internal constant PREDEPLOY_COUNT = 2048; + + string constant GENESIS_JSON_FILE = "genesis-allocs.json"; + + L2GenesisConfig.Config config; + + /// @notice Main entry point for genesis generation + function run() public { + runWithoutDump(); + + // Dump state and prettify with jq + vm.dumpState(GENESIS_JSON_FILE); + + // Use FFI to prettify and sort the JSON keys + string[] memory inputs = new string[](3); + inputs[0] = "sh"; + inputs[1] = "-c"; + // Use block timestamp for unique temp file + string memory tempFile = string.concat("/tmp/genesis-", vm.toString(block.timestamp), ".json"); + inputs[2] = string.concat("jq --sort-keys . ", GENESIS_JSON_FILE, " > ", tempFile, " && mv ", tempFile, " ", GENESIS_JSON_FILE); + vm.ffi(inputs); + } + + function runWithoutDump() public { + // Load configuration + config = L2GenesisConfig.getConfig(); + + // Use a deployer account for genesis setup + address deployer = Predeploys.DEPOSITOR_ACCOUNT; + vm.startPrank(deployer); + vm.chainId(config.l2ChainID); + + // Set up genesis state + dealEthToPrecompiles(); + setOPStackPredeploys(); + setEthscriptionsPredeploys(); // This now includes createGenesisEthscriptions() + deployMulticall3(); + + // Fund dev accounts if enabled + if (config.fundDevAccounts) { + fundDevAccounts(); + } + + // Clean up deployer + vm.stopPrank(); + vm.deal(deployer, 0); + vm.resetNonce(deployer); + } + + /// @notice Give all precompiles 1 wei (required for EVM compatibility) + function dealEthToPrecompiles() internal { + for (uint256 i; i < PRECOMPILE_COUNT; i++) { + vm.deal(address(uint160(i)), 1); + } + } + + /// @notice Set up OP Stack predeploys with proxies + function setOPStackPredeploys() internal { + bytes memory proxyCode = vm.getDeployedCode("Proxy.sol:Proxy"); + // Deploy proxies at sequential addresses starting from 0x42...00 + uint160 prefix = uint160(0x420) << 148; + + for (uint256 i = 0; i < PREDEPLOY_COUNT; i++) { + address addr = address(prefix | uint160(i)); + + // Deploy proxy + vm.etch(addr, proxyCode); + + vm.setNonce(addr, 1); + + // Set admin to ProxyAdmin + setProxyAdminSlot(addr, Predeploys.PROXY_ADMIN); + + bool isSupportedPredeploy = addr == Predeploys.L1_BLOCK_ATTRIBUTES || + addr == Predeploys.L2_TO_L1_MESSAGE_PASSER || + addr == Predeploys.PROXY_ADMIN; + + if (isSupportedPredeploy) { + address implementation = Predeploys.predeployToCodeNamespace(addr); + setImplementation(addr, implementation); + } + } + + // Now set implementations for OP Stack contracts + setL1Block(); + setL2ToL1MessagePasser(); + setProxyAdmin(); + } + + /// @notice Set up Ethscriptions system contracts + function setEthscriptionsPredeploys() internal { + // Ensure proxies exist across the full 0x330… namespace, mirroring OP's approach + bytes memory proxyCode = vm.getDeployedCode("Proxy.sol:Proxy"); + uint160 prefix = uint160(0x330) << 148; + + for (uint256 i = 0; i < PREDEPLOY_COUNT; i++) { + address addr = address(prefix | uint160(i)); + + // Deploy proxy shell and prime nonce for deterministic CREATEs + vm.etch(addr, proxyCode); + vm.setNonce(addr, 1); + setProxyAdminSlot(addr, Predeploys.PROXY_ADMIN); + + // Wire up implementations for the Ethscriptions predeploys that use proxies + bool isProxiedContract = addr == Predeploys.ETHSCRIPTIONS || + addr == Predeploys.ERC20_FIXED_DENOMINATION_MANAGER || + addr == Predeploys.ETHSCRIPTIONS_PROVER || + addr == Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_MANAGER; + if (isProxiedContract) { + address impl = Predeploys.predeployToCodeNamespace(addr); + setImplementation(addr, impl); + } + } + + // Set implementation code for non-ETHSCRIPTIONS contracts now + _setImplementationCodeNamed(Predeploys.ERC20_FIXED_DENOMINATION_MANAGER, "ERC20FixedDenominationManager"); + _setImplementationCodeNamed(Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_MANAGER, "ERC721EthscriptionsCollectionManager"); + _setImplementationCodeNamed(Predeploys.ETHSCRIPTIONS_PROVER, "EthscriptionsProver"); + // Templates live directly at their addresses (no proxy wrapping) + _setCodeAt(Predeploys.ERC20_FIXED_DENOMINATION_IMPLEMENTATION, "ERC20FixedDenomination"); + _setCodeAt(Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_IMPLEMENTATION, "ERC721EthscriptionsCollection"); + + // Create genesis Ethscriptions (writes via proxy to proxy storage) + createGenesisEthscriptions(); + + // Register protocol handlers via the Ethscriptions proxy + registerProtocolHandlers(); + } + + /// @notice Register protocol handlers with the Ethscriptions contract + function registerProtocolHandlers() internal { + Ethscriptions ethscriptions = Ethscriptions(Predeploys.ETHSCRIPTIONS); + + ethscriptions.registerProtocol("erc-20-fixed-denomination", Predeploys.ERC20_FIXED_DENOMINATION_MANAGER); + console.log("Registered erc-20-fixed-denomination protocol handler:", Predeploys.ERC20_FIXED_DENOMINATION_MANAGER); + + ethscriptions.registerProtocol("erc-721-ethscriptions-collection", Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_MANAGER); + console.log("Registered erc-721-ethscriptions-collection protocol handler:", Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_MANAGER); + } + + /// @notice Deploy L1Block contract (stores L1 block attributes) + function setL1Block() internal { + _setImplementationCode(Predeploys.L1_BLOCK_ATTRIBUTES); + } + + /// @notice Deploy L2ToL1MessagePasser contract + function setL2ToL1MessagePasser() internal { + _setImplementationCode(Predeploys.L2_TO_L1_MESSAGE_PASSER); + } + + /// @notice Deploy ProxyAdmin contract + function setProxyAdmin() internal { + address impl = _setImplementationCode(Predeploys.PROXY_ADMIN); + + // Set the owner + bytes32 ownerSlot = bytes32(0); // Owner is typically stored at slot 0 + vm.store(Predeploys.PROXY_ADMIN, ownerSlot, bytes32(uint256(uint160(config.proxyAdminOwner)))); + vm.store(impl, ownerSlot, bytes32(uint256(uint160(config.proxyAdminOwner)))); + } + + /// @notice Deploy Multicall3 contract + function deployMulticall3() internal { + console.log("Deploying Multicall3 at", Predeploys.MultiCall3); + vm.etch(Predeploys.MultiCall3, Predeploys.MultiCall3Code); + // Set nonce to 1 for consistency with other predeploys + vm.setNonce(Predeploys.MultiCall3, 1); + } + + /// @notice Fund development accounts with ETH + function fundDevAccounts() internal { + address[] memory accounts = L2GenesisConfig.getDevAccounts(); + uint256 amount = L2GenesisConfig.getDevAccountFundAmount(); + + for (uint256 i = 0; i < accounts.length; i++) { + vm.deal(accounts[i], amount); + } + } + + /// @notice Create genesis Ethscriptions from JSON data + function createGenesisEthscriptions() internal { + console.log("Creating genesis Ethscriptions..."); + + // Read the JSON file + string memory json = vm.readFile("script/genesisEthscriptions.json"); + + // Parse metadata + uint256 totalCount = abi.decode(vm.parseJson(json, ".metadata.totalCount"), (uint256)); + console.log("Found", totalCount, "genesis Ethscriptions"); + + // Use the Ethscriptions proxy address and its implementation code-namespace address + address ethscriptionsProxy = Predeploys.ETHSCRIPTIONS; + address implAddr = Predeploys.predeployToCodeNamespace(ethscriptionsProxy); + + // Temporarily etch GenesisEthscriptions at the implementation address + vm.etch(implAddr, type(GenesisEthscriptions).runtimeCode); + + // Call through the proxy using the GenesisEthscriptions interface + GenesisEthscriptions genesisContract = GenesisEthscriptions(ethscriptionsProxy); + + // Process each ethscription (this will increment the nonce as SSTORE2 contracts are deployed) + for (uint256 i = 0; i < totalCount; i++) { + _createSingleGenesisEthscription(json, i, genesisContract); + } + + console.log("Created", totalCount, "genesis Ethscriptions"); + + // Overwrite the implementation bytecode with the real Ethscriptions + // Do not reset nonce on the proxy; we only swap the implementation code + _setImplementationCodeNamed(Predeploys.ETHSCRIPTIONS, "Ethscriptions"); + } + + /// @notice Helper to create a single genesis ethscription + function _createSingleGenesisEthscription( + string memory json, + uint256 index, + GenesisEthscriptions genesisContract + ) internal { + if (!vm.envOr("PERFORM_GENESIS_IMPORT", true)) { + return; + } + + string memory basePath = string.concat(".ethscriptions[", vm.toString(index), "]"); + + // Parse all data needed + address creator = vm.parseJsonAddress(json, string.concat(basePath, ".creator")); + address initialOwner = vm.parseJsonAddress(json, string.concat(basePath, ".initial_owner")); + + console.log("Processing ethscription", index); + console.log(" Creator:", creator); + console.log(" Initial owner:", initialOwner); + + uint256 blockTimestamp = vm.parseJsonUint(json, string.concat(basePath, ".block_timestamp")); + uint256 blockNumber = vm.parseJsonUint(json, string.concat(basePath, ".block_number")); + bytes32 blockHash = vm.parseJsonBytes32(json, string.concat(basePath, ".block_blockhash")); + + // Create params struct with parsed data from JSON + // The JSON already has all the properly processed data + Ethscriptions.CreateEthscriptionParams memory params; + params.ethscriptionId = vm.parseJsonBytes32(json, string.concat(basePath, ".transaction_hash")); + params.contentUriSha = vm.parseJsonBytes32(json, string.concat(basePath, ".content_uri_hash")); + params.initialOwner = initialOwner; + params.content = vm.parseJsonBytes(json, string.concat(basePath, ".content")); + params.mimetype = vm.parseJsonString(json, string.concat(basePath, ".mimetype")); + params.esip6 = vm.parseJsonBool(json, string.concat(basePath, ".esip6")); + params.protocolParams = Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }); + + // Create the genesis ethscription with all values + genesisContract.createGenesisEthscription( + params, + creator, + blockTimestamp, + uint64(blockNumber), + blockHash + ); + } + + // ============ Helper Functions ============ + + /// @notice Disable initializers on a contract to prevent initialization + function _disableInitializers(address _addr) internal { + vm.store(_addr, Constants.INITIALIZABLE_STORAGE, bytes32(uint256(0x000000000000000000000000000000000000000000000000ffffffffffffffff))); + } + + /// @notice Set implementation bytecode for a given predeploy using an explicit contract name + function _setImplementationCodeNamed(address _predeploy, string memory _name) internal returns (address impl) { + impl = Predeploys.predeployToCodeNamespace(_predeploy); + bytes memory code = vm.getDeployedCode(string.concat(_name, ".sol:", _name)); + console.log("Setting implementation code for", _predeploy, "to", impl); + + _disableInitializers(impl); + + vm.etch(impl, code); + } + + /// @notice Etch contract bytecode directly at a target address (used for non-proxy templates) + function _setCodeAt(address _addr, string memory _name) internal { + bytes memory code = vm.getDeployedCode(string.concat(_name, ".sol:", _name)); + _disableInitializers(_addr); + vm.etch(_addr, code); + } + + /// @notice Set the admin of a proxy contract + function setProxyAdminSlot(address proxy, address admin) internal { + // EIP-1967 admin slot + bytes32 adminSlot = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + vm.store(proxy, adminSlot, bytes32(uint256(uint160(admin)))); + } + + /// @notice Set the implementation of a proxy contract + function setImplementation(address proxy, address implementation) internal { + // EIP-1967 implementation slot + bytes32 implSlot = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + vm.store(proxy, implSlot, bytes32(uint256(uint160(implementation)))); + } + + /// @notice Sets the bytecode in state + function _setImplementationCode(address _addr) internal returns (address) { + string memory cname = getName(_addr); + address impl = Predeploys.predeployToCodeNamespace(_addr); + vm.etch(impl, vm.getDeployedCode(string.concat(cname, ".sol:", cname))); + return impl; + } + + function getName(address _addr) internal pure returns (string memory ret) { + // OP Stack predeploys + if (_addr == Predeploys.L1_BLOCK_ATTRIBUTES) { + ret = "L1Block"; + } else if (_addr == Predeploys.L2_TO_L1_MESSAGE_PASSER) { + ret = "L2ToL1MessagePasser"; + } else if (_addr == Predeploys.PROXY_ADMIN) { + ret = "ProxyAdmin"; + } + } + +} diff --git a/contracts/script/L2GenesisConfig.sol b/contracts/script/L2GenesisConfig.sol new file mode 100644 index 0000000..83610cb --- /dev/null +++ b/contracts/script/L2GenesisConfig.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { Constants } from "../src/libraries/Constants.sol"; + +/// @title L2GenesisConfig +/// @notice Configuration for L2 Genesis state generation +library L2GenesisConfig { + /// @notice Configuration struct for L2 Genesis + struct Config { + uint256 l1ChainID; + uint256 l2ChainID; + address proxyAdminOwner; + bool fundDevAccounts; + } + + /// @notice Returns the default configuration for L2 Genesis + function getConfig() internal pure returns (Config memory) { + return Config({ + l1ChainID: 1, // Ethereum mainnet + l2ChainID: 0xeeee, // Custom L2 chain ID + proxyAdminOwner: Constants.DEPOSITOR_ACCOUNT, // Default admin + fundDevAccounts: false + }); + } + + /// @notice List of development accounts to fund (if enabled) + function getDevAccounts() internal pure returns (address[] memory) { + address[] memory accounts = new address[](5); + accounts[0] = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; // Hardhat account 0 + accounts[1] = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; // Hardhat account 1 + accounts[2] = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC; // Hardhat account 2 + accounts[3] = 0x90F79bf6EB2c4f870365E785982E1f101E93b906; // Hardhat account 3 + accounts[4] = 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65; // Hardhat account 4 + return accounts; + } + + /// @notice Amount of ETH to fund each dev account with + function getDevAccountFundAmount() internal pure returns (uint256) { + return 10000 ether; + } +} \ No newline at end of file diff --git a/contracts/script/TestTokenUri.s.sol b/contracts/script/TestTokenUri.s.sol new file mode 100644 index 0000000..4b61e5e --- /dev/null +++ b/contracts/script/TestTokenUri.s.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import "../src/Ethscriptions.sol"; +import "../script/L2Genesis.s.sol"; +import {Base64} from "solady/utils/Base64.sol"; + +contract TestTokenUri is Script { + function run() public { + // Deploy system + L2Genesis genesis = new L2Genesis(); + genesis.runWithoutDump(); + + Ethscriptions eth = Ethscriptions(Predeploys.ETHSCRIPTIONS); + + // Test case 1: Plain text (should use viewer) + vm.prank(address(0x1111)); + eth.createEthscription(Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: keccak256("text1"), + contentUriSha: keccak256("data:text/plain,Hello World!"), + initialOwner: address(0x1111), + content: bytes("Hello World!"), + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams("", "", "") + })); + + // Test case 2: JSON content (should use viewer with pretty print) + vm.prank(address(0x2222)); + eth.createEthscription(Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: keccak256("json1"), + contentUriSha: keccak256('data:application/json,{"p":"erc-20","op":"mint","tick":"test","amt":"1000"}'), + initialOwner: address(0x2222), + content: bytes('{"p":"erc-20","op":"mint","tick":"test","amt":"1000"}'), + mimetype: "application/json", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams("", "", "") + })); + + // Test case 3: HTML content (should pass through directly) + vm.prank(address(0x3333)); + eth.createEthscription(Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: keccak256("html1"), + contentUriSha: keccak256('data:text/html,

Ethscriptions Rule!

'), + initialOwner: address(0x3333), + content: bytes('

Ethscriptions Rule!

'), + mimetype: "text/html", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams("", "", "") + })); + + // Test case 4: Image (1x1 red pixel PNG, base64) + bytes memory redPixelPng = Base64.decode("iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAm0lEQVR42mNgGITgPxTTxvBleTo0swBsOK0s+N8aJkczC1AMR7KAKpb8v72xAY5hFsD4lFoCN+j56ZUoliBbSoklGIZjwxRbQAjT1YK7d+82kGUBeuQii5FrAYYrL81NwCpGFQtoEUT/6RoHWAyknQV0S6ZI5RE6Jt8CZIOOHTuGgR9Fq5FkCf19QM3wx5rZKHEtsRZQt5qkhgUAR6cGaUehOD4AAAAASUVORK5CYII="); + vm.prank(address(0x4444)); + eth.createEthscription(Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: keccak256("image1"), + contentUriSha: keccak256("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAm0lEQVR42mNgGITgPxTTxvBleTo0swBsOK0s+N8aJkczC1AMR7KAKpb8v72xAY5hFsD4lFoCN+j56ZUoliBbSoklGIZjwxRbQAjT1YK7d+82kGUBeuQii5FrAYYrL81NwCpGFQtoEUT/6RoHWAyknQV0S6ZI5RE6Jt8CZIOOHTuGgR9Fq5FkCf19QM3wx5rZKHEtsRZQt5qkhgUAR6cGaUehOD4AAAAASUVORK5CYII="), + initialOwner: address(0x4444), + content: redPixelPng, + mimetype: "image/png", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams("", "", "") + })); + + // Test case 5: CSS content (should use viewer) + vm.prank(address(0x5555)); + eth.createEthscription(Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: keccak256("css1"), + contentUriSha: keccak256("data:text/css,body { background: #000; color: #0f0; font-family: 'Courier New'; }"), + initialOwner: address(0x5555), + content: bytes("body { background: #000; color: #0f0; font-family: 'Courier New'; }"), + mimetype: "text/css", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams("", "", "") + })); + + // Output all token URIs + console.log("\n=== TOKEN URI TEST RESULTS ===\n"); + + console.log("Test 1 - Plain Text (text/plain):"); + console.log("Should use HTML viewer with content displayed"); + string memory uri1 = eth.tokenURI(11); + console.log(uri1); + console.log(""); + + console.log("Test 2 - JSON (application/json):"); + console.log("Should use HTML viewer with pretty-printed JSON"); + string memory uri2 = eth.tokenURI(12); + console.log(uri2); + console.log(""); + + console.log("Test 3 - HTML (text/html):"); + console.log("Should pass through HTML directly in animation_url"); + string memory uri3 = eth.tokenURI(13); + console.log(uri3); + console.log(""); + + console.log("Test 4 - Image (image/png):"); + console.log("Should use image field (not animation_url)"); + string memory uri4 = eth.tokenURI(14); + console.log(uri4); + console.log(""); + + console.log("Test 5 - CSS (text/css):"); + console.log("Should use HTML viewer"); + string memory uri5 = eth.tokenURI(15); + console.log(uri5); + console.log(""); + + console.log("=== PASTE ANY OF THE ABOVE data: URIs INTO YOUR BROWSER ==="); + } +} diff --git a/contracts/script/fetchGenesisEthscriptions.js b/contracts/script/fetchGenesisEthscriptions.js new file mode 100644 index 0000000..20a2dcb --- /dev/null +++ b/contracts/script/fetchGenesisEthscriptions.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +// Genesis blocks for mainnet +const GENESIS_BLOCKS = [ + 1608625, 3369985, 3981254, 5873780, 8205613, 9046950, + 9046974, 9239285, 9430552, 10548855, 10711341, 15437996, 17478950 +]; + +const API_BASE = 'https://api.ethscriptions.com/v2'; +const OUTPUT_FILE = path.join(__dirname, 'genesisEthscriptions.json'); + +async function fetchWithRetry(url, retries = 3) { + for (let i = 0; i < retries; i++) { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } catch (error) { + console.error(`Attempt ${i + 1} failed for ${url}:`, error.message); + if (i === retries - 1) throw error; + // Wait before retrying (exponential backoff) + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); + } + } +} + +async function fetchEthscriptionsForBlock(blockNumber) { + console.log(`Fetching ethscriptions for block ${blockNumber}...`); + + const url = `${API_BASE}/ethscriptions?block_number=${blockNumber}`; + const data = await fetchWithRetry(url); + + if (data.result && Array.isArray(data.result)) { + console.log(` Found ${data.result.length} ethscriptions in block ${blockNumber}`); + return data.result; + } + + console.log(` No ethscriptions found in block ${blockNumber}`); + return []; +} + +async function main() { + console.log('Starting to fetch genesis ethscriptions...\n'); + + const allEthscriptions = []; + const blockData = {}; + + for (const blockNumber of GENESIS_BLOCKS) { + try { + const ethscriptions = await fetchEthscriptionsForBlock(blockNumber); + + if (ethscriptions.length > 0) { + // Store ethscriptions with their block data + for (const ethscription of ethscriptions) { + allEthscriptions.push({ + ...ethscription, + // Add genesis-specific data + genesis_block: blockNumber, + is_genesis: true + }); + } + + // Store block metadata + if (ethscriptions.length > 0 && ethscriptions[0].block_timestamp) { + blockData[blockNumber] = { + blockNumber: blockNumber, + blockHash: ethscriptions[0].block_blockhash, + timestamp: ethscriptions[0].block_timestamp, + ethscriptionCount: ethscriptions.length + }; + } + } + + // Small delay to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 500)); + + } catch (error) { + console.error(`Failed to fetch block ${blockNumber}:`, error); + } + } + + console.log(`\nTotal ethscriptions found: ${allEthscriptions.length}`); + + // Sort by ethscription number + allEthscriptions.sort((a, b) => { + const numA = parseInt(a.ethscription_number); + const numB = parseInt(b.ethscription_number); + return numA - numB; + }); + + // Prepare output + const output = { + metadata: { + totalCount: allEthscriptions.length, + genesisBlocks: GENESIS_BLOCKS, + blockData: blockData, + fetchedAt: new Date().toISOString() + }, + ethscriptions: allEthscriptions + }; + + // Write to file + fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2)); + console.log(`\nData saved to ${OUTPUT_FILE}`); + + // Print summary + console.log('\nSummary by block:'); + for (const [block, data] of Object.entries(blockData)) { + console.log(` Block ${block}: ${data.ethscriptionCount} ethscriptions`); + } +} + +// Run the script +main().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/contracts/script/genesisEthscriptions.json b/contracts/script/genesisEthscriptions.json new file mode 100644 index 0000000..5c31cf1 --- /dev/null +++ b/contracts/script/genesisEthscriptions.json @@ -0,0 +1,427 @@ +{ + "metadata": { + "totalCount": 11, + "genesisBlocks": [ + 1608625, + 3369985, + 3981254, + 5873780, + 8205613, + 9046950, + 9046974, + 9239285, + 9430552, + 10548855, + 10711341, + 15437996, + 17478950 + ], + "blockData": { + "1608625": { + "blockNumber": 1608625, + "blockHash": "0x97f247e39d253649eb61abbf63dfbbb770ab698738d488ff16b30ef3eaab8532", + "timestamp": "1464560895", + "ethscriptionCount": 1 + }, + "3369985": { + "blockNumber": 3369985, + "blockHash": "0x5a6625602b867e0b7b7c6cda7afcab186d28bf8ae8573d7f92c5cbb0133bd2cd", + "timestamp": "1489779729", + "ethscriptionCount": 1 + }, + "3981254": { + "blockNumber": 3981254, + "blockHash": "0x77612afb3a6b09e52193ff2f11d3fca8f4eb2d22101ae9242b4ba7134db5f442", + "timestamp": "1499315038", + "ethscriptionCount": 1 + }, + "5873780": { + "blockNumber": 5873780, + "blockHash": "0x7e285858100a87bfaf12f220e54a5de3d83e650cbdbe722d70cbb3e79763f147", + "timestamp": "1530259325", + "ethscriptionCount": 1 + }, + "8205613": { + "blockNumber": 8205613, + "blockHash": "0xb961f942c539c7a02be19177a8f8b94c5255de96f94d367846d621432d11e24b", + "timestamp": "1563867259", + "ethscriptionCount": 1 + }, + "9046950": { + "blockNumber": 9046950, + "blockHash": "0xc5ffc39934832775c91a2915a5768df691c1cbc0a513ef7a25d79197248f1d8b", + "timestamp": "1575425759", + "ethscriptionCount": 1 + }, + "9239285": { + "blockNumber": 9239285, + "blockHash": "0x2b51e3267f7102de64b9423ef5e3db9399f2061a2bac37b480033b4e23d00abc", + "timestamp": "1578476736", + "ethscriptionCount": 1 + }, + "9430552": { + "blockNumber": 9430552, + "blockHash": "0xf618006af259d7157e744600cfbea3d4cb091bd013f873412eab85af5e1961a9", + "timestamp": "1581011454", + "ethscriptionCount": 1 + }, + "10548855": { + "blockNumber": 10548855, + "blockHash": "0x077fd69273760f74daa318e047bc97ea4d7f1ae54409e34734273d6561eb39ec", + "timestamp": "1595950237", + "ethscriptionCount": 1 + }, + "10711341": { + "blockNumber": 10711341, + "blockHash": "0x87903a90225b79f0796acb89368fb9748317ba57f9bc2f6241d81a954955c827", + "timestamp": "1598116535", + "ethscriptionCount": 1 + }, + "15437996": { + "blockNumber": 15437996, + "blockHash": "0x3c264f2f3c03f68015946d5519acb1367f6d5fbd5581383fba1dffe2bccf638c", + "timestamp": "1661829845", + "ethscriptionCount": 1 + }, + "17478950": { + "blockNumber": 17478950, + "blockHash": "0x53e371d54be15819babe32e5b9ce3beb69d55a6bf950f38e24c40021345021cd", + "timestamp": "1686755075", + "ethscriptionCount": 1 + } + }, + "fetchedAt": "2025-09-12T18:42:54.862Z" + }, + "ethscriptions": [ + { + "transaction_hash": "0xb1bdb91f010c154dd04e5c11a6298e91472c27a347b770684981873a6408c11c", + "block_number": "1608625", + "transaction_index": "1", + "block_timestamp": "1464560895", + "block_blockhash": "0x97f247e39d253649eb61abbf63dfbbb770ab698738d488ff16b30ef3eaab8532", + "event_log_index": null, + "ethscription_number": "0", + "creator": "0xe7dfe249c262a6a9b57651782d57296d2e4bccc9", + "initial_owner": "0x1c98757e3b2199df438553892a678a74187b55b1", + "current_owner": "0x1c98757e3b2199df438553892a678a74187b55b1", + "previous_owner": "0xe7dfe249c262a6a9b57651782d57296d2e4bccc9", + "content_uri": "data:image/jpeg;base64,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", + "content_sha": "0x2817fd9cf901e4435253881550731a5edc5e519c19de46b08e2b19a18e95143e", + "esip6": false, + "mimetype": "image/jpeg", + "media_type": "image", + "mime_subtype": "jpeg", + "gas_price": "20000000000", + "gas_used": "765496", + "transaction_fee": "15309920000000000", + "value": "0", + "attachment_sha": null, + "attachment_content_type": null, + "genesis_block": 1608625, + "is_genesis": true, + "content_uri_hash": "0x2817fd9cf901e4435253881550731a5edc5e519c19de46b08e2b19a18e95143e", + "was_base64": true, + "content": "0xffd8ffe000104a46494600010200006400640000ffec00114475636b79000100040000001e0000ffee000e41646f62650064c000000001ffdb008400100b0b0b0c0b100c0c10170f0d0f171b141010141b1f17171717171f1e171a1a1a1a171e1e23252725231e2f2f33332f2f40404040404040404040404040404001110f0f1113111512121514111411141a141616141a261a1a1c1a1a2630231e1e1e1e23302b2e2727272e2b35353030353540403f404040404040404040404040ffc000110800af010803012200021101031101ffc40095000001050101000000000000000000000001000203040506070101010101010000000000000000000000000102030410000201030301060305040805040300000102030011042112053141516171221391320681a1421415b1d15223c1e1627282b2d233f0c2532416f192a2437393341101010002020104020203000000000000000111022112033141516171138122917214ffda000c03010002110311003f00ed6f42a33375b2936fb286e27af4eea9954b4af516d63dba5031292491ad4c898b81e7dd4c1248750b6a6aaaa0b01614ed29900993adfec16a0427e2d6daea69d6a06dd68a40a8d45a886eda6eeb531a460a4ede9d95321e59893dde749a458d4b390a07693a551972f3994fe5a0d47f1023f6dab3f353929210f2b93a1b411af563d01274d2b3765c2dc9ca2e163a6d513441500656059a566b369f79acfe57ea29a0986344dff706e7db02e081f80684dcd727263f2d88e5d83c520245efead7afc6aa15ce590cbebf74107dcb9dc0ff007ab39b7df0d758d3e4b2268b3e38e79d99596f320b37b21db73a0ecaab9595efb2846dcaac514840bfcb1f8ad7d2a9343905b7b292cda963a927bcd009909691430da746b5c79775310c35f8c8f162ce47c7cf29707da72845a4171b1f69b0fbea5748f3f2a505442237593276fc85f6ed609dc188fb2b193272e28e48d4955948671b4751da34d3eca6acb302d6637716373d475a58756dbe6e071ccb1c256649080e2c4b2eb63b830fc3d95664cf38f0e4c114e8932a7ab1f5f6de36e9b7f06edade75cd4d2c923094fa5d6c05b4b6df968c519964dd23d9ac5cb3dfd446b6bf79a9d7ecc1aa09c8896fa174fdb5ddfbb8f91290b37b6c401f31dc08f857138beace834ffec4ff0030a2b36663c92e52ab15de7d5ae9a9b5ff00a2ad96c921b4cd7a044627478666f75d75dacc34ff0098562a4a27cac918eeee90b80a8ed7f6d428dc3b35df7ac5c2e7520899a48cbe4fab6c80f5ddfc5438fe531235c98f2d6465cabb3ba5afbbb3f69acdd6e316123aa877c31afb6beb2377e1ea7a91a1b9ab0ab23440ca034927cded8dacbdc2fd0fdd5cdf19caf1904fbe5959228ec60520e847cc4edefabffad71d2ced2b651408c5ecdaa946160961da0d4c593dcfe16d5b030f2d8cec1bf928fbc0b125d986c03a1e9e757608d255499628c2b1f4124bb5fadcedfdf5cec2e8fc9cd9524d18c70aa52ec2d117bedf86b7f3ad6e36572628466472428089265b5965fc289adb5075a4855fc8c3842ee78d545fd52c56de49fecb8f57c6b2ce3a878e1921f752693da88820dec0b6e602c474e96ab5913cf24cd02b44428dd03c840595d4fa81b7f0f8550e46795f98c48805f65dff009b34249b36d28deaefda45cd2ebadb9c133f2d74e238cbee5432c80007674d3b2fa0eb524c21f68dd58328b6f297b1f0db7a333ae11bba6d8d4c71431a1b96dc3bafd9fb29b9661c5f69e540f2c8fb23526d776f0b9eeeb6ab8e3d22041145125e4e854fa8dcaa85ebb8f61a554b94e4a78703232c4063eb07abf19705772f80ea34a553130b8ae9ac2951f1a03ae95e8732a57ae730e6e4070f9cb1620921f733bf9c660a6dee4b7f415274f3a9f8fbfe7f8704f5e2d8f9eb8f546e5c7751ae63970193ea1424ed27054d8f40db6f59dc9cb9191c73f0eecdbb84591f29f51bfdb611626bfda57ddf6507736a200b77d733f544d0e565c7c6b648c5f6217cbdf722f3db6632e9e376a5c948fce617092e348619b264674707e49e38646b1f0122eb41d3597ba912aa2ec42af6926c2b078ae43f50e613208f6e5fc8149e1ffa7324fb6443e47eeab3cd6064e44f8b911411e7458fbfdcc299b62b97036c80b0652cb63f377d06a001acca4303d08d6f4c2b1ca0804301a30041f8d73d2e463be2271f8f8edc60973e2c7e431c594a89577fa5a33b6d20502eb56b92c2c4e35f072b0214c59bf350e3911008258a66d8f1b85f9b4f56bd2d4c0b53713c73317750b239d0b35b5f015587d37082d699d43fcea2dafc45378ac1c3e4a3c9cccf81327224c89e26f7543fb691398d2240df280a2fa509218f1b93c8c78aeb145c4b2a2924d8091eda9d6b3d62e6af45c4e2c4005b823b8817f80a90f1d887ac608eb636b5c564f058319c4c191f8a810341196cbde8ce77463d45765eede745388e2cfd41343f9487da5c389d5360da1ccb202d6efb0abd67c19ad16e1f8f7dbba0562a77293ad8d579f81e1def24f126bf88851fbaa965e0e4c7c8fe8d8f204e37942f912804878963b7e6238fb84bb97cb5ad3c8e1b0b20dcee40000aaa7d20016000352eb3e0cdf961e7717f4c460909be451e948b52de1a5c7c6b207179b33e42e0f165609942c465d5a3b0d4866b0b9aebe2e0a285b74191246dde02feeab1f91cb1d33a43e6aa6b3d6b5d9c7e17d1d95011913b319e3f5470c614fa87cbb98b77f856d47c2412e333e5c6df999829c80744de8342b6ad3fca724bf26583aebba31fd147d9e4c7596163e2847ec34ea9dab0b23e92c69ca3322c4aab6221520bf993dbf65407e8481ae44cf1dfe504036f3ae92dc9aea3d863fe314b7f2807fb10bf9391fb455eb3eccd72f3fd0208071f235ed0fd3eeaaf2fd099e16d14cafa6b7f48bf86a6baefcd7229f3e16efee3834bf51c81f360cc3cac6989f676ae29fe85e554fa5e36d2f7d46bddd2a36fa3b9a48c9f435b5d818dff0065abb8fd5517fdcc79d3cd288e5f0bf1175fef230a627c9dabcee5e039b5508f13b04d112f7ebfc2b4978cfa8a345f6e29955372258dadbbe6005fb6bd18725c73107dd5bf79b8a78cdc06d44d19f323fa698fb8bdabcdc45f52a964099248531906ed653d40bdedf6545264f3f33207f7dda1d10ed2594837ec1d6bd3c4986e747463e0451f6715fa0527a5c5aff114ebf83b3cb33794e572acb9aecc074523601aea768035a55ea038dc35f9215048b1206a4789a54ebf50ecb5b453869d285e95eb6c2b45818f0e24b869bbda98ca5ee75bce599ec7fc5a54727138cf16322b4913e1284c79e37db2aaed0846eb588206b71576f4af4143f45c33873623191c65389322567dd2c8ea5482ce7bb681e556333071b3209e09502ae500b33a00aec074f55bb2a6ebd28da821c7c48b1f2327263ddeee5b2bcacc6ff22ec555ee00543170f871489226f1ed6449951aeef4ac932947b0b7cbea26ddf5775a5ad053838ac383919f93894ae4e52849b5f49b5b5dbde6dad3f2f05329e393de9e0922b85781ca5c35ae1858ab74ed1566f4ae2828af0f8231a6c6915a75c96df9124ac5a47716dac5f420ad85ad6b76518788c78a78f2259a7ca921bfb07224f7047716254580dd6ed3ad5cdd4afddd68294bc440f3493c334f8af39bcc31e4d8b235adb996c46eb768b1a747c562c6e59777ff00cdf94d58b7f2ae5bab5c96b9eb56ad47fe3ad051c4e2c6208922cbc968a00152177529b5458291ed836b78d4ff00948866b668bfbcf12c275f4ec56671a77ddaac5a950577c489f2a1cc6bfbb02bc69ae9b65dbbae3fc22a7d2951bd00a54afe14a80d237a14c79a18c90f22a903715245eddf6eb52f1ea1d6bf8d220d478d91f980d228b457b213d5add4f954d4965998b662e29963df7a701e34addd445aaa16b4ad7eca57a4585035a38cf5453e60546d8588ff0034087fc22a5bf6dbca9020f434155b8be3dbae3a8f2b8fd9513707c79d42b2f93115a1401353819e386814dd27993c9e9568d2a60308bf78a3a74a342a857f1bf951140edee14b4a070a44d3452d680dfb8d2fbe858f5eda540685e97d946fe1402969db449f8d0d7ca811507a520b41982292c6c076d567ce007a46b7b0074ac6de4d75f56b5d6df45bdbe34ad54fdfca63e9f94f68feaa2b2b9d0b166ea2e6c6df6573ff00a35f8bfcb5faefcc5bb52163d2ab2646e36bfde09f8538e46d22e2e3b48357f7e9ee9faeac52a624b1bfca75a6666547898cf9121d10683bdbb0574efae3b66613adce31ca0e4b94c7e3f1de590ddc6891f6b39f94572dc67271e6cdbf3232cfbd91cf5b96f4d4396d2e74e0b92cd7f71bbaf54e19e5c395ad604927a74dc753e75e4df7bbfacff57a74d66b2c9eb7d6bd0b19160863c75f92350a97ee1535c1e95cdf1dcd9940c78a36696d740fe83211a9efae862732c4920b0de012bd6c7b45ebbf876b662fb3cfe4d717f276b4875e94685ebb305dbd29694b752b8a046de14ac2813dd489ecf8d01a5434a561406950b1a54088a1ad389b78d0f31400d2d68dd68dc77d00bf81a5a51a540b4efa416de548eb4a80d0d7ca95fba958f5fba808b507912342ee42a28b9268eb5ca7d4dc83cd37e5226b4511b4963f33ff005573f26fd35cfbfb37e3d3bed8ff0028795e77f359822c6368d4dae75fb6afe1720235bb80cc3b4db41dbd6b998a2f56feed4f88a984921205f402f66d057876b6ed9cf3eef6749d7acf48ea7ff218438500d8fe2d3af955c83370f903eddd5a402eb7ea0f9d70d200ba81b49fb75a938dce971f2ccacc422804f90d6acdf6f7e67c337c5ae38e2bb099576ea85194df7291a5bbeabfbb6d519640c775adad87c2a37e6a194b39f974e9a59add3c6f58997c93ed7f698928485bf60352ccf13967597df86f49c8242a5832ed06cb637d4761ae7f98e6e5ce8971d5766c6dcc41bdcf4aacd9292c6c5afb996d7bfe3f9b71aa68493a0d3b876d5d66272e9359ebeebf82fb85effcc03415662306449ea4fe60d08351603e183aa912f77656863e3c472e36b68585cf85ebb692598e1c3c96cdafaad63f099285648d4c6c350c08dc2fdd5b5858871a260cdba491b7b9fc3b88b7a47655a2029e9614af5e8d749afa38ddad0d6958d1a16adb21a50a75a8500240a0076f7d3bce858501a0147517a6e9bbbbbe9f6f1a05714a958f7d2a0a983c96267e38c8c69048bd180eaa7b8a9d454fef2f8fc2b81cc93271332097072d5235f419e406e89f86399e3077a7602469df5a8fcfcdc64a91f2137bd8f38260c845bb69de53d0e35ea35ace6b5d67b3abf756dd0fc29a668ef620fc2b04f2e1e213248b2437dbee29b80dfc2dd0a9f034d3cc29360f53b1d5d0fbe9d2cdf0a4274ee23ecac01cd2afe2fbe97eb20837617efa9dbeceadff007d3c7e1484ca47436ae77f563726e0f8dfa52fd601f94dcf755edf663e9d18957a6d347dd17b58e9582bc9b188ccc42c7a0049b127c0517e671946d47dedf898e83c9453b186d4f951c30bccf70b1a963fe117ae0e690ca8d2b1bbb12ec6ff0089b535679afa84cb11c3c76d24d2661fc3fc354260cd86400012a6c4788ae1e7b9bac77f0cc6bb55cc3c68b2f1e29616dd19366fb0f4ab591c6826e8401a5c1ef1dd5c7f17cb66f1e0ac243444dda36e97f0ad197eacca29648543d87a9ae478e956f827324ca7eddb8b96bfe9e83fdc7f30bfd759f971fb2ec14e80581f3ffd6a863f3f942756c801a327d61458f9d3b2b9b491bd115f5b924dae3b2b37c366311a9e4f9aba8d33232026d717b769eb5660c6b26d97d464376158a9cee4a0da224fbe88e7b2ee3d0a003adbb6b5af8acf54db7cfa70bf9f8ad1c91c6bf2ca7e007cd5208eea5468481a907b3ba9637298f9b1ff0034049633701bf848d6d4d6cdc48d407916e7bcff004571df5b36c48e9a6ffd79a6862a0802f73d49b5eb7b83722584e40f413ab5fe0479572d3f2d8c1bd03dcb740a2c3e269abcae7649b075c68506a4026c3c49bd75f169b4e6cc7e5cfcbb4bc4af4d8b2f107a1321c9bf6b8edfb2ac24d0c84a236e74f9876807a5ebcacc996aef18ce2650542aabdf76eec07a695d9fd35bf0a348269bdf96662d2bf5b1205941fecdabbcae175749428d0adb206e0120f950f5db522fe5fd744ea7c052badfa8a81a4c9d8a08f3b7f4520cc7aa11e441a7e942802deda837eda3703c3ce96b46e6a802c7a1a54695078efbd20d031eeeb415c82971b911c3889ae509f1507b6a032f5d35f3feaa1ef5ab9e2bae63620cdc7192cd196c38a65b48ad79e3727aabf46d9ddd5854d938707b632104d1c3baccf0119705bb08937232ebd8fad617bc6d452728da680fcc012030ee6b5ae2ae2a7e1a52e34e2269f1a61950c7ac852e1e31df244dea51e3a8f1aa9f9893a6e3f1a9b0b9283127f7960575ee2ec1c77ed75b687b883e35623c6e1b2374904d229fc3892b2446fdcb3ed646f00429a9855139328fc675f1a4b9520e8e7e35660e3a1e414fe9aeed328b9c795753e092a0da4f836dacc662ac5581561d41d08ab84cad9c873f88fc687bee7f11f8d5432537dc34eb4cc5bf76c41f1d6b72163ec1720b69702b98f70d6e7153a4910bdda45d185ec001fbeb979b5b897e2baf8b6973ab21d4c533c6742a4e9489abbcce3057fcc476ec1205d40ee37fbab33dc6aebaff00692c72bc5c54a48bd02dae82a32e696f35709d92037eb4b7545b8d2dc7beae0ec9775374a66e342f4c2764a09eca45dadb7b3baa5c0844d3a87711c008f764247a54e86c3b4f70ad07e270e4ca961c5ca236eb109e3310208baef6622c4ff76d45ce594091a8d3cbad74ff004a66cf2664713926ccb6f8d60cb89958cca322178b7fca58595bfbadd0d75bf46714e723f38e2d1c43af7b1e83fa6b379e0cf0edcf5a0c48e9d691ddd841f3a0370ea01fb6b6c08e9a52b5227b08bd36e41d2f6ee3fbea02145cdc0b7669ad2d8bd94b72dc6b6f0a3e340d0a775831b0ea34fdd445ff008af4874d74275346e3eda054a9a5ada52aa3c4fdb7a42292bb56fa2f91dd64f6adfc5bb4fd9457e89e407568878ee27fe5a99ad70e2c412d1fcbc95dc2fd159bdb2c43ed63ff002d3fff000acaebefc5ff00cbfd35334e1c27e5e5eea971fdec79164d8b26d37db22875fb55b435da9fa2f33feac47ed6ff004d0ffc27349f9e2b7816ff004d3357872466fe7fbd163ac4b707da42e16fda45d8b0f8d692729efe4440c31c8a6e1867159425ff0082775dc3fc57ada6fa37286867814f7163fe9a8a4fa4f26152f264628402f72e574fb56a7d998e7f905654ff00bbc38a30c4fb33c1b57701e3133467e159af0c76baee5eebd9bf65ab527c5c65665214907aa8ebe22aabc49d10be9ff1a5eaca6228fb4c7a107eda971cbc325c30b1d1ac7b2a5963900b8dd73d2f634cdb2ecb18dbced4bccc138b9f8690cd85e030950db85ac3b6fdf550606133a8393ed023d4080d63dd715582cb6becb5bc2d41175bbdc1eeb5eb3aebd7d2aed7b7361d918b1c6018e5125faf65442073d2a40b2b1b2a961df6b7eda722ba9bba3136e9b74ad66fca6222f61bb481e6687b5adb78fb35a9bdb9cbef58ca8ef34f587209dd62376b7d055ca62201081d6edf753bdb4dc048b65bea17afc4d4ff0095918fc8c7c4935770f8c5952479675c6651e8de8ee18f602ea085bd4cfdaf1f0831f3d30983e3e1c5a7e298195afde2e4007c85373f906e472db2f26e64716b0d7402c835a6c98d39ba15d7b6c6e2b7b81fa630391403232668725406931f62add0f464637b820f75433873f10cdc95189102c2560444973b9fb2ca34af53e0b0a7c0e271b1321b74f1a7f33b6c5896b5fb6dd2a3e3b84e2f8ad7122b48458cce77487fc47a7d95a2a74ebad6a336e4fa3a5341a57d6f7fb2aa09143ca95e86eeea02580d0f6d070854dc69db4d661519906d22fd6a07b1207a4dad519661f8b4ee201fd96a8bde2a2c755efa0655617537140f79d86be923ed1fbe95529e5ec06d7a54122f3914ae5234db617df336d5f82863519e4b93797662e32caa3ffb06f087c99f6d6b07427d245fe148807434fe559b2cfcf6cbc78d1063d81b730f89028451f26f17f3c4c6737d44891463bb443735a46cac0769eca70bf53d3ba9832ccc6e3f955bb4b9c50b7e003dd03ffd9426e23367955a6ce32c6ba88d94a83fde0840ad5e94b5bd4eb0cd63cdf4de2e41532b2c616f7582311eebf7b317352afd3bc588bdb2acc3a6adfe902b4c8be942d6a627c19ae6737e9bfcbde4c75f7231ff00bc7efac87c68c9d52fe75dfdafd2a865f1189904b906273a92bfd22967c2e5c4b63c56beddbe5a54420737218edecb8bdebad1f4ca33dde56110d15768b9f13ad5fc6e1b02060cb17b8e3f13fabe03a54c532e4b13e9de432d77ac2aa87a3c9e807c86a6b420fa42507fee2550bdd16a7e2c0575763dd4bc6ae2266b160fa778a82c6481e63d8ce4b0ff00da96aadcee060c3042d8f12437620ed1b49d2ba2b8bdafd7a0aa19b810674d199b798e307d2bf2b6bd0da947258dc4e567b1f6622f12fe2360a4f99ad18fe97ce6b07f6e31e2d7ff002835d3a7b31858d00451a2a8f48f2b549f653066b1313e99c484efca3efb7f081b53eded356cf03c436a71c5bf8773dbfcd5a1af71a563d82ae119591c2e24a639b1e25c6922bd97621523fb4ba835341850c791f9a958c991b046a6c15557b95534ab65bd7b083723bae3e348a9d2cbe54c29c08b6834a6d8136602fe54482bd875a6ee1a5ef7a21c157b0fc0d2208fc5fb29a18926c2ddc4f6d02eda0340e2580eb7a86496451754f73c980fdb4e2c7bfa76e950c9b803b58dfbb4fdd40bf32c57d51b21ed06c7ef526a193200ea0dff00ba69b21b7e23e57a89e4b0d6e2dde6a29cf90a07500f655679fb4358d413e4c6352030ecaa2f2967b85f6c1e8189fd835a0b72e63eeb6d2c3c05ff00652aa0609985d6572bda2fb3fae953938774429d08bd3446a082a48b765f4a769d6976d5472ed8dfa6fbb2f2fc70ca5595a56e56360d2052fb919d4912285161e9d2aee36566272dca7b38ad94bef426eb2a205fe4a6967efeb53bf091488629b2b2a5c576bbe33497422fbb693b77edf0dd5771f162c79f23223277e532bc80f4051046bb7ec154739fa8343f4bed690433e5e44d8eacedfed9967903316fec25f5a743951ffe39cce043389c71e92ac3306dc5a0914c911ddde355fb2b661e1b0e17c7705dbf2ad33c4ae415df90c59d88b7517b0f0a595c36265c9348e5d0e4407165119da1a326f722df30eca03cab11c16590483f9490823ffc46ad626b898e4ffd28ff00ca2aa9e2cb43241366644d0cb1342c8e63b6d75d9a6c8d4dedd29d8b833e315ffbb9a68d176ac5218f6d80da2fb6253a79d41439b459397e3e37c6fcea98724980b0417062b37ab4d2b3640ebc56763bc6d198f3f1c2f1ecfbbda491e2b20909b159353dc2f5d065f1eb93910e534d2e3cf02ba23c5b6db64dbb81de8e3f08a8bf46c3931a584cd2b49912a4d2e496533349115643aaedf4ed1a6da643b8ec58e096475e35704edb6f5915f70bfcbe826a9f2d189f9bc38db1466afe5676f64b84170f10df76b0d2f5a98f89342e5a4ca9b20116db2fb7607bff00971a5372b8d5c9c98b2967971e7891a25784a6a8e55981de8fdaa2831f97c16930f8ec3c78cf1b24b98762a306d8e229591895d0eaa28e2f27265f2116495db91160642e441d899114a8196de7d3c0d6bfe9e8df97334f2cef8b37bf1bb95ddbb6b259b6228b598d46bc562c5cacbc9c3b972678fdb96c7d16f4faad6f98ed141438ce2b8fcfe331f232a319395951acd3e5313eeef71bbd0fd576dec00e955b9225313ea2504d91b182804dededc5d3ceb5bf428111a18327271f19c92d8d148163f56ac17d25941ee561524dc3e24d1e646e5c2e718ccd6234f682aaecd34d105063cd8aa1f326c3c4970b0970b204eb2828b24bb6f1148cb1d540376d29b952e61e1b08362343196c20671321b8f722d76afabd55d26442b9104b0497d932346d6d0d9c1536f8d57978f825c38b09cb7b50988a907d5fc82ac9736feceb418d2e309b99e4ef8233f6340159e455d97881b7ac8ebe14335201cae3c59382d910a601db8d0a993db22403a291d3a5eb5a4e283654f950e54f8ef9254cab198f6928bb17e78d8f4a963c189329328bbbce90fe5f7311ea5dc1f710a07aae2839cdcf27d338edb5a649736311c4cd76f64cf6481dc9ea07a4deade645163f11c9b478038f738d200c24562de93a7a7a5ab464e1319e39a34792359b2172fd057d13210db92ea6d722e4527e38cf0e462e4654f910cd198dfdc31e81bf87646bad067f238989c771bfa9f1e063e540227063660b202caad1ba5ecc1afdd5ba6f7b5677e910978db2a79b296121a28a66511865f958a4689badd97ab4db7aea0f812281ecf6a89a407a9fb2fa544e24eab2903c406fe8a8d9e451ab29f1b5aa6553192e3c3b2abb4927b8598d91459477f8d4724845c817ee04daaacf98a84837d3bb5a09e69c2ea0dc9ace9721e53a35d7c3a7d950995a72cdaedeed7e150fbd24f3985748e3ff00708eb7ec5a82556663e81e05cea6acc78f16cbab7abb49aaf3642e3465e5f4c4a2e5853adcd6d322e2a40813de613172e911bd9e458d4ec06c7c743a56e466a5106c6dddbf752a31cb3c790717361306585deab70e9227f1c4e3461f7d2ab8899ae984a57e6d477d4a1811707435115523d2c2dd9dd5180c358c92d7d401a7c6fd6b19c34b25d47681d96a235aa58eb86960a01707abb6e6bff8aae29fbaac53c5a836834d7ba8167e8a3b3a9a40003d46e7b6f409558ea7afdc29d636a2bd2969440d68108df3007cc5385bce9102f40c29fc2c53cbfae81f780ea1bcf4a7f95020f7d0465d875537f0d69caca3453af6df4347527b80e945b6fe2b7db404b52bdea3daac6c0d877836a7ed3d8df1d6809f0a6dedd685dc745561dea6d4d326deaadf60ddfe5bd049a53485eb401bf4fbc11408d3b7eda044d8694d5b2dc77ea681e94db903a5fce80bc961e150b383d28b336bb47dd7a89d9fb47dd532a6c8d6075aab248145cb76e829f2137370777df55643af4d280b48a5fadac3a7656564ca1a62ab73b8daaeccd65e85afd7c3bab3a120ce41163adfbadd9db4f881f9591ec631282db41d69706376323ca3d525ddafadcb1fdd5579c0e315963d6fd48e800efad2e311138e8990867082c0104f4f3ad4f566fa25963c71c8f18b28be39c9dcebd6ec88cf1adbb6ec2af72990d94eb931a3aa720a2026381f242042ea320b215dbe99196d6bebd34ae1f92979a7ca60eb2aa890086ca42ee07d1b4db537e95d6717cf7366093f33c2c934ad1ec79559b1d4a0b8bfb6cb65d7a95edab930b5f5164c2f1e122c7ede4e166450c607abf96e8430bf8a0bdae7b2f4aa932e44d9914fc918e195014c5c6049dba7a8977f54926dea4f652aa997ffd9" + }, + { + "transaction_hash": "0x533c5e38d1b8bf75166bd6443a443cd25bd36c087e1a5b8b0881b388fa1a942c", + "block_number": "3369985", + "transaction_index": "16", + "block_timestamp": "1489779729", + "block_blockhash": "0x5a6625602b867e0b7b7c6cda7afcab186d28bf8ae8573d7f92c5cbb0133bd2cd", + "event_log_index": null, + "ethscription_number": "1", + "creator": "0x8fbd0f243917b6e6be2d30bcc178f890ae330ffc", + "initial_owner": "0x8fbd0f243917b6e6be2d30bcc178f890ae330ffc", + "current_owner": "0x8fbd0f243917b6e6be2d30bcc178f890ae330ffc", + "previous_owner": "0x8fbd0f243917b6e6be2d30bcc178f890ae330ffc", + "content_uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAGaRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgICAgICAgICB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOmV4aWZFWD0iaHR0cDovL2NpcGEuanAvZXhpZi8xLjAvIgogICAgICAgICAgICB4bWxuczptd2ctcnM9Imh0dHA6Ly93d3cubWV0YWRhdGF3b3JraW5nZ3JvdXAuY29tL3NjaGVtYXMvcmVnaW9ucy8iCiAgICAgICAgICAgIHhtbG5zOnN0QXJlYT0iaHR0cDovL25zLmFkb2JlLmNvbS94bXAvc1R5cGUvQXJlYSMiCiAgICAgICAgICAgIHhtbG5zOmFwcGxlLWZpPSJodHRwOi8vbnMuYXBwbGUuY29tL2ZhY2VpbmZvLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOnN0RGltPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvRGltZW5zaW9ucyMiCiAgICAgICAgICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIj4KICAgICAgICAgPGV4aWY6Q29sb3JTcGFjZT4xPC9leGlmOkNvbG9yU3BhY2U+CiAgICAgICAgIDxleGlmOlN1YmplY3RBcmVhPgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaT4xNDg1PC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGk+OTYyPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGk+NDM5PC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGk+NDM4PC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9leGlmOlN1YmplY3RBcmVhPgogICAgICAgICA8ZXhpZjpTaHV0dGVyU3BlZWRWYWx1ZT4xMzA2MS8zMzQzPC9leGlmOlNodXR0ZXJTcGVlZFZhbHVlPgogICAgICAgICA8ZXhpZjpGb2NhbExlbmd0aD44My8yMDwvZXhpZjpGb2NhbExlbmd0aD4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjEwMDwvZXhpZjpQaXhlbFhEaW1lbnNpb24+CiAgICAgICAgIDxleGlmOlNlbnNpbmdNZXRob2Q+MjwvZXhpZjpTZW5zaW5nTWV0aG9kPgogICAgICAgICA8ZXhpZjpTY2VuZUNhcHR1cmVUeXBlPjA8L2V4aWY6U2NlbmVDYXB0dXJlVHlwZT4KICAgICAgICAgPGV4aWY6Rm9jYWxMZW5JbjM1bW1GaWxtPjI5PC9leGlmOkZvY2FsTGVuSW4zNW1tRmlsbT4KICAgICAgICAgPGV4aWY6RXhwb3N1cmVUaW1lPjEvMTU8L2V4aWY6RXhwb3N1cmVUaW1lPgogICAgICAgICA8ZXhpZjpFeHBvc3VyZVByb2dyYW0+MjwvZXhpZjpFeHBvc3VyZVByb2dyYW0+CiAgICAgICAgIDxleGlmOkV4aWZWZXJzaW9uPjAyMjE8L2V4aWY6RXhpZlZlcnNpb24+CiAgICAgICAgIDxleGlmOlN1YnNlY1RpbWVEaWdpdGl6ZWQ+NTY2PC9leGlmOlN1YnNlY1RpbWVEaWdpdGl6ZWQ+CiAgICAgICAgIDxleGlmOkNvbXBvbmVudHNDb25maWd1cmF0aW9uPgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaT4xPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGk+MjwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpPjM8L3JkZjpsaT4KICAgICAgICAgICAgICAgPHJkZjpsaT4wPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9leGlmOkNvbXBvbmVudHNDb25maWd1cmF0aW9uPgogICAgICAgICA8ZXhpZjpXaGl0ZUJhbGFuY2U+MDwvZXhpZjpXaGl0ZUJhbGFuY2U+CiAgICAgICAgIDxleGlmOkJyaWdodG5lc3NWYWx1ZT4zNTMzLzcyOTE8L2V4aWY6QnJpZ2h0bmVzc1ZhbHVlPgogICAgICAgICA8ZXhpZjpTdWJzZWNUaW1lT3JpZ2luYWw+NTY2PC9leGlmOlN1YnNlY1RpbWVPcmlnaW5hbD4KICAgICAgICAgPGV4aWY6RXhwb3N1cmVNb2RlPjA8L2V4aWY6RXhwb3N1cmVNb2RlPgogICAgICAgICA8ZXhpZjpGbGFzaCByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgIDxleGlmOkZ1bmN0aW9uPkZhbHNlPC9leGlmOkZ1bmN0aW9uPgogICAgICAgICAgICA8ZXhpZjpGaXJlZD5GYWxzZTwvZXhpZjpGaXJlZD4KICAgICAgICAgICAgPGV4aWY6UmV0dXJuPjA8L2V4aWY6UmV0dXJuPgogICAgICAgICAgICA8ZXhpZjpNb2RlPjI8L2V4aWY6TW9kZT4KICAgICAgICAgICAgPGV4aWY6UmVkRXllTW9kZT5GYWxzZTwvZXhpZjpSZWRFeWVNb2RlPgogICAgICAgICA8L2V4aWY6Rmxhc2g+CiAgICAgICAgIDxleGlmOkFwZXJ0dXJlVmFsdWU+NzgwMS8zNDI5PC9leGlmOkFwZXJ0dXJlVmFsdWU+CiAgICAgICAgIDxleGlmOkV4cG9zdXJlQmlhc1ZhbHVlPjAvMTwvZXhpZjpFeHBvc3VyZUJpYXNWYWx1ZT4KICAgICAgICAgPGV4aWY6Rk51bWJlcj4xMS81PC9leGlmOkZOdW1iZXI+CiAgICAgICAgIDxleGlmOlBpeGVsWURpbWVuc2lvbj4xMDA8L2V4aWY6UGl4ZWxZRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpNZXRlcmluZ01vZGU+NTwvZXhpZjpNZXRlcmluZ01vZGU+CiAgICAgICAgIDxleGlmOklTT1NwZWVkUmF0aW5ncz4KICAgICAgICAgICAgPHJkZjpTZXE+CiAgICAgICAgICAgICAgIDxyZGY6bGk+MjUwPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9leGlmOklTT1NwZWVkUmF0aW5ncz4KICAgICAgICAgPGV4aWY6U2NlbmVUeXBlPjE8L2V4aWY6U2NlbmVUeXBlPgogICAgICAgICA8ZXhpZjpGbGFzaFBpeFZlcnNpb24+MDEwMDwvZXhpZjpGbGFzaFBpeFZlcnNpb24+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjcyPC90aWZmOlhSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpNYWtlPkFwcGxlPC90aWZmOk1ha2U+CiAgICAgICAgIDx0aWZmOlJlc29sdXRpb25Vbml0PjI8L3RpZmY6UmVzb2x1dGlvblVuaXQ+CiAgICAgICAgIDx0aWZmOllSZXNvbHV0aW9uPjcyPC90aWZmOllSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpDb21wcmVzc2lvbj41PC90aWZmOkNvbXByZXNzaW9uPgogICAgICAgICA8dGlmZjpNb2RlbD5pUGhvbmUgNnM8L3RpZmY6TW9kZWw+CiAgICAgICAgIDx4bXA6Q3JlYXRvclRvb2w+UGl4ZWxtYXRvciAzLjY8L3htcDpDcmVhdG9yVG9vbD4KICAgICAgICAgPHhtcDpDcmVhdGVEYXRlPjIwMTYtMDYtMTBUMTM6NTg6NDUuNTY2PC94bXA6Q3JlYXRlRGF0ZT4KICAgICAgICAgPHhtcDpNb2RpZnlEYXRlPjIwMTc6MDM6MTcgMTI6MDM6MTU8L3htcDpNb2RpZnlEYXRlPgogICAgICAgICA8ZXhpZkVYOlBob3RvZ3JhcGhpY1NlbnNpdGl2aXR5PjI1MDwvZXhpZkVYOlBob3RvZ3JhcGhpY1NlbnNpdGl2aXR5PgogICAgICAgICA8ZXhpZkVYOkxlbnNNb2RlbD5pUGhvbmUgNnMgYmFjayBjYW1lcmEgNC4xNW1tIGYvMi4yPC9leGlmRVg6TGVuc01vZGVsPgogICAgICAgICA8ZXhpZkVYOkxlbnNTcGVjaWZpY2F0aW9uPgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaT44My8yMDwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpPjgzLzIwPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGk+MTEvNTwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpPjExLzU8L3JkZjpsaT4KICAgICAgICAgICAgPC9yZGY6U2VxPgogICAgICAgICA8L2V4aWZFWDpMZW5zU3BlY2lmaWNhdGlvbj4KICAgICAgICAgPGV4aWZFWDpMZW5zTWFrZT5BcHBsZTwvZXhpZkVYOkxlbnNNYWtlPgogICAgICAgICA8bXdnLXJzOlJlZ2lvbnMgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICA8bXdnLXJzOlJlZ2lvbkxpc3Q+CiAgICAgICAgICAgICAgIDxyZGY6U2VxPgogICAgICAgICAgICAgICAgICA8cmRmOmxpIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgICAgPG13Zy1yczpBcmVhIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgICAgICAgPHN0QXJlYTp5PjAuMzE4NTAwPC9zdEFyZWE6eT4KICAgICAgICAgICAgICAgICAgICAgICAgPHN0QXJlYTp3PjAuMTA4MDAwPC9zdEFyZWE6dz4KICAgICAgICAgICAgICAgICAgICAgICAgPHN0QXJlYTp4PjAuMzY4MDAwPC9zdEFyZWE6eD4KICAgICAgICAgICAgICAgICAgICAgICAgPHN0QXJlYTpoPjAuMTQ1MDAwPC9zdEFyZWE6aD4KICAgICAgICAgICAgICAgICAgICAgICAgPHN0QXJlYTp1bml0Pm5vcm1hbGl6ZWQ8L3N0QXJlYTp1bml0PgogICAgICAgICAgICAgICAgICAgICA8L213Zy1yczpBcmVhPgogICAgICAgICAgICAgICAgICAgICA8bXdnLXJzOlR5cGU+RmFjZTwvbXdnLXJzOlR5cGU+CiAgICAgICAgICAgICAgICAgICAgIDxtd2ctcnM6RXh0ZW5zaW9ucyByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgICAgICAgIDxhcHBsZS1maTpBbmdsZUluZm9ZYXc+MDwvYXBwbGUtZmk6QW5nbGVJbmZvWWF3PgogICAgICAgICAgICAgICAgICAgICAgICA8YXBwbGUtZmk6QW5nbGVJbmZvUm9sbD4wPC9hcHBsZS1maTpBbmdsZUluZm9Sb2xsPgogICAgICAgICAgICAgICAgICAgICAgICA8YXBwbGUtZmk6Q29uZmlkZW5jZUxldmVsPjEwMDA8L2FwcGxlLWZpOkNvbmZpZGVuY2VMZXZlbD4KICAgICAgICAgICAgICAgICAgICAgICAgPGFwcGxlLWZpOlRpbWVzdGFtcD4yMTQ3NDgzNjQ3PC9hcHBsZS1maTpUaW1lc3RhbXA+CiAgICAgICAgICAgICAgICAgICAgICAgIDxhcHBsZS1maTpGYWNlSUQ+MTwvYXBwbGUtZmk6RmFjZUlEPgogICAgICAgICAgICAgICAgICAgICA8L213Zy1yczpFeHRlbnNpb25zPgogICAgICAgICAgICAgICAgICA8L3JkZjpsaT4KICAgICAgICAgICAgICAgICAgPHJkZjpsaSByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgICAgIDxtd2ctcnM6QXJlYSByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgICAgICAgIDxzdEFyZWE6eT4wLjMxODUwMDwvc3RBcmVhOnk+CiAgICAgICAgICAgICAgICAgICAgICAgIDxzdEFyZWE6dz4wLjA4MTAwMDwvc3RBcmVhOnc+CiAgICAgICAgICAgICAgICAgICAgICAgIDxzdEFyZWE6eD4wLjUxMDUwMDwvc3RBcmVhOng+CiAgICAgICAgICAgICAgICAgICAgICAgIDxzdEFyZWE6aD4wLjEwOTAwMDwvc3RBcmVhOmg+CiAgICAgICAgICAgICAgICAgICAgICAgIDxzdEFyZWE6dW5pdD5ub3JtYWxpemVkPC9zdEFyZWE6dW5pdD4KICAgICAgICAgICAgICAgICAgICAgPC9td2ctcnM6QXJlYT4KICAgICAgICAgICAgICAgICAgICAgPG13Zy1yczpUeXBlPkZhY2U8L213Zy1yczpUeXBlPgogICAgICAgICAgICAgICAgICAgICA8bXdnLXJzOkV4dGVuc2lvbnMgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICAgICAgICA8YXBwbGUtZmk6QW5nbGVJbmZvWWF3PjA8L2FwcGxlLWZpOkFuZ2xlSW5mb1lhdz4KICAgICAgICAgICAgICAgICAgICAgICAgPGFwcGxlLWZpOkFuZ2xlSW5mb1JvbGw+MDwvYXBwbGUtZmk6QW5nbGVJbmZvUm9sbD4KICAgICAgICAgICAgICAgICAgICAgICAgPGFwcGxlLWZpOkNvbmZpZGVuY2VMZXZlbD4xMDAwPC9hcHBsZS1maTpDb25maWRlbmNlTGV2ZWw+CiAgICAgICAgICAgICAgICAgICAgICAgIDxhcHBsZS1maTpUaW1lc3RhbXA+MjE0NzQ4MzY0NzwvYXBwbGUtZmk6VGltZXN0YW1wPgogICAgICAgICAgICAgICAgICAgICAgICA8YXBwbGUtZmk6RmFjZUlEPjI8L2FwcGxlLWZpOkZhY2VJRD4KICAgICAgICAgICAgICAgICAgICAgPC9td2ctcnM6RXh0ZW5zaW9ucz4KICAgICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgICAgPC9td2ctcnM6UmVnaW9uTGlzdD4KICAgICAgICAgICAgPG13Zy1yczpBcHBsaWVkVG9EaW1lbnNpb25zIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgPHN0RGltOmg+MzAyNDwvc3REaW06aD4KICAgICAgICAgICAgICAg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", + "content_sha": "0xdcb130d85be00f8fd735ddafcba1cc83f99ba8dab0fc79c833401827b615c92b", + "esip6": false, + "mimetype": "image/png", + "media_type": "image", + "mime_subtype": "png", + "gas_price": "20006429073", + "gas_used": "1664832", + "transaction_fee": "33307343326460736", + "value": "0", + "attachment_sha": null, + "attachment_content_type": null, + "genesis_block": 3369985, + "is_genesis": true, + "content_uri_hash": "0xdcb130d85be00f8fd735ddafcba1cc83f99ba8dab0fc79c833401827b615c92b", + "was_base64": true, + "content": "0x89504e470d0a1a0a0000000d4948445200000064000000640802000000ff800203000000017352474200aece1ce9000000097048597300000b1300000b1301009a9c18000019a469545874584d4c3a636f6d2e61646f62652e786d7000000000003c783a786d706d65746120786d6c6e733a783d2261646f62653a6e733a6d6574612f2220783a786d70746b3d22584d5020436f726520352e342e30223e0a2020203c7264663a52444620786d6c6e733a7264663d22687474703a2f2f7777772e77332e6f72672f313939392f30322f32322d7264662d73796e7461782d6e7323223e0a2020202020203c7264663a4465736372697074696f6e207264663a61626f75743d22220a202020202020202020202020786d6c6e733a657869663d22687474703a2f2f6e732e61646f62652e636f6d2f657869662f312e302f220a202020202020202020202020786d6c6e733a746966663d22687474703a2f2f6e732e61646f62652e636f6d2f746966662f312e302f220a202020202020202020202020786d6c6e733a786d703d22687474703a2f2f6e732e61646f62652e636f6d2f7861702f312e302f220a202020202020202020202020786d6c6e733a6578696645583d22687474703a2f2f636970612e6a702f657869662f312e302f220a202020202020202020202020786d6c6e733a6d77672d72733d22687474703a2f2f7777772e6d65746164617461776f726b696e6767726f75702e636f6d2f736368656d61732f726567696f6e732f220a202020202020202020202020786d6c6e733a7374417265613d22687474703a2f2f6e732e61646f62652e636f6d2f786d702f73547970652f4172656123220a202020202020202020202020786d6c6e733a6170706c652d66693d22687474703a2f2f6e732e6170706c652e636f6d2f66616365696e666f2f312e302f220a202020202020202020202020786d6c6e733a737444696d3d22687474703a2f2f6e732e61646f62652e636f6d2f7861702f312e302f73547970652f44696d656e73696f6e7323220a202020202020202020202020786d6c6e733a70686f746f73686f703d22687474703a2f2f6e732e61646f62652e636f6d2f70686f746f73686f702f312e302f220a202020202020202020202020786d6c6e733a64633d22687474703a2f2f7075726c2e6f72672f64632f656c656d656e74732f312e312f223e0a2020202020202020203c657869663a436f6c6f7253706163653e313c2f657869663a436f6c6f7253706163653e0a2020202020202020203c657869663a5375626a656374417265613e0a2020202020202020202020203c7264663a5365713e0a2020202020202020202020202020203c7264663a6c693e313438353c2f7264663a6c693e0a2020202020202020202020202020203c7264663a6c693e3936323c2f7264663a6c693e0a2020202020202020202020202020203c7264663a6c693e3433393c2f7264663a6c693e0a2020202020202020202020202020203c7264663a6c693e3433383c2f7264663a6c693e0a2020202020202020202020203c2f7264663a5365713e0a2020202020202020203c2f657869663a5375626a656374417265613e0a2020202020202020203c657869663a53687574746572537065656456616c75653e31333036312f333334333c2f657869663a53687574746572537065656456616c75653e0a2020202020202020203c657869663a466f63616c4c656e6774683e38332f32303c2f657869663a466f63616c4c656e6774683e0a2020202020202020203c657869663a506978656c5844696d656e73696f6e3e3130303c2f657869663a506978656c5844696d656e73696f6e3e0a2020202020202020203c657869663a53656e73696e674d6574686f643e323c2f657869663a53656e73696e674d6574686f643e0a2020202020202020203c657869663a5363656e6543617074757265547970653e303c2f657869663a5363656e6543617074757265547970653e0a2020202020202020203c657869663a466f63616c4c656e496e33356d6d46696c6d3e32393c2f657869663a466f63616c4c656e496e33356d6d46696c6d3e0a2020202020202020203c657869663a4578706f7375726554696d653e312f31353c2f657869663a4578706f7375726554696d653e0a2020202020202020203c657869663a4578706f7375726550726f6772616d3e323c2f657869663a4578706f7375726550726f6772616d3e0a2020202020202020203c657869663a4578696656657273696f6e3e303232313c2f657869663a4578696656657273696f6e3e0a2020202020202020203c657869663a53756273656354696d654469676974697a65643e3536363c2f657869663a53756273656354696d654469676974697a65643e0a2020202020202020203c657869663a436f6d706f6e656e7473436f6e66696775726174696f6e3e0a2020202020202020202020203c7264663a5365713e0a2020202020202020202020202020203c7264663a6c693e313c2f7264663a6c693e0a2020202020202020202020202020203c7264663a6c693e323c2f7264663a6c693e0a2020202020202020202020202020203c7264663a6c693e333c2f7264663a6c693e0a2020202020202020202020202020203c7264663a6c693e303c2f7264663a6c693e0a2020202020202020202020203c2f7264663a5365713e0a2020202020202020203c2f657869663a436f6d706f6e656e7473436f6e66696775726174696f6e3e0a2020202020202020203c657869663a576869746542616c616e63653e303c2f657869663a576869746542616c616e63653e0a2020202020202020203c657869663a4272696768746e65737356616c75653e333533332f373239313c2f657869663a4272696768746e65737356616c75653e0a2020202020202020203c657869663a53756273656354696d654f726967696e616c3e3536363c2f657869663a53756273656354696d654f726967696e616c3e0a2020202020202020203c657869663a4578706f737572654d6f64653e303c2f657869663a4578706f737572654d6f64653e0a2020202020202020203c657869663a466c617368207264663a7061727365547970653d225265736f75726365223e0a2020202020202020202020203c657869663a46756e6374696f6e3e46616c73653c2f657869663a46756e6374696f6e3e0a2020202020202020202020203c657869663a46697265643e46616c73653c2f657869663a46697265643e0a2020202020202020202020203c657869663a52657475726e3e303c2f657869663a52657475726e3e0a2020202020202020202020203c657869663a4d6f64653e323c2f657869663a4d6f64653e0a2020202020202020202020203c657869663a5265644579654d6f64653e46616c73653c2f657869663a5265644579654d6f64653e0a2020202020202020203c2f657869663a466c6173683e0a2020202020202020203c657869663a417065727475726556616c75653e373830312f333432393c2f657869663a417065727475726556616c75653e0a2020202020202020203c657869663a4578706f737572654269617356616c75653e302f313c2f657869663a4578706f737572654269617356616c75653e0a2020202020202020203c657869663a464e756d6265723e31312f353c2f657869663a464e756d6265723e0a2020202020202020203c657869663a506978656c5944696d656e73696f6e3e3130303c2f657869663a506978656c5944696d656e73696f6e3e0a2020202020202020203c657869663a4d65746572696e674d6f64653e353c2f657869663a4d65746572696e674d6f64653e0a2020202020202020203c657869663a49534f5370656564526174696e67733e0a2020202020202020202020203c7264663a5365713e0a2020202020202020202020202020203c7264663a6c693e3235303c2f7264663a6c693e0a2020202020202020202020203c2f7264663a5365713e0a2020202020202020203c2f657869663a49534f5370656564526174696e67733e0a2020202020202020203c657869663a5363656e65547970653e313c2f657869663a5363656e65547970653e0a2020202020202020203c657869663a466c61736850697856657273696f6e3e303130303c2f657869663a466c61736850697856657273696f6e3e0a2020202020202020203c746966663a4f7269656e746174696f6e3e313c2f746966663a4f7269656e746174696f6e3e0a2020202020202020203c746966663a585265736f6c7574696f6e3e37323c2f746966663a585265736f6c7574696f6e3e0a2020202020202020203c746966663a4d616b653e4170706c653c2f746966663a4d616b653e0a2020202020202020203c746966663a5265736f6c7574696f6e556e69743e323c2f746966663a5265736f6c7574696f6e556e69743e0a2020202020202020203c746966663a595265736f6c7574696f6e3e37323c2f746966663a595265736f6c7574696f6e3e0a2020202020202020203c746966663a436f6d7072657373696f6e3e353c2f746966663a436f6d7072657373696f6e3e0a2020202020202020203c746966663a4d6f64656c3e6950686f6e652036733c2f746966663a4d6f64656c3e0a2020202020202020203c786d703a43726561746f72546f6f6c3e506978656c6d61746f7220332e363c2f786d703a43726561746f72546f6f6c3e0a2020202020202020203c786d703a437265617465446174653e323031362d30362d31305431333a35383a34352e3536363c2f786d703a437265617465446174653e0a2020202020202020203c786d703a4d6f64696679446174653e323031373a30333a31372031323a30333a31353c2f786d703a4d6f64696679446174653e0a2020202020202020203c6578696645583a50686f746f6772617068696353656e73697469766974793e3235303c2f6578696645583a50686f746f6772617068696353656e73697469766974793e0a2020202020202020203c6578696645583a4c656e734d6f64656c3e6950686f6e65203673206261636b2063616d65726120342e31356d6d20662f322e323c2f6578696645583a4c656e734d6f64656c3e0a2020202020202020203c6578696645583a4c656e7353706563696669636174696f6e3e0a2020202020202020202020203c7264663a5365713e0a2020202020202020202020202020203c7264663a6c693e38332f32303c2f7264663a6c693e0a2020202020202020202020202020203c7264663a6c693e38332f32303c2f7264663a6c693e0a2020202020202020202020202020203c7264663a6c693e31312f353c2f7264663a6c693e0a2020202020202020202020202020203c7264663a6c693e31312f353c2f7264663a6c693e0a2020202020202020202020203c2f7264663a5365713e0a2020202020202020203c2f6578696645583a4c656e7353706563696669636174696f6e3e0a2020202020202020203c6578696645583a4c656e734d616b653e4170706c653c2f6578696645583a4c656e734d616b653e0a2020202020202020203c6d77672d72733a526567696f6e73207264663a7061727365547970653d225265736f75726365223e0a2020202020202020202020203c6d77672d72733a526567696f6e4c6973743e0a2020202020202020202020202020203c7264663a5365713e0a2020202020202020202020202020202020203c7264663a6c69207264663a7061727365547970653d225265736f75726365223e0a2020202020202020202020202020202020202020203c6d77672d72733a41726561207264663a7061727365547970653d225265736f75726365223e0a2020202020202020202020202020202020202020202020203c7374417265613a793e302e3331383530303c2f7374417265613a793e0a2020202020202020202020202020202020202020202020203c7374417265613a773e302e3130383030303c2f7374417265613a773e0a2020202020202020202020202020202020202020202020203c7374417265613a783e302e3336383030303c2f7374417265613a783e0a2020202020202020202020202020202020202020202020203c7374417265613a683e302e3134353030303c2f7374417265613a683e0a2020202020202020202020202020202020202020202020203c7374417265613a756e69743e6e6f726d616c697a65643c2f7374417265613a756e69743e0a2020202020202020202020202020202020202020203c2f6d77672d72733a417265613e0a2020202020202020202020202020202020202020203c6d77672d72733a547970653e466163653c2f6d77672d72733a547970653e0a2020202020202020202020202020202020202020203c6d77672d72733a457874656e73696f6e73207264663a7061727365547970653d225265736f75726365223e0a2020202020202020202020202020202020202020202020203c6170706c652d66693a416e676c65496e666f5961773e303c2f6170706c652d66693a416e676c65496e666f5961773e0a2020202020202020202020202020202020202020202020203c6170706c652d66693a416e676c65496e666f526f6c6c3e303c2f6170706c652d66693a416e676c65496e666f526f6c6c3e0a2020202020202020202020202020202020202020202020203c6170706c652d66693a436f6e666964656e63654c6576656c3e313030303c2f6170706c652d66693a436f6e666964656e63654c6576656c3e0a2020202020202020202020202020202020202020202020203c6170706c652d66693a54696d657374616d703e323134373438333634373c2f6170706c652d66693a54696d657374616d703e0a2020202020202020202020202020202020202020202020203c6170706c652d66693a4661636549443e313c2f6170706c652d66693a4661636549443e0a2020202020202020202020202020202020202020203c2f6d77672d72733a457874656e73696f6e733e0a2020202020202020202020202020202020203c2f7264663a6c693e0a2020202020202020202020202020202020203c7264663a6c69207264663a7061727365547970653d225265736f75726365223e0a2020202020202020202020202020202020202020203c6d77672d72733a41726561207264663a7061727365547970653d225265736f75726365223e0a2020202020202020202020202020202020202020202020203c7374417265613a793e302e3331383530303c2f7374417265613a793e0a2020202020202020202020202020202020202020202020203c7374417265613a773e302e3038313030303c2f7374417265613a773e0a2020202020202020202020202020202020202020202020203c7374417265613a783e302e3531303530303c2f7374417265613a783e0a2020202020202020202020202020202020202020202020203c7374417265613a683e302e3130393030303c2f7374417265613a683e0a2020202020202020202020202020202020202020202020203c7374417265613a756e69743e6e6f726d616c697a65643c2f7374417265613a756e69743e0a2020202020202020202020202020202020202020203c2f6d77672d72733a417265613e0a2020202020202020202020202020202020202020203c6d77672d72733a547970653e466163653c2f6d77672d72733a547970653e0a2020202020202020202020202020202020202020203c6d77672d72733a457874656e73696f6e73207264663a7061727365547970653d225265736f75726365223e0a2020202020202020202020202020202020202020202020203c6170706c652d66693a416e676c65496e666f5961773e303c2f6170706c652d66693a416e676c65496e666f5961773e0a2020202020202020202020202020202020202020202020203c6170706c652d66693a416e676c65496e666f526f6c6c3e303c2f6170706c652d66693a416e676c65496e666f526f6c6c3e0a2020202020202020202020202020202020202020202020203c6170706c652d66693a436f6e666964656e63654c6576656c3e313030303c2f6170706c652d66693a436f6e666964656e63654c6576656c3e0a2020202020202020202020202020202020202020202020203c6170706c652d66693a54696d657374616d703e323134373438333634373c2f6170706c652d66693a54696d657374616d703e0a2020202020202020202020202020202020202020202020203c6170706c652d66693a4661636549443e323c2f6170706c652d66693a4661636549443e0a2020202020202020202020202020202020202020203c2f6d77672d72733a457874656e73696f6e733e0a2020202020202020202020202020202020203c2f7264663a6c693e0a2020202020202020202020202020203c2f7264663a5365713e0a2020202020202020202020203c2f6d77672d72733a526567696f6e4c6973743e0a2020202020202020202020203c6d77672d72733a4170706c696564546f44696d656e73696f6e73207264663a7061727365547970653d225265736f75726365223e0a2020202020202020202020202020203c737444696d3a683e333032343c2f737444696d3a683e0a2020202020202020202020202020203c737444696d3a773e343033323c2f737444696d3a773e0a2020202020202020202020202020203c737444696d3a756e69743e706978656c3c2f737444696d3a756e69743e0a2020202020202020202020203c2f6d77672d72733a4170706c696564546f44696d656e73696f6e733e0a2020202020202020203c2f6d77672d72733a526567696f6e733e0a2020202020202020203c70686f746f73686f703a44617465437265617465643e323031362d30362d31305431333a35383a34352e3536363c2f70686f746f73686f703a44617465437265617465643e0a2020202020202020203c64633a7375626a6563743e0a2020202020202020202020203c7264663a5365712f3e0a2020202020202020203c2f64633a7375626a6563743e0a2020202020203c2f7264663a4465736372697074696f6e3e0a2020203c2f7264663a5244463e0a3c2f783a786d706d6574613e0ae718b3c000002cb74944415478016ddccbb39d55f1c671f89d7011101110c24d72c21d14945b15974a85a2ac72c48c113347fc6d140e9830a00252e1263711500143ce091088802217514021bfcf7abf3b8b4d740d96bd7a753ffd74af7ed77ef73ec1132fb8e082134e38e1c46510fe6f19ebcb29b7dbb279636383b0631904ae6c8e1e3d4a38e9a493824a332db3a1cc9efe5ffffad737df7c437fca29a75c78e185679e79a6ad934f3e394b4a36225832fbfaebafcd421840c8e639d818b698c56a313c3acd5ab267f0d5575f1122462f62b239991293fffce73f5f7ef9e5a9a79e7ada69a7ed98403cedad2fd334f324344221b39f7206eb66d358a44b2fbd94b1baa0c2a6ac2cd5e2dffffe37831f2c034b69583296ad65218a522182c573e2b0894c5b6651184fdfb99ba625f710f222c3cc2b4020417def7bdfebec77285bd61322615d99bcce89dc729aad0bc76de1d1eec71f7fec7c2cf1c0e09ffffc27fd0f7ff843253bebacb3f49425ba769509bfe89652209015d12c318372c62224cf9ab684c992320e73a6a1b70c3921c0e94889831199d1ded37f443b764a096d2dead5e94dfdb49c694ccd715e0a74d14517e9674da428faff8c33ce50234d74de79e7913ff9e493fa1c54cc7a88d6e3c29c59b1594fb59c0b6a8b60976ff661d21062387158a6c9171fee42b7b4150e6563b00ac5de7123d3e96997c6b239e376cd96e1e41521337c9a4f3ffd545174713785aa19e4bffef5afe128a85e636c098d805f20641ab201ca281c03824a4932c16cd007c23e4d8e314c4986b3de59960219e1b73cce65870683183a0b2320b3b1be95667d8e4a66643c84cf609d07f9edb7df3ee79c73bef8e28b0f3ffc5044960a67f9f9e79f9f7efae9dffffef76528f4cc3399a301d932210321cadf3cc3b1b1942d1b82612bc7643881d0b321e762d716c7a028092d4baa79281d0b3712959143f28cf13f85693305be05a65977b154110f1a1e1e46e1ccd8e82c82994cd9bd10d76848c9881b7d5b85a0b7241b52288bc56475d86d1dc78d010dc02c059d540b64e66897d096792ebf73c1cf6dc294e3fadf4b9ab935acd706fddc2208dfa67af509a84634aad3f3181b96281a090c4a863041585a02a4394e864963d86a97bb22ce658e66369592c066d22b3a4d027df2005dc67844c1d14231822604b7e8be334d83ef68bfbb6043d19c40d63e6e25d5713d51fa04a4f1300a1d26c1e875212fa4d18b892d666473d4f39a9a5c4aa72d1a5e553f8d99d208d39201594d298b1582b96ec5a72d38a358ac9bc36a8612e80c30057ac3926372cb29cfe57411c905efdac2c0236928161eda6156a7d21c0712374a0cdb82899b2a03516e4a98702a4a66c5257723e71e942d830cc4e015acb97478cdc1607a1146b1a898829833813ec4a90ccb726a72493395ebcb69c0172d2f566e28f73a1bb53bf7dc733d98f2913303c62a18d19611631c19665e320e1e3cb8b5b5f5b7bffd6de7ce9d5e4428afbaea2adf4394cc283aa810f2b534ec5282224fccece90de1ec0234e811ce65ee9e28640e20d27288a59966eeb62c1265c234989a69cfa041a3283a4899b2af0b683c929b9b9b3ffad18f140e1315648c2bc7c8084aa6e4b2bdbdbd7ffffe23478ea878fa2869b1bbefbefb9a6baef15aabc566d0a248877b68e6d008536fd7084a685e6606948ae5de30d3181b027003117491c84cc35d17920b436eb92e4c36e1842c0c2a1e1ccb25e81278f9f096b62b5fa5ce3efb6c85eba96166049e40af520f3ef8a04ad1a74452b92de573f8f061068ee4fcf3cfc76124b6dc778498fccf194e392698d98bc5b744eccecf0dbb1b3a790215207f7274a75bfab84e17bb3364f673a66fe4223198d858a6f1d0690a6cbcb2fa0a8d091b2e133c998b87eed7bffe756fb0227267e9c0f9da0542e33cbcb55d7df5d53446a127945d1a7a1c4ac492577298e40c18dba214c2483f663e254cc8d336d3ac4331db0a8259c3921e3ac1c865ceec8325b077e6bedf1058c2afb72d1da36f421f7cf0c14b2fbdf4e31fff98260466f3e494f2a1871e7afffdf7838a920f569df88f7ffcc30b2d999767dc530cdc19b0448c9260c49f00133241a0e8b121249b19a42120190df6418d62915a4c94899840df004798699039e237a22d5bcd0bdeb8653066a365ba9524a33a0a27314f9fbe90a75dc343f4ca2bafdc71c71d61364303fedbdffef6cd37df9c1ae0f49e5fc542def0b9a164be666abda79e7aea97bffc2525fb46a9d1f04a4f304acd6e5b34ec35ec7acad99b29598ef25b98e5c661a89666211834f413171c258d61cb48c3c0b055e058b201d5153ecb64a9344ae6fccd9e20b553b8e79e7bce57221f7676c30cffa38f3ef2d9073cde219b959bbb1ad1fb3123638fe1ae5dbbd4b743c287a5ad84604bd63951c639bdb951a019115a7999c7c9cbaa39aded2c38d0571142616cb1efca983c8acd3d0d817d2ecaa4227c7397a14cb813144ea57a8e6eb8e106cdc2b1110e026e2b25088da3d00d32583f82b1141d94fe527480b662ceeb18deaa211853ce393333cc42204998fa8440e857973f6b2a2855c127179927eb69ca5a86667af6e4debf6bddf95a701cbf78988d1c2b162fcd2537d5e4eb39f581c8c0aed019abc2a14387fefef7bfd333f3a0b129379f7a3e43dd831ebd3ffde94f0a8a8f72b36c40882a34636691c08601391b33999250683dc10b208d2128cd0e8c49ec261c0d3bd75bcef405cbcd92bd54298b47a8c4729b9672665f60b36109d0b0d52e630309ee7a44a740963365c6b8f656a5a69b9b9b3e31751004f60aa7be349e53086fbcf1867a790dbaf8e28bedcecc19932336622fe5802f50e99029d95846a6a51949c86dd91d9859db934f336d1d34b68fdd94096c4060c3b9c4d203b1c52b90043676c34f6f694be666e03d38da8a6557186332633303dde4d55c20a5d44af4d8f3add65c2c7d85faf9cf7f7ef9e59783e572e0c001f60c224663901b60e7a00768662fae012d9992598e04be2c07a6902d94c0b9d92b1342a4ed468e40c91ea20c5b32a6846bb0b76c5016d8e75dee66f9681f83327ed014425178b9bc626f8bbb2184678da35b7ff2b464e006d02f5a8fec077e35cd57a35122136d4a82d900b8d05cd5ae65fa11ec58beb37690d9d3e7c572f5b0e06a01d74659cd678a32b77cc2624c30db4a6306c28652007a8fc9679f7de65aa1ec61e930e6770621dc4766c5522915612f1ca819940b3e551c3239f6f0bd6479f47a1eafbffe7adf845c1d9e56a7dee303c4881eaf049a10086564b695925c462dcde5483964e8493d5caccbb957321d446000da9c1010de85a144ceecf00d4a5bd0aeb8e20a8212a0a2287233fb98f7f4d12ba254bd5bfdfef7bf07c851df89659e5138aab257302008f0f579871e1bf5fdf39ffffc873ffc418b7912a181b5e54b3502762539e9118c684fc112a6c132a12d72020363da63b5e1055a6c5a91722373a8468a55abe36dd790a75d4bce036c69227aa0b6cc062fc5f2c5d86e0f1d81a68f30efe294b7df7ebb3c5dcc1253568f1b4135231363efee4a40df9971f426a1ac975d7619fb975f7ed947217b0f725fa19d99fb8b99da858067076939b82ea999919c725bccc6f662605746643992cd70d46187a74099ca93059fec70728c4c0934cccc1e0a3ed0959225a296461564d396dc6032b025a45d65b20bcaece3df2e049f5c37df7c33cc5dbb7681a209814b142d3d807adfe36957455082c918888632fb32a89a5c944f65094ea5240b0d4aaa6192091d33d9901440f8684c477a36723116abf12d85bce1d3d739832b612a14ede97f0ed283e22920a35b1d1933c3d8ac2e9676b99851d12fcc6ce9595bea02040fb7d224cad75da3238c8c25dc9dcd929901a15af7e591258466d7b966ecd1437bf7eedd0031513b3414545991811054b3a521f9c09bd17bf2c9277d9d4243f519d0b3019b57c5220f9ee5491569460e535dba6b1132983a340327bb2c69d8b874a0cb8197ba206acbe8886808bec1307600322f936acaac23f5ac390f086cd46bd2454905e93b2d4b45a781c9c6c00719e5a384b944def09cf601c280cb62b8ba6dd918c818b6cc76cd1e5bd7df3befbcf3eebbefaa9d88a0726cb64cff6da54bd5b61c1c111e6a41290182e64242050d062a25b70a24308da825ac4f1d910c1d973bc839c7b27a91b58002c1acfa84c3870f43586f529886ba38092e30cb8d0d3d280478f9b9c22e1c1a841d0c17e5636cb0a4510b5958b244d8bc6cae0c6898c131dc801db0d2cc32750652a3dcd0d23d6b1065688f206465c2093f0d65d0c0c20933582ca3c8cb16388346a109ec951557f6b55e9da8f48c951eb3bffce52f968c11c74153b0e188bd514a72b38503347419141a1306f045748a7941638c3c77665c9e7df6594a20dd24f41c6df13527bcfaeaab4223a9143e94b8173a0e6623cb7167913066645478db9aa27e5114074b46cbeeacb4a8068d2d8eb1b104d5563dd51220771a2f1012c3ccb323b45d6f0ff015a23fc4828a9c2dc86402a59317ba34ea4137ba8b4fbdcc109c0103d5649f2579dfbe7d4ecb120e86a33ccb315b62e22fbe82c2f405806652626fd09708178279bc0718d808c6c1d0d59e5e4ac12ca5a1f0ce470731a3e4296170f2970fc796d0a5416349b6cb8671ee9e0e7a19968cabb0245f7bed35f7852e1057088e86e87ffce31fd97b4b80a3168cd5ab8a63a57f9901412f2fdcf40833d5611008026e03d782d2e0065620724f6e668a283bb2dd34d217cbd2a13a5150384b6414ab3259d3b22650ea82ce4af20a540769784bc3568ef4586a7ef61848cf2e5aa0d8ecdab50b51dfd7980191b35607c24547e08da5dc3c8f7c6d89ced2ccd85dc3926c20ea3b8d1082b26723904600283425ce307d6e5608fab2307b9575453aa4adad2d94f8b25744b3d2432e3b3871a67fe69967fcbe66b9b9b9f9ab5ffd8a0b7bcbf11a69c1c75a92d09d9547d7ab43a7217976a8985dc602148c863125f7ee8ef49221d02b936f216437b15a30134b9799f103ebf4e46f29904e996d35ce70c78e3befbc131f052d0de174d9f6f63617e0106ce9ac8801414c7141293d2540332805aa286c40591aa29735a501d08836bd232cb5c06d65367e85b09d29509582c8c8e168609c18c8df49da02c7b8d88a2b3d28a069da428e995750ef3e0801919277227fec93395f4b9600b9488c8d035056bf05bbf241a9857b44f551d29810d8e0137b0df2de7bef89624033a3c7d28c21e686101d33d92b6b07c318d5bcc85cf027d8ad4634558d8d88960604bbe92d578f9e4c2a0708d6361c3b259655900684dd8a2d0633732171b5543b5f9eb5aee435bf84652b7f09ffe4273fd114726029bce782af2d95bde4924b94a0ba70b1cbd7ed13694f96f2556237481d4a60869ecb659467f9851a1398d81aaa10f93d7bf6380671dde582969ae49d96b349901a34b3252842fd8e2a338734bd3670b5ad04364a9811e8b06a340e5547bdc001ad3a763b25fdef19f9e94f7f5ace709c3f2f8fb3c6c1d5f3a22292b494309c2ae5ab9c1f947dc0ab26104571ddfa32ec86e2a5168c0ddc3c56cac11d3864712148d2dc29aa8e721852552f5ee8b174540417220e8ec199b1e14e768bd9e2c805b2bcd441e23ea0e52b4707ef6fb70425e2b2fa566cafc01acab5c2d3368af44667c5406c39d4f0c2289c2632f0e891642c25be5292bc0092d72f1c19e36d8925fb8b2fbe180f0fac5f1d1860c9918de4d545e91175c7fba0141d20044511540882a5dcc8f2c4473e72ee0ccc5cf00188add230f069e083d23de843f991471ef16b352808ee87b28340706c048e86a50ac89a99448cd59d85a27b11b4f0b6916351452dd9e59fde5278b11d174ed5ce165f0cd4086fc9d02b99e68253e726e82fef357a07a6723cfdf4d3ea52bf308b2b3e0ac7c61f13958399538c9e408c65c2dda1464f1fa91102b841e02b4fb32cd843b08589b6a2f4edfdb1c71e0362d780433682227021d357710258963b249635b83ccdaccd2c724b433628c5f3a46828b939310334bac278a8293d3ec2a04ba37c3ffbd9cf5e7ffd756d652925e10ce590839314d7131a385f506c44540ebe968aaed1bcefb8c8944cf4da8a0b407ccc0a64905b76547c2d9909e7847cbebb1c9cb13350380760ab349931869331d9b025160db9ad6fff315b9e2c8e1b48b4c5c116cf1e1f4ad7b9a5c6561a3c84ec1702f97bdcf0e3e21a722bc9dcd3c74cfe866470f52c28abe7915e45b823c78ba53a7a42f92ab10a8aab61f7eeddebf97de18517581ac0cdb69af149a0812f4a6c2915cb89fa90a54798ec7af524adfb660c9381c1cb8c58b99b8df1462a1346060bcb8445b15226dbaa993582ae918316908301d7c94b55bf60a65bcb9f2c6d5792b70777bf8ab0a4f101f2d65b6fb94a9586bba2db4217acc15e8d84c3c7a3476f489eef2db7dce23c74314d06b89159cea12240184b8fb28bb2d7467c06d6092778b3517789b3b11c95f8af110e84c5634ca3d9c225b0274334981aa30d4e3a495d0cb570262e11fc2c95c395515bb94419db524dbe3a82a52a50c2541a20961e527a14bb62a4d1bbe891234724c35808855671666ac10c2001aba0c86e00e5764ef38cf191523333f70364e9301657e1b0f55517316498d1c3f70a46b0645945cc55a059508e9525cdf896606d232da1613b0b4b825db87dea59ca47f2a848069b9e1dbb866e97063686acb486975bf5baf6da6b8128b15b43321cd5028272fbc8731f79bae96505500807c0577d4b72e6e3f144494d27b1b831a0c1a4b652087a32327264ef24a62563e17c108b229661ab3197b8ad178b7e75674d0b0e645113a63f41005763bb8ae56c991968b9a1cc08b1510b87c6c02b08258d5aa897ccdd149a8e460270dc569a11b2eb16334f9f7c984958a3b9cb3d718e24107324154b9efe54c1c579084a0fd359aa35a5411622566607200aaae6b6a0696a3c9f7aea29fa906dd13b1b1a809a54b10879518e7f9f65d1c887766c2c3e66839b8199f40876e3a74df485eb86529eca4796794f93302cc9dacdecdad64d3271982cdd625e20d8a36810a48aab6664a6be8ae21e84a01ce52fa8406471fdb5225914037fb33e525c2e2a05103782f79badad2d061c1b39ca8b3d5666c6967110050df638df7aebad94e8d198479236cadf9c40c3b9651ab37b5d98ca54da649984050751061cd5d4d2f7677da4c5c89e412edbdbdb3e832470dd75d7e98eae24b370ce50265a803d1bb16abaee26988a6836f0d6e010bc8e08271934201802d130c05609b8c0414f081a8282368f021f3daab97cd567c6c545e19855e7c9279ffccd6f7e231097bc103644593d6e3c8bb7808c8eb067a46c76f2d5859e200d173c72760533f0d6a7c2d8d5149e472da004ec95c0479efc2dedfa3b585e66e556297912144ba574a217ab42802dba25011486e87917179a4b06f4e2225022d0080c7a1215cbd35dcfd2836ac061098160b605445dec2647b225cd78382d68097384d5921c5c5520a3081da8cc4b92463f03719e786366561dbde6e1e588aea74fb9d5c5d3675668f8f48a4e00e84dc23527bdddbb77d33300288a12d805d85c021e31c83ab72401ea0bd1f52f33c36e170219324bac9c59495511981c11f0eee66f162508410859ab350d7c6679fd8fe6ccb47a99b35397a89ba1ab0e50cf82605e2fc972f317d3d2b68b967b5ae64813c47367f385e312a91f31ae589492e9fa90213495952434967691e66b8e0c47f83a45a0b2523b514a8c3d330fa02a3b4b6f121c0562c99e4d4901242083b91b50d02242b605010edaecab2ce3f118c6c01c8a39c48cc886a2d01bcc40a8177470bb76edb2251e8dbef01c49cf96e4e975966e3277d732237b42056503dfcc0ca663e7c8184e1f05a2c4bb1c246c7031f00941f2cc8ac23d9e661f0ecad4cd0dd353cf4b14f6aa46e66e4046002b9797ca3a213717e630192306418e900dd157ffe09ba742446bc119cb7cccc6fab3cd530cc63e776fbbedb6cdcd4d6f0622c94db17cde8b44706749431518bb86b03496b8e38f40f0d1128b0d473694c849156f80948e1d4581cc3810b890996958bb7e33604caecac0d9a02ab454294b8abd2d0830f171931a48d2a894af5c8ac5d2afc9fefd2a0240cc2aeb458cc085f1a89affa93ab3464ca158562f720566c993d22812baea65577f2996a6f0aec486c64b39d9b320933e6ec8de57f9566808f41848200e0211a42ab477513226ec2d6da9115902c0cdb61c00bd6295b95d322f0f3263c8983800658229a8a5e1c0c4c58aa5c1cca0213336f31d1bcb0f5ebee70b2435f5f2708c7bc1a852c7c9a8a4c78f10ef10d192a430b6f6efdfffe28b2f32a071f266a7aa709a8bb14c9c30a15647171bd473171151f9846f972363ede6be233096aa19695d60e65b6e3e5b81dbe5ce31101aac80c41c1359448023654fa2e85c440f7926ce982c533310c6bcb88beb53e2db5787e990a965f1cc08814e6f0681847c7c8258ba62183b4f955a1eb5f1de244fb9692ed554599918ce93de2e33829c11aa35e0a325107d8cf1034bb645e6a5fa30f93263ac2e1ad01930b06b3083164f7285a087d9481694bb9125e54c368d651ae1181b75e2aab3da6ee66004914fbc69e84b5b0e4e5e3c3f1e20ad528bd3a8a312d0339683f4e83b1921a13130508769d8953ce31a24fc992481064ec43c0e0e867d215c4c8aa547a2ca9200993192b382944e11255b647a4caa94a511f8fadc69a5f1007241d5533fe8ce416b5836432780cb799a495e81f8eb2fbd63e979ee670399602f805d8e107cc563d0bd60ab2708940480ab380d597a4ac3c5ac7066beb5153351d4dd6050ef5082f555c116337a985c461b7c35feac99d26ce9aed42376e5cf6c3119ffa914af1cd95baed7cbd2bb880479753652189db56ec7399ffc6d49bb13b6e5404462803167ef0a8e9a655d4683935f01993128551a2f62152566954c9b58aa8b19a699bb70aac65750513a7f826614cb1930a054202ef0bddf16a5b3a1b4cb98a563abdc2aa5fd19f04595920dc1a031e08b6e36d2a494b57a71542c5e680f0846425a0b66a629013201f5056720f217435b195e492480197b2f7e9e6abb2c7d9c2b8433616f4b39fcb184206aa52133c62364963460d9ab97254b43d510e08295b1bdbd4d29a2d20bd4aeb8ae791d07410a1058f2420331636b6bcbc7bf6c116663b061a05e4689975d7336664bfd2e3a3e64d5378f3f8a89ad6ce86253484b42c938a26ccc94667a9c1c6fe5501a39f85b16d223ade5add2bd5e8f7051561f059e79f694a8875f5608c134ec226046cbac707cedfa18f222027cd05dbe0ff6d2205b51bcb5f8c60e938b01270e042f5086fb01141cee7593e8ec9921c0c54ca308cce0d34780b1d4dc305df3e40d0b51ab14533e9655ca92aca8fc7b808564504d150b4a43bc3d7bf6380455ab537c566a1f6505e588f4456872c6c93923e4184049b82a88c237fc42b4eb3700394b5e866830660919734a6898b80d18db120e02bd12d3d328842c7459bbb60802e1e0d6e3a5f5b85735c8346405a957d0666f4018b729490c5a7670cd91261b6207911204179e02d02b1677e86e253f90cb476920f4d50c2677e5a0572675afb785a3512c4bec011ab24251941236cbc73fd078f8e187191f3a742828a1c5e505ad9c59ba98c4c28d0b2503ccb1328bc205389ebc84a85e5829bd5abb25749f5d5e0677b291bb04eb24faf1a731b9e161d62066bc05268b146fbbcc5256750ce0b28125b643d6537e66f203b9c070158b3b346cb8e83250bc9c362844950909368a48ef90c585c68c505c111f78e001fd2fabae394c0892f7e871d1bf62415338c8648e25b9243efe652d4114c5aa46960a41668c365fffe94ff720585ba28f522dcf2c3496dc4b76749361cf9a2037730108c52660c086806ef51a784b4b9a29d1d502f7dd779fdfcf64224fc64625932d5a7253172ce5a9d0fa8560a9466e25df3145c79819c07c15940b8daec18a6cd8dabd7bb7a2db05c2d8cd45307063a6340dd105a2a9fa096464b8c71f076c6d71470026035b5841b66ba964c638288b52626abb9e1a892ec5a2010dce20cbcd60830d2fd4f173449281ae64fe4a28005f68421a0c2cb9fbf0e265e9fd88deb11bd03cc2667aa3bee6bb04dce1b776bffc28962d69180cfceeaa17bcc10144499e32711e7a102583a6b8660c5b92a301c1b75aa3b8fe235a5dd9235cca6231e6c5d7e12153f946cb400fc5dc395036684073c07229eeb81d1bd51422771aa4c59398d8d0d5575decc2e1cb00947cfcf4ee78287d4db9f2ca2b817b6c5d7fdc1132c7843d5f7153bae0f14ea9bebffbddef9c8a8a83a2b70bc12e7c68950c3d84350b1a0411273e33d5e76b502a198312149d25281ad1ab8688c231181972708c6616158e6cf0b44bc34e566c9c3fc1a0c4128f50eaacbcd0ddb973a748b28560540815e468e6e2e508510f829700985a52a7c05741c8d09620a7b8ce7c14d2c8ad8a1344f12b900c9941c3796b6b4b6ba88b408c751c25330c0db2fcd1a02911f7a99614cb89e2094474b12a133302cb8a4e30b8331bcf64e7095421991a41ab14a59c69c8ac0d1992d51e964832b71455cebce0527a51b09baf4230b6d5ac22125029508cf9cad96d25499f685c6c39794a967ec0f47d000124593ae18aebb5a07e118ba39fed6d31930847b246930b9b82969170aaa046be542a96badbe54ec940740634958c2cb5da930d7039aeee2c2cc3e266f0913c811119095cb177e07504f618ab4899d02b874b97a09ad0622c9e1373ec4aa6468ce138467d010d3e4b5030fbb94e576a1069a8175f95b2f44e0b9c3d0d59328632c114c5908cac4ad51920ec13462c5e9833360bc44cc2ae2a8dafb92488925d82b29215bddcab46ad2a2e7750ab6241afa778b2360b69bb4ad17080c8878393712c72d6edeac5ac63cf91810f26bfeaf6a081028ea53c95ec9d77de81acef5c37ec71326862e30f1c4a46af647d544193362f06cc2a0d1919a531c3070e4494056c3c1cb664ae9a2c3137f42302a82a93819ec3502043087a82b84e91638940835c4118742d8c9f326c64d12c86ed2ac55a30540c323dd2229185e19b71b46a22eda65e6cbc7601b4346c495507f94eabc40ead0e171a51696313882ad3d0d3b09780589662158e1c136658f18a36d956b0342db1f5918280ccfbeb9cc3d6b92c55535cb23acac5f021fbfcf3cf43806f6808722190190295ffb1278021b6019ab2a52df5822bb0516266f13457914a9592b1fbc2c3a23b24838166c14f14989041a97875646c08c4122da3878b462fc0778fecdfbf9f3d261c959b1266341c2740bb1c4bc12e28dd6116ce91f812a661fd906bd6ce9e09034f36065f2d0c9cb1448080f2764d00656620ae706c8c51ac489bad8bcdd4926c4688be2469222a9e63b7eb5aa114928665a4715532c3df1db49210aaa3ffa5a788ddaf4228a24758f266cb1ac7ec46a3e4eecf79fec1889388188d64662ce17a5a3b5aa1f90a6169a885587c1130bb4f8533d0464f45180844c391bd737251c8421bba0d3bb9ea6066631ed7909c0d848cb473b9ec8ccf51ccd4cbac58e209af047a580c4f8a46a0c9583c368683b58b96ff8642aff955d383e0450c0fcfa9d8dc4574a45e945074e67a47e17ca2f172ddfa5b8b04202fd4c65de660a261a6f7b0abe07a4aa81aca21ba5cc462e3672ff794d2604ea34c88c9c535afdc044bc6a0544d0897061b75871c202fc2778ac5c1a85265ce9311257206d2281a6431f090b05419886a97bd51df215724827b4a9994c33f715091cdcd4db59e0da80abd3ac1910f635bae0fdf81e48cb4d29b7be4eb2c1c20e80bf92828b6333109573e47e8846a6a75c1161fb11820509a384b04b872285950343a0007bbccb03260ae8a35b51382206d16ec922d2b874a91d1ad28643f09a062d798a4c93ec830b0e5da76c11d38704086be6c3b37be601d261c459498a5a2204d50d9fee1b01683c058a5a4aa520690dc1d954ec45092336ea7eb8981a99781ab54f8cec9c7ab2a381bf43c6e2e32a5e122ae220ae77868a0296be9b31cd572c103b5c0cfc26cb45402326b4bd521d33460d55996483b5e2d2092b44317182159a1a84dd8f8c228670357ff059387c299730765d7e0221077827bf0f1c71f574720ce5c684a027ca5011b1f84693cbf5280a09a3850c2514d661e67730f204b209e2fb03e1f9509ed00b970b46b28101cb16a08e0485607f37829656d58340433c8558ab5653e2092b50041aa0403399f5c1d7268c24bd5b5cac6b10b4f8f8db36589aea7437f39523860219099a1eb1f7b6f6f6f3346008d4ac646e6ea525ba98e70b2951b4bcdab5920c06420a8bcf494d67309d070775da81447ff868d01581f91589105421814a502a1617049666060327e562e3d0bbccd96842a65a9166855ef5a00a293e18f7dee7dc021ed302b9334c4f3e4eb730572c1ebf0027164e35724b9693d8c9d84242ba59f193cadb620d3b3b4c585651d840f1ab6aa2f2fdc6804520b81686c2953f5e2eb9610ddae9a3a3fc43c98ae88cedb0c3359a65540a56002419e06c81870d9895d8d6c5892138260a33ab1679912968a0863961b363a48d5a414727ae563899fc01e43be0e561acc68ecf2351838463fd5fbd182800077386d91b5496d65b6ac58ee17a5f444df75d75d37dd7413587a34ea29b3674d223e2bc03a5ac6de607cc8a0e1c8ab08034389ed12d41419c84253ca8b923d4ae3cec249ce36d29a1b2c08ed724ed921e0340703d584930d4e0ed04c6988a177802b998a9befb9e71eff1981a7868b1098393d9c34945f787105c84c38749d50bc61524a1ea66281ad8e0c2ebbecb2a0f485af7e0055c14930533e9f157ed2511aee58f1b2250a0447250be1c4d29e9e038dcca0a78f5cb1846080eac8130a4f1bd606d9dc76b30209463f9b8b3eb30e878c07a553153e4e92e4a59b7849c62c96a532c9ca7f40b36fdf3e962ae59e72f12b16c6008d6870c198d2dc5b12aa6081884230a40d1c482f1c08a8057c2e3e28447cf4d147d9281c1c392a9f576521f49dca0aa146346e49021b470b04320443e9d9a8204aa3586a6121e15982e38a85a2b42b1c39b3ec97d4567f2b978f2b433c0de245546c9f925b5b5bce36f64e5256ba1d756f06f4340830532ff9936d098180015c147445046e8ba03432d1a428b9b3c9040f17043dc246450c9512081f179604e9652e6d802c81ab946f08fe61aa575f8e3e73bc6778bf11d4527381adeb15270ea358585ae3d158787edb5976f9b316068a212a1bcb665e940ed65730ec19c3a441518d2ca5877a24b49227ce83e3b431c3a94c506706139a8823ccc68622fab2c2de967e5168a155c779281633327bbb7a81868bd034c0dddf9085e0e24864ce98814c39ea4d7f0173a26a74f7dd779b2b255fe1c412c298e108925ddd59ff5d2c7b3ccd86a8a561666954a91e132430d326de62f046d72797ee505f1f3a34045b2e1d47edd2317be81c2933192a933a8272e0bddc0a2719df0a554a8f20004d44ec6b8aca810677f84a50088ea01c003e2a624be61e373241084745c94671bd27df7befbd37de78a3d2484139044552448015b7c3507af8828e7771514906991deb4a93b2028924c6a8d362cc99de6c698b97a1d5c5500be9594213be64d4083f80d276ce1c7941f0c6287fa5648cae1004df0afd468aa8e4b9d4fbda4a566c3c59bcec468c01a5d022c2f1b49ae3c052202066666c4467ac4c9d196ec8c8d71603b9088d331b4788b0e3290532fcf1ff7753c23244948f34cc064d035df1ecdaeaa8db955b39db6223a4d886b3a537e8dd26e2391c5718721e4c9ff460014a096f393350e50e098ebe63801c6296b6548a99bee00e4a204b55287f96f4c069c88c416188001c4a82e282522cb22dc5d244649f360464d485a5565529e5961a1cfd48e3865177b97ce7c7bf802a043604b3f0096443f8b9944c4b8e5553241a37a594c42855bb3dff68417313b9b3e4438f138d4c1c9d4c248f22d21c9526475e6474e520a28f333326222600614fa6919b258672337700b6808335db756cc2a1ea5085668333c099174a420722ba6e05cb60dc59b48d590bcec631f5f8f1cf326694e1ce5d82ddc869967870a11115278254b9bba4e85dba18d46e48d802a8045596c62e97f2b7a504f2f40440730662c1a411cb880cf776c14a9e0cc160ac1c6a2dba36b16b70f7ec3b1542e4d7532033e6581b22e3bb0199fb788e6c402714d892432399b391864d66a82464a30afa828d2512b212c090866c8157717a50070f1ef4e06837991024831377c98345464aaa068d230d4bb367a7a02ce1288ac1c5834f2f9610d591a3a03496041d441682a0df9969112eea55d64b4ee3192a4d5b68034103374bdf4c788dcecad459c9936ccf606ae66f90d1b26b408708c27f31c0d8856246a501d16090a52d1d21644f1990eccdf50b3471c91e31d415850682d004a5540b4b794adbf3eb8ef7d2b877ef5e4a5e5c804b8c81a02a08d996e8a0ec3a09f86830832017bb609d0441d16d55502e644322a288de69091738fdeac54439d81586c0334d25486ec6490c7f4c769e15456cd0421a9855e28a251fc6283a52ee8a4566c091fbe6e6261efa455cbe340436645eca24794fae0bced9d0fb9de7fefbef17cb13a1406e46ee6c8a52e68a2549197abab97896c1d6411c6964676666a9a0f8b02f659c059282d094ccc8aa9c23cdf8bc97063b8316dd8a0245a54ade0c4e863472f345d4679c7e56a6eca1a3e5135d1fb194ad48d00c320d214d8180b37771e86b5f80fd68432867806899fb7c50509fe21e5b3c7ff18b5fb86b5d79a02cd5cbd3a10bbcb2799952561ffcba8fafa52c9412a6813f1769e3d961972c10b1e8b9942cb3f8aba65d8d89392883e320cdd382913337f3944cf9d8b234d3b3ee13d7b939130f0e207a330338d095869990e6b6929595c086127b8144a4c44651d085c6b7640082523e822d06d1f013586f98b8692ebeca41a3943dbfecb11548d5688020cc5797f584421314010835110d7b96f820604bf555d012b2c388b3a5735a3d8672002a07b30d82416869066ac6cf09a8bad8622067e6ab34c208668b8c0a777a089641491e3fee158e8087e6c20c3277793286c0519bf0b54523969ce9fddf8ba8a69f7104f5f5d3fbbdbe53af43cb60000788fb911910ee0a8d955ac0513ebb388b65460032bd0e8d362592c525e868343c2b1ab3f2ad8a258c5186100969844c0954a5b2e1a97602888a77432483191b338a9664086498b51ec76a0a0d0f09a82c0d8a0647c97b5174d1a8a99b4ee6b83a5508c295bc36f471eea7025570fe04df3dc9a8f2124221c0b237808b823332946830c02a03c72084b80dd121b08450eeb263201784bf7d0ca35b69ec31a5e1466389715b6a24fcb1128dff6dc93e2f51ab11a276e5101447176d32a28cedb221b307c251f95c7c4e95a5a6e3c25e3f763d49523200b9b8e654d0852007c928623cc1161d79be946561e66e88a25e422887313963c25e208fa15d96ea8b210e623936b146b12016803501150294f42553e09ce11aa29a69328e1c2f42f58a3d4083068e5d2e0c0c9d42894a088ce118d276852b9647893d2fc6fac8a05738c562a3b9140e9a6201d15cd024691054a14b9d6f595802c704a68361a6252b073d17b358b67496e54c0d3833b3e61ddfaa221aae995bb3343a704bfe22d118e48ac2d298323383594ab0865d331e214c7b66f4d068ca413e042cb91813845265a5a75ef091d680041ae70fd94f63cab734caeae33bf7c8870384fdc45729be3e251c89889800cc12a5a018a0a44cf4ed6e786849421ac5688f32bd000433fddc22d018b6d6e52ccdc20febc54caac09198f81cc96606081964360acad7169960690b758d86b456bae38e3b9e78e2094fa86c3d3205f2eb9076701903512081aa781991ab1a286676597ad87d5daf10ee476cd940d33a90e9f9aa8c2e267b30ebe8ff07685e8ff0bf2bca4e0000000049454e44ae426082" + }, + { + "transaction_hash": "0x8ad5dc6c7a6133eb1c42b2a1443125b57913c9d63376825e310e4d1222a91e24", + "block_number": "3981254", + "transaction_index": "48", + "block_timestamp": "1499315038", + "block_blockhash": "0x77612afb3a6b09e52193ff2f11d3fca8f4eb2d22101ae9242b4ba7134db5f442", + "event_log_index": null, + "ethscription_number": "2", + "creator": "0x297d64eaa76b6bcb44ded3ef4d24ae103faf8110", + "initial_owner": "0x2989eeef9d1fcc42a363e41ec55d6ebe9531eea9", + "current_owner": "0x2989eeef9d1fcc42a363e41ec55d6ebe9531eea9", + "previous_owner": "0x297d64eaa76b6bcb44ded3ef4d24ae103faf8110", + "content_uri": "data:image/gif;base64,R0lGODlhEAAQALMAAP3eAv7pUv7xmPW/A+CTAf8AALAAAP///8DAwICAgAAAAP///wAAAAAAAAAAAAAAACH5BAEAAAsALAAAAAAQABAAAARecMmpqp14VVN6r9lmjJynUAlirdy5qFWKIEd9aQMArCutqDlAoCIQWGY/RXCoCBibRwVhGahaAzvVlLcCDH7aX218ULq0BPAMmJYgCenVoD1BSuFwUGbNQ2QwazN/EQA7", + "content_sha": "0x15cf0960fa5236bc2586c12e491f359bdb6be9314e25a5e61ec02502ccced645", + "esip6": false, + "mimetype": "image/gif", + "media_type": "image", + "mime_subtype": "gif", + "gas_price": "21000000000", + "gas_used": "38544", + "transaction_fee": "809424000000000", + "value": "0", + "attachment_sha": null, + "attachment_content_type": null, + "genesis_block": 3981254, + "is_genesis": true, + "content_uri_hash": "0x15cf0960fa5236bc2586c12e491f359bdb6be9314e25a5e61ec02502ccced645", + "was_base64": true, + "content": "0x47494638396110001000b30000fdde02fee952fef198f5bf03e09301ff0000b00000ffffffc0c0c0808080000000ffffff00000000000000000000000021f9040100000b002c000000001000100000045e70c9a9aa9d7855537aafd9668c9ca7500962addcb9a8558a20477d690300ac2bada83940a0221058663f4570a808189b47056119a85a033bd594b7020c7eda5f6d7c50bab404f00c98962009e9d5a03d414ae1705066cd4364306b337f11003b" + }, + { + "transaction_hash": "0x7002b415aa4659361c6e41e4f16d8573b91e8ca551fea4310f5fed6eafa10cd0", + "block_number": "5873780", + "transaction_index": "31", + "block_timestamp": "1530259325", + "block_blockhash": "0x7e285858100a87bfaf12f220e54a5de3d83e650cbdbe722d70cbb3e79763f147", + "event_log_index": null, + "ethscription_number": "3", + "creator": "0x58b65418f44bb7980b5d8c3676c207728a951342", + "initial_owner": "0x8d5b2e9f4356484b6988426738b8e241801e66fe", + "current_owner": "0x8d5b2e9f4356484b6988426738b8e241801e66fe", + "previous_owner": "0x58b65418f44bb7980b5d8c3676c207728a951342", + "content_uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgpMwidZAAADlklEQVQ4ERWSWUycZRSGn+/fZpiFYWb+YRiGHVrAQoFiS22twQqkVZogab2R2gs12hhNLxqMMTFRGxMTvWxiYi9MXOKSNrZJI0akJtqmdJNSa6FImEHCNiwWOsLM/Iu/38lJzs05532f84lTUF56VD9DNNyuhOoF/ohtqTpywG3/unRRTKfv0F36hq0aMZExfNiZrPAlxuzri6uDczVlr4qzxwI/2fFQx+c/5KzZ2YAU0StYLk0z3PgLr9e9QFfpQaSsi++/vsynX9zly54gz7X47ZsLWXE6UD6g+ONFT50dTpnnfxdSW0gh1Txi522b46OSt2iv6UIPx4iVFLOjpVVcnz1u562Oio3Ko3Zu8TY7q6r2S+ZaxjbnwnJrS0gkDyTwxQvEicYz4mBdt9AjUaHHCgWSEEXFZZzqPyFGJu+ypkoiafpETWWNkKzVFNRZ3NwzQW9DH2+3v0d9fBt6vIRIYSFYOTIbaezcBq3NjSxuOcDAx/0MzkeIx2IoaxX5TNVP8kHuJJ3VnQT1CPnBEB6PB0ORnEYDWRJkLZOiaCFNh3t56bUBGrwCr6airMtzhPUW9mitBIMBCoJ+JNni4UwSY/RPrOIYWlkZCg+Qo0Xs3beb/1+BlkNIIMVSxeRdNsmlsxQWBXArmwhrA9dSivxvz6NduMj6yChr350jvbBAVU0lfUf6+CMxi+IskrS0iufqKFOJW8hWFi1fxa1l8NbGkU9/iGYahB0bUksTsicPb56b7p6n+Wci6VjLOnxdjpyZKibPfcLMlSGkjEALeFBCKuqtEbSGVsRX3+ByeJhuDSuboWlHM4ef8TuAV1A0rw9FT+O/VIKp92M0unFV92AbArXQDesbWIe6UZdWyBk5crkskUiEukrnt9oJpIDPS55hohYbBO/Uod0+7iC6itCisLMNtXcXaudestMzmIkpVJfCvbH7GHNX0IwJW4o9/hjtbRVkZpOMLQlSJ8uxfnwW7HFsHJmahvCqyB37sV0eBoZ+490jr9BRa5C8cc2Sn3i0/J0SXbW3lKaEWZ0R3t1e/GXLiOJ550wGrKUwpiccNiv8u/wX8wPPc+zQoh0q2yqGf76fES/DUENX9Mnt2/1mIJCWQ2FBfnkAl3cSHL+ZTcg+hE1nluGk5arD8uSbifFl+cabyUHx/q7ayr+vjX+mw75HUOytCKKOeB9hp/JiOOH4wfTZpGwYST+w77EuZ+BSDF78D7LnWv1awbWeAAAAAElFTkSuQmCC", + "content_sha": "0xad8cb732550362bd5da07c97610ef8de8a04fc7f8a01f298ade1523f594b6b8c", + "esip6": false, + "mimetype": "image/png", + "media_type": "image", + "mime_subtype": "png", + "gas_price": "2000000000", + "gas_used": "150608", + "transaction_fee": "301216000000000", + "value": "54083036655664", + "attachment_sha": null, + "attachment_content_type": null, + "genesis_block": 5873780, + "is_genesis": true, + "content_uri_hash": "0xad8cb732550362bd5da07c97610ef8de8a04fc7f8a01f298ade1523f594b6b8c", + "was_base64": true, + "content": "0x89504e470d0a1a0a0000000d49484452000000100000001008060000001ff3ff610000000467414d410000b18f0bfc6105000000206348524d00007a26000080840000fa00000080e8000075300000ea6000003a98000017709cba513c000000097048597300000b1300000b1301009a9c180000015969545874584d4c3a636f6d2e61646f62652e786d7000000000003c783a786d706d65746120786d6c6e733a783d2261646f62653a6e733a6d6574612f2220783a786d70746b3d22584d5020436f726520352e342e30223e0a2020203c7264663a52444620786d6c6e733a7264663d22687474703a2f2f7777772e77332e6f72672f313939392f30322f32322d7264662d73796e7461782d6e7323223e0a2020202020203c7264663a4465736372697074696f6e207264663a61626f75743d22220a202020202020202020202020786d6c6e733a746966663d22687474703a2f2f6e732e61646f62652e636f6d2f746966662f312e302f223e0a2020202020202020203c746966663a4f7269656e746174696f6e3e313c2f746966663a4f7269656e746174696f6e3e0a2020202020203c2f7264663a4465736372697074696f6e3e0a2020203c2f7264663a5244463e0a3c2f783a786d706d6574613e0a4cc22759000003964944415438111592594c9c6514869fefdf6698856166fe6118861d5ac04281624b6dadc10aa4559a2069bd91da0b35da184d2f1a8c3131511b1313bd6c62622f4c5ce29236b6492346a426daa67493526ba1489841c2362c163ac2ccfc8bbfdfc949cecd39e77d9ff38953505e7a543f4334dcae84ea05fe886da93a72c06dffba74514ca7efd05dfa86ad1a3191317cd899acf025c6eceb8bab83733565af8ab3c7023fd9f150c7e73fe4acd9d98014d12b582e4d33dcf80bafd7bd4057e941a4ac8befbfbecca75fdce5cb9e20cfb5f8ed9b0b59713a503ea0f8e3454f9d1d4e99e77f17525b4821d53c62e76d9be3a392b768afe9420fc7889514b3a3a5555c9f3d6ee7ad8e8a8dcaa3766ef1363babaaf64be65ac636e7c2726b4b48240f24f0c50bc489c633e2605db7d02351a1c70a05921045c5659cea3f214626efb2a64a2269fa444d658d90acd514d459dcdc33416f431f6fb7bf477d7c1b7abc844861215839321b69ecdc06adcd8d2c6e39c0c0c7fd0cce4788c762286b15f94cd54ff241ee249dd59d04f508f9c1101e8f0743919c46035912642d93a268214d877b79e9b5011abc02afa6a2accb7384f516f668ad0483010a827e24d9e2e14c1263f44face2185a59190a0f90a345ecddb79bff5f8196434820c552c5e45d36c9a5b3141605702b9b086b03d7528afc6fcfa35db8c8fac8286bdf9d23bdb040554d257d47faf823318be22c92b4b48ae7ea2853895bc856162d5fc5ad65f0d6c6914f7f88661a841d1b524b13b2270f6f9e9bee9ea7f96722e958cb3a7c5d8e9c992a26cf7dc2cc9521a48c400b7850422aeaad11b48656c457dfe07278986e0d2b9ba1694733879ff13b80575034af0f454fe3bf5482a9f76334ba7155f7601b02b5d00deb1b5887ba519756c8193972b92c914884ba4ae7b7da09a480cf4b9e61a2161b04efd4a1dd3eee20ba8ad0a2b0b30db577176ae75eb2d3339889295497c2bdb1fb187357d08c095b8a3dfe18ed6d156466938c2d095227cbb17e7c16ec716c1c999a86f0aac81dfbb15d1e06867ee3dd23afd0516b90bc71cd929f78b4fc9d125db5b794a684599d11dedd5efc65cb88e279e74c06aca530a6271c362bfcbbfc17f303cf73ecd0a21d2adb2a867fbe9f112fc3504357f4c9eddbfd662090964361417e79009777121cbf994dc83e844d6796e1a4e5aac3f2e49b89f165f9c69bc941f1feaedacabfaf8d7fa6c3be4750ecad08a28e781f61a7f26238e1f8c1f4d9a46c18493fb0efb12e67e0520c5efc0fb2e75afd5ac1b59e0000000049454e44ae426082" + }, + { + "transaction_hash": "0xb012f3c2118806415c40302485e75e9482670c315943889a68cd303f5a9c8f15", + "block_number": "8205613", + "transaction_index": "95", + "block_timestamp": "1563867259", + "block_blockhash": "0xb961f942c539c7a02be19177a8f8b94c5255de96f94d367846d621432d11e24b", + "event_log_index": null, + "ethscription_number": "4", + "creator": "0x11638dd6622e660890d16c0a34a78da083e7d046", + "initial_owner": "0x34cd05cf228a7e8f60772f1096e5b9e7a03695fc", + "current_owner": "0x34cd05cf228a7e8f60772f1096e5b9e7a03695fc", + "previous_owner": "0x11638dd6622e660890d16c0a34a78da083e7d046", + "content_uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAMAAABHPGVmAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDY3IDc5LjE1Nzc0NywgMjAxNS8wMy8zMC0yMzo0MDo0MiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgTWFjaW50b3NoIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjI2N0RGNkY5MUM1ODExRTk5QTc2RTE5NzMwOTk4RjYzIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjI2N0RGNkZBMUM1ODExRTk5QTc2RTE5NzMwOTk4RjYzIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MjY3REY2RjcxQzU4MTFFOTlBNzZFMTk3MzA5OThGNjMiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MjY3REY2RjgxQzU4MTFFOTlBNzZFMTk3MzA5OThGNjMiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz55wCcYAAADAFBMVEXqUlv1vMDhHizWChneHCn2yc387e7zlJn4ys3//f3gDRzcEB3jOUXzjJL2sbX1ub70hY33x8rtnKLxb3rtk5n86er609X4zdD3zdLzmZ70tLnmSlPkUVzfNELaChj4wcXkKjX66ev2wsXlTln22dz++vr63d/qSVPmYWriMTz0aXD65efqanL74eP0qa7iFiX63+HqcXrpYmrsiI/53N7++PnjIC/iPUnZChfeDx3lRlHYChjyr7bcChnqZm/seYHiJTH41tn52Nr51dfhLTrxrLLrkJfYCRjoZW7eFiXdDRvfIC70oqfypazlQU3ugoraDx31rbLxfoXuipDdGSbaCRfwpqviNUDxm6DxnaHcKDTdFSP4xMftdX3cLzjaCRj98/TaChfqfIf74+X99PXaDBrcChf97u/mPkv65+j6vcH97/DgMkDmXGbzwsbrXWf83+D1xMfmRE762t3dUlz119nvjJL85efuqKz0sbX+9PX98vP98fLmanLve4PtYGrrgIbsZG786+zrgonfChjpbXXgIy/bChr0nqL+9fbqd37yqrDfER/pWGLmWmLthY3aFybnXmnlLj3+9vf99vfvj5TjLj7oeYLsc3zbFSPyvsLdCRjpRVHgJjPoWmTuc3vwnqXWChfqN0PaChrmQUnlLjr76uzlNUD54eL40NPse4T1u8DYChvjWWPkIi/jVmDvl53wkJfgKjfsanTgGSbXCRfYCRfWCRfZCRfYCRbXCRb+9/j++frZCRbVCRfWCRb++/v99fb64ePpYGr82dz+3uDxp7DpcHf3v8P9+PjqTlj5rLDrcnvsr7TrdHrXChbmN0TpUVb6n6j87O32cHvzv8T84+P6rK7rf4PnjpbWCBf6t7vXCBbqaHP78PHrbHLuaHH1t7vbEyHwSFPsXGX5s7bqipHvi5TpbnnubHXzoKfiQUzcDBziRE7oVV7sWWPmam/gLzrpaHDlbXniN0PkM0DysLPhOUjmO0bnWmXkXGPxgIb54OH84OL0trr///+opxNvAAABAHRSTlP///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8AU/cHJQAABhJJREFUeNrsmV1oHEUcwMPtQaEXfNEn0w6BPDSRQrFCb5Nh0zwcbq/R9LBZqw11OeGOIAmFUAQfbCk970TTT7AYrh+2tRQkTdu0/5nZg6QPeRBK8fRNRUGi4FeMtCZYfRln9+5yu5dLcnu369Pty3zu/m5m/p9zLfx/eFqakCakCWlCmhBXkJ7nfYf8lTU0LdjGX/UTMqZSAKLpmfQAf9237VKAECI4FLIouNzrDyTEiPkwFcSjMX1hib/kOWSbtZJIT2ISMQKUEo2xHcPfeyxdcXMhXaJyaXoBIyaOCIjBlODS6EfeQQIGRkTuKDQuHZzQsxgskqYNTZ/yCLJFjo2lafLsSsfNA4NJmTGTxDQdRTo3eaDxCTzHk7MpR9/x8JCCCyuaYXL38mijkACJipOhe1cN3NisIAmsM1Kz0UjbvgYgZ3X5BP9KpYEqY71dwYScJ2DJtxYSpqFeA6nANOfTeXlkjfGBdBxjU76BMYx2teyrB9IO5oEMAvy45ruHeoKTelxgKBCMUtPvuTf1stbJ+fuI4rvrbsXYDqGw1DRCWvKRW8hbCZgyS0SzG235oeG+QWxiWGyfy5UsgSVandgYrEGAEgRkgSnpb82eUdb/NItWakxtyGjTcGBREqthfe4gE2raKvsp+XIDxqdZIyGKM6Z7UE65gYSZUqjMU3JyXcaRGUgUXsFCdVjETSCBpAdWuSkE2u515v2Uui+9Wax3ywDfpY7WDgkZQ6XjoaG1p21nFJVbf5gKmqwd0kGk/YXaAxXG15yWndUfV7qi7trjrixpLdYiYLRXn7Nfs6+DjypEeNWZhdohu2DFCk9dYT1V53SD/GClsSc2I+SLANpZO+Qwlj8u1echvqfKlHEqh0v1nUFGWUxA8ufchKkIukrV0xLFq8afugfqG8X6F1GdQKi3gxG62VUs3G4772OIpj6sGJ9SaVHDXxhElKABobpAlXddQY7ncdl+hzENOocPAi5anK6M+PaSqfsE1GWXUX0Mrpcbj3RodaxzBubN8vaIsI3YWtJuURt3mzpMw6StFaX6Yrn1N8tZ37shzuLaxaIxBsg/7RbykElzdkWz6cQ2bK7j2TYEgNPFvhEZqhnTjZIgJXfP7p4UqrxWqC5KIIxOC6IQ7z5UOkJhUvbWkWmFieyMX3NRq+xkII9+FgSaHbdbFFDqSueS4Ih4nmjGeet7QGKDGigTR8pjywSk4bog0StRR7sf8mP8TtIK+yXkkHdE4F59iWmHkTldYaz034cEgiKFv+McAP2VOrPfVG7M0X47+S0TVpBltjrn9QHVhutNsQOwo6JngeSYpd72Z05I71DdefwWgip6BkFeHcbFcpBp4LJAoU+cHfP51QqXJpDpaQASJJOO9m+KtCp4uakDBBq59tip4ofOpa3elyxA6puG7lZ046K9+TlTTqwSDsDbGrvAOUccxuKwWmk7FsVmTTV4S/RBRvvXrp56zDn+TIqSTMNXUeO5tN1mymcqgxrI3moYEiYJWytSIUc92nqSVfulmg42j7iL9lXoUXUD7xYylLP91JDqCNuDGPBJLyBhMlm+gcAZu8W8gHPGtDd3kPLsihrcZrpt4Oc4VPW49UBitByny/iyTfBE0tPpEaRFXolRn5OYbR+FGh7kHkH4NVLyUVsg9Uk5dID73dwzyEUoqfkTm8BOAKDt3kF6If5LMagsW7JWg8BV7h2Eb84Vs4gzM6Xw5aGIixa4l5DrpdwUaeeLXXsBtO2eQu7K0g+Fs8bFmGSrDvkD3FMIX4BCaopw4f7ksUSMEPcY8qjgM15EtKiGhEhHvYZwov5jFZmvrVhORNyt3HNIyoryjjHrinWPUPUo9x5ygWBhiju0/MumvRdqyH2AcKaK8G0kbhrddgq5AV8gQXOD+pnYtGMSmQ1wXyCLptUKkD4zydvI49YN4Ql0i0+JPYsQ0C/4Bem/cl249DaeARLgfkFOsUQwBiHBiN/xDcKz1PobCpC719zN7rcYxAhyHyG/ymbWW5sTqRvCM+ZmsUV/IVfNlbjcLPd/MKtAM9xvSJQpo75DeM9l7j+knqcJaUL8e/4TYABs8ZsKcvh43AAAAABJRU5ErkJggg==", + "content_sha": "0x9fe462508561564820e69bb73645cb44d9acfad59eacc6469bdd03c403c7ca4f", + "esip6": false, + "mimetype": "image/png", + "media_type": "image", + "mime_subtype": "png", + "gas_price": "1000000000", + "gas_used": "341280", + "transaction_fee": "341280000000000", + "value": "0", + "attachment_sha": null, + "attachment_content_type": null, + "genesis_block": 8205613, + "is_genesis": true, + "content_uri_hash": "0x9fe462508561564820e69bb73645cb44d9acfad59eacc6469bdd03c403c7ca4f", + "was_base64": true, + "content": "0x89504e470d0a1a0a0000000d4948445200000064000000640803000000473c65660000001974455874536f6674776172650041646f626520496d616765526561647971c9653c0000032669545874584d4c3a636f6d2e61646f62652e786d7000000000003c3f787061636b657420626567696e3d22efbbbf222069643d2257354d304d7043656869487a7265537a4e54637a6b633964223f3e203c783a786d706d65746120786d6c6e733a783d2261646f62653a6e733a6d6574612f2220783a786d70746b3d2241646f626520584d5020436f726520352e362d633036372037392e3135373734372c20323031352f30332f33302d32333a34303a34322020202020202020223e203c7264663a52444620786d6c6e733a7264663d22687474703a2f2f7777772e77332e6f72672f313939392f30322f32322d7264662d73796e7461782d6e7323223e203c7264663a4465736372697074696f6e207264663a61626f75743d222220786d6c6e733a786d703d22687474703a2f2f6e732e61646f62652e636f6d2f7861702f312e302f2220786d6c6e733a786d704d4d3d22687474703a2f2f6e732e61646f62652e636f6d2f7861702f312e302f6d6d2f2220786d6c6e733a73745265663d22687474703a2f2f6e732e61646f62652e636f6d2f7861702f312e302f73547970652f5265736f75726365526566232220786d703a43726561746f72546f6f6c3d2241646f62652050686f746f73686f702043432032303135204d6163696e746f73682220786d704d4d3a496e7374616e636549443d22786d702e6969643a32363744463646393143353831314539394137364531393733303939384636332220786d704d4d3a446f63756d656e7449443d22786d702e6469643a3236374446364641314335383131453939413736453139373330393938463633223e203c786d704d4d3a4465726976656446726f6d2073745265663a696e7374616e636549443d22786d702e6969643a3236374446364637314335383131453939413736453139373330393938463633222073745265663a646f63756d656e7449443d22786d702e6469643a3236374446364638314335383131453939413736453139373330393938463633222f3e203c2f7264663a4465736372697074696f6e3e203c2f7264663a5244463e203c2f783a786d706d6574613e203c3f787061636b657420656e643d2272223f3e79c0271800000300504c5445ea525bf5bcc0e11e2cd60a19de1c29f6c9cdfcedeef39499f8cacdfffdfde00d1cdc101de33945f38c92f6b1b5f5b9bef4858df7c7caed9ca2f16f7aed9399fce9eafad3d5f8cdd0f7cdd2f3999ef4b4b9e64a53e4515cdf3442da0a18f8c1c5e42a35fae9ebf6c2c5e54e59f6d9dcfefafafadddfea4953e6616ae2313cf46970fae5e7ea6a72fbe1e3f4a9aee21625fadfe1ea717ae9626aec888ff9dcdefef8f9e3202fe23d49d90a17de0f1de54651d80a18f2afb6dc0a19ea666fec7981e22531f8d6d9f9d8daf9d5d7e12d3af1acb2eb9097d80918e8656ede1625dd0d1bdf202ef4a2a7f2a5ace5414dee828ada0f1df5adb2f17e85ee8a90dd1926da0917f0a6abe23540f19ba0f19da1dc2834dd1523f8c4c7ed757ddc2f38da0918fdf3f4da0a17ea7c87fbe3e5fdf4f5da0c1adc0a17fdeeefe63e4bfae7e8fabdc1fdeff0e03240e65c66f3c2c6eb5d67fcdfe0f5c4c7e6444efadadddd525cf5d7d9ef8c92fce5e7eea8acf4b1b5fef4f5fdf2f3fdf1f2e66a72ef7b83ed606aeb8086ec646efcebeceb8289df0a18e96d75e0232fdb0a1af49ea2fef5f6ea777ef2aab0df111fe95862e65a62ed858dda1726e75e69e52e3dfef6f7fdf6f7ef8f94e32e3ee87982ec737cdb1523f2bec2dd0918e94551e02633e85a64ee737bf09ea5d60a17ea3743da0a1ae64149e52e3afbeaece53540f9e1e2f8d0d3ec7b84f5bbc0d80a1be35963e4222fe35660ef979df09097e02a37ec6a74e01926d70917d80917d60917d90917d80916d70916fef7f8fef9fad90916d50917d60916fefbfbfdf5f6fae1e3e9606afcd9dcfedee0f1a7b0e97077f7bfc3fdf8f8ea4e58f9acb0eb727becafb4eb747ad70a16e63744e95156fa9fa8fcecedf6707bf3bfc4fce3e3faacaeeb7f83e78e96d60817fab7bbd70816ea6873fbf0f1eb6c72ee6871f5b7bbdb1321f04853ec5c65f9b3b6ea8a91ef8b94e96e79ee6c75f3a0a7e2414cdc0c1ce2444ee8555eec5963e66a6fe02f3ae96870e56d79e23743e43340f2b0b3e13948e63b46e75a65e45c63f18086f9e0e1fce0e2f4b6baffffffa8a7136f0000010074524e53ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0053f70725000006124944415478daec995d681c451cc0c3ed41a1177cd127d30e813c349142b1426f9361d33c1c6eafd1f4b059ab0d7539e18e20098550041f6c293def44d34fb018ae1fb6b514244ddbb4ff99d983a40f79104af1f44d4541a2e0578cb426587d1967f7ee72bb974b727bb7ebd3edcb7ceefe6e66fe9f732dfc7f785a9a9026a40969429a1057909ee77d87fc9535342dd8c65ff51332a65200a2e999f4007fddb7ed528010223814b228b8dceb0f24c488f93015c4a3317d6189bfe439649bb592484f6212310294128db11dc3df7b2c5d7173215da272697a0123268e0888c194e0d2e847de4102064644ee28342e1d9cd0b3182c92a60d4d9ff208b2458e8da569f2ec4ac7cd038349993193c4341d453a3779a0f1093cc793b32947dff1f090820b2b9a6172f7f268a39000898a93a17b570ddcd8ac2009ac3352b3d148dbbe06206775f904ff4aa5812a63bd5dc1849c2760c9b71612a6a15e03a9c034e7d37979648df181741c6353be81318c76b5ecab07d20ee6810c02fcb8e6bb877a82937a5c6028108c52d3efb937f5b2d6c9f9fb88e2bbeb6ec5d80ea1b0d434425af2915bc85b0998324b44b31b6df9a1e1be416c62586c9fcb952c81255a9dd818ac41801204648129e96fcd9e51d6ff348b566a4c6dc868d370605112ab617dee20136ada2afb29f97203c6a75923218a33a67b504eb981849952a8cc5372725dc6911948145ec1427558c44d2081a40756b92904daee75e6fd94ba2fbd59ac77cb00dfa58ed60e091943a5e3a1a1b5a76d6714955b7f980a9aac1dd241a4fd85da0315c6d79c969dd51f57baa2eedae3ae2c692dd62260b4579fb35fb3af838f2a4478d59985da21bb60c50a4f5d613d55e77483fc60a5b1273623e48b00da593be430963f2ed5e721bea7ca94712a874bf59d4146594c40f2e7dc84a908ba4ad5d312c5abc69fba07ea1bc5fa17519d40a8b78311bad9552cdc6e3bef6388a63eac189f526951c35f184494a001a1ba4095775d418ee771d97e87310d3a870f022e5a9cae8cf8f692a9fb04d46597517d0cae971b8f746875ac7306e6cdf2f688b08dd85ad26e511b779b3a4cc3a4ad15a5fa62b9f537cb59dfbb21cee2dac5a23106c83fed16f29049737645b3e9c4366caee3d9360480d3c5be1119aa19d38d92202577cfee9e14aabc56a82e4a208c4e0ba210ef3e543a426152f6d691698589ec8c5f7351abec64208f7e16049a1db75b1450ea4ae792e088789e68c679eb7b4062831a281347ca63cb04a4e1ba20d12b5147bb1ff263fc4ed20afb25e4907744e05e7d89698791395d61acf4df8704822285bfe31c00fd953ab3df546eccd17e3bf92d13569065b63ae7f501d586eb4db103b0a3a26781e498a5def6674e48ef50dd79fc16822a7a06415e1dc6c5729069e0b240a14f9c1df3f9d50a972690e969001224938ef66f8ab42a78b9a903041ab9f6d8a9e287cea5adde972c40ea9b86ee5674e3a2bdf939534eac120ec0db1abbc039471cc6e2b05a693b16c5664d35784bf44146fbd7ae9e7acc39fe4c8a924cc35751e3b9b4dd66ca672a831ac8de6a181226095b2b5221473dda7a9255fba59a0e368fb88bf655e8517503ef163294b3fdd490ea08db8318f0492f20613259be81c019bbc5bc8073c6b4377790f2ec8a1adc66ba6de0e73854f5b8f54062b41ca7cbf8b24df044d2d3e911a4455e89519f93986d1f851a1ee41e41f83552f2515b20f549397480fbdddc33c84528a9f9139bc04e00a0edde417a21fe4b31a82c5bb25683c055ee1d846fce15b3883333a5f0e5a1888b16b89790eba5dc1469e78b5d7b01b4ed9e42eecad20f85b3c6c59864ab0ef903dc53085f80426a8a70e1fee4b1448c10f718f2a8e0335e44b4a886844847bd8670a2fe631599afad584e44dcaddc7348ca8af28e31eb8a758f50f528f71e728160618a3bb4fccba6bd176ac87d8070a68af06d246e1add760ab9015f20417383fa99d8b46312990d705f208ba6d50a903e33c9dbc8e3d60de109748b4f893d8b10d02ff805e9bf725db8f4369e0112e07e414eb144300621c188dff10dc2b3d4fa1b0a90bbd7dccdeeb718c408721f21bfca66d65b9b13a91bc233e666b1457f2157cd95b8dc2cf77f30ab4033dc6f489429a3be4378cf65ee3fa49ea7096942fc7bfe1360006cf19b0a72f878dc0000000049454e44ae426082" + }, + { + "transaction_hash": "0xf8574fa029ad77486e49bc6c93e971046842bbc6b546d98175adcb1afa2b345f", + "block_number": "9046950", + "transaction_index": "92", + "block_timestamp": "1575425759", + "block_blockhash": "0xc5ffc39934832775c91a2915a5768df691c1cbc0a513ef7a25d79197248f1d8b", + "event_log_index": null, + "ethscription_number": "5", + "creator": "0x024655ce44de411a8f27c4c2c287a09cc37835dd", + "initial_owner": "0x024655ce44de411a8f27c4c2c287a09cc37835dd", + "current_owner": "0x024655ce44de411a8f27c4c2c287a09cc37835dd", + "previous_owner": "0x024655ce44de411a8f27c4c2c287a09cc37835dd", + "content_uri": "data:image/png;base64,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", + "content_sha": "0x8000b77783639568af2af67d30b2f28213eb7241a7853dec7423103d3f40edfb", + "esip6": false, + "mimetype": "image/png", + "media_type": "image", + "mime_subtype": "png", + "gas_price": "3000000000", + "gas_used": "714736", + "transaction_fee": "2144208000000000", + "value": "0", + "attachment_sha": null, + "attachment_content_type": null, + "genesis_block": 9046950, + "is_genesis": true, + "content_uri_hash": "0x8000b77783639568af2af67d30b2f28213eb7241a7853dec7423103d3f40edfb", + "was_base64": true, + "content": "0x89504e470d0a1a0a0000000d49484452000000640000008508030000009a24ad41000000017352474200aece1ce90000000467414d410000b18f0bfc610500000300504c54451c0b0c1d09202c16123119243a220b372428471d0c441c1353181550192f4728064725184e310356280e562a175635055a34184f302c70141c701c316a250e652b11663b06663918752b137535177527345534415c413a6f46096c4429674438764729754a367d5d2d78563d7e603f5a444a6e51486e50637e62599b131e8b1d2d8c381994391e85212d8824378c392395222d962539913b22bb1c1daa1d23a23c10a6252ba6283baf3f21ad303db7252bb62839b83529b8313e932c44b02e45925408804e28855e298758379d4221945c2497583a9b62118960249d662596663b99702da65a0ba44328a25428b54829a7660aa46714af700bab711bb06d0ab66e13b2720db77814a06a2ba26b39ab7523a7753bb47b25b77c37855b4590694f97726ab54857a77648b36d69c01e23da291dca2d3ae8351cea3a27d1324af5374fcc4b19c3462acd512ad94727d94934d45121d85435c07a18ce6f29ca7625c97b31db6a2ad56a35d6772dd37933eb4919ed432bec4835e65828e75538f34829f24c39f25729f4513cf17b1be96534de4453d65866cf7444d46d71e3444ee54759e85940e65159f34f40fa4559f55541ee5665eb6642e77744ee7e54f46747f5774af57556f36b76df7585ef7989ba850cbd831bbe9019a7802bbb8427bc8835b9932fb59633b5864ab18a77c3821cc99817d09e1fc88a27c58b33c6902dc69233d08e28d18935d3922cda9934cda015d2a718dcb41bd7aa32f28a1fe29c37e0bc1ee7a53bc78943c68e59c99948c89855dc8e4cd78150d69848d99b56cea14bcea254d3a14cd2a75bd3ac64dab46ddcb874e58946e68955eb9642e4965af68348f38354f6934cf49a55f39270eaaa44e4a755e9b54cecb153f2ad48f5aa53f4b34bf8b956f2b967ebc925efd329f1cb29f8c338f2d42cf5da36dec248e5c547fbc24cfac657f8de45f8db58fcc76ef9e04af9e157f9e161af8e85b5a28cca9387d99ba0d3b48df38a95f797a5f7b288f8acb6fabac5dbc2b8fbd28cf6dbabfce491faeaafd4c6c1facfd6fadbe2f7eacefdf7f8000000000000000000000000d7521bd6000000097048597300000ec200000ec20115284a8000001a5a4944415468437d997d7893e775c6157f24d826d8aa1b9228188711dc5499556b75d1050e921a9669e1a28d1ca7566a1c0c0c84a46e6de37569c17c385c90a6e602d18c84255e454bd449b191daba9e18d9281f262de94744702ca7f9d870ec62962b76edf251dbfa67f77d9ef795644a77e4f7956ce0f9e93ef739e779640cf5f5f54d88e6e65073735303beabffe13bef7ff0c1871f5ed262b8ff6030b80bd1be5362fb373c9d88503822c19791e8f1e3676e167d2a0c0d4f3434343587be170a859a8551ffe377c8c840865e5090f69d8af2ccb79ac3213042a14824d4d9e9690e1d3e7efc94b6ea0dd1d777960f433d19d0f164735393301afe3d2b64148c5f071180ec6c7f869467b6ff6373a4390242a4d3130222da75731508a5e36c1f204d0d4d4d995cd5d77b90ad0f3ffcddef84c2641d0c1e04a4bdfdab8f7ee1af1ff8abcf7de18b4f3cd1f4647327fe89a7b9f9f09fcb940a22982e30a0a459c940fcab4044c9e8a5e1d46e0af9dadf3ffa9777cd9b37afa4e4b6929279f3ee30ddb7eacb4f407ee8d8f1d7ce9cd172d5a79e72a2ef0c1150826c3521539a0cc48f982d5dc8d02f83c1bd8f7ee6ee3b6e2fb935afa4a4c458629c673496959916ad5db96acd9347e0c6096dc59b844a163ca190061db1dae5f4fc44b2a518c3bf0c3ef5853b8af300c8d3a2e4b6db4a4c0bcbca162e5ad9b261c39a2390f2e79490211403446888d5f5aefa7ae7cb09c9d6301130e4a9bba1e1d6bc825b85709b604aca4c6546a3a97251cb8696556b8e9cd4d69c1d446a0c2ac964cab5dae974867ffbfe87bf13064d7ff4ce5b8b4b8a059009d852b2a0ac6c414565e5a2752dc0fc39e7458828d119ee7a67bd0b901f11a22c197afed13b8a6f2dc65be7da062120a0c458067316981657b580d2b2415132e9cabe508cb3868c0ea71b08a7f327bffd800c5086ce3d707bb1a4a980370382cff8160463a5d1b8704155cbfdeb5ad6b61c59a3ad9a1342520cf489c4ea7a8570baded1850c27bf783b1019017c99a52c302e58b0a072c9fda0ac5dd9b2ea086a4c2dcb9b1667cf28860ea95708a7b3f19df7c120e47f1e9ac785b391a118f2f28dc6aa05558cfbf158bb76d52347d02f7f1ac2d095b8444785d3e9fed1fb8490d1544a1ddafa19117979f9f979798585c6aaaa25f7dfbf8494aacab52d2bbffc9a2ea24f5ee0eac32beae0ec82e74e9695c4e3620993e52cc3821a826f3e2f9fcbe7e7e71716f2665cb2a40a5f50044855cbaa0d47142237a4e14901848407f0703a57bb5fc674142527cb32ed67b8c5909f6fc8c7db2f34e252b7c225f7180b8dc625a054562e46211ff959ae1d082a52d93a6bc8aa58ed763ffec3779490fffe5ba38218f8c60b6fc183cb233406220fa2f083aa4a63d5da956b37bca620725729d374400997a70a32dcee1fab021efe4159010ab7b0d0c0f78f28c082d9c82f14be7863ac5c8294ad6d41be6e9022ae508982e80877a364ebd2a59ed292123487b626e2965b0cc8190d2980eff04718684c8a332e81f5ab3664ea4b97a486b0281119eed578b8dd2f63cc335b2781403e48916415e6150a252f8f6be2861f93612ce0f7f07ef17dab5ab47c49681c84184f155cdf89cb034b388087bf5f4a479408045e15e061c417a3d254695a202cb4bec95889fa5a7cefbdab8e203b5ac80b083995f184085e0d9ece1f6302d392efcde324513e188d157756381f7ef8a1879d0f39cb4ce88c164cacb58b2b4d6526d3c2458bf193aac58b16dedff24a8e120da3858132a8c2edf13476722f41b62e7d9fcd2e884293cb138ec493e75383a9542aee79a4e59513f1e485e4896804db906bcdaa87ee05158ffb5a8e645a454b96dc04423b185ff234325bea08d1331fe9422f141a1734bd1a4f26fb5323574618177ace0cca8b542a9988f7c463912f3d8474552d5e7cdfe2c537f62328c2e8330881321a3d0219e81f1a1eeeb9137b0832b5a46a4d223130000d58f7ca952b5771c98dccd4486a20998cd4dfc959b96081f1de5c88ea140d0325ee7a85f0bcdcf3ee07efeddefdc26f86de9a8fb985d951d5d295ec27634841481090d205ca40dce35c484a49c92c881622844abe040093e509bff3fe7bbdbb7605771d7cfe6eee2380ac890b83a151148404e60d5a622ea40b755656a14394254a8752e2763f0600109d80bcdbfbcc2e1ee576dc054801c64534991c60bab0dca0407294405d6a20958cd7af050314d36c25922e1d825c21558c1fbe9bd80a084ea54f7d924a8cc6b5305d53c2f72ecb4b607d82875203fd49cf4aec92c6923b6f9a2e697a4224e0cabfbdfbd39dc8160f739f9c43c8a235bdc9fe7e2a1912488642c6d0206e28b264e72a404a4a16aeb9c981983a0021a013175ab1e7a75b75c85db7e354625cd9dc0b2542992d44f90e04fe301959b588ad9fb1241becf90c4422f46a622bb3c5d876d71c1cb18c9ff7c4132a5f59464e710d013230903cba6125210b6fb2018b8ebe531a04c7da48eca75bb72a21c11d4ac913917822410808ba0e389fe108a43ffee545c6792565abb48573426b933ec3630d54110945a3dd5b255bf03db8e3813938999a3cf1389c4fa5d0f02244ad9ea34694c41b4c388997ddf458248c33063042a1e8e16857d76e05112577cfbb2d6f9e334e21a8e1643291bc3088be90c5195441e3e949225c8133a5e96610a19c3963e0c7981f44a23fe8ea1286823ce59c5f52606a8e25923425193fec74797a93aac21818954d8f3445d943a0c49da5c8d64d0b98144042a1c3a1ae081851409ed195b82a8a8df7c2114848c49b2b4ae79656b02f29868c8144736969716945883a07921ea7d1b4018b9e566be7864a57088057bb10dfa2922021075f7cf3f891bfb8f7e1081d49c643cea6c3d16834c15a06611085d00f71f891cb194785a7068e3a3fff37ffa1169dc551d922047f9b8ce8d35bb7b613f1e2efafce5c9f9999f9b8211cc31ac9de480fdeef5b6816c8925cc9586624f12e0630bee2a1d7e0189c7a5d5b3d1332be98aec8f709519604838357af336666463cad91583c8176a49c2e16413cc96c61924855c3786922e4ebedc1c1218cb26b57469494ac1e2008111d5d5d470572f0f757ae5dbb363a3a7afdcaf966cfab703e168b849e70cd2da8704563314252c9586f2472b8390e0dd484dae32b62af5dbb7efdd44d860b942821dfdabab33d187cf3fac835b4f230b2920c43097cc767e9c3a18ae2e2f9b02841483c12a97796169436d031141ffb95817f04ca9f640c61887429df778b90ab2c9e0f2f5ebc38349294bd5dd23f3298ec0a75e11d4bba506fa1e89ab2629727128925e11420d83ef14f4646af5dbf7a565b59423ce983f1c2104b826f5e1b85b1171389442ad51b6eed66bf0f7110e23dcb49423ce108481e8d26cef30d0cf4f78080af0171252325b7cca88471746b9b38726564e8e24022f11e4652184a38b9d87b0c3ca384d4772c867efc487fcd74a54019bd7efd75f17a56408940b66038065fc412d88720048bc75b51c26873b5cc5b6c775c84e01555110b4a3f7954a2a45cbfa216cefddc6d08a96cd192e08bdc4f07512a38a224621e4fb728e18ce2e052018a0aa40eba59c0ac7230de1b822dd7ae7f2cebcefadc6d505dc299b26bd7a1c1fe049230044a7fa2bbd5c3eae2414516c6b01f3c7decf0f13383da8ce40508cb8bbee31a1a86f39a925c8a2124428e5248f085c1642f296ff7f7f6f776a3ba505eea0001c8e0f1472a2ae6cfafa8a8701dd738a204c546218984d4e4b5eb6725517ab7a87429e39f16c8de5fbc913c974c9e4f9eeb4d26ba1bc3dd52c3a26224d9543a776e6969a9609a902b9e5b9048e8e07081928be81408d17c9fa52422c6efc6dcdad51efceecfe2f113887347e3bd112a892570ee026330593fb7a0a0b898187242e8181102482f9d83948b1709799db39d4a723a5fa50b967036b6effdaf63c7e227a2f163c7bef90d1cf600a114989e8c390b18c5e050507d8c026949e25c0f20fdf484c9d21cd1f2c5118c30c8e8124b76b5b7b7effdf6b78f45a3dfdef254db575b1bc3b11820f00887d158133e4c0807513c3714865b083a826923e65f1c66b2b46cdd44c9d35b31b784d2be77ef9e1d3bb6b5f97cad9e707737eb0ba948c62f9c9e5bc00fdb8a52813d97462806ba092f2fc226366286920d313eba1df54b0283bf38ddd6d6d6d6fa786b04bef06d2631e3534d0506ed81d93890ea8ef00f12f16ea88df79e3e736164e42c26b086c82851e9e2a867b6748a7a6af3b57da5b1351c41afc4b1124659aac735bf405236b7c2138706fc41b217135920b2e0ac389533bce80936c56d19258ad6d6e6fb7a239a11f30b6b20ffdc007b42aed20aa72bc431805d917f12c1e82105f3f0f479a542d7921354127dba6d5790806d14c2df9c325d6deec64837eb2bc63a46fdf4277b7ac29df082966362a12c62b16ebc0550c0d016d443f344ea0b9e44a3cc9620a023b84f49f1f9dc282f388f0b9b300d40f4038071cb61820d9e007c45623d6ac59cd0984a16956c6913c84e8a09b2cc84d1f61537204807de690c99633700317281b05e742036fd5ea88ce34d9c40879fbe514b36e009b3a5337894d8b76f5ffb4e9fcff795d59e0821d8813df8b4efe299195bb0c7ed76391b3a39a17be2cc16261cd6c718999d33f55a5557241a7d5ad6e70d1db94fa44009286e4c2f562a92dec5df4f12856797ab9efb3d12d61deb8d77636f3b79f2e4e993b318ea3ca11581211addb29d085d0818502210b81209c3e87802d514ef7437bcda753c7498cd13135312f148770c7fa33b7e5c28b84e6541b91d1f41b6b8be8f8c60fb3e1822c9db4c29ab3d9e56649de5844fa02ce75751b511b64d328e2669453e3d1ed7e1e3a49c3c75f2a4a600a1f722c540c976ae494690058c277c2f427c9b57bb1a1bd1f310c23e49a08e90bf5ef99e231e1e851b3dae92cf3c4919c4e4bcfd9c304422db61870fd92203f77d2866952d4871badc0d9cc40cd42ccce9c5fecf3d174e791ac3d0117ee8b6a5de38b59c3e0d4a6ea7eb61886e41b67c3bd924a0d077bc42c33336fbf86b3d4e160a81d13135fcc1e06f09dcad614f636bfc7ff36abc5f3f010295cc96a28ffa6f3ccd64810137a4ed71df868e57944d2c287798bf32c04711392d72a6e0b49540a2a0c313199caa5abadcbbe3c4ff9330c33fedd82186204f54c23b421050b279b51335eb461da3cd659f1a94c316e623ce8fad3c9a5d4f7f545bb7dcbb5b281a6356caf09971cb76295eaccfa2a2eff22dbbd1e7dbb9f5732e277aaf3e860f400ac20023ee89614262eabc9e4effcb7204b4007383272a5d7d06d5890a021dfbe00a9c17c466dfcecd73a0a3d1ed6a8c25f00148c39021edc3869f49cfacafab2565dbf367fec4141586ed182464e0da075f14438617e3b39f6872b9dc8d8f39f1c65954e207ce0e3869f726301dbbaea4d37ff0db96d62d47c6bcbbff33db27b961d8c113043ec5b5b3b0f6b180192a5bbecd9f367fdce07637524b183b31c730a749248c2e49607682913e04025c01c4fbb5effefc67dac25aa851bfad7d4790ff938866e783eeb39c51d53edfa6da4f58664e11028a9b1f251811ce1aa80063108cb4bf0eb1dc5b871aabf37ab7e572f4d94527067f8d81b58bfdced4ed939d050dba79a3b9fcb9f48ce731521e77bbdd8f7b504ead3c2b71378cf750477adc0625c4f0b2d9bc5edf779e9f85417581f1abe1b701113b30e6b12f8a924d9b6a3ffb69f347e9f4954e303c548312e0efde30163184e38357c9483f5b57675b6a03c1b67c85adce66b3ad809cddbf501c6d76ed0a1e1c1c4e410210004a01e3eedbb8b1b6d66cb68c61998fd1d7e1301430b88b2112e7afcc0863c20b0019c4e0ab0614707cc1e7df50145c80fc6a6478e82095a0c2e809703e106a6b3f6d36af9de04257229e564f27f384b1ce0196b830a264a4d3cf79eb1c6a7db92f238572fcbef6e02fdec4e1020f4006472e0df3ff90798480ed5041027598cdcf4dc94a5723e14e26ac515205c4949281f08aefcc9360f06cb559adcb96d9bc7eefb6e03fbf719e9e1c7c616494ff692975cccdd7b7bc76a9d95c2b0f5a223173268261886915899f1fd1122531e6a7126aa9ab5b01c40adb32abdd66b503b3c2e115396ff4197e931ab9343afcf68b2f1c3c18dc1b0c6ef32e355b158331a9ad954e8f9ce05c8f0e5ecd21200e79ebfc75bcea5057758e658e65cb2c7684d58e972ba026d01e7cd170e9d2e828a4e0f35e2af59b1ddea535b23811f45d654b6266f0fc89bed984747ad20f255e07be200761b39aca2d562b31761057ac40a9057c0632462f8d5e8231c9a71eb4565757d7607924cc6c466e7320539313135206b9c16cd539bc30065afc75d6f2a2a272888010bbcd4e3194e3cd4086dffae683d5640082108ad5a1ad954e4f4f4c4e4e4d4d4e6bdfead181f521a54e2840949bad56db32bb0397dda11e08c3b0304687b73cbad46aa9b65497979bcdd524986badb6f5da5ae8ebb1c9a9e9a9a91b20533ea6cb4f82dfebb09a2d369bc3ca65218617bf1c8e658611404686cffdc3d21a91515d3e871809abe3596db1f4c4a1b1892930a672f28778090cf80d35f270f8e10a192009467b8892812d0fd6582cd5d516dccce573e698cde4d4d8ea9ed3164b1f3af4d2f8f4d4f4f4e464ae2d53c896d7eb2020d071604f00cf004805a81006209746877efee0d21a4040b0984d58bdbcdcc484596d8e97b4d5d263870e8d4d4f4f4f8d8fbd94adeaf40496f56f04c77fe0a375eb5e99f80e84414c6e2c5b8f9b61e8dcd71eb45a85c007fc80a46a81d4652157912f646b627c6c2c47ca212cbfd18f084c5c7ee5f2e53f8e81c92a53eb67c3f0d5076b6ad4fad52624cb526dadb19814c4e6e078d4620c90c9f1f1f11c53a6bd01668a4afe7879dd2beb2e5ffd0e2830c52fee20280397a146acb0987899b0bad50a1c9c419bcc824c0132313eab51902daf5729f9c3471f7d7cf9f204855885418c3c245d600080f579af46b7b2046a4489c331ae2d87981a0723e77bc40128a1277e9f77ecf22b1f5dfe788f3fe0b75a15825ffef5b8f60082c58111042e6b0d9a0562d890350e7f2e642a3d35bbe3a70250422140753cb76edddf3d4b2116dcf400c3010620e524c8454956046e609891dc1cc8c4e4f40d9043019f1f181f9488355835e0b750002f15cf92623798e037d7b7e3322b8862986d75394aa626d089e339e58b9112086c0c4049004a9837dead367921f1ac7f0fa4acf7db01a1063b53558e4b2035f812253990094c95c959be4ff8b07ac00b90970fac0a8bac6a7d2d3af6ecc11dcd0888e4c95ece22c378b3da6c28ea1b954cc292c9f1dc01f99208c143c900c8ef6765a9d8c3071150e230c012bbc94e8c06c180b72a488e12c9d6147cd1be17db9504ae8e398908d42986a4af830c62302155ba14c36eb690410ca717d29be9134c47d417a6bdf63d9a33e0e3ac82124a41576269fc0b5d97bf839c0e281148b9285132e0863a085840a975dc0099cedd4ff6c312aa10416a6d8d11e820034f7ba0c68f11e98712e5861d0c8b45ce33525ea2449f5d131a047358fb09b315d8c435015362b0afa8422327802fa60c10ff7a8399a66b42d821a2c4620124ab64666282731e5338a3e410970ef837e10635583d80ee50cffc821050546df9e9898eb0606d42744f30e83408e61624000139da4f0ec0924d4c96240c4b7a6b65758d11081ca02b7bf6d012561708e4b043707710024ba48435c8d838f6f7a9f43432a67e00db992c75692b335978c13b55e0ea801258b2074a2cca772b4cc143f7a4dc5c8e12d63c1963ed52054112c816bd204389f153882274607922a0040c81e84a288461cb48c97832098886d0b39521e085380f4372a3633f3180581cebe94986a1389462a327162851106c233c43685a18c8962c9d898d0eed05851c08ecdf0fcc7e301c2640d6c3785054010b040701d41795e8909989713a220095ad29bccfac08864d13227f20cf7874205b84a04f3083a944380041070e9bd2275ee509263c7c8712d4b0a24cdca003b3587b81a00e0a41333ab0b0634f46892603145a42882811084ea7822040654bdeef268d84bbdf26af7284080742caed4a49b95d2ad8ae4128c46646ba2c7ac78b10240bf5ab189358669694baac10f1035fe0016211c87a4ee11c4b6cac61078ee55082997a4820935022f344a3a86ecf8657672821b8cb54e9f0dba1808359aa8bc52505c616911206c30e253ca662c0f318cc13aa2a2e74bb0a5d4d9df68c9fd37006362c5cf6f272fb7a74bd52a2254bc6bc0de5258d529b854cea8e103221cb6403c35e0b01b0e9f7431bb3555464e78185b38ba6e003181386c2623b8af1101ae09afa495b1fc1cc56ae2539952541d3a1849d5854444bfc061c7c992e00a8864ac4158bd90e257eac392905ac5448c76b6be92113528bfd341e0d22e9720082745189915d223dc2a0e9a46495f0d30f74688e400b0f103921735105019a2dc819da1d9fbbecdce50d45daf0d22848158b98d505e3fd50004794120098b01b6a2bc3c0d2d21e7c706ff43bf8c9cb8e7d0b4ab02fcaec42b66c182a741e4aac66293ebc7b40b85be99e4ca8f2d1235bbd78646ef89780d845090e5f8622d5f24a09ed60b644895920ac2d268b3a680b77926c2886be389f99406c86bc9b8bf2d127dcb48a8c32bbb4868712653d3a5e29912ea11f140288ac9a0981e84ed08b9c5010d9198b8a7820926cd959bfb01e0cd950f017716c840c4d0a2053b39b4417a2859c2214085d82a1928fa3108f44504229ba12a60b14360a3fcc4ca015c576bde1736d67cba9e0d2da33198ac33611c87a282997a39dee3c5bd1e630d314fc452028831c794c666dc72b7d9ecc0a0d81a1827481214ae03c767969c565f2195c29b14249c08f8d5706972aaee9e959d9d2844807c274156408c641083c01c58017525ea2447dd207453e3d007208072e062d6779e9d9a2cbaa0dc56fb15d5e61faaae1c80a569e1062bc073b0a3cc9300011069a35f02c0f43e0c8144670593dc0c812f8a4856e09da4429a127282f3b6797cdca72c32ee240b2e8094e2007c6d5ea3284d3e9716d1d2e0943645d5d4386a343cc96a25b8aa8c46ec82f325209a590c18b52d8f278ab7e7c24d174f096cd969e2c4d463674df1d0e33946420741e9305e9b2ab74e1cfc51428e990b323082264023b9ebea619972268132b13827138acf0e49e7bb89308e41e4e2f12f9ab10042adc0c080e129a12addda7f4918235b5c39c20e4951eaab6b08acd5c5efe29f86db77f8a107ed8e26155872069922f9c0f0011212cafe934f657d962b11485a8cacd9181bfcfbb640b36a3ba8aeee17b27843d4f8c464050098a0dffe825198f14835e514b71759437433134082d443e1503e98212292fbbfdff00101a2d047fb273540000000049454e44ae426082" + }, + { + "transaction_hash": "0x679ead2ca053e54cd742127aacc148b46e9ffa35b94c834307e4f8d0c5b628a6", + "block_number": "9239285", + "transaction_index": "109", + "block_timestamp": "1578476736", + "block_blockhash": "0x2b51e3267f7102de64b9423ef5e3db9399f2061a2bac37b480033b4e23d00abc", + "event_log_index": null, + "ethscription_number": "6", + "creator": "0x401e8a5661f9d5c8c9909bd0ea6d5c8411bd02da", + "initial_owner": "0x0000000000000000000000000000000000000000", + "current_owner": "0x0000000000000000000000000000000000000000", + "previous_owner": "0x401e8a5661f9d5c8c9909bd0ea6d5c8411bd02da", + "content_uri": "data:image/png;base64,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", + "content_sha": "0x83a33897317c76ecb32a5ade7068c1b5adf7f9b3a965a2f3c8c9710f021ee89e", + "esip6": false, + "mimetype": "image/png", + "media_type": "image", + "mime_subtype": "png", + "gas_price": "2000000000", + "gas_used": "186856", + "transaction_fee": "373712000000000", + "value": "0", + "attachment_sha": null, + "attachment_content_type": null, + "genesis_block": 9239285, + "is_genesis": true, + "content_uri_hash": "0x83a33897317c76ecb32a5ade7068c1b5adf7f9b3a965a2f3c8c9710f021ee89e", + "was_base64": true, + "content": "0x89504e470d0a1a0a0000000d4948445200000172000000ce0806000000235b89b300000c49694343504943432050726f66696c65000048899557075853c9169e5b5249688150a484de4411a44b09a14510902ad8084920a1c4981044ec2ccb2ab8165444c086ae8a28ba1640d68abd2c8abd3f2ca8acac8b051b2a6f5240d7fdde7bdf3bf9e6de3f67cef94fc9dc9b1900746a7952692eaa0b409e245f161f11c29a909ac6223d0608fc30811ec0787cb9941d17170da00cdeff2e6faf435b28575c955cff9cffafa22710caf9002071106708e4fc3c88f7018097f0a5b27c00883e506f33235faac493203690c10421962a71961a972871861a57a96c12e33910ef00804ce3f164590068b7403dab809f0579b46f42ec2611882500e890210ee48b78028823211e9e97374d89a11d70ccf88627eb6f9c19439c3c5ed61056d7a21272a8582ecde5cdfc3fdbf1bf252f573118c31e0e9a481619afac19f6ed66ceb42825a641dc23c9888985581fe2f76281ca1e62942a524426a9ed5133be9c037b067f6780ba0978a151109b411c2ec98d89d6e83332c5e15c88e10a410bc5f9dc448def42a13c2c41c3592b9b161f3b8833651cb6c6b7912753c555da9f50e424b135fc374542ee20ff9b2251628a3a678c5a204e8e81581b62a63c27214a6d83d91689383183363245bc327f5b88fd84928810353f362553161eafb197e5c907ebc5168ac4dc180daece1725466a7876f079aafc8d216e114ad849833c42f984e8c15a04c2d03075edd825a12449532fd629cd0f89d7f8be92e6c669ec71aa303742a9b786d84c5e90a0f1c503f3e18254f3e331d2fcb844759e7846366f6c9c3a1fbc1044030e08052ca08023034c03d940dcded3dc03bfa967c2010fc840161002578d66d02345352381d7045004fe844808e4437e21aa59212880facf435af5d51564aa660b541e39e009c479200ae4c2ef0a959764285a32780c35e27f44e7c35c73e150cefd53c7869a688d4631c8cbd219b4248611438991c470a2136e8a07e2fe7834bc06c3e18efbe0be83d97eb5273c2174101e12ae113a09b7a68a8b65dfd5c302e340278c10aea939e3db9a717bc8ea8987e001901f72e34cdc14b8e2a36124361e04637b422d4793b9b2faefb9ff56c3375dd7d851dc2828c588124c71fcde53db59db738845d9d36f3ba4ce3563a8af9ca199efe373bee9b400dea3beb7c416627bb1d3d831ec2c76106b062cec08d6825dc00e29f1d02a7aac5a4583d1e255f9e4401ef13fe2f13431959d94bb35b875bb7d52cfe50b0b95ef47c099269d29136789f2596cf8e617b2b812fe88e12c773777370094ff23ead7d46ba6eaff01619efbaa2b7e034080606060e0e0575d347ca6f7fd0800f5c9579dc361f83a3002e04c395f212b50eb70e58500a840073e5126c002d80047588f3bf002fe20188481b120162482543005765904d7b30ccc00b3c102500acac132b00a5483f56013d80676823da0191c04c7c029701e5c02d7c01db87abac073d00bde827e044148081d61202688256287b820ee880f12888421d1483c928aa4235988045120b3911f9072a402a9463622f5c8afc801e4187216e9406e210f906ee415f211c5501a6a809aa3f6e848d40765a35168223a19cd42a7a3456809ba04ad42ebd01d68137a0c3d8f5e433bd1e7681f06302d8c895961ae980fc6c162b1342c13936173b132ac12abc31ab156f83b5fc13ab11eec034ec419380b77852b38124fc2f9f8747c2ebe18afc6b7e14df809fc0afe00efc5bf10e80433820bc18fc0254c20641166104a0995842d84fd8493f069ea22bc2512894ca203d11b3e8da9c46ce22ce262e25ae22ee2516207f111b18f442299905c4801a458128f944f2a25ad21ed201d215d267591de93b5c89664777238398d2c2117932bc9dbc987c997c94fc9fd145d8a1dc58f124b11506652965236535a2917295d947eaa1ed5811a404da466531750aba88dd493d4bbd4d75a5a5ad65abe5ae3b5c45af3b5aab4766b9dd17aa0f581a64f73a6716893680ada12da56da51da2dda6b3a9d6e4f0fa6a7d1f3e94be8f5f4e3f4fbf4f7da0ced11da5c6d81f63ced1aed26edcbda2f74283a763a6c9d293a453a953a7b752eeaf4e85274ed7539ba3cddb9ba35ba07746fe8f6e931f446e9c5eae5e92dd6dbae7756ef993e49df5e3f4c5fa05fa2bf49ffb8fe2306c6b06170187cc60f8ccd8c938c2e03a2818301d720dba0dc60a741bb41afa1bee168c364c342c31ac343869d4c8c69cfe43273994b997b98d7991f8dcc8dd84642a345468d46978dde190f330e36161a9719ef32be66fcd1846512669263b2dca4d9e49e296eea6c3ade7486e93ad393a63dc30c86f90fe30f2b1bb667d86d33d4ccd92cde6c96d926b30b667de616e611e652f335e6c7cd7b2c9816c116d9162b2d0e5b745b322c032dc5962b2d8f58fec13264b159b9ac2ad60956af959955a495c26aa355bb55bfb583759275b1f52eeb7b36541b1f9b4c9b95366d36bdb696b6e36c67db36d8deb6a3d8f9d889ec56db9db67b67ef609f62ff937db3fd33076307ae43914383c35d47ba6390e374c73ac7ab4e44271fa71ca7b54e979c51674f6791738df34517d4c5cb45ecb2d6a5633861b8ef70c9f0bae1375c69ae6cd702d706d707239823a247148f681ef162a4edc8b491cb479e1ef9c5cdd32dd76db3db9d51faa3c68e2a1ed53aea95bbb33bdfbdc6fdaa07dd23dc639e478bc7cbd12ea385a3d78dbee9c9f01ce7f993679be7672f6f2f9957a357b7b7ad77ba77adf70d1f039f389fc53e677c09be21bef37c0ffa7ef0f3f2cbf7dbe3f797bfab7f8eff76ff67631cc608c76c1ef328c03a8017b031a0339015981eb821b033c82a88175417f430d8265810bc25f829db899dcddec17e11e216220bd91ff28ee3c799c3391a8a8546849685b687e98725855587dd0fb70ecf0a6f08ef8df08c9815713492101915b93cf206d79ccbe7d6737bc77a8f9d33f644142d2a21aa3aea61b473b42cba751c3a6eecb815e3eec6d8c548629a63412c377645ecbd3887b8e971bf8d278e8f1b5f33fe49fca8f8d9f1a71318095313b627bc4d0c495c9a7827c9314991d496ac933c29b93ef95d4a684a454ae7849113e64c389f6a9a2a4e6d4923a525a76d49eb9b183671d5c4ae499e934a275d9fec30b970f2d929a65372a71c9aaa339537756f3a213d257d7bfa275e2caf8ed797c1cda8cde8e573f8abf9cf05c18295826e6180b042f8343320b322f3595640d68aac6e5190a852d423e688abc52fb323b3d767bfcb89cdd99a33909b92bb2b8f9c979e7740a22fc9919c986631ad705a87d4455a2aed9cee377dd5f45e59946c8b1c914f96b7e41bc00dfb0585a3e247c58382c0829a82f7339267ec2dd42b94145e98e93c73d1cca745e145bfccc267f167b5cdb69abd60f68339ec391be7227333e6b6cdb3995732af6b7ec4fc6d0ba80b7216fc5eec565c51fce687941f5a4bcc4be6973cfa31e2c78652ed5259e98d9ffc7f5abf105f285ed8bec863d19a455fca0465e7caddca2bcb3f2de62f3ef7f3a89fab7e1e5892b9a47da9d7d275cb88cb24cbae2f0f5abead42afa2a8e2d18a712b9a56b25696ad7cb36aeaaab395a32bd7afa6ae56aceeac8aae6a5963bb66d99a4fd5a2ea6b352135bb6acd6a17d5be5b2b587b795df0bac6f5e6ebcbd77fdc20de707363c4c6a63afbbaca4dc44d059b9e6c4ede7cfa179f5feab7986e29dff279ab646be7b6f86d27eabdebebb79b6d5fda8036281aba774cda716967e8ce9646d7c68dbb98bbca7783dd8add7ffc9afeebf53d517bdaf6faec6ddc67b7af763f637f5913d234b3a9b759d4dcd992dad27160ec81b656ffd6fdbf8df86deb41ab8335870c0f2d3d4c3d5c7278e048d191bea3d2a33dc7b28e3d6a9bda76e7f884e3574f8c3fd17e32eae49953e1a78e9f669f3e7226e0ccc1b37e670f9cf339d77cdeeb7cd305cf0bfb7ff7fc7d7fbb577bd345ef8b2d977c2fb5768ce9387c39e8f2b12ba1574e5de55e3d7f2de65ac7f5a4eb376f4cbad1795370f3d9addc5b2f6f17dceebf33ff2ee16ed93ddd7b95f7cdeed7fdcbe95fbb3abd3a0f3d087d70e161c2c33b8ff88f9e3f963ffed455f284fea4f2a9e5d3fa67eecf0e7687775ffa63e21f5dcfa5cffb7b4affd4fbb3f685e38b7d7f05ff75a177426fd74bd9cb81578b5f9bbcdefa66f49bb6beb8befb6ff3def6bf2b7b6ff27edb079f0fa73fa67c7cda3fe313e953d567a7cfad5fa2bedc1dc81b1890f2643cd556008303cdcc04e0d55600e8a900302ec1fdc344f5394f2588fa6caa42e03f61f55950255e0034c29b72bbce390ac06e38ece743ee6000945bf5c460807a780c0d8dc8333ddcd55c3478e221bc1f18786d0e00a91580cfb28181feb503039f37c3646f017074bafa7ca914223c1b6c0856a26bc682f9e03bf937f7987f42a448046b0000000970485973000016250000162501495224f0000011ab494441547801eddd7b8c5cd75d07f0e3f8153f123ff3706ce755376e9b90366968a08594a6499ab624a850d2f00fe20f8454412504142a044248943f90104508551415511550d416da8a06d2524a8b42681b5ab7c54d53024e9c38b1e3f8edf86dc79cdf7867bd5ecfee99f58e33e7743f571aefee9c3b777ef33957dfb93e73ee9d5927f3922c04081020d0acc005cd56ae7002040810e80808723b020102041a1710e48d77a0f209102020c8ed03040810685c409037de81ca2740808020b70f102040a0710141de78072a9f00010282dc3e40800081c6050479e31da87c0204080872fb000102041a1710e48d77a0f209102020c8ed03040810685c409037de81ca2740808020b70f102040a0710141de78072a9f00010282dc3e40800081c6050479e31da87c0204080872fb000102041a1710e48d77a0f209102020c8ed03040810685c409037de81ca2740808020b70f102040a0710141de78072a9f00010282dc3e40800081c6050479e31da87c0204080872fb000102041a1710e48d77a0f209102020c8ed03040810685c409037de81ca2740808020b70f102040a0710141de78072a9f00010282dc3e40800081c6050479e31da87c0204080872fb000102041a1710e48d77a0f209102020c8ed03040810685c409037de81ca2740808020b70f102040a0710141de78072a9f00010282dc3e40800081c6050479e31da87c0204080872fb000102041a1710e48d77a0f209102020c8ed03040810685c409037de81ca2740808020b70f102040a0710141de78072a9f00010282dc3e40800081c6050479e31da87c0204080872fb000102041a1710e48d77a0f209102020c8ed03040810685c409037de81ca2740808020b70f102040a0710141de78072a9f00010282dc3e40800081c6050479e31da87c0204080872fb000102041a1710e48d77a0f209102020c8ed03040810685c409037de81ca2740808020b70f102040a0710141de78072a9f00010282dc3e40800081c6050479e31da87c0204080872fb000102041a1710e48d77a0f209102020c8ed03040810685c409037de81ca274080c01c04670abcf78127cfbcc35f04083423f0e1fbaf69a6d64116ea887c909ab645800081210808f221a07b4a0204080c5240900f52d3b6081020300401413e04744f49800081410a08f2416ada16010204862020c88780ee29091020304801413e484ddb224080c0100404f910d03d25010204062920c807a9695b040810188280201f02baa7244080c0200504f920356d8b000102431010e44340f79404081018a480201fa4a66d112040600802827c08e89e92000102831410e483d4b42d0204080c4140900f01dd53122040609002827c909ab6458000812108f886a021a0ff203ee575975e98eef9a16569dfe113e92fff63fbc05ee2facb16a49bd72e4afff0ad5de9c8f197cedaee2bf3f3bef3866569fbbe63e9effe6bc759ed2ddef163afb828dd7af5e2f4d4ce23e9eff3ebb610280908f29290f6be042270d75d72617ae9e4c9bed6ef77a5f7dcbc22ad5a323707dba2f4ab9fda7cd6c36e5b77715a9fc3fcaa65f3a614e4b7adbb28ad593affaced4de78e1d078ea52f7c6fefa49bf8d0bbafeeb47fe4e1e7d363db0ef55cf786550b3b96cb16ce11e43d85dc395e40908f17f1773502972c9edb09f128e889ed877bd675ed8a5361fcdcdea33ddb27baf3de1b97a745f3063bb278e0e84bc5209f3f6756a7a40be70ef6b9277a9dee9f1902827c66f47393aff2be9b978fd6fdc90dbbd2ebaf5c34fa77f797386a8de5d0b1933ddbbbeb6d78e640fedf42f7afd33f632868e781e3a7ef18f9ede20b67a7a3c74fa6c33d8673c6afbc62d19c14eb5b080c4b40900f4bdef34e2a306ff6ac747d1e628865cbeea3e9603edafdc5375e3ae163ae5fb520afbf60c2f6dff8f4e674e0c8d963ec5f7beac5cef8fbd8075e938ff27ff3ce2b3a77c578ff37f39bc064cb4fbf6e79baf3554b265b451b81f32a20c8cf2baf8d9fabc0bd372e4bb34e8d42a44f6ed8998ee7c3e913f97641f7cebce131bfa6d2d07ca9bd5b676cf3976fbbbcfb678ab1f4b88d5f366c3998bef2c4bef177fb9bc0500404f950d83de964028be7cf4e6f7ee5c59d55f61e3a91fe67647cfc573ef1d4190ffbe03d6bd3f23cacf1bf2f1c4e7ffcaf5bcf683bd73f7ee94d97a545f34f8f5fc787b8bd9613f9e05e90f79271df300404f930d43de7a402bffed65569ce05a70ec7271ad688f66539c46389e191e92ef1e1e3fbef5895ae5832afb3a9184eff749efab7354f6bec2e37ad5998de78eda9a3f3af4ef339efcbb37162ba66af65d9c253e3ed4b17cc4ebff78e3567adf28da70fa4cf6ddc7dd6fdee98b902827ce6f6fd7979e531f4f1be9f383d3431d19344f87ebd4718beebb5cbd3e517cf1d7d580ca7c487896f5d7fe618f4a517cd4d23232fe9ca65f3d37d379f0ae0d1078efb2586663efb9ddd9de199714de9cae5f3d3afddbe2a756794c473cece6f14efca63df317ffd8b8fef4d6fca01fea32321fed8d643e9d1cdd37bf35892433a6e932d51c3588beebaeb2e19ecb4c9ee76fd6c574090b7db77d556fe9acb7b0f478c2d388ea8c707f9d5f943c63b5f7d6660c763d62c9d97de72dda9a196b1dbe8fefee33dc6b0bb6d637fc69bc7b37bce9ea6b82abf714488c751f883f948f7f38feded1c9d47c0ff4c0ef3bbf20799178dcc4ad9bceb48faf37fdf3676b3e7f47b6c67578fd932fd6c6c637e23b110182b20c8c76af87d2002bdc272fc86c7cf0b8f607fdf9b2f1f3dca1ebbfefe23273a678c8ebdaf3bdd2f66b3c4d1763fcb916367cf5a89c745c0c749471b9f3bd4196f8ffb3efa9f2fa4df7edbea4ec077433c9ee713dfdcd9731a633c662a4b9c3834d1b0d154b6635d022120c8ed07031588333bffe0a167a7bccd6b57ce4f0b474ed0f9ccb777a7b75fbf7474a863d38e23e9b73ef3f4e836e383d0fb5fbfa23353e5039f7d3a1d3bd15f908f6ea0c72ff19c51c3bb6f5a9ee7a32f4e313edd5d22c0e38d266eefbfe38a7438bf21c451f1438fede97984df7ddcf89f633f44edf7cd67fc36fc4da0978020efa5e2be975d2066a6c4d8741ca57efe7b7b3a413e5111b78f0cb36cda797820211e47de317c337e3a639c2dfae077f7a43899e8862b167642feb23c361f1f8cde924f4e8a5b9ccdb9f1b98313957ac6fdcb474e5e8a3b7bcd693f63657f10988280209f029655cfafc05fe5e18cd270431c29c7079db1ec397822ddbe7ee2b1f36eb5dbf71f9f346c77bc782cadcdd76a8923fb4df942558f6cda9f6eccc17dd3da85e947f2c5ab9ec9e3d911d6718b0f1fefc81fbcdeb87a6167dcfcc51ec33edde71dff736c90bf27ff8fa2d745c0c63fa6fbf7c7bfbe236ddf7f7a064df77e3f09848020b71f5423500af128f427c74cd98b53f67b9db63ffe05bd90837ab2a3e68f7d6d47fae2f7f7a618c2e92ef7e42b2ac60c9c08ecb8c5b87fcc60890b5dfdcda3f92a8b8fa61497078830befb354bbb0f9bf467cc79ef2ef1c631952566ee08f2a988cdac754fef5933eb757bb58d0a2c5b30a7339c512a3fa62676c7dc8fe46ba64cb644188f0df158f7773ff74c8ad9373f9be77bc751f8ea3cf412d32ab7e440ffe0c86700bb0f9e7d8d96c99e67fda5a766f3c459a6316ba5b4c450cf5579e68c85404940909784b45725f0675fe96feadfefdcbd3a07f9bcce65753ff270ffd747bf2b4f7f7c265fdbe5f1e70f758ebe7fff9fb6744e128a0f57d7e5cbe58e9f3239159c985e19cb5339c4ffe85f9eebeba11fbeff9abed6b3d2cc1610e433bbff9b7cf573f305b5629968b64a9cdc1347d0b1fcf55777a4185ae96789b336e384a458e2187e5fbe3cc09379cc7cc39603e943ffb6b5336be5e839ce90c9135ed2c5233361a67b3251a740ff10182320c8c760f8b50d81980218b34cbebbf560bea0d6aed1b1e398def781bb56a7952363d17146e65442333eb88cebb6c4b663664a9c79f9ba7c5a7edc7ee1d64b3ac32a0fffdffece87a1539d3ef896eb9674e6c8c71bc4239ba67756681bbda4ca97534090bf9cda9e6bda022b17cf199d2a185302e3b62d5f0f254eea89b9e771f9db08cb4fe52b267ee9fb53bb3ae1779e3d98e2164bbc29dc92e793bf367fd019df7c14ff0b880f287fee96159d39ecfff8dfbbd33fe779e4fd2e6f1ff94074773e9b732ab355faddbef566b680209fd9fddfdcabdff1e2f114d7168f53e7df90a706c6493af161e44fe5cbdec61221fe17f96bd4be9d2f333b9d25e679c7d50dbb57387c55be0ae21df954fdf86ed208f527f2917bbfcb0f5fb578f48a8adf1a79a3e8f7b1d623d08f8020ef476986af132179ddc88c8b89282ecb611acbac3cd522863efa5db6ed3b9a628ef45496382d3f1ef3b7791ae0db5ebdb413b0dd192a317a1ef76dce63db7bf2187769b969cda2bebfdd27865e628a64fcaf20be20396eb1743fc4ecf55c31effde76f5dd9698aba630aa385c0a00504f9a0457f00b717dfe8defd4ab5d2cb8b208d53ddfb5de24cc98fa7a9057977db71899518de88db3b6f589ade91875662ee777cc3cf1fde7b65fa723ea28e6ba34cb64428af5cbc78b2557ab6bde25436f76cebde191f70c6987dfcaf21fea7f0a75fded6f3ea8bddf5fd2470ae0282fc5ce566d0e362dc786dbe54ecf958622ade20960737eee904777c31440c7fc41cec37e4218d5290c751fbf3d33c63724dbe86f9d8eba8745f4f7c4146f77f0a5fc8971d787a40afb5bb7d3f097405047957c2cf09051ef8c6e447b5133e701a0d11f0cbf2b044cc22e9778971ed3ff9d2d614e3d9319cf1d1475e98f0a18fe73334e37f030fe5808d2f6a98ce126797de9d87739ecdd76619bbc4173bc78945f7e6b351e3a25ce7b2c4d99c7134efacce73d19b398f9975322f33e7e5965fe97b1f78b2bc92350810a85260a69e4075facb09abec164511204080404940909784b4132040a07201415e7907298f0001022501415e12d24e800081ca050479e51da43c0204089404047949483b0102042a1710e4957790f2081020501210e42521ed040810a85c409057de41ca234080404940909784b4132040a07201415e7907298f0001022501415e12d24e800081ca050479e51da43c0204089404047949483b0102042a1710e4957790f2081020501210e42521ed040810a85cc0174b54de41ca2340804049c0117949483b0102042a1710e4957790f2081020501210e42521ed040810a85c409057de41ca234080404940909784b4132040a07201415e7907298f0001022501415e12d24e800081ca050479e51da43c0204089404047949483b0102042a1710e4957790f2081020501210e42521ed040810a85c409057de41ca234080404940909784b4132040a07201415e7907298f0001022501415e12d24e800081ca050479e51da43c0204089404047949483b0102042a1710e4957790f2081020501210e42521ed040810a85c409057de41ca234080404940909784b4132040a07201415e7907298f0001022501415e12d24e800081ca050479e51da43c0204089404047949483b0102042a1710e4957790f2081020501210e42521ed040810a85c409057de41ca234080404940909784b4132040a07201415e7907298f0001022501415e12d24e800081ca050479e51da43c0204089404047949483b0102042a1710e4957790f2081020501210e42521ed040810a85c409057de41ca234080404940909784b4132040a07201415e7907298f0001022501415e12d24e800081ca050479e51da43c0204089404047949483b0102042a1710e4957790f2081020501210e42521ed040810a85c409057de41ca234080404940909784b4132040a07201415e7907298f0001022501415e12d24e800081ca050479e51da43c0204089404047949483b0102042a1710e4957790f2081020501210e42521ed040810a85c409057de41ca234080404940909784b4132040a07201415e7907298f0001022501415e12d24e800081ca050479e51da43c0204089404047949483b0102042a1710e4957790f2081020501210e42521ed040810a85c409057de41ca234080404940909784b4132040a07201415e7907298f0001022501415e12d24e800081ca050479e51da43c0204089404047949483b0102042a1710e4957790f2081020501210e42521ed040810a85c409057de41ca234080404940909784b4132040a07201415e7907298f0001022501415e12d24e800081ca050479e51da43c0204089404047949483b0102042a1710e4957790f2081020501210e42521ed040810a85ce0ff01f105115339de2a4a0000000049454e44ae426082" + }, + { + "transaction_hash": "0x7c7ee60e80c461120953c6678ca5381b56a7069a7a8c208c38fe8e3654265e05", + "block_number": "9430552", + "transaction_index": "10", + "block_timestamp": "1581011454", + "block_blockhash": "0xf618006af259d7157e744600cfbea3d4cb091bd013f873412eab85af5e1961a9", + "event_log_index": null, + "ethscription_number": "7", + "creator": "0x60a282c92b749d3f4acc04e97dbf5f07276d31d4", + "initial_owner": "0x60a282c92b749d3f4acc04e97dbf5f07276d31d4", + "current_owner": "0x60a282c92b749d3f4acc04e97dbf5f07276d31d4", + "previous_owner": "0x60a282c92b749d3f4acc04e97dbf5f07276d31d4", + "content_uri": "data:image/jpeg;base64,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", + "content_sha": "0xb88553eea82943f2833c88f2f82f8791b00661794b7731f30c30eef2c9b0b941", + "esip6": false, + "mimetype": "image/jpeg", + "media_type": "image", + "mime_subtype": "jpeg", + "gas_price": "41000000000", + "gas_used": "371320", + "transaction_fee": "15224120000000000", + "value": "1000000000000000", + "attachment_sha": null, + "attachment_content_type": null, + "genesis_block": 9430552, + "is_genesis": true, + "content_uri_hash": "0xb88553eea82943f2833c88f2f82f8791b00661794b7731f30c30eef2c9b0b941", + "was_base64": true, + "content": "0xffd8ffe000104a46494600010101004800480000ffdb0043000c09090b09080c0b0a0b0e0d0c0f131f1413111113261b1d171f2d28302f2c282c2b3238483d323544362b2c3e553f444a4c505150303c585e574e5e484f504dffdb0043010d0e0e131013251414254d332c334d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4dffc000110801bd014b03012200021101031101ffc4001b00000203010101000000000000000000000102000304050607ffc4004d100001040004030405060a08050500030001000203110412213113415105226171143291b1d123528193a1b206243342537292c1e1f025343536436273741563a2b3f144546483c24582a3ffc4001801010101010100000000000000000000000001020304ffc40020110101010100020202030000000000000000011102213103411261133251ffda000c03010002110311003f00f7aff5dde65045febbbcca0ba39986c8201440ca208a08a296a228a0a5a8862141142d0f484a9c902859405052d0b44a8521dd31290a0850b52f741004a4a6a40840a5294c81468129098a4288042521320e4a13aaac856243ba942398908569485155108527294a21084b49cec950a421215615594aa5489d21e683c47e13c3c3ed4ce069236d7abfc16feefe1bf5a4ff00b8e5c4fc2c86e3c3cb5b38b495dbfc17fec0c37eb49ff71cb15a8f78fbceef3285a0f3f28ef3296d7472597a29692f446d14d68df8a5b52d036652c25b51035846d25a165039768764b9c69a8d76d775c9ed38a6c462a164514ae681de7b4775baf5ea91b0b7d062824c2e25c1b99b207343b5aab1d6cd51d39aaaed037b6a3c101a80e1a83a83d571a3c1cafc1f09f088a633032e468ca1b556de4741af8aab0982970fd9b237872e913006b4343dfa5b9a3a5e82d41dd0e0ebca41ade8a19810482286fe0b90c8a47366124534370b43a980b643e0d06c11b788f2546070d88c3973e5c290e0c711918db7b8ef7ae9ca8144761b8a81ee0d64f1b9c7601e0da726b734bcee0bb331584c461cba3b0c700d70a700399f0d39e8ba3dad0cf3701b8789cfef5b887001a34ea50c6e3246d8f88e91823f9c5c2bdaa3268a404c72b1e1bb96b81a5c49b033ff00c3bd0e3c2b8bb8a33b8bc10f035041bd28d25c2767e260c3632292095ad93291c37b7338ec413d3aa0eee761697676e51b9bd02adb3c523b2b258de77a6b815cd6e0f13c2c5e774b23e48db1778b5ad71aa73a8721cb9e8a9ecfecdc4e0f191bded040b05cdaa0dadbad9348aed3886b4971a0373d10240dc803c573fb57032e2a27189fae42d11904826c1bdc6ab3637b2f112c02263db2faeeb3600baa6ee4f5e68aebb88d35147c52e9bd8a5c23d998bf44e0f08177118490ea155a91ee522c062990621a632d0e735ed14092731b035e9aea83ba94d5d58b5c4c3613130c0f649148f395c434106ce7b02fcb708617098886695cf89f6637346da122c6bf65f8223b46af9242b9b037111e2c39ecc4b98e78f59ad02b2559af114a8c3607151c91192ce52dbb17bd93f48ebe2a0eba54ced0a4cc8014a4224a04aa14a5298a52a0421210ac294ee82b2d485ba2b4a42838ddbf86e3f65cda6ac19c7d0b4fe0b7f77f0dfad27fdc72d72c62589cc76ce14551f838ce1762c5191eac92b7d923966c6a3da3ff0028ef3295179f947f99f7a0b6e661b23b2014451450510445052d14494021614b4412813b52168206bad9292a5a5412c8d54b510d0ec8b06f9d2a26c5b207343c59209a0458005935cc2bad64c6610e2f8604ee8831c49ca01cc08a21156e1f131e20b8c5742b7148cf89187607bab52001757e5e2930f866e18c990921e6f5e497198738bc3ba1e2be2b20e66558a3e288a709dab1e3715245146e0d68b0f246a79e8b69706824ec173f07d951e0312e961790d73729665155b8d77ded6e2010438020f23cd15861ed56cd8b10085cd25ee6d970d8006f7f15bc916b137b3e06ce646b434f138a035a051aae9b2d451050e48204fb5153aa52066d94ba40a221d0e894ba9425028a04da528a528014a51b40a05294a3e0822179285121045a44a53148510a56cecfc01f4405800697bdded712b268bb9d9dfd4a3f377bca955a5e7e51ff00ac7dea041ff947feb1f7a81560c982509916228a051151442d00472280950ec85df92074164d0080a0838868249d06e7a28e7060b7501e2854e6a1419232416c735c3a83689400a544eea22028a288225254252b9c03493b016514730e8813aaae39992fa8e074074f155b317148ecac249bad905ce34d26ebc7a2e661a47f1b8b348f0d79cacb141de0ba134cd822323ee8745561f14cc546648c1abaef0ab41436671c41a707499b2d6b5e22f9782be62e0d66f9738cf5bd2b6d21d4a159b12d9dd3b0c7206b4e835d8d1f04eece27840b268e73c88af7da664ec73f28f0af1bff00c24763a16e20e1cba9c2b7d92515768b1ce849612287cea07cc734a58e8f0ac6bac80f05fde2e245eb7f62b5d8b8d93f08de6ea07d897138a18770190b89048af0452631c7843493535dc04f23bd6b5cd672e95a5c5fc770164800f7858cbfbee96e73801baa8e2003eb8f6a2b13db89c8386652f2d144822c65d7c8dab1bc4b665e2513206dde82b4b57fa437e7b7da819c727b511972cc19a366b0465bbde8583f4dd1d969c45d332839f38aafb7e8ab40cbff00307b538907ce05031d90b409e8521714049a49ba849296d40576fb38fe231f9bbde570ad777b3bfa8c7e6ef7941a1ff947feb1f7a80a121f957feb1f7a81564e0a7e4ab09c14193b40cac8586170690f165550b648f03a97bded7d9611a8d7c76eab6cd332089d2c869ad16529c6c3e8fc50eb17544d1b456773258e19a812f194d5592072097022588ccec435e6477786e6f92d23151bd8e20b416900b4bc73d948f10d709091944668db821a58cfa33724cea1abf3d69a9d45ac7da4f8711100d91aea22a813adf3a0b661b16cc40716d0a17bdd8470f89f48e2535edc8eab71dd11964963f476c2d94ba4e4407651e743655caf865c17023e20d729cd138d9eb75b73b5ae2c6997106211c8d00d6620804a9e96ef4ce016e97561c4f2be9a22b3e0648e084c7f2b4deb0b80be75a6bfc569f4967cd97ea9df055cd8f1162043a1daf7b17e0926c7164ce89b96c341b21c75fe7de88bbd21bfa398ff00f5390f4803fc29cfff0051478c380d96c1cc01599d8837a815e682ef4b1fa29ff6129c574826fd91f159ce23fca128c492eaca1069e3bbff006f2ffd3f14ae90ccc731d048d0455b8b6bec2b3bb1046ed080c51f9a3da8a7c3e15d0c8e786b05b6aaf9fb12c584742e0e043bbd67bc7f9dd59e927293947b550dc6b9cea2c03e9416624cd342631016e6344e71a049861343116fa3f3fd28200e4965c61691dc07e94831e4ff00863da835f166fd00fac1f04a24989a30868ebc4bafb1523179881937f15735f990510615d0c8d79315f3cac209df9a5f4369c5b710e21cf06cefd2b45aad26ca0a9f858df3090824eb76e3aa134025cc6db9b2e56d8f57afd2add5427455554a247b800d6641cf36bec591f8490b8d65dfaadd6820e77a2483e6fb51f4692b61ed5b9c94f920c7e8d2746fb55823736b6571b0364b67a2020bb28cd57ceb64a5ca1294b94b41b4a4a5252d840577bb30fe211f9bbef15e78b977fb2cff0047c5e6efbc506c93f2affd63ef4014253f2aff00d63ef4a0aacae0532a414e0a0188846223e1b8d30efd554cc135b846c2d6b34766bcb637b53192c91c4de13a9ce7569bd7354c334cfc107e636e78ef3cea1b75f141a4614370ce843b2d9f59adaae88e1e0e03487485e0ea41680b28c4c9e82e79734bf3682f502fc2d68c24dc60f7666105d6035d6792030e1d90b5ed680038ecd154390490e0a385f9fd67565163d51aa9c67b61c43893c50e200e9f36bcd2b5b3c10cae95c6f2f75b61d479f208199828e27b1ecd1c0d9245dfc11f45687670f78783a3b980771f4ae57654988189709f3e56c59887b89afe6d68c2473b676ba4734ebb1198ed675ebc90746489af958f70d5bad56eab9608a4cc5ed198e9986eb131f21c6dbcbfd62d2dcfea8154340abc43b103139b3e48cbec1ca01d001e3fc506f313a83389dc1a65cbfbd526104faaacd7d2f33410cc9dee84e95f4d5aab15898841201337355535c2ed147d19841db4f1d9018465eeb161a7830c1e2694f15ed27478363a74b530b89c3326cef9226891b4d3601f1be9e1e48369c2c66c56a370ab761e31b05cef92e3e6189613b9f94f3aaeaaf7075df7a9116968ba4bc1603a375590b25cf795d57d569162ad145f0b1deb0d946e1a2a1a154e2039c065b3e4ab6078001b423670621c8e890b584de6947eabc80b3383e8eeaa0d9398722b61885faf37d6b92f05bcdf37d63be2b239b2720e563438335b4459c36f23257faaef8a3c1673327d63be2b186499ec8752bdb9af9a02f898d750327d63be28705a79c9f58ef8acd8a64ae92d8d7115c91635f945828ab9d0b00bb7fd63be29784ceb27d63be2ab78716d00553924e8e534d6ae0b2bf3feb1df14bc28effc4fac77c52c7a329c4daa32bb884ebbaaad9a34068ba1d4da19bf9091a7ba10b40c5d694b9025292a225e9baf45d947fa3a2f377de2bcd48ea0bd0f63b8ff00c322f377de2836caef957feb1f7a4cc92577cb49afe71f7a00a22f0e4c0aa130255d45b249186549a8e9d52b5f0869222003cd9d3759b1364b4f24ad948686d0d115b0622368a0da079001164d1df71b96ba00163cc7a04d19a24a2b44b8b631c01693cf657364ccd0e1cc5ae64c333ecf92d4c91ed8d8044e7803f3481ef289634ddf2409d551c690ff00e99ffb6df8a86596bf20efa5e11175f8a974a832cbfa03fb612f126ad201f581171738a4a68d728f62a5d2ce0eb037eb3f821c598ff82dfadfe08b8b49e80216a9324c3fc167d6ff0004a659f942cfacfe082f2eea5290de6015467c47e862facfe094c93fe863fac3f043173837a009481d15264c41ff000a2fac3f04a5f88fd1c5fb67e0862e21bd15658dd742ab3262394717ed9f8252fc47e8e2fdb3f0445995b5cd0cade8aacf88af521fdb3f04b9a7f9b17ed1f822af200e4934555ce7f362fda3f040ba7bf562f69f822ad3a204aa4bb104ed0fb4a52e9fa43ed2a22fb094959cba7e90fb4a85d3fcd87da5516922f64b7aaaf34fcc43ed29f30dd410a4a17a22e3696d014b7a28494a5da52a05a0144020ae4398af49d8ff00d99179bbef15e71c17a4ec71fd19179bbef159a39383ed6e263b13138e8267b7d8e217706ba85f39871262edbc5d1d3d265fbe57d0701209606df44e6eb7d45f4530695606a391698565bdd362fc151c46b70f24cf83265d81209276e4b639b608048be6392a461e98f6995eecd7a9f1e688a219c3e095e6001d183a75fa148313c6c1cb318981cdf55a0ebff85a99031b9bba0e676636374c216084c4d6f708ad34d11a518625cf78730777f3becafb3ed5a152dc3c60b8c56c79ddcd367edb5382fbfeb52ebe0df8222e59f17398212f6004f8a8e8dcd201c4cb676f57e0a9921648471310f751d017377f62261992cde8664908cd7f9ba903c524134ae8e6739c1c5a48008d011bedba8fc131ce79749292facdde1cb6e5a140e1e363cb84f28706807bf400e5a522abc36227964709034016e039f2ae7a6e8c13ccf91d9db4ce60b48cba78a63008c93c79859f9ffc128805b889a717a9efff000411b882eef3402090329d0ebcd663889bd2b21e2642e3440006ead7e1d8e707ba59496ea1c5da8fb150ec0406e4e24c6f5278a75f140b362312d99c2371a2f01a325fdab75e82cd9597d0e3759324c43b7f945042d680193cc1a34f5d068cdba42e1cd5062ff9d3feda4e16a471e6d3fe620d3981e629292b31602ecbe912e6e9c450c35fe34ffb68345e9aa5d3aaccf6c6cacf88901f190a863006b34df58515a094a48eab3ba30d2334d36ba0f943aa431b2b37164adaf8a511a4b875409b59488721799df946eee29a0835b14979257bab7a94a86346bd10fa153c06fcf97eb0a46c71bcd36490d7491c98ad04f9a5b59dad81ee2c64cf7386e04a744bf205f904cfcd645714f2fa554c69b0949558858e0487c87ffb0a1c2683eb4bf5854aab0ec821685e8aa25a00f540a968215ddecc9f26058de85df78ae10dd6fec97997001d980f95947b2470508f10f2476de2ff00dc4bf7cafa0f623f342d1ce978078bedac5ffb997efb97bbec67531a16796fa7a10dd91aad8a31ea1355ae8c2a7021848de973f0d24bc49ccb20cb90386bb69d342ba94850bb2066daeb5511cc81d2c8313df69245817b69e4afc3b1b1894e63eab76601a91e5aada9649046ccc413c800374573600ec3b9e2690863459ba02c9f2e43447d261f4871333082ca69cc34ebfcf82738c8647534b8d8b1a73abaf3a55b715138b689ef069bad05ed7e6831c123239220fc4802cbb2e61434e65074d1f144867664cd596f9deeb64f8c6412358fb24f4e47927926113039f7949036ba24d04157a7e18bdcde2b065e77a1f25871b33647973710d6b280d1c3f70ba5b86258e90c60bb30be5bd1d552719f8cf0434ddef7e16832cf8b8a4c344c64fad77aceff0067557371d03638da646fcd3b69e27c15b2e29b1b807e6162ef9248e712b5ce6d8cbb8235dad066c76222930ce6b2766a793b52ab8a56330a63e3456e1f3f6b5aa3c4f15d595cd159b523f72466299248e6343f4e794d20cf062618b885d2c7a5001bcca31e2a38ad8e99849b7d83a7884cdc744f9787643ae9038c8c3f25171bad02066e370e581c65636c5d13a854b717135e5e646d487afabd11971f1452be22c7b88ab23c55b34c21607b83883a683541cb8e48c62dcf74b4dcf64871d4787c1747d330e4b8711a00e77a1f251f8834d2d1b8bd792413bb9908acd3e2227cd99b2e8d02e8ee3735d4aaf14e64f3b5f1ccc01cddcdfdbd16fe33bc556f9c8d74b456573a29628809c8ca2bbc36fa3aa0248ce1e26b240c3a9b2d3a79ad0312ee83daa1c43ba0f6acea324af6498731364b717eee156073530b332112de7249baca47f02b519cd6c1012b8f44d05d898f21caeb2741dd2b2c1306e84ba9ad20774fd8ad7cd235da1d1033c80586979e80ad419f0a1b0caf739c4e62766edf66a9626e5c539c5e72d5d88cd1fb345bdcfc916670234b206a921c4f118e7006873ea811933733dc730cc450ca55e7cd5306344ed79008cbe3ba58b12272435a68734169d90b50a1ba888a6c94e886aa8b19a95a7b01e1dd92c7759663ff00fa39530b7bae71d80250fc16933fe0fe19df39d21ffadcb3563ce3599bb5f187ff00932fdf72f69d93a342f23133fa4f187ff932fdf72f59d9a6834272d57a489ddd0ad5443ea8578f35a730792d6921a5c7a0dd526692fbb8598f9960fdeae26813d163c362e4978c5ed0d11e80ea069d4d7f348abb8b37fed5c3ce46aa316ec5be073638e2613f9ce92fec01261b1933e194bb5735a4825bddb0b36131d2e204c64702d659d4501af5e882810e2992076584d1cd41e40cd972dedb5724830f88f90cec81c620d17988248db97d8adc3e2a49257095ad6024968cd64eda0fe79acb85c44e71af12b9fc30486e6a00f975417cf1cd2bc3dd0c59811fe33b6f62937a53e011b2381944107885db1be8b249da0f6e29a03fe4ec37706f5ea927c5e21b8a9d82c3411979e883537d30621f23840e0efcdcc411f4d6cab2dc4094482280bb35b8e7367900344b8a9a76bdb9080da0e3636498a9e4898ccae198b2fd6a37ec281e76e22723347164e99fdc6b44b00c4c4d782c89ce79b2788472a1c957262a764111ca43892d37add7ef423c549e88d91c0e6cf9493a5f440f0b3151000c70101b97baecbfb92c50e2639330643b556776d76acc3be4930e73100ea03ceb7e61289243832f0e0f790487501a75a414330d3c733656b22bbbae21a3f6267c3887cc1f922001b1f287c3c13e1a595e6a415a1af148fc448c8c92467041232d8cbcc823708049877be57c8e8d84b88d0ca7e0b4093100691463ca53f059db8895ce900361a4d682eacedd7457be4cb8532023d5b06abe9a414cc24d0b8513d0daafbde29a092571b7d657026c6ba0d9188bf884c864a074baa1a73a4099ced9bed41f65ab3e1e791d8f735ef718f5a15d765731f29c4e52f340ea387ba295b6a1dd0124ee91d94b69aea1a7d15ba699f20c5060200d0586ed6a206a8f7ab4b5267c825c8c1b51dfcf757078bca7d7ab2028ace4389e699ad7ee281ff32ba8f8a603c15143d93c8dcb9a31d743aa46e1e66c663124618796537ed5afe84a4d20cd161a488119e2208163273ea84513a271ef3083560037ef5a1c5567c101b40b9452900bd54e68d234817112f07b3712fbacb138fd8acfc1415f83b850391907fd6e5ceedb9385d8d8aff3332fb745d1fc16feefe1bce4fbee59a3951b2bb4317fee65fbee5e9301a10bcf81fd218aff005e4fbe57a1c0f25795af4307aa15e2a96580e94b4dad308f6e7616e6736f9b4d11e4567f43635e1e26c402d6e5152569e4ae7c8d89a5ce3407812b3bb1b0fce7fd5bbe080bf0ec0c7378b3969bb1c53adeeb1c9868ac9cd2dbb7f9576aad93191722ff00a2377c166762e3d7f29f56ef82293d1626076532f78d9f9476a55670f1ef727d63be844e323bfcff00ab77c15671319fd27d5bbe0888ec346e20e690e5363e50e852fa347de3720cc6cfca3b5287a447ff0033eaddf043d263e927ec3be08a67c0c77e74bbdfe51da2538763ab33a535aeb2153d263e8ffab77c103898ff00cff56ef82297d198d68635d2868d8711c87a2c74e01d280e366a43aa6f488ffcff0056ef829e90ce927d5bbe081461980001f28039710a1e8acc99334b96aab887644e223e927d5bbe0a7a447d24fab77c100f456660ecd26602af887643d0e217f94d7fce537a447ff33eaddf043d2187949f56ef8204f43848aa7d7eb945d8769159e5ae99ca3e90cf9b27d59438cdf9b27d5940be8ccbcd725d55e73b2070ccd68bf5dfbeed53719bc992fd59478a3e64bfb05053e89158395d636efbb4fb50f458b4195da6a3be743ed569985fa92fec140cbff2e5fd84159c2c27769d35f58a5384809cc6305dd6cdab7886bf2337ec21c43fa197f647c54f28a8e12070a74608e84928fa2c377905f5b29f8879412fec8f8a1c477e825f60f8a87929c345f307b4a9e8f17cc098c8efd0c9f67c50e23eff002121fa47c5500e1e2fd1b7d8a7a345fa267b11ceff00d03fda3e29b88ffd03ff00687c51553b0d165be133d895b0c6c36c635a7c02b1f2bcd7c8bbf6826607385ba32cf320da04caa52b29421445748e89a90caae1ae37e1210dec8783cded1f6aec7e0b7f77f0de727df72e1fe159cbd9918f9d301f615dbfc16feefe1bce4fbee59ad461ff00f90c57faf27df2bbb823b2e0b8d76862bfd793ef95d9c1bf65615e82076a16bccb9b0bf40b66634b6caccde2aa2ed772a172471eaa0c58cc6be19323580e9799c4d2ae6c518a363b292e70ba076e655b3e1d93b8970d6ba7b3ff000a9970e246b5af71245d9a1a83c9109362786c6bb29b759abba035290622e0e2115d6ce9ed4d240c74618e71207549c201b9733aaef74022c48918e751b6de808d4258f15c43ea38372e6b4f1c4d630b333883c8945ac6b5e4b74b155c82ab15438974af0d319162ef9051b882e9cc61b40680970d48df9ab190318e0773437f7a030cc6c85e1ce077a0740a291d3bc4c230051ad6c955cb8c31caf61681948164ef6af742d73c3cb9d986a28ec94e1e373cbc8d4ee7f7a0ab118b742f0d01b440209b56990885afeb575aee9b84c24b89b3596ca1c105ad6f11e0015a1dd024f318630ea3afd8965c470f0bc52dd48d05eeae3136401ae25c3506cee8185a5b94b9c6b6d75082a6e20bb0fc40034f436978f27a3bdf4cce05e5b3a2d023634656fd1e1e491d135ccc87d5db741970d8a7cf0c92168ee9a001ab4ccc4b8c4f7b9aca68d007ddab9b8789a1c180343b7013370f1f798006870d5a0a0cf0cee95c464fe4290cee95ee6e4d1bcef65a99132320b41142b7d92c71c6c738b477b9eb68288e6324941a72d7cddbcd54cc43def038740e97d6ff0082d4228dafeeefae81c80643190006b4837575aa0ce67904ec670fbae716935ee4d34ef8e56b0349d75d3952b8c516707286b8f4356924661f377f2e63c8baad05334cf6bc868234d3bb75a5aab133ccc7b1b19dd998e8b638460383e8070d6cf241cc89cc048696baa8fbb55064c44f2c71465beb165935a27333db876e84bcb75d058f15748d81803a4ca000402e37a205b01634b9a0b771af241971b3cb1e1cc8c01a43a8e6d687c5598495f2c01cfdcf3aa055d2b21306421b90ec3ed4220c11811d64ad2b6543040ea8a9bac80a688a0aa3cf7e16ff006741feb7ff0092bb9f82dfddfc379c9f7dcb87f85dfd9d07fadffe4aee7e0b7f77b0de727df72cdf6b1cd7babb4315febc9f7caea6164d9716577f4862ff00dc4bf7cae8e15eac57a2c3bec2e831d6d0b8f857e8ba513ac2d315648698e20f2e96b0c45e5efa1d45300d0d6c56dbd1648b191ceecac07cc90a8cf851896b807925966c8aae7bf3e9ec5435f2676fac035e1a6f6bf82d9e951be5318736c1adc6bd69677629864c947d968314b2cde901a4111b1d65c01d3f82dad7873dcd00f77991a155bf171b5e5a4ea0fb3c549312d88805af75ed4345154c81fc62733acb801974ad3409a46c867b05e19409d34d05d7b53cd8c10119a37f780fa2d5d1c9c4607557874560a1cc7bc465ce35909b22c93e499ec73b0ac635c439c07e6edf0564f88640db7f4277e9aa1e92cf4713512c34371a22aac8e9a0631ee34e71d72ec10c441c1c0b98d7baf950af72d2c933459f2defa028c4e7491871665bd85de8a0e742c78c038173b35eba1d07952b9ad73619996f2fdf6eab48c430b5eea3918ecb7d4f820dc435f139f94f74591482bc30219c37b48774234aa4638c359216332924ee2afa2b60984d7a65214120744e781757cf7a419a08dec0012e3dd02cb6bf8a18764bc4713986e756fadf057c18813970a1a78d92a45886c8f734348ad76abdd064c2c12c3202f2e73751b78dfb3c516615cdc536476506acf74f33d7aad1062db31eeb5da9236d057529198a2f983386d1b5f7af741631ce7839985841235e7e2b2c503c4ad710e246b649d75f0e6b47a4b0c81b5ce8d0377c920c535d370c01575eb8bbf2419e3c34cd782fcdabfe772def756f04f1750ed5e49234155f151d8c70c5709b11abae775d692cf8c923c4089b183decbada09342f74a1edce3f3451ad3af92691878cf716c8eda837635d53c989cb398c34b88ab202ab11899a3791146d700013676b414e2b0f2c92191a0fab96b36be29e489e787424a6803ba40f7ab711893001605902bc75e4899aa48db5dd7835d414086379c3b1b96cf76c5ec93131be56e560d083cc7b8a6c4621d0bc31ac2eb1f6f2fa3749362cc70b1ed67add4ede2a0ae5c33e4c3323aa22f4b040d3c13e130fe8d008ec1e668526931059876ca40d75abe5f156c76f8dae768ead551294a4e02941650842149eb540b5079afc2ffecf83fd5fff0025777f057fbbd85f393efb9713f0c07f47407fe757fd2576ff00057fbbd85f393efb94ab1c190fe3f8cff732ff00dc72e8e15daae6487f1fc67fb99bfee396ec3bb6562bbb867552ea42ed02e2e19da85d585da05a66b51268d7458a3c33607e66d697e7b755a8bb45c88f153bb14d648e219a9aa15b9e7ec5a45fc27324748d31e67eaeeef8f2fa157e8cce26721a7bc4d56d6155c599f8b2c1200d075d3905a5af6bcb834825a68f81414bb0b66437de7ec6bd5525c2895d9ac695a10aac53a4f498dac71034b687566d56d3c4b1918c22be7ff050512610ccd687bc586804e5d6eef4e8ac8e074623689086b010455075ecaac419c3e20c02cd976ba6df6a85d30c2c44b835ee075d7415a5d5a2b44b08906a48abdab9a56e18863581d4d06fd555c72bc60d8e73da5f744e6dfcd186491f8375badc0ef7b79a0b9b096479038e5aa6f51f156471e48dadbba157d556d04097e5038035a5efed4b0090c5331ee36d69d86a09b4558cc3b1a64fce63cd9691a028b206323c99457952789a7851d937945df92b32a0cedc3b23616b3bb7cc044c0d3980240737291f65abf2a995066186c87baf3554410289ea959838a336d6d1aa3b6ab56553295114330f1b0e60d19b5d6b555b306c8e46bf3bdc47ce2b552148acc306c1207db89041d6b7f34461c094ca0b83fade95d296852b9aa333b0edf49139bcf55be891f848defcda820de9d56a22d4ca3c5067761d8fd4de6bb0e0751e48498764849717596e5dff009d5682d432950677e19926aecdb568e214187607b5f44b9a32824de8b465532f8a2333b0ec73c3dc0661a029b80ce1b59472b7657d521c9067e0b0060a34dd85a2d8c3450baf1374adca85520af2a05aac41026540b55a81083cd7e17459fb1daef992b4fb6c7ef5d4fc15feef617ce4fbee547e12c3c4ec2c501bb407fb0dabbf05bfbbf86f393efb962ac79e97fb4319fee66ffb8e5bb0fc96297fafe33fdccdff0071cb7618134916baf873b2e942e2b97002ba31580b6cd6a73b4a59a46b091dc69ad468ac7148355515bdac23bed1946ba8d026696e42f34d6dea4e949a485d2464300be57b28dc3bb84e0ed49db2bc8fe4aa2b64b04cc7005ae1b90ac85f1bfbb191a0068720961c1ba389cc91e1c48acc09343a51f7a7830670e18065ca1b4459d3c90164b1c8fc81c330bb1d14188671782090edbc07d2a4785744fcc0de636e05c74e948b3b3d91e244ad3cec8eaa294e2e0648184d973b2e834b4d262638df96470d3424ec34b41f8191f89e287b036c1c86f5239ab5f84cd266cd40b8135a20576218d9046438bc8ba0107e2e28c8b26cb73556b5e49e4c1365943ddaf76893bfd052cbd9fc6734b9c346e5272ea3cba22964c5c71be36b8389905b6a931980838b468e806964a33604cd92dcd05ad0346e9a74d53cb8674913439ccce0ef974ae9ba0064023e20b7377ee8b41f206461e411668076853fa3d43c36bf2d8d486efe28f07ba017175106dc020ae195b33039b75e48cb23616e6793575b22dc3e4635ac79690e0491f9dd42b1f1870aa17cac5d2233c78864a1e5b5dd1675d7f822d7dc6f751eefc2d58200d63981c40757ba92b30c18d706ba8381b00697d505314e256bdd94b4305ea7754613b41b8a76511b99a122f9d2db1e17846dae1948ef0ca353d55186ecf661a69656b8b8c9c88dbc902c58a64ae0d00dedb1d0ff00e10662d924c226b4ea48bf6abe3c2b23c81a4f74dea01bd292fa1b1b2078710e1ce87d3ed4153f16c63cb08768689a5a034d6c91d85639c5c1c438ee7aeaac6b035ef76671cc41a2741e483362310e85ed0230ebe7afee084d891086d81645ec69593619b3105e4f74d8140fbd07e19b239ae7b9e4b450d6be95024f89643107d1ef5d58a51b307c3c4008b342faecace0831b9ae717975db9d57aa324624001739a07cd3486a8f481c164994d1a07ba52e1711e92c2e2dca41a215a2060664efb80aacce3a5291c2d889ca5d475a26d0120da149d440a94e89907744464ed187d23b3b151737c4e6fd8b2fe0a1cdf83b853d4bcff00d6e5d32db05bd74587f07a2e0f63c718d32492b6bca472cd6a3cdc9ae3f183ff00932ffdc72e9e19ba0588c67d3f1848ff00d4cbf7dcbab858f41a241ba066816d60a0aa819a05be38ae969194825579e46ccc63584dbb5d395782eab30cde6131c0c0e24989a4937b73fe42a9ae7bcc8d91c1a0d36b901bf9ab310e7c4f8c31ba6a4ea392e83b0ec7d9736f6377d3651d031e0e66837fb9067680ea163355d5ea15188748d706b05684df52b7f05a1e5f94662289ad4859e5920b78900b6db7bda5e809af0d95563c462258c401b9733ef350200a4679a68f0b1c94733b7ebe0ad123a492dec61034a216b8a389d180d6002f975517f1c73f8d2ffc3ccc5c038bf4ae97ef56e699f1b8820381164ec0735bb84cd48636eef6e7b7b510c0d24b5a013a9ae688e746f788a7739ce194076671baf82d71b5c226e720bab5206eae6c61a5c43402e3674dca9950574a52b32a1481292e556d052911501aa046aacca81081294a4fc902102209ab44a8a5295390948d50040a34a2210a5a4e50ab514aa224208809484e81d902208a08a094ee9f924210029b01817c786218d25a6491c0f9bdc7f7a53cd76fb3bfa8c7e6ef795291e11d0fe3f8aff5e43ff515d3c347a855ba2fc7711a6f2bfef15d0c3c7b245ad3047a05ba3652ae162d6c6ad3276b53e445a135223176831de8aecb9b71ab4d56aabc28963c3c64c67beffce7171a3cfc17454415e55e7e57367c7e38f040898d23895ebb80dbc75bfa42f46b3cb87606f71a0346b43da8d737cb93d95086f66c0384f8c65ba90dbafa9f15be2b63c0374744d641a37aa478b3cc91aa8eb7cb5d5f2d51ca99ada68be8a2ae34b48654e774291094852752905795023c15b494b7c5056810acca9691484252ac2129081292b9a9d0282ba4a42b4848a295294e42055425288a8814a54c45a5500411510211a2098ec95140a1c942a2052bb5d9c7f118fcddef2b8a765dbece03d063f377bca8479e745f8dce6bfc47fde2b7c31d01a257477899b4ff0011def2b5c2cd8aa55d132800b43425605680ab221bcd1454a4014595d8f63711c10c7121c1a4f9ab5d3fcb98a36e72d16ed6a905aa7259311da0cc3b62b6e67c82c32e88f329a6c63618e37f0dcee20b036e481e48731b6ba92b30d95c0b9d987448dc666c3b257332b9e68337fe79a78f159b0e657c6e606ef9abe9af246e5abcec979acedc735d14ce70a31d9cbccb7915460fb5062a7642622c7b999aaf6465d0a4157c5938c2373236edad937e1b6ea86e3dafc7fa3340343520ede3e488d482c93635cc9a46319ea90db734d59f253138d30c991b19243335969af8a0d681e6b34f897c3144eca2dd798969a1a7d0a4f34b160c48d60749a5823f7041a12955e164925843a5686bece8055744f210c639c4ec8a8815930b8c76233e601b405559b34930d8c9279180860638917cc20d9494eeb9f1f684eeed1f472c601646ff00bd3c98ec98b11d0c9b1b06c9f041b0a42b26331fe8c0d0398d65b15e28cf8891ad6656d12ccc49db96883490852ae39c398c2fa6b9ee2d001bd54c53dd140f735b6ead350114c82a61c4894e52cca689bcc0f4e43cd66c0e2e5c44f2325229a39339a0da94accdc4ca25a7340693f9ceaca2d5788c44d1ccf6026b5aee8e82bf7a88d8a15971189745200d6db72824dd7309e67bf87198eedfb0680795f32105c90a31e62c19eb380335722a1452209944348576fb3bfa8c7e6ef795c52bb5d9dfd463f377bca832167e3129ff003bbdeb4c6d40b3e5e41fe73ef5731bae8b4cd58d14ad0344ad09f92008ecb1cd3ca3142289d4681f52eb5d4df45b2c13a1048dfc1065381667e235e5b267cd7434e555fbd3be00e7c8e1216978ad00d10c4cb234b5909ef9201ee5d2ab1134ccc34661717c8e3bb9a01af2e482d9705162082ebb1a5f87ee53158366298c639ee6b1bb068fdea89717343838dee01efb398f215cb4e686171734b8573dd45c1c1834dfed41abd19a226476ecadf2d506e1626b1adca1d55a91b91cd5584c44b231eec4646b41acd75446e2b97b79ad12bf851bdd57945d75455630cc6e1df0ea5afbcc74d6ff009a420c2c58724b06a401640d878ac787c66227c547196d47a92efdde691b8ec43b1113016373b9c68dea361cb641d40dcae7104f7a957e8d1e6cddeb1cc38837e6b262279dd398b0e7d57806883fbd5988926e2383096b58cb3ad5fbd05c70b1673251ce4825d7abab607c10930b1cae2e70398e99af503a0f059f1b34f14592373449c327578dfd9ba71899198589d25f11db83409af0e882e92064afb7d9e596f43f4251876640d717380d893a859e2c4487039de7bc4d03744f873d7c12433c9c3c448f7134330bbdb957fe10683868cb1ac702f01c1d64ea48d45a77b43985a4917d0d15930f3c924cd25e0b6a8ddd8ad4f4f72d6d9192c6d7c6e0f6385870d8a05e1b5a1d5603aac5ed429208626bc3c31b980adb6589b36238e6dc00b398069750be4abc54988f48218640c0e07bb42c74d515a8e0a16ce66b7f10eb79d33f0f1c92f11f79b4e7a69b68a8c6cef8df1800b05ddf53d3c9263649c6183f21601aba9fb20d3361a199d9a460711d5218232d6b487536eaced6b2cf24f176731ce7063ac6ad3ae5fde53899e2398b9c416b41bbafe4a0bf84ca8c653dd208d79d56bd5191a1ed2d75d1dc5eeb2e1e795ee9811744900558fb7aac785c5629d8f6b1cff922d17dc26cf9a0e9b218a3043236b01df28ab49c08ec1c8056de0b1b311882f7b83db9584de97449d88bf029711362998d91ad36c70a68e8836f0181f9c0d6c9f3bd3555cb84865797bd84bbf58e8b2e2f132b5b86c8fcb9da6ff8956b6671830f2714025ed0ff001074af3d941798985cc7106d9a0d5030c61b972e979b424514b8a73db182ca0330cc49aa0b3c72621d039ad70328acbdcad39dddea8353636c77941b3bd926fda83967c39938878841b177989bd48d06c1682894aa28a2215cbb5d9dfd463f377bcae23976fb3bfa8c7e6ef7946cee6d4aff00d63ef573455205bf2aff00d63ef4e02ac0845054e28bc61e4e1fad5a1baaf140cec3c6e1de65fd275bea99b13237bdec600e90e6711ccd57b82e761048d99cf99c496465c6dc4917d42b3073492cad266638b8667b5b4685683426b5283a194388247aa6c25922649799ba9e6343ed5436364b8d73839c4440580f341de23cb92cb8a6cf99e1ce2f0f7775a49005ec01ea83a3c2610d05a1c1a2803aa4f458444226c6d0c06c01c8a4a7bb1a007b9ad636dcdad0dd8afdfec5a505270d139ae698db95c29cdad0fd09dcd0f0438020f229aeca08aa9b87858f2f6c6d0e37aa3c28c1bc82ec1badab656724a811f0c727af1b5dadd90a3a38e4b0f6036003637ad9394102b9a1c29e03874212f062c99386ccbd328a4e7740a05c8c68a0c68177eaf34b1c6c88111b1ac1fe51498eea5a0411b1aeb0d00d55d725053400d0001b01c914a81386dcd9b28b1746b6bdd0746c75e6683757637ad93a0811d1b1e417b1ae2362420e6309692d6db7d5d36f24e521409c3633d56346b7b73ea816b492728b3b9adfa224da08a56c6c63ad91b1a7c1b4886b5b5400a14291281442b98c70a735a45dedcd42013988048d11bb4bd5055c28f286e46e56ec2b40886b1a5c435a0936686e7aa6290a2c434451d900d6b6f2802cd9ae6545110940114068287920533922827253929c903b210a775dbecefea31f9bbde570f72bb9d9c7f118fcddef2a34d6efca3ff58fbd0b45ff00947f994ab4e665343b808228a942ee859dfc5334b4680840921a6859e8b9181824931ae95d15b438badcf3a1db4d35d9076baff3694d12341a2aa07bdf1e678a249a194b685e9a1e6ad415b6789d33a212b0cad16597a8fa152ec6e599f1cb118da1a5cd7170362ebe8dc2e4e37098b32c92b9ef6c2ecc3203ab9ce7002ab6000b2a60f02063a713c8f91cd602059ee593544f3d3746e72d7176909e62dd2365e5617380321e7437a5d18dd986ab81d9ed9668b2458568844a1c6473725969b209d49d762176d8fcb201d545b3c2f4a892955734286c98ec9104409452a088224a5b410ec9495094a50f684eaa6c81dd0bd145a8529452b8aa852828a22820512950052d448e2a2813a940a881440e8a7241154294a89e69540521298ec93922c02bb9d9c7f118fcddef2b86bb7d9c7f118fcddef2a2c6e7fe51fe652d267fe51de652ad39a22364111b228a282081ad04114193b4240c85a4fce095997d6000277f155f6c36f0ec20e81cb1418b688cb5ced687d3a2cdaf573cef3ae997f741d00f1549278d19077700a8762a20d20b811e21360736271067711c38f6ad895752f393cba850b58311dad0c4fc8c1c477b95d06284f60368aae378bed792a248ded918d7b08735da83d5314610f825514400a5b45c9094052909929dd00ba285a9cd0500296d1294aa2285040ec8b0093a204eaa1d903ba2a14a5128150214b689411102876285a17a14010452b90817694a84a04a285aeef66ff518fcddf78ae02eef667f508fcddf78a0df27e51fe67de95349f9477994b6ab02a02a04af716b1ce02e86c81f30ea1406d70f0a71188c531af90b45990e570fdc3c82d52c85d889837574645968228573ebb9f622ba360ec6d4bb160823cd72f17887e16384332e7ca5c3ad9e7d2b556601fc3c1991ee2e739c4969366fa79a0d98884622131bb63b7815e6e5ecbc73652191e602a883a15de81e628651296892325cfb3435d6fcbe0ad64b9a064ae0185cd0e3deb03e952cd75e3e5bc7a79f7763624338b2491c6d06dc1cff006eaacc0974584739b2bb2cc4b8455a368d697aae8b993631ed9192359034db039979cfcedf6e8b8537a4b71030a67ced6b067f922c2353a03e3d566cc749f25eef9174ec74ce6b41394721a7b50c4cf2e1e23c194b2570a61bd6cf4592389d2628b5c246476729162881b74af1f057b9a621f8bb19c4bd5cfd4d73d77598efee3d0603131cb0c6c8a39435acf59cda1616a1235ce7b41ef36afe95c0ecac6cf139d03e27cb2bac8ca29a4f5be4176a08cc4d73a470748f399e40d2fa0f00ba478fe49f8dc5a949b52d02ab985a1cd14b74809296d6377683219266e20b464a23876e24796fa230e30cb388cb400f6e66d3bbc0722e1caf920d691c573ffe26238f1159b12e84919a36e86ba9d852bf0b3be76b8b83080400e8cd83d45f3aea862fb4173269e618dee39e184e5eeb4ec3ff00ea755d1cd6d0688be4451404a526957c52710f8b2e8d68766bdeef44dcd146fcd0b5821e21c5677190b0bde059347edf0e8b6da024a4cd66ad42b1b886f68eac2cccda040d243bea7c106b3b253e6b3c98bb770e06f15fcf5a03e95cb0ded49277be4c4ba28849969a1a40bdbc54b5b9c6fb76ed05cae0bb89c3ff0089e27899b2fa82afd94ae1076843f93c53271c9b2b28fb42ceb5fc7fb6ed9212b18ed1c8e0cc5c4e809d038ead3f4ad5608b05566f379429495094b6ab284aef7661fc423f377de2b804aef765ff0067c5e6efbc541ba43f28ff00d6296d193f28ff00d63ef4ab4c181d14751041160efe29567c44ce8de1adb072937428f2af3d420d0d86268606c6d194d8a1b14c5ad21da0a76fe2b10c51e14923b30aa68a1747ad2af0b8d763316786408dada70bbb3d7c115d0735a75201af041b1b012e0d6824ddd73e6550252ec618ace56b2cf427f87ef5636663a47c6d782f651737a5ec81cb585d98b1b9aaac8d48e88380228815d2b452c6baaa78c762d00df5455d6b9d8bc142d96499ad2259ab33b3741a2d7c437e0a8c53dc72eca55e6e5d72e469638b413fb9672fdd6e7b1ae249581cd009aeab18f673d78d68c039cec7c66ec0df4dbe95dfb1c970304e2c9896d5d2e9b26796eff62d72f3fcb76b520b9d262e66ca5a1c281e616b6bcad3962d4aa664094452dc2c0c64ac6461a25bce5ba175f528c50450b1cd637477ac49b2ef33cd397247b9e1bdc6873ba135f6a28b5ad8d8238da18c1b35a28042c01a0a0a98a773e1e2cad6b1a458a75e9e3a2cb0e3dd2ca1a5b7985b761cfc7a20da40739ae376d363545c6c2e7e37b43d165e18666245ddff003e2ad9b15c36c5435935aaba15651163585b3caf3f9c1a07d09cebce9530ce6569ccc2c2dd1d9856a9f9aaa5e0b03a322c70f6a3fcda6250ba4a4a8092b9f2b5f88c5bfbc031a32937b0e6b4e265e0c2e7debb0f3e492284360e1bace6f58f32a56f99e369c01e8a461b2eadee1ebd173206c2c99b186c923cbf57079cadad7cb423657333473814f0e6ef91d41e4751e5aa9231b8c314b03dccd757b4fab5e1d565d27ed4e30ba3c4c8f07770206ba1adf7a4fda530e144496d1198875d7b46a0ad0ec2b246dcd4f7d566ad8f50964c231d473bac332017a0d143f3818584fa270a66687938d8a591aef4673df8571930e0f7a23bb7c5bd426c462a5e1470bc64796dbcf41d2ff7a3808811c61bead6e9cb9fb546fd4dad71c8c9981f1b8169da912b9ef23058932c75c079a91a3f31dd56f3b2dcae1d4cf30a4af41d97fd9f179bbef15e797a0ecb3fd1f179bbef1465be4fcabff58a55243f2aff00d63ef4b6b6c1951261d92ccc95f76c06872561721654153f0cc7c2e8ac86b9d9bbba7d0921c18c3b8ba190b6da41045df43f46ab40d94b408e8016c791e5ae63b30755df5bf34ec6e57c8e2f73b390689d1b5d10b52d154e21925de705bc9a0557d2b2b83f29df65ba46b64616b85855f09ad8f28bf326d07321e27106af3f4ab256c9a7acb7450b6237767aa67b5af73491ea9baea5067c3b7e48666ebe211923191d4c1b7409e1863c3b1c1966c924b8d929c9b1aa61b5ca923786e8c23c820d6c9d1cba8e683c926408bae618a62e2786e5b220fe20cc080b48d39a16869ad0bd3542d2da218b954fce47c9bdac3e2dcc9894b682b8a17c4d0d3317b40aa2d0154cc277e32f3ea32bbae22cddfd2b4e62a5a0cb3e09b34ad7b9ce3deb3af2e811961ee31919f54fe79274a2b4134abe6832fa2c9e8b2c4e7c4e0e1a0730d7d3aeab4ec05d2854450252b8a84ea81d51144d4f9e18cedababc962c53a68f125ed75802839cddacd9d762287dab5b8fe3ccbd470cfbd28c642f0ebced682412e690166bb73e156718c6be09016922f33345a9ad0c686b4000681550181ee7705ba0fce1eadf82b247f0da5d4481d0244ea848f2c639dd05a81c1ed0e1cd669b14d730b035d67a8d9550e2d91b4b48d01e4949c5c6a9a26ccc2c78d3911b8f258269dec6c8c734b218a8656916e1cb5e9e4b6b66120b034eb6ab9447c46caebeee848d47d2a62cb9ed9e1864cb95f08646f6e57b735df8a38273835f048497c26afab79156bb17107e50ecfa5f73559dcf6371d0ccc272cad319bd351a8fdeb2d7f6f0d97a2f43d947fa3a2f377de2bceaf41d95fd9f179bbef15b70f4d929f967feb1f7a5b4b29f967feb1f7a5b5a6565a16973219903da17e296d0b543da1992da199039769b94b6973204a81f3216abb53322acb09494b681281aed0b4b685a07b4b76a13a6e950325250252e6f1404b90b52c2540f6812954282122d02529285a03992927e852d071d11512dd251235ce735ae04b7700ec89d50679086e321759d416a4f41160823992728b24ab314da63640358dd7f47355e2a59439823f54805a6f73bd78a95d66df4918e0e2035f234b9e3d56b081e6b4f25ce8e498c9c40c329bf2eef8720b7970ad96627518f190c6d224a398e85656b4122cd0f05be68c4840269bcd0744c7332d6836f051a9de409da0619c18091974a34b9b820e9192c7dfa2336604823c9744b070b86f24b7c0d68aaf458980ba0686bb91bb558fbd71a28c0c734b6770198dbf990ba78f606c0c7b47ab235d7f4aceec2ba3901c8e2403ab7f9f24d338fa14a1ce7119da066e5b2cbafdba248d0af43d95fd9d179bbef15e6c9a017a3ec93fd1d179bbef15bfa71b3cae97f2d27eb1f7a54d2fe5a4fd63ef4b6b4e696a5a9685a037aa886e85a063497440b94b4110510b5442828a15152f44be289d9294049e885d216a5a039b44094b6a6a8212828a200a20a7340502894b680204a25294556e94b66630b3bafd03af9f9247e330e0e5e2d9068e504d79a9332532c6621d45dfab7cd3fa2b3d10e1f33cb79baf53ada2324200ed395808a0dcdb733b8f77b55f8994431170f5b9026923636c58b6b5974233649b2492ad962133729739a2ff34ee8aae19db8b8dda000e87bc0aab0d210e7e1a61de66ad2798eaacc361f8677793988199da6fec498f824152c62a68f51e214ae9cddf0bac0d072fb165c73a410931920817b7308e1e51897f1c7ab972d5ec79a18ac406db1ad2e2456836b5926eb219a6c4402f470ad89177d524a43fb364e238b5c1da1268dab6393878ae088f76800975d9099ce7c7c56cf0358e033539bfb8a8da9c3717d05ec76b2375bbdd598373b504f2f6aa703301847ca1ad199c4d0e415afc400c63d9940d45d6c912ff8be595cc735ad8cbcbba102964c5ca676e1a3aa7bdfa8bda959254cc8df978a01b22e967c0833e2649cb6a3612d66b63c52a7331d071d57a2ec9fece8bcddf78af364ebb85e8fb27fb3a2f377de2b4c2f97f2d27eb1f7a4b4f2fe5a4fd63ef55ad39994d125a3680928142d4400eea72514e4814a8828a836a5a0529514c4e894a88154d03a216a1414139a9651a5104bf14142a200a053995073400ee8523cd4400ee94ee9ca545401372414445196a673cf300278f219182434c2e01c6f6083b743922bb1e93c78a1618a0e135f4622cd18df9d9af7039ae538b1d8a2d3219230486b9c2b30bd154472e5d123aba2c16ade0b23c671628e324c6438175349d2afdca4cd8711361f346dcc1e4169d350dbe5b854d0ad964c460219a9f97249c9edd0a63a73d6f8af4c5d1707851c1867c6d99a72c91925fd49703bf3e884986c1be40d6b22c4b5ae2048796b742b9af12ec6cb8494c2ecb2b76d452e83250f666c95e16a635d7363d062638dd8989a191119892400336868127c692cd85c3be4803218ed8e2d7380d1ba13b1d2bc579bc462190c46a1040e56b13247e3df90bb86cd886ea4fd297c1cf36bd87686161c4606560618e221a4491b1a34a24815aedef4060a18f125ac85b1b5991acee8a2011640d8ef56bcfc5858a0151b6af737ba731308d5a0f9a466dfa7731b0b23ecc9ea36f75a0b4868b0736ba8e9b52d1d93fd9d179bbef15e6446c16e0d00af4dd91fd9b179bbef15ac65ffd9" + }, + { + "transaction_hash": "0x9b601e32cdd8682fdac06ea23e7af794c93b8b4e49538c90c3116e0cdf2e025b", + "block_number": "10548855", + "transaction_index": "67", + "block_timestamp": "1595950237", + "block_blockhash": "0x077fd69273760f74daa318e047bc97ea4d7f1ae54409e34734273d6561eb39ec", + "event_log_index": null, + "ethscription_number": "8", + "creator": "0xcf7cd49c66df55af4b6b3279474a03569570a384", + "initial_owner": "0x332f3caefcdf0361008c95088a132a605eac006a", + "current_owner": "0x332f3caefcdf0361008c95088a132a605eac006a", + "previous_owner": "0xcf7cd49c66df55af4b6b3279474a03569570a384", + "content_uri": "data:image/jpeg;base64,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", + "content_sha": "0x7ecab119ef87e736a8c5e6863de8593a1f41165c35e0d2717e0f9a48241ee964", + "esip6": false, + "mimetype": "image/jpeg", + "media_type": "image", + "mime_subtype": "jpeg", + "gas_price": "50000000000", + "gas_used": "118840", + "transaction_fee": "5942000000000000", + "value": "0", + "attachment_sha": null, + "attachment_content_type": null, + "genesis_block": 10548855, + "is_genesis": true, + "content_uri_hash": "0x7ecab119ef87e736a8c5e6863de8593a1f41165c35e0d2717e0f9a48241ee964", + "was_base64": true, + "content": "0xffd8ffe000104a46494600010101006000600000ffe100824578696600004d4d002a00000008000187690004000000010000001a00000000000490030002000000140000005090040002000000140000006492910002000000033737000092920002000000033737000000000000323032303a30373a32382032313a34363a343600323032303a30373a32382032313a34363a3436000000ffe101a0687474703a2f2f6e732e61646f62652e636f6d2f7861702f312e302f003c3f787061636b657420626567696e3d27efbbbf272069643d2757354d304d7043656869487a7265537a4e54637a6b633964273f3e0d0a3c783a786d706d65746120786d6c6e733a783d2261646f62653a6e733a6d6574612f223e3c7264663a52444620786d6c6e733a7264663d22687474703a2f2f7777772e77332e6f72672f313939392f30322f32322d7264662d73796e7461782d6e7323223e3c7264663a4465736372697074696f6e207264663a61626f75743d22757569643a66616635626464352d626133642d313164612d616433312d6433336437353138326631622220786d6c6e733a786d703d22687474703a2f2f6e732e61646f62652e636f6d2f7861702f312e302f223e3c786d703a437265617465446174653e323032302d30372d32385432313a34363a34362e3737303c2f786d703a437265617465446174653e3c2f7264663a4465736372697074696f6e3e3c2f7264663a5244463e3c2f783a786d706d6574613e0d0a3c3f787061636b657420656e643d2777273f3effdb0043001b12141714111b1716171e1c1b2028422b28252528513a3d3042605565645f555d5b6a7899816a7190735b5d85b586909ea3abadab6780bcc9baa6c799a8aba4ffdb0043011c1e1e2823284e2b2b4ea46e5d6ea4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4ffc0001108007f010c03012200021101031101ffc4001f0000010501010101010100000000000000000102030405060708090a0bffc400b5100002010303020403050504040000017d01020300041105122131410613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f0100030101010101010101010000000000000102030405060708090a0bffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a82838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3f4f5f6f7f8f9faffda000c03010002110311003f00d4572324839ee7d6a4e5864d451918c1c67f3a03160c0920e703b715e7346c4c08ed4e14c518e3b0f7cd3aa46397a8ab1daab54eb8c0ae9c3bdd19c875252d18ae92028a36d3248d9860315a603f346455436b3e7225fcc9a3ecf73ff3d57f33480b7914555f22e7fe7a2fe7479173ff003d17f3a00b54556115c8ff00968bf9d3c25cff007928026a2a2db71ea9f9d35a3b86fe351f8d003a691429079aa46a736b21e4b2fe668fb249fde5fd6a5a60414b537d924fef2fe6697ec927aafe66959810d1537d95fd57f5a3ecafeabfad166043454df657f55fd697ecafeabf99a2cc643454df667f55a3eccfeab459888a8a97eccfeab4bf667f514598c8a9558a9c8a93ecefeab4d78d93a8e295980ffb42ff0075a97cf5feeb7e550d2d17158a332bab2c8410abe9d8fbd4a4ef45700e4e39f415a1e5ab065600a9eb5525b6209193b4fa5672a76d8a4c4465db907e63c7d69ecf1a1c330155add5e2b98a0946579dadebc56835b46cbb4af06a1516c7cc460e71efd2a74195a8fcae08cf4e869f0be4953c30ebef5a52838b06ee3c7dea7d252d741025155ef24d91aa86605ce32bd40ef50f9be6410e492cb2853918a7625cada17a8aa62664dea8aa19a62b93d3a75a596e258df6001982ee384273edc74a2c1cc5ba2abdcbb04888cae645c8e945c4cd1ba220e58139da5bf41f5a2c3b96296a8b5dcbc058c86d81882a4e4fa71d3a55c53950718cf6a2c09a63a8a28a430a28a2800a28a2800a28a2800a28a693f3803ea6801d454684809e8454006ec83dcf7c9fe7c7e54016e8aaaa36c780401b86495f607da8cb0915499338ff0066802d5473ff00aa34fa64ff00ea8d27b01568a4a5acc65ca6c83229c7b521e4115a08ae54363d54e41f4ab04e4541dea453918a0626fda727a77a4953780e8d861d08a18557f39ada4e798dbf4a0459827de7638db20edebf4a9ea040923248b838e86a7a1011945320723e60300d34c11972c579241ebdc50f2ec9a28f6e7cccf39e98a9698b464660898105782db8f27afad34db4471907206321883f9d4d450164324892450ae3807230714d36f190010c70720ee391f8f5a968a02c889ada26c641c818043107152e3028a280b0b45145030a28a2800a28a2800a28a2800a6919e8707d453a8a006850140eb8a688941cf27ea49a928a008c4600c0ce3afd68f293180302a4a28012993ffaa6a92a39ff00d53526054a28a2b31977b52507a5256822223934d91fcb50fd81e7e94f6fbc6a3997742e3da8025382323a75a8678f7a11de99632ee431b1e474fa558228dc0a762ccb72133807391f8569d5144db7a8c3a1cff2357a840569ff00e3f6dbfe05fcaa69a41144d21fe119fad433ff00c7edb7fc0bf954cf1ac80061900e6adf42575295bdc3224aacccec13782c08fa8e7b7f8d4a91916e65f36466284e7771d3d2ac346aceacc325738a62dac299daa402318dc71f9517425164765931862b2825472ef907e9c9a74ee629a390b1d872ac3b0f43fe7d69cb0888621257fde2587e59a568bcd88c73ed707d011fd68d2e093b58ad13334903b8c990b30c9fba31c7e94e86e666f259c26d93230072383fe1564c68590e395cedf6a41046a1005e1395e7a517416657fb4ca2dbcf2170d8daa01e3278cfad27da660a3e51b8c81412a541047a1a9c5b421597670dd464e3f2ed4a2de3000c1386dc32c4f345d05a45692698aeddca196608480707a1f5a2e2393ed0af96385c9da1bf4e71cd59682260c0afde6dc79efeb51cb671cac1893c2ed1c03fcc1a1340e2c8eda1954b0908cecc6e2093f9e7f954290cd83b59f058b73bc7afbe79ab905aa40ccca4e48c7403f901517f674407de3f8a21fe94ee2e5d09ed94ac2a085071d14600fc2a5a645188a35404e1460669f50cb5b0b45145030a28a2800a28a2800a8e7ff52d525453ff00a96a4f602a51451598cb94521a07435a80c6eb475e295aa2f3e2071e6a67fde1480a08c62973dd4d698219430e86b3ae0013b63a1e6ac594995287b54ad048b4833229f4a9ea14fbe2a6ab02acff00f1fb6dff0002fe553bb044673d1413504fff001fb6dff02fe553c88245da7a1ebef4dec895bb20333a9762bf7635729e9d73cfe151dd5c4c9711244dc1c12044cfbb9c751f77ea6a74493ed123baa85202ae1892719ebc71d7deab5c69de7346018b6a2850648f730fa1c8c1a4510dacb766e93cc95da263c0dcb9e8dd7e41e9eb4d7d4e748646610c595dd1f9d28563c92318c83c62ae4768e92a36f52a9ce31c93861f87def7a8ce9ecd03c6653b5c1cc79f973d867aede9c7b7e1400c9efdcd899a39add5b7ed2c922c8a3f125466a26d559329232893e52bb5a3c1cf3d0b679cf6cfb1356e6b599e1f29272006c8277640f424104fe63df34c5b7ba58d1898de74c0e5ce08008ce719c9ce7a5004ec643348a8d8608847a672d4aaec6690ed63b6307602339e78f4cf4a8a4b4791cbbc9bc950021e10e3b9c727a9e3a7f3a92386484bb2b6f2f966de7196edeb81fe14010b5ecc2e234fb1ce032b1db98f27a7fb5f5ab71b33a0668da327f85b191f9122a04b693731965dccf83b80c1461fddebc7ff005fae787b35daa2ed8e177e77132151ec4707f2fe740115a5c4aca15ada661b9879995c753fed67f4aa57975762e9d206980040c2a1200f5ff5478ebdcd5db717b12aa34306327244c73c9cf4db505fe9be748af0c5113c97de57249c7f791bd2801f6374fe4ccd70ce4c6724b29cfd3ee2ff002ef4a97570e8136c69334a5064160a00cf3cf271f4a2d34f0b6ad0cea07ef378113edc703ba85fe54a9a7f940b432b093ccdea5cb381c631827d09ef4002dcdc4a5628cc4b28dfbd99495f9481c0c8eb9f5e3deacdbcbe7c092e31b8671d706a0167220568a755986edcec990d9393c6477031cd5882210c2910390a3193de8025a28a2800a28a2800a8ae3fd4b54b515c7fa96a4f602a51494b598cb46925916185e4738551934a6a9ea570b12c71ba1659339fc2b51191737f2ddb1dc4a27641fd7d69a8b91d4536d248246632a10a091cf18abac2c840cdb594804e3dea8929c3e74938581c6d5fbd9e456921314809aa1a646eb0b2e40766c9e7b7e15798e4e0f55e3eb59c8ab686ac4431522a6aa161264ec3d474abf4d300a2ab5d4af1bc2b18625db1c0047e20907df8f4acfb7d46796e963f3e13b9b18da9c7e5293fa1a606cd159371a818eee689e758820c2f283920609c927a9f4029cb76c2dee4c774b304c1598b46c0038e0e0a8cf5eb8a00d4a2b205ddebc30b463cc57e0ba2273d7a1f3319e3dc7f2a9aeaf5a0f253cc8637c067fb44ca848f4e33ce7db1401a34567586a06731a3c9685997a25c6e7271fddc0aa83587f3dc19a154c7cb9d871d7fe9a7f9f6a00dca2b3925b896d0bacad365ced6b6541c7fc08904555b6bd69150c493b9db96613237cdc76df803db8ebda8036e8aaf78ec9672b2ee0c1723079a413cacae7ecd24455491bf6904ff00c0493401628ac096fee040df3bab953801886ea3b14e9c9e83dbb71ada748f2da23c858b1241ddd78247a0fe42802d514547e745ff003d13fefa14012514d5656195607e8734a082323a5002d14514005145140054571fea5aa5a8ae3fd4b527b014e8a28acc65a359baf4124b6a8d102595b1f4078ff0ad234aca19083c83c56a239786c64054193e518240cf352eaa4436fbc11b9fe5c7b77ff3ef57a6b592d7cc9235695719007273f4aa735bc9a918d5b31aa2827239c9e7fc28158cdd3ae9d2748fef29e3dc56dab33124b1c7a7a5450e99059b6ec12fd8b76a9ea1b1dc9ecbfe3e93f1fe55ab54ecadcc79771863d07a55caa4055bcb769da265484b46db8190648c60e07a67d7dba1aad1db5f2bc7b98346841d9e70c7fe8b07f5ad2a298149ece4dd23ee32977385790aa80463a01cfe3f9d02dae441244d32b3b60f9dca93ec40f6f43fe35768a00ca3a5cce3f78d6f290a5434d1990f5278c9c8e08ee6ae0865d91444a88d02ee2392c476f61ef5668a00ab1c772933002311172c5b712c7db18f5f7a8ffb3c655bcc9812e5a4c4ee011cf4c1fa7e557a8a00a2b6645a18e482294efdcb1c921751ff0002209f5edde90d9ce042cb2233c4bb5548c28e0727b93c67d38edd6b428a00a774b77223c51c70b2918dcf2907f20bfd69c3ed3223a4b04214a9036ca4e4fa7ddfd6acd2d00644fa6dc496ed1e616dd9e0f1c9ee490738e9d07e157ac61782d56370a08ecb8207e4aa3f4ab349400b51b83b970706a4a420673de802370ce98c75ea376298a8f93f2f4ffa6ad562900033ef4011c803140c3827914c11ef5e36e1490030c8a9880c304023d0d218d0800a2903a71d2801a877a8f9b00a8f9476a6aa6f85071c7a8c8a9b0339c73485148c15047a11400d88fc9d00c1238e94971fea5aa4000181c0a82e64508533c9fd29302ad1451590cb7de990cc256703a0381f4aa925e654850412319a4b16db381fdead2e2b9a029bfc54ea6ff155014ef7fd7fe02abd5abdff0058bf4aad59b00c53a33b1c353add04932a1e87ad6aaa85185181e94d202150acb9a82ee2053701cad5ea2a80c5c515b5452b018b8a315b54516118c051815b345160b19914a538ea3d2ac9963f2f70fcaad52d3b0ccb772cd9e949938c64e2b568a560327029702b528a3940cbc51815a9451ca065e052e0569d14b940ccc0a302b4e8a3940ccc0a302b4e8a3940ccc0a515a7451ca066d15a551b428c7254668e503fffd9" + }, + { + "transaction_hash": "0xbb23ca1cb7f48fc499fad87beda307757599dedb5bdc09962aae807ebce32524", + "block_number": "10711341", + "transaction_index": "103", + "block_timestamp": "1598116535", + "block_blockhash": "0x87903a90225b79f0796acb89368fb9748317ba57f9bc2f6241d81a954955c827", + "event_log_index": null, + "ethscription_number": "9", + "creator": "0xd3b5aacf391534fa7e988472306ec66f82642656", + "initial_owner": "0xd3b5aacf391534fa7e988472306ec66f82642656", + "current_owner": "0xd3b5aacf391534fa7e988472306ec66f82642656", + "previous_owner": "0xd3b5aacf391534fa7e988472306ec66f82642656", + "content_uri": "data:image/jpg;base64,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", + "content_sha": "0x9cbe0b31e32f711e95c35fb805164df22f5c2260fb5afe686d20b0538d623657", + "esip6": false, + "mimetype": "image/jpg", + "media_type": "image", + "mime_subtype": "jpg", + "gas_price": "92223323330", + "gas_used": "298344", + "transaction_fee": "27514275175565520", + "value": "0", + "attachment_sha": null, + "attachment_content_type": null, + "genesis_block": 10711341, + "is_genesis": true, + "content_uri_hash": "0x9cbe0b31e32f711e95c35fb805164df22f5c2260fb5afe686d20b0538d623657", + "was_base64": true, + "content": "0xffd8ffe000104a46494600010100000100010000ffdb00430006040506050406060506070706080a100a0a09090a140e0f0c1017141818171416161a1d251f1a1b231c1616202c20232627292a29191f2d302d283025282928ffdb0043010707070a080a130a0a13281a161a2828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828ffc200110800c600c303011100021101031101ffc4001c0000030003010101000000000000000000040506020307000801ffc400190101010101010100000000000000000000000203010405ffda000c03010002100310000000fa0cd2229a4e614619e947b6641343d25e8ab335504609762f323d57e73c9f18980297f3240b6a78f985452e77aa934461d602f899b30ced99b5d704d0c0615c583375506999e29664426ab3101d534454d2404e9f41953a768d27a05c6940ec94fa9c5fcfdbdbf199e01083f5ca27139857199ccb3be680f52a871237338a5468add1aa10fded98a71fa8cefe6d2f78381088e7274b19aef6a27f3426369d9c6f0bf403b75b40acb8c1a0f50c34f3b3522691facbacdd395501a491c70442f2b4efd3575a27f373ff0036ca90928bf4e6aacb28de76f8d59b09eb3cc0e933bdcf0d29ae6e97a555809c7c9202368e0e8f35dd349642dcd3ed27e3452417702391a9b919e854e813394d702f7cf0ca8b3a6ed2734e4f91489fb246817bc7b85d346bd97b861e8164233f4753938839a6195fa341f585f858fa430f4679e54383faa72f3e9ab22f357672d0dbb5ec343fbdcfd3f35c34c6550d13b821b7bb05e709323e84ba67b7cfa2cbe30e9f4da7d22b7598cf3ea5f2f3ef13f0569d2f905b4a4807e9f110b1e62a9a794b47117338377667f465a05c342b9458be58699d07a739267ee511e7f47bba7af098bc725f4c8ef59ed24d2a358b068e9c8f855ea4fe709a4ea69b7cd5b9d42bccd58d6df4609fd51abb9a49cb6de7ee6c9ca8cea7ceafcdfaa7392fa573c7999b4d4e35cf7a08a09c43ac0ececf2a70416911874bc7d2877c275dd3e7d33a6dd335b7084cf98ac3b067e8f5f1292f51511d61cf45c3b93b0f9b75187739fd30c89d734b8e0446dcb5c7cfa30d2c1dfcd9e9cf0bf2d5133eae9553a2feb5575ae62f9571d35b94b99655e7dcf9e6986030551bd0d59c68a67a5f9f4ce2987a3a92fc23bd15196a2332db6758a126ad7061c33cd7bae6db9a24e773b09d86648b3dcf4112ad38be661e71baeb35b60aaa2ab3f5eec78a6f3b4cf757bf957f4b6820c38779aab53069ba5eb24ecd293a541e94791382a097f5cc7cf9f77357b9fac4c72a6bd1b67bcf6fe5f69184731e2a38cb4753a35350210b3a38761fbd48cf366f5a3091a717ac28eeb5477de7d57b065cf45cd7a6734f303705b94a54527e0b6964571e387cf539393dcf9983d30e683daef9dd5d38d3a545cbf9f21d244bae7aab89f150cd2f7be1ed3a5985f1242afb5992315c35995038e8b75359fa07b84f96ab35ca7ef3eab1b67c24ce3d11d9fd5d03156fa334b72b0238a00752283ded333e6cc6a7d9eeb4c960e5467b973b2fd72cf3bcb4844ada2de4752d21973af2565a663aa7c2ca50b973a0d39b9ccb99e42fbb150d73b76ee10f69a6ac779fd3cee39a1569a8c33d0d39decdcebbd33daa543d28c612e7460421059f0811532665babca59677a7a6d166d3616c3f604c8da34edba63296daaf0c2e5c0ce01027288304a738ef69c4669f6e646acfb4a0f3a2517db017d88c9599ebf47d60b344fc53b32aaccd52f70a6e7c0a964705ed388d1256540240516577301cccc4942f350f3d3f58d613ddcdaf2cb5eb4e6cff002b44d9b65ce16f4171c72daa035c9f1a24805a67a3b4400cd39e05aebdcf4508ffc4003010000201030401030304010403000000000102030011120413213122053241142351243342521015204381617172ffda0008010100010c02ab5482a57540bf9b865b8a4eaa223a14e3aa4e5e8f5531e69651963f3716a5af9a6fdbabf36a001ab5a858d116ab0ab0fc558511cd5ffc4a2a425e4a5628d8935139db37ee59b660b83e7a8d7caf1a093867d64a25563271a6f51dc5459971779579b1ca80f2bfcb39dc23e2f4bcd5c635139691afd3b61cd16bd3361cd7b96f5ba03daaff00ec96530f352393c657a8a20640df1341e21871526af6d5b0c4d2cc6494dd8979d99fc40b532f8863c15eeff1a494eee1581056ad7b9a06c003d0ee80a8b53119714ca849199312413332c752754bd54a05a94f1fe58e3cd3de4191ad3446f76a668e13e46cdea3acdc1fd52c666b0a9386278a88791b36470b9e3abf958745709e271dc9ac27154ba186459e252310f32848eefd49af58711831af4ed6a6ad1adc489a3da903ee5c6d0dccfa2e81fba71c52301c9361a8f51d30b8dccaa2f51944ec596f0e9b50ba98734eef4e81eb50d87165cb7a62c4658d65f8a6bbc96162d6db529cb0bfb41be361bdcf401125c9151a5a35392e33f71e3d7fcb97c69db6f16e6b5bac7992c7a604328c95aa30e9daae7e9ae6481896245a8357a8eacc3e298d4939661b86ed7272f85666cb83cfa74bb532bdaf5101246aebd56ad46de7624ba49271b6c29a14872cfce981ff00d09a32064a6d4b68e38cb761bf6ce15a96676bdae218fc54f028333359b92bc20b9a92fe2bf324b600039545fb767001e3febd3d4a249fd6a4708b735ac7dd6c8e58b38205b82f7058de82db10cd8d2f18fe617658c0f7d5c37551fb6a4519702f53e903709ee781871ee324022d3de615c0e5b86da0c01005f542e8001b6c517c7e2b1f28ea66b78f54ce0100791db93714eea64b17b85ae63b0efcabd3a5bde2b8beab509077ee9256924ce5e9c1e6423963f3d570cb6c6d4966437161a7e79eea2c30f26e64711724dab4fea49270cb6336a2384c99b797fa84ee59885150fa8b08ad25ef36aa49b926f41b292f52dfc48e2b5051c28248addd8b4122035f5329eca5e67bc973cd10d248338b25f65d42d964628e48eac0bfe46999cc8d8117924de91e57e1b1192e5c992e73b530c58ee7b8a9c915bb92210b20517302f1c562d76a90c9259ddb2a54595997a6908cb2790b3a88ce0dbc76229111ae22b2c9a768fc92cc93c37822b70c4112f1cd480ffdcd794a2d86589460a1ab5079b1e2a0bc92f0e50f2266471e6479722bf90ad426ce8b002cd6b05e38906789a620a251f7647b407c9cf7a8d4c77eae2f16e21caf51315402b4da39cb7b16a48658a4b4ab49978ba735098fecee468835ba767d598f266a5492156224521b484cac81bc0c4639511cf849093b597944a832c90135ad8dccaa80d956267216d6a8f10712be3a801783e75ff357f41f3ea5380889f333787b6a01946cb7b108310a3a55cc8b0b8d4a32c78a2dea142ccdfdc1292e4e6a29a0c07dc55a86b54865d5e319f25d283a99222c323118d5d7214ce36308ef9b48e2ea46434da8f29182e55aa2249931521e59eed1a1f6c6f72df8d544778cb8deb46b84f635aa808d54829eecb911c6271607b55bea63ad70fd472d60171cff002d8a36094432658b789ce08d5ef883231667caf5110ca6fc092d2c8cc1bca05862851258919e3142711c734cdcbefde4644ae3d94d29189b547de08a1c2a39db5471696233e9e200b9a9345288f236bc6bb73639705833917a8c0af579d586d2f351cec12cde6111645564f6e718d57b856adbf559e36a0e98d87151046b9996e4a078196328d22c38641db155199c79353b038a8a0bb72023b835712c4a25b672cffa991435d2495bee70a57cb1e05e9409029918868956556c8f969200cb7fe32420c732c079895c2e3bbe382482ef35e85b6c2d7a9b81205415a5d6cd922950c350dfaa90a351182f9588d2c8d14819ae69a425afd9db04f89b80dc756a8087cac091044f9260be5ac259bbbd40b68d9baa9a0f25913a0099187349a48dd727d422999496b539b73912609963919245f1421d2d6b569988550e2d503c91a850f111334ccb6bad3c165e545a4611ae657c5b7cae4230a189926676cad244d8963c0d2ce219ec5432858e5d4cb18e17e95526e16e9ad8f62708d8d4777bfbef2c39c6cc78ad3262d63e35cbde53661605d446306d9f1173e7a9b9823b801bbe3e0ccca6d954a086c538abf1c0b0396e5fe6eb8b3116a8093c2a8a8488f2b6582b80a2e6c0b3499322935f4ad2f339f160d8953d68e044b9b5eb5f187d37e08d0c1c966e18132650f504922499488c6b212364054c55753220a65cf102a28b3d43dfad46a307c50d2ca972cd8e4c0092102e1987385282273fd4636eab89325b54a71391b66c3eddf1a077dd805b1506ec4f21388dbf0a21924242b4891962ec235c8792f70353b002ecb2ad24c2e31956f66f279a3e34e8a7f9645fdb61d68fdce6afb1348b7b9d93f559bf2b6d986472b7afaa6d32a84b17c7ba8e23b963cd69fca490d4f37242d78cd8bdad51838f1d63ee7bdea5271e059613e409e5621f717c6a51fce36f2d3d8b5e4e52411bc3e1ed1628a68a83dd617e2a48bef36e1e16150b8c72599a758a52b36dbd49ea64c76446074fea52a7f42267bb099478ea5d0aa6dd9d9ad269a659054c3d84a5cae9feda8b02fa8d1491de476a22da748e9e216ad125e4b7676a45e2b52d1a6af15bd80f2fc0d22c464c1db1ad2c176bf12269f6a69a485f6f3f548520911635c55751246acdb6fb4daa11c68716ff0000f35ea31979de4c1e90490aee22634ebe3cd458e3735883f8a8ddbd818db50837e45c79d2cabb3f7db8d39e8c8de3a7f26deeab58728169a16753206e2119f02e6823ee5941031279ad82265121505f4ee8b88153a1f8ad32ba3964f13168e5791e5dc4057511ea1917237fa7c81bcb70b15a4f71a852c3dc6996b5bafd84db51793ea266999ddee4b6e295b716882867ef4fb33222aa829ad8e24b60b669343bcb9a30ce54bc0886d531648c0bd2c922104395adf1369011efd3fdc8e554350e704f896bab3c8d3659588f200d2ade9576a11870b87dcdcc4e4d15d79f720b8f7356d5c56c95f9b0c6d2777a434ded26b51309752d2137a8b2bfb78d4b147f03cb3b17cb2ad1cc72cafcaea6091bef1c0c6dcae0de32437898f96e46e4c71e1ed6d2c2ed6be14f0ed76f5b93431651a8b89d67981b59b6d3c6ebc4cd8c96a1dd246af1f35b012994dee2a2279534878a7175a9387a1d57a96a896fa61c0f8602b2c105ef9d8803c6b1bca30e2b1b5fe149536b5d5d49361624c9cc76f25176555b3e34c925cdd80a8e5fb6d1c9dea1995063db4b24adcc632dc913bdca7d4c8ed7248326a3caf1c4ec22d6cc0f30f8ff00aaa312bb6c697590dc8b9151cd196e194d2b0adc007278d64d107b86b8fab8edf35af1149f71059ca9f78069d2e73fe123dec9978ba20e7dd4a55ef76b24720362eb8c7cdee8c0899c95c646a90f8213892d93b7250059f2939b9467de6b7294a56e6e6e12d2734dc1ea84723b5b2b87fab85ac09a12349cbe21f615e4b5ebe940e4ee3503b66c19e83092c3ba90f38e2c695aff0014cad7e02d44109c1e002b51a7c2193052015fb9f8120cf05a76e15470447689993b8dadc7758e4aac078bc9870cde4f292ee02d07b0bdee52d7bb54cc4a906958210e01bc96cb8a4b46e2ed5a83e406471890248dc2b2e5e5d0acfe0d8d677bf850071fcd4904722dfa3f482de0f5b2ff00325a9e231b2b3497ad40f16617a6a246c46d57f1ff00ca1bfa6dcaf17e9d7b925764e79524f3f151db262eb7aeda97f6d6fd4879bd7e2bff00aef01737a9bf74d451f1f158714147e2b01f8a6885be6b690fe6b6548fe55b209f73d6d0c2c29036d4c8f62cd088bbb5600c0cdf0b0fb79e5c6438e2371c7746027a36a2bca2fce962c9cdfa688e76bd6cf1634da6fbb615169959572eb52969dac481ffc40034100002010302050009030305000000000000011102213141511012226171203242528191a1b1c10330d16272e140a2b2f0f1ffda00080101000d3f02ff005d81f4aeccc745517f252d56a753569f49db82f45fecbe3dc56963bf913bf7e150df2f918bd06f55c1fec7b3c3dd16877d0d9098aee9e1cdf315db1e52e18166862d2383e331d24447e4f696cf82e1dac3db2cfa1bad58ae35a6835f31293965343629b13d296e7fc45aad49d74e31371eecc7fe19965e7ba2abf076b0add5d28d8efaf077e5dc4a3e053a7ba2ff006917ee4890f5d05eac66a17d0e79e3383ee69d87d484da37f457c90b097d87ff00606e2db89dfb8ed61d2c8b8f6a8f745b6e34ccf0c6d087a1f73bea69ba24967dc9851f912e6e5d476a7b112991cb6331c174db516a89e64b63ee7ca4f5bbd3b95a1fd0a7af1382bd363371e5b22691fd089626733fb93053f281d972d3922ef5959455bfb25e6341c8f1b8b636d8abbe0d794f679ae2fa7f8295c8415e5a1dc4a6a29b8afe489f060716efc3b912fb0ad3cba137334fbb40fa3c14d09c46a46168f61a9a6713dc9f9897ccd3f91d51cafb94f4a6f2606648235dc6e3c70d4d25c19f06cf84529f642736d17934be60d7732dee31330915d6aaf90de082a9aa99dc7ca4b11ca8aad2cd4abea3272f2c7b0fea2577cbc17ea4781ebca645d22bdc6b9bc143bbc40a9dc569e31d4d6872f29514ee46bac0b08fc8f613bea843e1a8ff00ab055d50f43da1daa1318bdf5f91b969e8793422ed1ab39b3892abd302fa1338c11d2bf1c1d23aacf1f129b285612c3cb1589b8f48371ddee356235dccdc43b591d85b094dea3fba0f5b94c3aaa1be68592a589c31754a33d8fc912ca714d43c95be5fe45b198d8476d383d45b137fe06fa7b7617d08e5e5fc9eed3a8fb0f727a47ed14ae5c14eafd5fa181fd0453685d8591ee854f57610d5f8273fdc62e6e3d06a4aedf13f4fbd9198e63b57c2715234e57306dc679577d86ac2c771deae0f73bec2bc8ef1c15aa3edfe49d8e69f22be4a9cbb8d5b948e35682b74bfc1bd394466ad3f93155b27ea28739b684cf226253f1e0eea8d91a933e853a2395beab8ede3c8aac25929ab0a98f80f37b0b3382ae336b1112647bebc13e6815cfd4e892996ff0091da99d87514e8273cbb936e0f77a0b546b03f75e4f59f560899e53b7a0f7c18ec2db428a6ca9b73ff0091e6da8c9c3d10a634915a119c943ea5b0c64c78e3f61edc17c91dc77f1e87b2b61dedecb1ab41331b70e679d05eab3f4d4c55b087b0f06bd2556a90b05bf6b2f7663c0970ce351917e09dbf81650f779334ebf0e199e51ff51e45abe9476aa4de93bd3e876e0af3bf91dc4e079333cc3df24ee2d50f16295cbf012f90bda485b5fe83f693891714ecf7272b27bc793b5467d6e2f844dea11c96812d09c33419b2293b9ef0ee87e83bdf3c3c98e1e44218a9f87057b0fea8983b933ca6db91abe11ccd181afd8f279e1e783506a84dbfa0da53b142e95d8fb706ec60982227e24f1fffc4002610010100020202010305010100000000000111002131415161718191a1b1c1d1e1f0f110ffda0008010100013f214243e995936ce30c92adcc43205ebbc275b9a109c2190406af59cdf39f57e7289f181bbc6a651c77892e130b5f70c6be0cd526f1e8ce3260ae09827018374b8dea14c3ad61e586d9840dd0778285e5de3160bc3e3062adc63bcceb09c5cbeb1ce8a4203b2f6f392b29c135233e6e0d34a0c8debd98eb206f61c66bb7b7042345d72e4c13841311c1c498ba99d6868d61b36458044c3445ca99d3e1c96c6bdf581b714e7ff3ebce100daa038bd206f7d6284b6d997688aae9300cf8cd65f58145b6a05767d8c7412601575d7d318a62c8367d3ac88d9b4f5e70b2da4acd2f388d751b99627ed9df870f8cd4279cb83c8f18fe5d050b872cbc3198f0baa09d738075e31d2186e3c65bd72a77acaf9c72ae0e710f44a0cad1ae1e00381b5fa621869eb92fbc497186ce8f1f3f1877f6a2b3f87ce0466ad4e75c9f9d62a86d14c5def7f9c10569d8f9eb1261820acfe18bf0e5953fafd71283d7ad19c78ceaf4d26f144529045fb61d0d559c7c79325afb003f7ca81b1aa06f14bb11cef38dd184441cab0c2ca8066d6f67ac07e902c93a7ce00434217e2c1cc0bcbb05971c96ad8154c59a4510a0f979c66085717ef1799816858c3979bf19c0ad6a33d332842f95c27af193a52f112135f19c6f60d6f53f673925491a00ea7931aa3a3948475faecc6922a857b1ff00b9dae35694a3df465ab0e9d07b038f1a9742db37f6d650697c26239b0057d8cd1d719223d62e436ed29b9c62720ffd78c683a6274df11de41a703b9c43054328c2cf9f18b9ea8b71e3df9c594a20a57e86f38465555f80e706a9d03a0f8f3884138f903877ca23015dff005896d561cf89f186094f2126d0f9cd026a1cca75f9eb0571b8a8ab5ad1c4727b1151ad66fe709552157c3afdb1c566c1f5bbc27001a8629d604d5481c4edbe30209b4dd7cb0ebba1eb45c4ec72b381af9f8c1752a85b78d4f585c9e4aff9a0c6016d4e8f473d2b4f3e3088bcc3d78c3c08dbbbbae03ab7ce292f0e2100062393c01bbbeff6c5135511d7835fb7595d0d06457a062c041a8059e3e19cba56a69ce0080a0350f8f5831013dfa6a3fce3b01197bdcde68be1022af77f383825288a97872c60022ea17ace069c5a8750cacba99753d66c4b6b7bc113783566697d6a547f3388dd132f93dfaca3a6d357f2cedb1150df4fef35e1a0c57e5f59087b4df7d7ef8cc44689edc7ca0ec3bf819b6cf3aaff000c510b8595e33541ce8e3b7de0117709ae7c739ac4457f729ef25a80d48d73dfbf8c21f48f2fe30fe403037f3d4fdf2a1a23b05c2001150c5e0fbef2c2c9cbc698a087e37f4c366cf419cb01e4c43828d793fc60a8e8a04d5371c03a968d7479c9ab465d3108eb1f29e31a5dfa2cea6715300769e2195c92466cff007cff0018a082955ff6bf4c60ba217eff00ce51c09e13cb1449c2dd79eb1731c56820727fb9c7a674096484f13202eb511fc034cc5f51014cf10fa61c5252da771b86265abcdd6e603ab24915796e09082bc9831d7395812a234fc318e404160f9e1d6a89414354beff00ee0ad231947dfe99af80367dc9ed88091543ddef22b444177a66f04950d31e300bd2c21c211b9268601dde8716ac8b3c6e7ddc148282d7da7e0d65db0a09f87e710224048eda69c4884493a74fe4c8d0d5efde5511d314c44ad0652079b39e0c640437495e879716501c829ae17a30bcb59f149afb62e3c5c59e99b5ab16c3353c778841493420687a71a1b9551f67f565bbb602d3fe304c04d007a2ff00595402eaa5e76b10983974f04f9a60f6d809a7cbf19bd1182a5d9a312d397833750a06af6dca341ca8ecde3f7c2ba8a66f1e89a379d1c565aa07e99bb2071744d3870890a3ea7e930fb152461e1f4758f653e16e28cf581c8261b68bac17b353af551fa62be28a953ba19be6ed6b2be7012addc6191c4145ca9d424b82871e44e74e1712d82964316c20043a5e3e990ab9e9bf8b9245115fab8a9e037495738a4149f38217b009b9bcb7384dc00402822cc7b5109ddf2079fe30c95582559bf183ad6e83479fd316fe6205fa626e1358d8475f185ed6aaa221fdc30de035b07a4bc66e200157bc3abd50b9bf16070ed3edc62a7b9eaa3bdebf7c58b26e04bd4a3841810d0e25028534fdf34814b41cb374045839d9e7166ea541c5db8230b3b59ebf1303c181c6b09b97de27300b98bbac0ae450ece6f3e4cb0485891c3402016a1de030761043a26b0c42696d9f2be6f5829a0ace1f96452280c12bb0c08436961ae0ed70c8061abd88efd1eb1a748013875fa6380b0da8ece6e29409aadde3d0261201e71fa5681aedade11a85b3c4fafd708c4046ac748e0b35c8115c36a114edfe0c3981b4f078c5a0488038aac1ba2abaea61245443c9e3121716e11eb1daa4383be7ed8007aa5a8dbacd19246a463e3de3d06643cbf6f591e4450879ed3e981cb1ea5bae3eae737e24e91637bc68b4153adeff00dd6f0348653621bac04ed10207c79358b4a9751b3bfce09b00188fda78c9a0a723679ff183361ada9f5c3756baad4b9b88ec08f2c7bfae4767ce3da0108a4bc7410d31677bc224402899b590af26bdfd28d98cf4e551b3e99b1100921975fcd367d3aeb2d6bd6ad0bdfe2604d4e415fa758f66ba08a7be27784c4d67270d3b70499d4041ff00778a1a1814422eff00384801b15e5ae262eea102ecf9fb626b94c8a3bfc6135331a43c7cfe32c805022a76b000d2d7b6f9f9c10bd2e827e1d7ac617e9c187f9b4485fedef1612529aa39a634c22d060eca7b5ebed80315a28b849920894db7f439d9c46c3d9c110d9148efbb91300d5bf37c7a31ba4e2c1a4371769bc8e58816febf4984df2081d6725c76d1ef10c96c846becfce2602f21053d602a5844d727588505d87a3e71616984d686beee24c257ae38d64d1af389f3443143e71296e957ac45d800598e743ac750ad05e5fc63db3a27a7bfdb037a41a94471eb3814ef8ff53070a889be7f5984b15924dbd6fdb097fd5d3e876e44e1ba937efbcb7877b6e63c0fddde143ab458b50510901f3cef15549ab1a95e53ce40488e03830d57060f62802ef4b866a724cb100f5742b0c12eea8823bfc6ee285b40db24e6cc404d01b47dd3ac135090ba0825fce3dd635079df18a0a1711a3a9fbe2357243ef8a3603a4d7d31a90853c8ef7de00840a5869c5ce403281edbc389761792de3032860a839c18c97280de4b8051c6b04fd58d10c5e1cd76c8210ebb79c273b1340f9c3fab1048c7e99ba2d172be926021699443a72f8c09353706df45f38e1302009eb09591eb620bf9cad241a976ff00dc52a790717f78411f05b6f4e240ec8222de3f4c9a94450e83e9868e29ababbf18164e3175648981c79f38e56ae0e4af8cb0a35aeb8d1f231c0f3cd25f3825402ea3e58f6298171f585aa14142af262cd4e30d182cfdf098ef37f82153feef2fc6cb696e6ff0381ca610ad24010c710dd782d3f9c282b36b4ef3b313c7f3fa646264a04f06ff009cd3a250b0a30581484a43a6125e498f7324b410f02e071f12af5e9ea6f1edda8004df9c844a50c76f1f19bc3ae32b0520511de91ef1c53a79527d1c4b97c540f0671862495ad7b6ce758835da5029ddebe305af14054fa6691724419811a87078c61a6271ee9d1be7de56a4004b17898a10d4810dbbfef14dec3b8877fd305af695501df79537e2b69a63fb61a57b146744fa18c49d299b19306346a0e26d5fbe76e0b2be6189ab463f45f8c8db8134ecd625815142fbff00380a951071b20ec3bfef03487a60b921b0681958aaaca2e83f4e320f1069e67ac6a8ab18da60b79577d1c6d2b40e970515579c9cf188f08157c636449ab53b988ab2037741f1ce3d4104733e715ee54216fc99cfc94dad8f48e12a01408870480d82dd2f7fae01811182535fd3e931c20ac1f62c7ed89e06e90aefabc64e9a14522cda7e98b355cb69f4fb6f0a298691b9a8f783403a478cb9a68e87830c03ce4c0ad6edc554a96ede718829c18608826b264e8c16eeef0bedcb0eb9c6897051d3383201b1d8169e58d2d7626ff397c8439ac1df9c43ac0c3a1e98ea88451753771449a2140c882eea233de266d512c7daf27f59a78f418cdeafdf9c11b4b603f56214761de1d38ab42a37bd610e4a001fbe0f0b235b2e3d9b04af1f18a8da0853ef8e935f761f4c168c1cc333bd95d89f8cd6bd211de719e3ac3490395660d0a7bb8acbd7990c04626814f5c6945404376dc166c41f705ae1ba48e9dfac4c952c273e13f7c51c03b7a3d7d7e3218c5be5eb5e79c8c6dc8209fc627481550b8ef5101a7c1c7a81a1a20c2692e9103fc66b2b4a215df3c30d185a52fa1c34a5468149bc3a6b83bfef2ea52b41bc66f0798c4e2d46d3250d27008fc7bc78e0f95afdf0c80276218ea0f21413ce4b570d204fbe4c4a5d4598d04a5138c10eb64343f5c00b4814a8f8fbe0ebdc4af931000767db5b3ef8974414547ce18e200ecff69cdb0472374f3802003ca65e898a0d662fed8418afbc68340116ade66155a242993e7004834ae00287360cc6ad9107c75e32e88f5800546e0c3048d20ad3d7c67341a89bca3a2ad29ca0507156b861a44ddc91596ec632a844e779af4744c5971d2610c3598e01b444ddf7bc491e683a17bc6802a86d23b2e1406f2f999042c30d40c8b40331a1be304db4bbe5ff00183b80ba1bf27188ec36905d778c5370bc9f1f9c476d7071c86de72f1560b83301c1c0071d6773cf832637f8cb7866dd8c4d2e13c455ef1da0df4e212a73078e6cea9c5df59bc53aeb5c6b118141d14c273ae9eb074d51fb3c6733ff0042f6f7912ab6804c25a462c93bce098437c78f3945f7e98ef72d81f0c916279e016d5bfbe187a817d67fffc4002910000102050304030101010100000000000100112131415161718191a1b1c1f010d1f1e1203040ffda0008010100013f10f9946aecc107f88dc011307e4d8ffca88995310a8d30209513bd2eb6996da0c1e11228b7cdc65fad34a992d09103f4554a369246cea03051119dac802688089c6544d3cf8d460afe91e1197da002e881011ffb5000b9918853437334200ae128b70c002b6788097614a4eb1b7005dd7a7106cf9501559a30c3d84ed38910b98fba0afcb0133719773590753d17990704150578d807f9b235230582ea82a87e2c88e20bafc02ff8004045010cc1d6441352bfa322f193ac822b530c40a9a7d96a9b88c43958b302c42bbdd05803908d445e424b47b02a5492731b08e64d0984d57164d42e12cf185d083d26693f1b044422093f9920305628800bc8e557e5c1bb10ad06628769be12024f1d408fcb0b7027f6655180554c531692395a82cb461011890402cbdc831d5324913a2de6207413a6bce034bc81772dd458904ca3850b192d56c276077d4cad758a4bb460b75bb6318372abbf85b44015b4954a22ea8b4623104c945b269c9542dc55528ddc5a816baaf810631b222a9534702cd1cba9b8a431a03c5be58a388c3249366dd3381023f8b221125693765c3b84e4765388c9024040d484e2d60858fc64523020dc4db5018ba5d4a725282a5e022907140af6902100c685438c082f0ad5948894c090fd102acc6563fa05331980b042495eb0c70142e65ec22e139922b0d839a15095e1a8859999dd58a603572e082e9601484b6abba0c5de7c07aa181582aaddb3b2c0d28adf5712e715b2b538ca06c416dc4714ded010342b79c348077a77bf036610c8420ecbf22cf890eecb822e086c505bb153f78084daa611549828fa1b95999a1a07a5a355a25cac7261002bd96257d3138d0bcf461064a74c2320ce8b460b81b526477de805fd3dd4066862b820610a2eb0822580224167ddf0845dcae68c1641973fa98c9caf0723b279385a6183b002c87ed7914a6ba11c2d6b09066e26d349a7815054597ad834c68c2216425569db041c8adf225055124c68d7da97575320c4c4f085556c72690c4389364000c4568a2054ddc4fa09018ba0e4057e1c03304153722031af958d3994416c8e7ec04064a5e9e931ca2d858a7449c26a1a2e107331e12f68010117854f0dae0c4534c4cb795d1d7f60a218aafe4dcf65f8ba4360190eaff3000c5c0aeaaa4c1310b59dd1dd04763f041d8b30b48e8b8d35e4abc16f2102356293c471c10e8d87597b9e94af075c3a816d87570f29978f8fa105815dc0721d7793202105754d8343f4abb3434e4ed051ed11a042e980a9f41e100455233c1d1ac89fd734408de1ec01ed5a8d8d1c95c1dfe24e0af744150c17b248332b75549ce0c91564e0621001c09e5c8819a24b2e14d04c3de6a8998b4709a26c739ab73ad8448dab979ad4116d5120c069299440814894632b7126950aaf41424626a04d3060aa8aecc20aa63d698e892d69ad82b1b8ad79618921049fcc92040173599cd2098c750a0ea684ba16c05071e54ae551806c8ace5e9690a8fc96c9800507a95fc1010f2b655022c355fa9580155aa69301b26baa2e507f56c9b01e9d68d00464111833941d34cb88888815d145aaa60c85c4a3b917d1725bf1336bcfe24f3d3a90004d5ad773c5f02de6146aea3f1671f15c708ab3cf1a42b32b3b9015bf35a5cad1c63b1d0baf764a1874fbb1145add1754b8810563e148495e8b04e8b4c99905e78007e29454723140b44b22d4a4774ad082470165dfa3906c58bdc22eb2afa09d4a50b1e4cb409c0d5c833056892021829490bdb66c2078cd8fc6c0b3c240043995379cd040011114e4481bb0d86579cc0b2157e56f58c086094979f1b182a67bb64330e1e4ab2ecc291056d058d8834dbfa2df83034f8a4bf2664e06cab787c1ad05ed17570b08b1f2412e526990630ae64c17ad302a0826ed18401c25e53f0fa307b002f603bdc8c957230c282e0c2e54acf3000c09b2ac49637298c7271875dd510ac8b4d3955a2638a19ccae37ca5aa88994ac5405a94149a3a4060f4a5169d141e88e6d70d8cf1ba9b8e9a8261cecb3d11b9c0486580cc10316da9c5143aadd358d30d12b9199e928832be99400c0453583dc1262455171b884190a9995a6314cc3fe0be9580d61f9538dc4050335127b8bb1255a018656d424f61502a6577d1d536097400a4cccd15aa8c6d102d647c9b9c76c1dd182f76435d96c660851693d554f7e108830a882c454406756f0c1171fba83ec84995a6500ceeb2b7825342abbb80947b7450359388c6912b7dc0d366cc95e8c1860ea469c2e78153428a8d4a5040a01b26b155762632a98a44b28437554a9d18126e70960da2c84e0008d45663a4822f724a0f8e41c64425b478ca2cae0944f712fa2cae63a2aa26f3a28583c88194624da82cbc0229873cb8aba0c001958f10485c181759e9c1b5ae4712108152bd9471618da42d1fc0150ba8fc22d11480a2e1ed3b4f54cf8a10e1a30089996e422094b6f7c3001bd46194a221949364a9935f7825dc72418c562fa156a5c306231165aa4e0242c5a85a3f7479aba70c1a3e2ca74121c1343b299ceb00c4152cba8c005a08a95108a29220e6eb5632f4762c35d03a46d94a288342264a8ab4141d1b983928b19313861944048b71990600ac6a73371e6e02bfaa34bab3d97ab822315eaef065a1e5bcde454182034d58cec157f3c22213177cc9c0002fb32486c8a3568cbaecbcddaaaf25042c609e834141cd91d85aa4402232559ac50c0e7ec169230c045c96046053352b146145640c96d00105ea191b5345a02e83464564ad0b5568c02dc090981c2cbdea9899ec142d6e29104032a2455558cb82388f9dcf20d29e281bb851b9a16323aaf052f93413eb644f52595322c29175a1a0b454bb43e6cb099d7b4d0f0255ae710889b010b857022d04d7a6c5343f85cec34c80e1693ba044ec817801000b1cad7d21062c10bb61dc6254512c79175dc16ac5ce9402fd55b021a7304fa1a495943bf60268a9c41ce9a8f6553d7a492257d443c3ad0152b0781267e64131432f49ad24d12790e58c178780526154961a26089c1977a402696e94cc9a326aafbe00f6a5acca5063c531eb52dcc3755695800fb5bc031320b7a191e843bad2189807ca180a8366291344c6281489c077685eaabfb630908724b8e8442027cb0b86b21b21a29d83e3a0891934a298012b80a2e8296aa49a633a24fbc3028315a1980971a21f864ba8b26cb3530b599945a9b2eda3c1a3d4172397382c58426c508eb07560940a4047297e4fda01a91555c429248422acd90d28ed882c60e56f1bc7713759edfa9a04c12d7c4086ae156d540a324fd4159a2450c278325d06db09eab2204c963e32bcaacb68025c0ba1a806c305432112c22cdd20bd9601d831d964ac30f2bdeb133f0ce800000e5690382223de0a87a13809a9975444c5c349959d04eb570bd368241da2eb7f1cc43889adb22a4419263029884e700f5126df141604c66550dcc814243460b2361c038acae5b02a69dde0451f36166320eb4a29492272578cbd102ba91825c2482b7c66c3a24e0ff281b890b4cd612d23802c058481c9e4b64ba068b775967a2082eea5e7520e3a2c31a4099caa7efbdd6fb56d9a11a164615b4584de8535578d208e88930a1a0742cd4c05596a85bf2c24e1eebb496217300bbc4822ff0068e0c38bc1800e0159078aac62e8bcaee9708a97c48dd85885ee980172fcb6017c61c6f812f64715be204901cd8a0237d706a3eb45aee54a20dcc973fb6965ce68a9f3828654808098a923d76d41085ca4f36a2223353a272bab8c1c01483ee058665d698a132113becbcd3881594a1ecf9846cc9154c0a2b11312152fba7a5e92b0d84a0c919ba937681a42580602ea5b039c06472b63937a5065c7085aa0db9751510d2a9679418b85cd000c445690807b072e7c856a67b2fbe98104be1e3082bd88a0db404f74027b45aa66203d002d70e27893965214c997608fe7918f82bdc9d2194558628fe98dd6645a80e4d5b2cac807e8164260a460b1caaacc8adf4cbb66e180acaa9cf2fecc499a11a2feb5457845086eaad164ddd62ed5810bf998160b8b1845d9cc0bafc8ab7c00c2f5d2905bc8624c85689c4244dd7a260745ad0483258aa0301079602d04b9104f58aeb260a6f15523accc44d967f8c90878a044d805c89001e4b4811b0abe0147be248a96a51782794d324450080708205242d4609302c048c84221c05c6a819ceb04a382804839116cb880660449331270812ff9280811098703b0c7d48badb8912110a364181738069b181d14f986930a6d227540857c250da8d3a0c21189bc8380618082c3fea3b609044492f4a2bdbf3b5552e984efd699c9e555cc8a119b9aaf6ac109ead86292266651e09f37033240bfffc40020110003000203010101010100000000000000011102100312203021311332ffda0008010201013f00d420d13cb635f1ea75f50841af29897c7b1dbe14bb6c6c4c4f4c9a5c945fbf4a5db6362627a6420b8e0bf3e0ff0007c90ff5a5a211dc799de92885914a51bf931f1d3a4d211068ff00284831e45d52fcbb6ba1d084dbcc79e962622de08c994a26263637ac5b3129484210789d07c710f28622d21239194a262664c6fe1619ba210c6420d9833b1c9aea211d0e85f7c8e168843323b9dc6cc1986265c63e3837042294e8216d190e0f15e21d4ea438b12c3b980d2f3442da333f4eaf708753a9d4e3c4c9c1e661c83e42f9a51b1b3b928fe5c980b03170c5d294a5294a51921de7cf9194c5f63170a5294be5f95ef26648e1c0c50c7a5e9f95f087162a13f46310b485b588bdd294bbc06658c32ca18ff0c7fa75108841af852f9c06659532c698ff000c7fa4290eeceef534f74a426efe104a0b332fe18ffc899971d118b3144445e1f9841ea93f059d12a65fc31ff91e10c1690be78b327e5213d213166510be78b327e10d0d8843c068ec2114a5294be5eaeba9d4e874d51b1bd2114a52eaf97aa53b1d8a52ea8dfc1eb01f946621797bffc400221100020202030100030101000000000000000102110310122021301331320422ffda0008010301013f00d596290a4596596596597d6cbf8596290a4596596596597d6cbf9515f1a1449ff925087215fefbd6e8a28af8f314e993ff007c7263e0269f9deb743451451c05012327a510437f3e5d28a28532321b1cc8fa4a2244dfcf8ed0a44644a436b4bd163b1c5218c7b42ea9097463911992989b109d0b2d0b3d89d8c7b42ea90969c8832440a2baa42451434343890d7128b14c5934e0420488145754848e2711a1a27216423908cc74498847e425fb25fa19024c8a2d96c4c5331ba323212a252b12268cb1b218c699ff005b8ce873b238fc25fb25fa1b20f58d168b4380d1745d929d1198a64e629588690e8a2894688a165f375d9b1b253233d7139162112f50a34c94c8b1a1a386e8a2cb3f21f906c6c922312246270380842f49c69146380d1470eed5904514514471918f54b8993210204d8915de0ac6ab7456b975c9904ac8111b208ad3f9b1b10b7917a28f84236c978b58d9f9569fc6cb1b1b10b7923e95e1823666f192445e98cb2cb2fab1ee8c68af4bf068660fe8cdfd69e98fe2c7be6639eafd1a1a307f466feb4f4c7f5a284c4c72190649ee863f8d143d733989899cb51649eebad14515d58e2514597debad14515d5091451c0e1de86310863f8a10c7d508e4cfffd9" + }, + { + "transaction_hash": "0xaed7c628d01ac0d7962ded81a8a3c934e7ff71fe83978daa9152c0284ecb89d4", + "block_number": "15437996", + "transaction_index": "220", + "block_timestamp": "1661829845", + "block_blockhash": "0x3c264f2f3c03f68015946d5519acb1367f6d5fbd5581383fba1dffe2bccf638c", + "event_log_index": null, + "ethscription_number": "10", + "creator": "0x7000a09c425abf5173ff458df1370c25d1c58105", + "initial_owner": "0x000000000000000000000000000000000000dead", + "current_owner": "0x000000000000000000000000000000000000dead", + "previous_owner": "0x7000a09c425abf5173ff458df1370c25d1c58105", + "content_uri": "data:image/png;base64,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", + "content_sha": "0x6e0595f67cfb8222430477bc1916be46ef796c281d4086c0b897666f618773e5", + "esip6": false, + "mimetype": "image/png", + "media_type": "image", + "mime_subtype": "png", + "gas_price": "10751793348", + "gas_used": "1249064", + "transaction_fee": "13429678006426272", + "value": "0", + "attachment_sha": null, + "attachment_content_type": null, + "genesis_block": 15437996, + "is_genesis": true, + "content_uri_hash": "0x6e0595f67cfb8222430477bc1916be46ef796c281d4086c0b897666f618773e5", + "was_base64": true, + "content": "0x89504e470d0a1a0a0000000d4948445200000410000004b008060000004279b91e0000800049444154785eecddcfeb2c5999e77117b573db7f80ff808520ccd42df5f6b71abd5df7d628da38504c33ab590d4a21082e849ecdac06869ea2e98146e8c52c6636d2c2b81b5c8c9ba6a1611622ee147776afd4952d9670a73edff653fdf8d48938917932f23c91f97ec1e1de9b111911796e3e27e279e2477ee4230000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080a378f5d5671f7ded53cf9effaba72ffe1d8d46a3d168341a8d46a3d168476d9f78edcd8fe59c1797f0f0f0ca93a7cfdf7dfde1c5cb270fcf7ff6affff0c5ffa2d168341a8d46a3d168341aed88edb58717df537eaba67f2be7cd6930cef17e47aa73553878f2e4c5c7f36400000000000ee7f144f9b3d795efaa5144b80057673ef9c9873fc8d300000000003832e5ba8f27cc9f3e7f374fc3891e6f5b78faecf5fc3a0000000000b74057db2bf7d573fff2346ca4874aa813b994030000000070cb7415827e3420bf8e8df464cac77b410000000000b861ba7dffc9679e7f35bf8e8d5440787c22e595e9b211ffb4469e0600000000c0a5a9783023ffbd194b050477ec29ed948730fad689c7db270000000000d8d952fe8b8d963a50af39c1dfda5414c8cb5942010100000000704d4bf92f365aea405d4da0243fb6276ffcf19fc562416ea73c8891020200000000e09a96f25f6c744a076ade4d49ffc3c32b2e2a2cfd44c6480141cbfca06871021545b6dc6671eef20100000000759d92ffa2e1940eec15109478b76e7dd04f653c79faecf538ef5a01c1573ae87d31e1d732f4da8796fffefcf9ea076fc7e395139f79fed5f83efdea44eba73b1e97ffc69b3ff8d0f29f3e7f372f1f00000000702ca7e4bf6838a503570b08ef27d8314957221e6f79508bbfb8b05440582a1ec475ab00f0581408cbcf3f45e902820b028f458cf7e78fdb18af3078f2e4c5c73fd8f6a7cfdfd5fa1ee7f7fb554400000000001cd629f92f1a4ee9c0b50282a7e5ab0d745582926f4ff399fc560141570578bedfbbd52014271eaf36081eaf30f0b4b0de7825c4e3d506e10a02151b1ee70fbffff941e122170ac2bab9a501000000008eeb94fc170da774e05a01e18333f529c1171511fc3e9de9d76bb980a0e4dfffce897a2c2cc4d7ad95fc7f700542e33d1f143bdedf66bfe622c7e3950cdcae000000000037e794fc170da774e05a01c1afb79e2d209eeedb18620121160ff2b312c4eb5572ef871bc6a62b093e48fe7fe75f6e61f8704123deaee0d7e2f6f819095b1eb80800000000388653f25f349cd2814b0584788541be7ac09cd0370b08be0521dce210f9ea802dcdefc9eb8bf2d50fe642446cba4a21df020100000000389e53f25f349cd2814b050425d77e7da980e0e70eb40a084ed4f5677e18a27c708bc2fbf3e8fd6bcdef39a780f048cf3cd0af313c7dfe6e7ce0e2d63e0200000000d4744afe8b86533a70b180f0910fdfa290e5e93189d7df75bbc007572284871b8ad7db7a9ec192b30b0849fca587a5e20800000000a0be53f25f349cd2816b05840f7ed920ff8ac1473e5c2cc8af793e3f2cf17139bf7bd8a2c46724b49e4ba0d7f2eb2715101e1e5ed16b4bcf6ff0d511ad6501000000008ee194fc170da774e05a01c149beae12784cb4c3cf353a998fbf7af0a124fe77e24f3eead90a1fbcfebb245ecbfabd2b01e24f3c862b174e29203cfed464e3a7203dcdf3c6ed01000000001ccb29f92f1a4ee9c0b50282f82a041700f2bfe355053989ff800a028de721e8bdf199048fbf9410979fae7c38a58020f19719b47e2dcfcbf8e7d73efc6b0e0000000080e33825ff45c3291dd82b2088ae027001e031f156e1e0fd643cdf62d04ae24df37ef0fe7055c1e373121a0f378c573cd8a90504693d3c51458ad6f20100000000c7724afe8b863d3b70cf4bfef75cb63c2e9fa20100000000dc8c3df3dfbb40070200000000ee01f9ef203a1000000000700fc87f07d18100000000807b40fe3b880e0400000000dc03f2df41742000000000e01e90ff0ea2030100000000f780fc77101d0800000000b807e4bf83e84000000000c03d20ff1d44070200000000ee01f9ef203a1000000000700fc87f07d18100000000807b40fe3b880e0400000000dc03f2df41742000000000e01e90ff0ea2030100000000f780fc77101d0800000000b807e4bf838ed681afbefaeca3af7dead9736d378d76e9f689d7defc58fecee172885fda9e8df8dd17f14bdbb311bffb227e697bb6a3c5afb6f948f96f3987e9c08787579e7eee0b7ff9fac38b97cfbff8f66ffed37ffe2f2f69b44bb677befecd97fa7ea9fdd19b5ffa1b7de7f2d71067227e693b37e27747c42f6de746fcee88f8a5eddc8e18bf87c97fab3a4407beff457c78f685efbff5a5b7dffbf14f7efa12d8cb7bbffdedcb1ffcf0472fbff2b56ffc5adfb9230c82e511bfb812e27707c42fae84f8dd01f18b2b395afc1e22ffadec081da86a96be903fffc52ff3f715d885be6bdae17ef6ad2f7f2b7f1f711ae217d746fc5e0ef18b6b237e2f87f8c5b51d257e8f90ff9676840ed42531aa6a01d7a46abdbe7bba6f307f27b11df18b1988dfcb207e3103f17b19c42f663842fc1e21ff2dad7a07eaa11cfa12ead218e0da74bfa01e3a94bf97d886f8c54cc4ef18e2173311bf63885fcc543d7eabe7bfe555ef406d9f1ece01cca087c37cfa8dcfbf93bf97d886f8c54cc4ef18e2173311bf63885fcc543d7eabe7bfe555ef406d9fbe84c00cdffece775f7eeedffcdbff9dbf97d886f8c54cc4ef18e2173311bf63885fcc543d7eabe7bfe555ef400640ccf47fbef77f4b0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1db8e700f8de6f7ffbf267fff08f8fed57bffaa73cf9f768bae795f8de5ed3bc7b3b655ddef61ffcf047ab9f3b7f8e566bf9f92f7ef9f2f587172ffffd7ff88fbfd76fbd6dd3fb349ffe94f8de5edb4bf501b0ba3de317e8217ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07ee39002aa17de7ebdf7c4c76f5e712cdf7d69ffce9e37c7ffe177ff5f89a9256fd7b4bdb33c1d5b6699bb6ac4bf3aa2ff3f629d177d21ee5f95aade56fffeeef1fa7fdf5fff89f8f4580dc772d2e3aa869d011fd99d7b7d4f6527d00ac6ecff8057a88df31c42f66227ec710bf98a97afc56cf7fcbabde817b0f804a5c9de07efb3bdfcd931f3941d77c3e8baef769bb969a0b136aade4fc127efc939f7eb0ed6e4b0584582c51c140c97d2c3c6839793b3d2d7fb6d85abc5c6d9fe84f2f4b573d64da366d93d7659a37af2f36bf47dbbe97ea0360757bc72fb086f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1d788d015089ea5202eeb3e9ad696b548cd07b94a8ef219fb1ef6da33f472c82883e7beb0a81b8fc53f97d713dee0fad2bdf36b1366d8d0b22fa6c7ba93e0056778df8059610bf63885fcc44fc8e217e3153f5f8ad9eff9657bd03af3500faacb9ce6a3bf18d97df2f5d9dd0e2db1b5ac9ba3e8b824acb56e2ebb3e97a2d5edda0f5f94a817c6580681d9aee8241af80e064bb7505402c16781be22d1aa7f0fb5ab784781be2ff675c8faf58d8c20591d67a2ea9fa0058ddb5e21768217ec710bf9889f81d43fc62a6eaf15b3dff2daf7a075e6b008c97d1fb4cbcd67b4e92eae5e433e3be5240cb8bb738b8697d31998f2d1730b4bdb138e1f9960a089ede2a46880b257e7f2c829cc25713e8b366f17611174c729f6f110b3b4b9ff752aa0f80d55d2b7e8116e2770cf18b9988df31c42f66aa1ebfd5f3dff2aa77e03507c098bcc7e71e2c25dd2d3e33aec4387301c1cbd5bc4aa2e36d127eafcec62b518edbb1c6ef6d25d45bae2670b1c489bfdfa3d7b58dba7241d3b4ad9a168b17910b23aded9078bb48ebaa8f2d745586deb7d7ed2151f501b0ba6bc62f9011bf63885fcc44fc8e217e3153f5f8ad9eff9657bd03af3d00c6245f2d5f45b04649f0da99f1b8ec3cdd67e27392af65faf5b542c6d272e59c0282137d7f9edc5428c8dbe36ded153be2c31b97b679492cf29cf2bc8473551f00abbb76fc0211f13b86f8c54cc4ef18e21733558fdfeaf96f79d53bf0da0360be852027c96b5c2058dade5840c89cc0b7deeb247ead98b1968c9f534088dbaa6281ae88d072e2ebf9a1872e3af46e478857219c7af5818b0fd7b8fa40aa0f80d55d3b7e8188f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1d78ed01303f9be094e71ff82a82d6830a65adc09013f8d6b46b16105418d0fb5acbd3342f2f6e936f2d58dbce7895865bafe0605bafc6b8a4ea036075d78e5f20227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a075e73008c3f27181ff8971f60d81293f4a533eaa3058456326f5e776b9e730a083dbe122026ff2ea0acdd5ae0f5a83013b76ba9e8125deb9717a2ea036075d78c5f20237ec7dc42fc6a7fa4cfa0b656dc16edcf349ff7f93a0ef07b7bad45c70279994bfceb4b3e89e15f615afb7522ff92938e55d4b4aede673c12e277cc2dc42f8eab7afc56cf7fcbabde81d71a00b5937632eb1d767cb8612b318fb63cd86f5601413c7de9ccfddab31b5a5c6cf167f1ad1fad87479a3f7f7c30652cdaac151ec40756d73c40aa3e005677adf8055a88df31b712bfdef7aced035bfba758e4eeb54cc711f16abbb57e8c272cd4e23391d45afb651f73a8e5abfad68e438e84f81d732bf18b63aa1ebfd5f3dff2aa77e03506c078597d3e4be0e45dd397ae2c90deed0b32b380e0e4bbb57df1b90ffe8cfed585a5e5e5e732b8d8b274e012d7110b00f1a71c5bfd12f53ee31eaa0f80d55d237e8125c4ef985b8a5fef47b52fccfbf2582838a540bdb44f7761dceb6bcd13c57da00be9f104463efe88c50eef0ff5672c3cb4f6f54743fc8eb9a5f8c5f1548fdfeaf96f79d53bf01a03a00f2c5a0ff48bc585b57bf5bdd35e4b6e970e3664ef02423c1889eb886749e2e7f30150ab4fe243107db0e35b1a5a975bc62241ab0fe3c15bebf34b9c276fcf9eaa0f80d55d237e8125c4ef985b8a5fedab5a270a7afba7257179f9aa06ef3bb5df5adbef8b8bebb948e069ad9313add744eff79509a77c96aa88df31b714bf389eeaf15b3dff2daf7a07ee3d007ae7de3a08b0787b43de614b7ca8e0d23264ed40e2d40282af1070f3fa75f0105fb778b0e36dc89740e66df79913fda9032ead333e64323e8bc0afe503207171a1758064bdff87f87f704dd507c0eaf68e5f600df13be6d6e2b775ab62bc8d6e69ffd4b2b6cfd632bdacb5fdbe78fd4b097f9edeba62309a556cdf03f13be6d6e217c7523d7eabe7bfe555efc03d07c0b8236e1d04443109cef7eac71df69ab50389b583915601c1aff55a94efb38c6da93092efc554d36bbaa2c10727fefcad871bc6ab155a572744f1e151f9c0c77dd75ac79eaa0f80d5ed19bf400ff13be616e337160c5a05852dfcbe2d4587b5fdbeacedfbc5ebf2fbd7f6b7e6cfb47445e25110bf636e317e711cd5e3b77afe5b5ef50edc73005442ad1dec969dac0e123c6f2e20c4696bfcd388ad33ecde96bcec382d1ea8c46d5f6b2d7a5d050005775eee12cda76240abd0a0695e56e6ed6c7de62cfe7464dea6b5bedb53f501b0ba3de317e8217ec7dc6afcc62be9d4f2b38f7a966e2168192d2068bfe7ed94def2840202e456e317c7503d7eabe7bfe555ef400640cc547d00ac8ef8c54cc4ef985b8ddf98946fb98a20f2f3845444d8a297f0bb80b0f4f0460a08c4efb96e357e710cd5e3b77afe5b5ef50e6400c44cd507c0ea885fcc44fc8eb9d5f8f52d896e4b67ff5b4eb9fa407a093f57202c237ec7dc6afce218aac76ff5fcb7bcea1dc8008899aa0f80d511bf9889f81d738bf11b9fcb137f6e71cbed71bd0718b6f412fead05045d2921be02626979420101728bf18be3a81ebfd5f3dff2aa7720032066aa3e005647fc6226e277ccadc56f7c90b0137627f0ad87f766e7fc44e2680121170c5c5058ba85429fc105842d4591ca88df31b716bf3896eaf15b3dff2daf7a07320062a6ea036075c42f66227ec7dc5afcfae189f133c59f38ee3d4cd1f36dbd7d417a05845c20c85cb4d09f127f36ba55f088bf107174c4ef985b8b5f1c4bf5f8ad9eff9657bd0319003153f501b03ae2173311bf636e297ee3cf37e65f3b7212afb6f6738e9ee7945b037a05845810c80f52d4b6b86811b7cb85105d09118b085a96a7b9e07064c4ef985b8a5f1c4ff5f8ad9eff9657bd0319003153f501b03ae2173311bf636e257e7d563e27e2911facb8f4ab0cf16186ade9167f8e58cd571028b18fafc72286fad8cbd6df35cdeff33645b1e0a15b19b43cbde66283da29458eaa88df31b712bff8e77145f1a07614d5e3b77afe5b5ef50e6400c44cd507c0ea8e16bffaffd6f6e6337b999210cda7e6fb8c7586d3afadb578f9f3d27bf4bae6cb674a7bf41e2f6329516ad1fbbc2d4a42d4e2761c15f13be668f1dba2387662bdf6ec82385feb33c722c41a5f71d06b3111d0ba7de5406edaa6d6b30c628121b723c76c44fc8e395afcc6fd576eda17e9fb70ea3ef156e45f633982eaf15b3dff2daf7a071e6d00c46da93e005677b4f8dd723ff45242a2cf990fe45b2d260e5bdea3338c5b8b01fe89393525243d4a4cb66c83e639e2811bf13be668f1dbb2b52828b13098cfe03bb9e9dd1ab09604c596937c6d9bb7d531a77fb78a07a6ab0ef4b914f76adab6ad63c51110bf638e16bf5b8b6ffa9e1f717f348202c2e555cf7fcbabde81471b002bd281892f9bccafad1d9c645e46ef20ac824b6d6bf501b0ba23c66fef72671fe0e7a7b62b96e225cab1c565c638f4b2744014e75772a1d7e225c9f9fee84ceff376f93d6bf11d8b256afaae7bdb3c3ec49fb95bbab4bb32e277cc11e317b783f81d73b4f8750141c56fefbbbc5fd53ed4b719799f794fbc7f573b8aeaf15b3dff2daf7a07ee35006a5072f55e03910ed8d70eb68fac35f0f8b553fad6cb880950554e8c46b7b5fa0058dd5ef1bb375f1e9c93e678eff129df2d27e2f9126a1710e2550959bc5479e9aa08f17c9a273fb9bd25def3bd36f6c5f1636d791511bf638e1abfb80dc4ef98a3c5af0b086bdbec7d70de379b8bdf9a4f45872d572ab840e1e5b54eba45da5fe65b0c5de8b8d436e575b48ee3abab1ebfd5f3dff2aa77e0a5074005643ceb969ba6e54b0b8fae35f0f8d2e5b58424f3329606d5adb4debdfbd889d9e8b6561f00abbb74fc5e8b76f8f927dfe219fbb5843f73fcb50e78b614102416115a071f5aaea72bb67dc5436b9da2793c7f6fdde203bba5f55745fc8e396afce23610bf638e16bf5b0a08715f978f23e37e2a36edcb5bfbc17842c04deb5e7ade89f69b397fd0157f7add57fee563ceb56d6aed4bf57e1f17c47997b6a9b2eaf15b3dff2daf7a075e7200d460e300d4d9377db915acbe5cd80340ef8cdcd1b40a08e7f032f200790a272e5b92961114106ab864fc5e5b4cb275a0e1efd496670b442e44b4bef35b0b0831865bb732f840486398793ccb0759120f9c5a0756593c686bddd65115f13be6c8f18be3237ec71c2d7eb7141094747b5f14f76d3151f7f1bd4e9039e1cfc5f4b80fd434cdeb7db5ffd4eb966ff9f3d5cb9ecfafc763ce53b729e628de261f23c4751c45f5f8ad9eff9657bd032f3500c6c06c1d804b3ceb98033bf2e5484bd397282139f53d5b68605b4b94cf2d20e465b606c8ac5778f1ff432f61d267ea2d2bcaf35240a8e152f13b4b3e43a171a175d66089bfef31b18fb61610c4dbd09ad7c58238cd072fad82c79603b5acb58eea88df31478f5f1c1bf13be668f1bb65bfd42a7eeb4f27d8f9363bedaf5bc574bfa6752d25f1713b62913e1e57c6ab0fd43c6deb36c57c245ef5188f33b44c0a0897573dff2daf7a075e6a0074e0e540ce3c1868be9c942a186210abe9dfad82440c745511e3d50d5a762bb1d5c0150722350d28ad03760d4ef101676e7a7f3e43d82a20f8b556dfe6edf0d3abfdefbcedf1ec6c7c4fee97bcad79fd4b9f49f3e4ff0bd3b6c475fbe13b14106ab854fcce14bf5fad585ca2ef73ebc025f2b2b72cd70717790c8b9736c6838e78a626c78fd79b9fc9b0c6efc9ebaf8cf81d730bf18be3227ec71c2d7e970a083e49166fe58b85f198f4b7f8b8d2cb8dfbc67cbc2cb1b860deffe56d136fb79a8f397bb71cb43eabe76f1d2fc4e390a3a81ebfd5f3dff2aa77e02506c07829f229670fa318bc0efaf8effc2c01bf1eaf68f0dffdef280e405ab606cab88e38a0c4c4c4cbcadb1393f7ad0584bc5cfdddff8edb1e93f2b86ccda3ed8e4596b8dd5a9797a73ff56ff75b5eb7a6a9c54bbdf2401f3f73ec03fdddcba28030d725e277a67ccfa3be575baf22ea5d7d20fece9e5240c8638d1f86d84aecbdfc3ccdaf9ff27fb3b4acca88df31478f5f1c1bf13be668f11b8f83d75afe4956bf4ffb5afd3db7f8c06089c7adad63c43cbff898b2b5af6efdca526f9b6231447adb144fae1d45f5f8ad9eff9657bd032f3100fad2a3d6a5bc5bc4c056d0bb08a13fe320108b137e4d2d26d14b95cfa5c1c9498806322fdf03935ed3df3d90c6b3f13169d95a40703fe5ab2ab40d31898a839bb73b2715b9e26bdef6fc39fdba5a2e9678308fff7ff133e53e582a769ca3fa0058dd25e277167da7fc5dd277d03190bfeb4bfcbdcd097f744a01a1151f713cf13d96b1c5a25aeb80eb94ff9ba531aa32e277cc91e317c747fc8e395afcc6635b6d776cf1b96599f7b55b9ab48e89a3783c6afe77ebea80b81ff6f6c5dca0d7a4b74df1d68da3a81ebfd5f3dff2aa77e025064007f2b9cbf1e0b47426d1411d0facfd5aeb12e1d699c4b5e4245f7e1c939a2c5e6de102456b606a15109c6cb4b6210ea81e209590e8ef1ad8f2951d719d719a979393102727ad64ab7529b60b14ada250ab1a7caeea0360759788df59fc1d73f21dbf57f96a98acf59d6dd95a4088f114bfd3312e7b2d1ef8b4ee235db3760b5365c4ef9823c72f8e8ff81d73b4f8f5feecd46d8ec783da3fad3589fbd3d6feaf75b6dfff6eedabe371b7d7e1cfa263dbbc0db949dca67c3c2dada24675d5e3b77afe5b5ef50ebcc400e883f47397e3f7b71276694d77a0e7e70048ebec7c1c1cb41c0f2a2d9eaf3590499e1e0726f36b711b5cc4682d37de63d6da361713d4346ffc3c717ebf9ed7e179d55f797055f3b639715bfb3f6d1551ce557d00acee12f13bc352112a5edad83af0b0ad573d6d2920c42b217211335e019463c6ad75054f8c91b5755b8ce7d6c14d55c4ef98a3c62f6e03f13be668f1ebfdcca9db7c6a72adfd626bff6ede2fc7e5b54efc592cc87b79719bd68e15ac558488e2551647513d7eabe7bfe555efc04b0c80e70e4ab696584bab20b03610b4b647034c7c06809b138338dfdab225272571b034bfd6dae656d2bd34b8e9f578cb40abc5f95b0584b8ec5ef3fbf267ccf2fce7aa3e00567789f8bd3625c88ef97cb0a0f8f3b4b5cfd58af196def758627cc5ab08961e9e98c5f88f5743f8ca2c7d9e56cc9bdedf1b03ab227ec754895f17c37a07e28a03cdb776d5cfd16d1d5b4678bc583ac6b816e2774c95f8ddeadceff652c1df341e68dfe9f1231e47b74ef2799a9a39816f6d5bbc5dc1ebef9dc8cadb246bdb14f383a3a81ebfd5f3dff2aa77e02506c078f6fc1cbd83e74b14104c838a06a35c4cf0ba671510e232bcde7899b69afa417dade94b572cf8b3c7be8c03ad3ebba62d352f2b7fc6cccb5b9abe55f501b0ba4bc4efb53961d79fad8425c6426b472fbdefa779be7c05816250afc5678fe47ef47bf3eb2dad6795c44249dc067d665f51e47855d3325afd5119f13ba64afcfa3bb8b4cf337f5f2b6cf35eaef119b7f6f7de88df3155e277ab91ef76bc4a2f16da757cd93a868f67f475ecaa7d9bde978fbd2d1ed36afbb45ced43bd1f6ec58ca7ad6d533c49110b11da56ef87f33a8ea27afc56cf7fcbabde819718006382da7a004aa6814581ecb37a0ede736e6168ed80b70e925abf07b378e9721c605af2f498f0985f8bdb90df17b52ed18aafe5e7269c524010cfbb9490656bc953fcbc793da7aa3e00567789f8bd267f3fd5d6ce62c67b245bf3f960a6f7fdcb07064b2d2f2716efb68c69f101a9b108d03a006a358d6d472b1e08f13ba64afcfa7bd8da9f465bf7ad47768dcfe8e38e5e7fef8df81d53257eb71af96ec7a45c4ddfe1580cc8c7a8797e37bde6fd653ceed6fe6fe96adbd6f1716b1dfafb29db940bfcfefb51548fdfeaf96f79d53bf05203a0834fc1bb76b96f4c207c26deef5dba9fd9f3c7837cbfd6da01b70649cdd7daaed665501ec4f2e023717eaf7b6b01c189446bb93161f2725bf7565b1cecb614103c502e1569723faefd9fc4e2455ecfa9aa0f80d55d2a7eafc1c9b45aaf90a583097d57356fbecd41bc9c567121d27b3d6f6efaeee9bbdc1a17f4bae7db42dbebf9732c8996a798d274c78efeaed75a57241d05f13ba64afcb6f6252dad7debadb9c667f438d0ebefbd11bf63aac4ef56deafb5f6a95b683fa77d56bc1a41ffceb70a98f6cffa8e79dfe80782bb20d0ea3b4d8bfb7eed1fe395c1799fef6d724c799bb49ca56dd2f27d4cecedd2ebdecea3a81ebfd5f3dff2aa77e0a506c078c64e8199cfda69471993e49878e633da0e7a2d33be271ee8fbb5d60e381f00f8fe2d6d574e16e234afd709b22ba5a6f73aa98f95d3ad05046f575eae5e8f95507fa6b81da66dccf3e765e5ed8bafb7fe6fbc9e780975bce7cd979f79de58e18dff8fe7a83e005677a9f805ce41fc8ea912bf79dfb324ef5b45efd1eb4e226202b05620d3fed4c985e6f7fbe3f24c7fd76b9ae6a42417c3f5bad6ed64c2c9c71a2dcfdbaa657a9bf267342d2f16259da8ac71d2a6f538a9d1bfb7f4f7de88df3155e2f7687c82aa7532adc5c7d3f15818f5e3b77afe5b5ef50ebce400a89d6b4c6cd554a9ccafb5068d78bf94e68fff56cb1553bfdeda01b70e00e2197b2ddb0720deb69c08c74ba994307b87ef160f4cb61610747012fb42eb70321ed7e7cf14abae9a2f6eaf3e8f3fa72bae791d5aa6d7af65e5cfa43e8dafe5c2429ca665fadffabbb73bf7dba9aa0f80d55d327e815311bf63aac46fdef72c69ed5be36b793fe996f7f9f9526237ed637cd220aec3cbd5b4f83e8bfbe056cb67227db631cf178f3df2ff4b3c99919bb63baf43c708b1d81ee7a580701baac46f451e17726ce818d571118fa3e39890e3c231998b86f7ae7afc56cf7fcbabde81971e007d39516bc7a9d772921a2918f24145eb8cb9787a1e68a47590b3b45d3e60c93b7ffdbb75c0a0f7e7756e2d208806c9bc0d9a27160be2f235c0c6031d27fe12cf64c475fbf3e7d7973e9396d13a539397afa6fe8a075f1410e6ba74fc02a7207ec754895f8fef79df96b5f6ad797fa3695a8ef6db717f1eaffef33e50d3f5fe7cb5a15a3c69e0fd8d8bd73e9b6f2e6eeb75af275e2d978f216231dcebcfc71ff133c67dbc8e23bc0e6d432cea5bbc2253dba0fdab5e8b2732d47afdbd37e2774c95f8ad289eb4d29ffaaec502a05ecbe255b79a5f2d1e2fe7db17ee5df5f8ad9eff9657bd03f71e00b5833c7527a91dadde9393fa4bd172b5fc7c3bc3120d5aa77e861e6fc356dad6addbbb85fb780b6feb1eff1fd507c0eaf68e5f600df13ba64afc6e4d687b0504fd3dd23ec309830b02ade70859bc752e2e2b16b2733120debb9cf791deb698acc4f5e7c2794cfce367f4fa5b67406371c1fb48af57c94fde6f2e3d106e06e2774c95f8ad2a161162d36b3956cd4584d87a271fef55f5f8ad9eff9657bd0319003153f501b03ae2173311bf63aac4efd684b6574068bddfc9b7df13e7cfe29578ad02828a11a78805098b094a8b0b1ef1337afe5c70b0fcf9f3678e62c1a1d55fd744fc8ea912bf95f90494db96ab087cd2ce0d6dd5e3b77afe5b5ef50e6400c44cd507c0ea885fcc44fc8ea912bf4e687b07f7bd02423edb2ebe6c3f17107456b1c5672ce33ad61272d17ab55c4dcfb741ba596ffd795df1aa043fbf2037afd36749bd8cfcec26894592d9c911f13ba64afce23e558fdfeaf96f79d53b90011033551f00ab237e3113f13ba64afc3aa15d3ac36eade47eed8a02c94587fcef2c27f0f1b5d62d044af0e37dd231c98f0f63b653d71faf18e8352d3b2ec3ffce3c3f058463ab12bfb84fd5e3b77afe5b5ef50e6400c44cd507c0ea885fcc44fc8ea912bf4e68f3af2544f1cc793cb31e0b08ad2b10f2af1a78fea5db115c0c6815105a09795c7f2e30b48a1bbdf5b78a245e46afc062dede567fc62b1a28201c5b95f8c57daa1ebfd5f3dff2aa7720032066aa3e005647fc6226e2774c95f88dbf4ab0243e3b20fe02424cd25b09b1936927f7ada4de7a0f316c1510e293de7301c3ef8beb8acf45c8f34b6bfd7ead754b428bd7dbfabf8deb8ffd3803f13ba64afce23e558fdfeaf96f79d53b90011033551f00ab237e3113f13ba64afcc69f508c3f5368f12711556488d36341209f71d77cf9f900f157107241202e6b6b01c1d3f2bae37ad4bccdb1489197177f2121aedfcf71681529b41ebd9e7fde51f3ebb3c7e74ae8bdf1b68abcfe6b237ec754895fdca7eaf15b3dff2daf7a07320062a6ea036075c46f3d4e82f6fc7f7102d23ae37b4dc4ef982af1dbfab9356d57fcdd76b77cd63c26fd6afec9b5f8ba9611136f27fd6a5aaf96e90245eb1682b50242fced782d4767f83dbf96e5e5eab338998febd7dfb58c7815465e7f2c3af837ea157b71bb630123deeee175eb3d5eb6ff6c7d9e6b227ec75489dfa370dccdde6fdd8aeaf15b3dff2daf7a07320062a6ea03607555e2d707cabd03836b24d7b35de3336eedefbd11bf63aac4af28498e67c77353d2dbfa2df6f87df799fafcbefceb0ead82859a12725f0d1193ebd66ba665c58728baf96a8178eb85fb5adbb3f49ea5f855ace5624a7c5fd6fa3d7b352d7fad20724dc4ef984af13bea948785c6eff22928205c56f5f8ad9eff9657bd036f6900c4f1541f00abab12bf3ea0e81d182c1d9cdf926b7c46273fbdfede1bf13ba64afc464aae95c8ebffd64ddfb37ce9bee5ef7b7cbf92e87c3b44e479e3f7b8955c6bbab7638996a579b4bc5cb0d0fbd4f2ed04be52222edbeb6a154bf41e5de1e0f76cfd7c79bbbcdeb5cf730dc4ef988af17b2e7d37f55972f3be3dfeba895b2b46d67879b3bff7b7a27afc56cf7fcbabde81b734001e9107e27cc0732faa0f80d555895f0a08ffe21a9fb1ca8118f13ba64afc8e38e7fbaeef6d2bd197ad6309c611bf636e217e7b2e198f55f65bb7a27afc56cf7fcbabde81f730005676c9c1f988aa0f80d55589dfaddfe356b211cff8f9aca02e85d63cba747969993af3a72444975d6b7e9d19d4fb5b6710e3193f9f15cc4f54d7f2349f5ef7ba5b094ea4f768ddda062d53ff6e7d46f3b6b970a875f4cee268bae6d57a3c6f950331e2774c95f81db1f67d5fe25b1de2b311e20306d77e0d029743fc8eb985f8ede9eddb15b7daf7799fad18d6f7aab5ef5cda6f69197a8f5afea9545ff1e37d66deb75bdeefeb7dde976bfb96aea03ab2eaf15b3dff2daf7a07dec30058596f70be75d507c0eaaac4efd6ef712bd988afb5eea156cb070c3a3869dd8faccb2c7dbf745c870f5cf243e1e2f2f2b2dcf203e03cbf9799e7f567c8ff2ffe9cada683aebc0e1d00b5eed3f6819afedeebefbd11bf63aac4ef08ff2ce1a99f23c77a8ccbd9dfeb7b41fc8eb985f8ed598b49ed075bfb28b7bcdf5eda6fc582622c3cc45f87c94def89fbcc738e238eae7afc56cf7fcbabde81f7300056e6812d0fa8f7a2fa00585d95f8ddfa3d5e2b20b8699a96a3b30631a988f71ac783165f29900f1ae215063e70d1f2d4f46fad374f5722af65f9ac8ad7930f3c62f140ebf19507717be367d4e7f1eb2a72e8df3eebe2f7c4a7b8e7a7be6bfdada245afbff746fc8ea912bf23fcdd3ee773f80ca1e34cf137fb3b7d4f88df31b710bf3d6bfb9ab81fd6fecb57f12dedb75b0584a5e281961397adf7c42bfcd4e23e3c1f47f83d5a8e1fd8da7ad8e991558fdfeaf96f79d53bf01e06c02de2c39134e0e4b381a601ce07fffeb7128d2d974879f0f3a5d6b23638df83ea03607555e277ebf7b85740880704a238f134c598c4ab055a973bb6961513ef5c0c7062ae961f88e66d8b071e6beb8f897ffc8c5ebffecce384b7395ee9a0cfaad7748096e78ffdd5ebefbd11bf63aac42fee13f13be61ee277695f1313fc7cbb82f6594eda63613c1710bc2fcbc5038945c5ccebd6fb6ced382216f0f33efec8aac76ff5fcb7bcea1d780f03604febe7a4d45a098507401df4b7de17074bd3e0151318b73800e7c1f95e541f00abab12bf5bbfc7bd0242ebfd31f996387f160b0ead02423ce0d8221624d65e8b7cf6257e46cf9f8b17963f7ffecc513c186af5d73511bf63aac42fee13f13be61ee277695f1393ff163fcf24f64f2c20c422402e1ec8d27a2516ea733142edd4e51d55f5f8ad9eff9657bd03ef61005ce34147d54eff9c948a004e027cd6d33c00ba3aaa7ffb416e1ea0e259c938d0a9697d6af112af5b1bd44e517d00acae4afcaeedb8a35e01219f6d97fc4c8118b32dad33176b09b9398e5b854135eb1d38e575c5a246eba7b0d43c1eb8c0e065e4073d4a5cdeec7183f81d53257e719f88df31f710bf4bfb9a5681206aed27bd5f8bb71be6636c8945f2bcaf74cbdbe5f72ced97f3fcb7a07afc56cf7fcbabde81f730002e51729f0fdccdd5d19ca4b406ae3c2d5e85e08152cb8957332809585bd6bda83e005657257efd3dce97f467ad838e584068c94587fcefcc7115a7fbb5d6e5908acb583488497e7cdd7aebcf9f311e0cf59a962dde5eff3bf3fcb3c70de2774c95f8c57d227ec7dc43fc2eed6bbc8f6a15b9a575a55e3ce6756bf5df29fb4c1f73f83dade589e7cf9fe3c8aac76ff5fcb7bcea1d780f03e039e27dcef1acaa07c05c5890d699d1b54136264eb734a89da2fa00585d95f8f5f7b8750b8fc533e7311e621c5cf20a84d82f6b09795c7fee4b3f8b40cd3cffd2990e171dbcacf8b97b0516f3f6b6fab375f9e62cc4ef982af18bfb44fc8eb987f85ddad7e4fd72d6da4f7bbfa67d677c48723e3e8e0584bcde251410eaa99eff9657bd03ef6100ecd101b906333f11daad35e0f8f53ce0491cf4ccff6e252ee70c92b7a6fa00585d95f875d2bc94d44b7ce647bc6cb1574873ccf9ea81387fd67b88612b0ee3b6e70246bc35c9e2e7c8f34b6bfd7ead356eb4787b5bffb7f1cc4eebf2cf6b227ec75489df5ba5f140634a3c09d07aed52341e68d9ad71ac22e277cc3dc4aff735f93bbdb61f96d63eccafb9901e0bf4b9b8eed75bfbec160a08f554cf7fcbabde81f73000ae8983e0526b15105a83da5a01a13568553a93384bf501b0ba2af11b7faf59ffa7f9e1a3f9a1a3717a8cc11c57addb8ce2d54139818ecb6a1db8e4e54beb69d1929f5fe26d8eafe7f5c7e4beb57ead2b171dfc2b10eac3fc2b0cfaecb92fe3fda3adcf734dc4ef983de357fb14fdff68f9fa53ffcedfbd5be7f120f671ebb54b691d035446fc8ed9337eabf0f7391fa3c6ef7a9ea671c65702c67d94f783717e17e9f3be2eee3333ef33e3b22920d4533dff2daf7a07dec300b8240e80f949b04b83e35a22d23a78f0bf73a2214bebb827d507c0eaaac46f3c607053a2aba438bf9e632126fd6afa3c4e7ee2c34663f21397a9f9555cf06bf91602598bdbf83468fd5d450027e95a96b741eff518118b215ab6dee37578feb8fe5874d0742f4beb6b153072f142f3c7cf18b76926e277cca5e35731120b4cada6f5552f2468fbf4dd1add2fb68a05add7cee1312a726233baec6b217ec75c3a7e2bf2b8d18a45eff3d45408d03cdae77bffa43fe358e3f9e3b2e2b1432c16c4e3634dd77183f7995e7edc677afea55b0bd73ec751558fdfeaf96f79d53bf01e06c025f1ac69be7c2a26352305845615d6e2e55bb734a89da2fa00585da5f8f5c1b3bfd3b93971cee2017d2bf971621f695db930a1a6f77b79715d8ef5d6fa5bc50f355f2d10c702f7b5d61f8b1bf13d5e57fe7fd118d37a4f5c57146f9588cdb75ae9efadcf734dc4ef984bc6afbe3fb1b0a5ef89f6314e74636cb6ceea55e22b7946bfdf716c597bed1c3e737a64c4ef984bc66f551e335ac7a879cc894dfbd4fc9e560141e25585f136bff89c84dc72bfb78ebf234fcbeb3eb2eaf15b3dff2daf7a07dec300b8243f2d3d8a075b230504afa375c01693a55b1ad44e517d00acae62fcea6040ffaf3a10d0f75f7f57329c1364cb07f44e78f45e1d40e44bf823cfab84c3cb6fc5a8e75b8b334df35513713e2d57ff56cbf7526bfbfc1e6f83d7958b1e7e8f5e77dff8acca12f7a5fbd1dba565f43ecf3510bf632e15bff1405e07ee4bdfa9788b4dbe6527d2f77469196bce794f8bc784d67e36d276ae8d0f796c597a2d537fae2d579cd89ce39cb8d5f62c8da1e7227ec75c2a7e2bd377446d2d1e34aec4fdd4d2fedefbadd6b2bc8c1cf3decf6a9fa9314bcb68bd5fafb5de6f5b3ec7d1548fdfeaf96f79d53bf01e06c025f160ca073eadcb846345b4959c58ab8010cf22eabd5a8fe68b050ab5730e286e41f501b0ba5b88df2d07f499e248f1db4a581cb7f71a53d744fc8eb954fcc67d59ef7bafff33ede3e27e4d74c01f1f1aeaa6c2448eb318b39a16dfe7cb8df381baafe6c967147de9b3e5f57b3d7139adedd46b799dadb1a5f59ae8732841895723f98aa2b85cbf3f371719fdef4c7d92af745aba2a2b2ed3ff5f7e4ddbd8fb3fde8af81d73a9f805ce513d7eabe7bfe555efc07b1f00f3c18c9b12ff7c09b19c5a4090a54bbce22d14973a20389aea036075b710bf4b07f46be2330a7ca6437f3aa696ee83c46511bf632e15bf4bb7cd6ca50439ee0b95b0ae15b91db38abf78ff724e74a35898d7b263b2aed7e21544de166f878b1d713bbd9cb89d7a2d163b5a634beb35f172b40c1523e272e31584f1b9255e8e9a4f0ef8f528eeebd5b4fcd8dffa771497ed6dcac711ad33bca7227ec75c2a7e8173548fdfeaf96f79d53bf0de0740ed847d40e0b30df900c44d345dfdd5ba3c59eff38151a475e8ec830f8c7ce9b2c4838f7b547d00acee16e2d7674f4ffd1cf116a09cbce4679a601fc4ef984bc5af13cd56617b0bc792f64f71dfa684d8896bdc4e27e18ebd56712116f1b47ff3fc795fe76d8ffbcda5cf13b7332e4785056f675c4eab58d07a2d9e2c88571be8efaded5e2a14b45e8fcbd0b679f93a2e8863d8d25518da5e170b6201a5750c722ae277cca5e2173847f5f8ad9eff9657bd0319003153f501b0ba5b885f1f749ff33954d08b85032d83e2c1f510bf632e15bffefe9f9b54c664358b49b193e85840c8eb6c150b9ca0e7c45f5acf1b582a202cad53e26d1c5e5eab58d07a4ddbe4ab0eb35681a35528587a3df6558b0b1ff12a04cf1faf7c30dfbe91af5a3807f13be652f10b9ca37afc56cf7fcbabde810c8098a9fa00581df18b9988df31978a5f279ce7dc0a17cf90b78a6f71ba971f13e5d6a5f4797e89b72b2851cf4583a85540888509bd5fcbce2dafb7552c68bd1669bbe2325b5760b40a054baf3be15f5a5f6bba97919f5321bded3f05f13be652f10b9ca37afc56cf7fcbabde810c8098a9fa00581df18b9988df31978a5f27e7f98cfd1631e95d4aea3dddb7def93dad33e4e2f96301a1f533a6da6e6d735e6fab8010b7b3d7ce2920f8f5a5766e01c19fa5550c10af37f6a59711fbcf96b6ff1cc4ef984bc52f708eeaf15b3dff2daf7a07320062a6ea036075c42f66227ec75c2a7e7b49ea9a9102c2d2b62f25c07ee650eb5714e2ba7b05047d4e4d5b6aa7dec210b74789bce6f1550ede96bd0b082aa6989791fb4f5adb7f2ee277cca5e2173847f5f8ad9eff9657bd0319003153f501b03ae2173311bf632e15bf4aec9554c65f3358a2e94a567dbf7feb1685a835fddc0242e6edce0976ab80106f6168dd6ad1d24ab65bafc55f76c845145fdd716e01a1758b42d49aee65b4faafb5fde7227ec75c2a7e8173548fdfeaf96f79d53b90011033551f00ab237e3113f13be652f1aba28093e0dec3f57c4fbf1263171b9cb0c684dde2c309fd00c4530b085a8ffede2a6e38218ebfdad02a208897bb74363f3fa0b1956ce7d7d60a1371dab90584d6e78b5ca0683d449102426d978a5fe01cd5e3b77afe5b5ef50e6400c44cd507c0ea885fcc44fc8eb964fc3ab1747219cfa4e75b07f2af21f8e704e39509a204f69487085a4e805d10681537bc4df1570e5a67e5256e674ef65554f0346b25dbadd75a3f8da86df776e7e5c6ab32f27b72bfc479b5fd2ea2e8cff87f168b05add7acb5fde7227ec75c327e8153558fdfeaf96f79d53b90011033551f00ab237e3113f13be6d2f1eb24da4d89714c825b49af28c98d0f38d47bf2fb6232db4a94a3fc9e788584127125d2faeec475c482475cbee6f11507793bb52c4d7391432d16465ac976ebb5d86fda366f97d615afc0d0ebfe4c2e66b88ffdcc04cf1bc5e56b7ebd377e8e5c58f1eb14106abb74fc02a7a81ebfd5f3dff2aa7720032066aa3e005647fc6226e277cc1ef19bcf9cc71613e04c497eeb7d4acef3ad014b89b2b512602d43cb8f89b3b7291734c409bae7b1a5edd46bf9aa8456b2dd7a4d85899cd46bba3f77bc52407f975814f1eb6bfda2e73de4cf1e9717795aebffaab5fde7227ec7ec11bfc056d5e3b77afe5b5ef50e6400c44cd507c0ea885fcc44fc8ed93b7e95182b09cd05801ebda795bc5e8ab647cb6f3d13e1145ece255d62bbd6f8ff243fac7106e277ccdef10baca91ebfd5f3dff2aa77609501d0072cbd1df7b907444772c9330c4bd6ce705c53f501b0ba2af18bfb44fc8e217e3113f13b86f8c54cd5e3b77afe5b5ef50eac32006e4d68af915ccf768dcfb8b5bff7567d00acae4afce23e11bf63885fcc44fc8e217e3153f5f8ad9eff9657bd03ab0c805b13da6b24d7b35de333faa7a37afdbdb7ea03607555e217f789f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1d586500a480f02faef119b5ec2dfdbdb7ea03607555e217f789f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1d5865001c2920e809cefab77e2ac9bfadac9f45d2d3a3f5a79e7edca2e728685ebdd7f3e9190b7179a6bfeb35ff5493df17f9752dcb3f55a5f9d79eeba0f768395e9fb7297f46d353a6fd33535a87b77b691d7a5dd3635fc4a758f7fa7b6fd507c0eaaac42fee13f13b86f8c54cc4ef18e21733558fdfeaf96f79d53bb0ca0038524088afc5df838e4d4977a444bdf5934a7abf7fb339aec309b7a6c5f799b63b2e272f3b27f89abfb5ad7a9f12fdbc7e893f6b959b9695d7a17ec9dbe179bdee5e7fefadfa00585d95f8c57d227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a075619009ddcf612dab502829b126d5d49a0b3f5bed75f2dfe72835f5782edb3f239e1d6bfcd05044d8f5717e4e529f97722afe94ed4f3551071fd5e8fe68fdb1b3fa3a6c5cf67fa8cdee6f8ba3ebfe7d732d5affead6bbfaed6ebefbd551f00abab12bfb84fc4ef18e2173311bf63885fcc543d7eabe7bfe555efc02a03e0d684b657408849bf28997782ed5b12e2d502f9e7209590b796e502829ae6899498ab70a079f26f3b7bdb94c49bd6b9f47963e21f3fa3d7aff564f1f378fdadf59a8a194bebbfb6ea03607555e217f789f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1d586500dc9ad0f60a08adf73bf9f67be2fc2d9ed62a20a818718a98dc5bbc9aa0c5058ff8193d7f2e5e98dfe3cf9f3f7314b7a9d55fd7547d00acae4afce23e11bf63885fcc44fc8e217e3153f5f8ad9eff9657bd03ab0c804e68f319fcac574068f165fbb980d03a3b2fbeed20ae632d21175de9e0072d7a5b72b3defaf3bae25509bde65b25bc8cf820c8c8f3534038b62af18bfb44fc8e217e3113f13b86f8c54cd5e3b77afe5b5ef50eac32003aa18dcf15686925f7bd02422e3ae47f6739818fafb56e2150821f9f9da06dd4fc2a5cc4670e586ffdf9218af18a815ed3b2c5dbeb7f679e9f02c2b155895fdc27e2770cf18b9988df31c42f66aa1ebfd5f3dff2aa77609501d0096dfeb584289e898f67d6630121ff1281e484dcf32fdd8ed0ba85602d21f7af36b4b6bf55dc68bd16b58a249ebf5760316f6fde1e597b06c3b5551f00abab12bfb84fc4ef18e2173311bf63885fcc543d7eabe7bfe555efc02a03a013dea5a45ee2c3ff62221d13f25642ec65fbea81b89cacf710c35601c1097feba714fdbeb8aef8a0c63cbfb4d6efd75a058116afb7f57f1bd79f7f1de2daaa0f80d555895fdc27e2770cf18b9988df31c42f66aa1ebfd5f3dff2aa776095013016019424c75f4750921d7fe2504586f8ac84fcde289e6d77d12116096241c0cf316825f06b05045fe190d79d6f3df0362fad5fe26789ebf7ad10ad22853ea37f5ad2d35c24515fc5a28aa67b7b5bebbfb6ea03607555e217f789f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1d586900f499fc98402b298ecf1750cb97f13be9f67c4aa6354f7cfe80a6c5c43b5e19a0f9b50c17285ab710ac1510fcab0a5a87a6ebdf9e5fcbf272636124ae5ff328e18f0592bcfefc20452d4b8501adcff3c702863e6b9e3fceeb3f5b9fe79aaa0f80d5558a5fdc1fe2770cf18b9988df31c42f66aa1ebfd5f3dff2aa7760a5015049724cfa737361207301419f235e41e0a664395ed1204ab073c142f369f95e464caebd8ea5843b2f4bcd570bc49f6d745fb7d6efe9f1f3442a18b8c8905bebe18e5a6f2ebea8a958b15610b9a6ea03607595e217f787f81d43fc6226e2770cf18b99aac76ff5fcb7bcea1d58710054b2affbf4151c6a4a785bcf36b09c70ab10a1e459af6b39f992ffc8f3c602432bb9d67cda86b59f99d4346dab5a9e4fef55cbb766f873c6cfe875e5a287e83dbef2e094cfa779f5a7b74bcbee7d9e6ba83e005657317e713f88df31c42f66227ec710bf98a97afc56cf7fcbabde81b73000e602c2164ec25b49bacfd4af152d7019d507c0ea6e217e715cc4ef18e2173311bf63885fcc543d7eabe7bfe555efc05b1800cf2920f85689787b83cec6fbea83b55f83c0e5541f00abbb85f8c57111bf63885fcc44fc8e217e3153f5f8ad9eff9657bd036f6100f4cf129efa39d69eb7c0d507d7517d00acee16e217c745fc8e217e3113f13b86f8c54cd5e3b77afe5b5ef50ebc8501d03f9778cee7d00313f53e355d75907f4212fbaa3e0056770bf18be3227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a6ea036075c42f66227ec710bf9889f81d43fc62a6eaf15b3dff2daf7a07320062a66f7fe7bba507c0ea885fcc44fc8e217e3113f13b86f8c54cd5e3b77afe5b5ef50e6400c44cef7cfd9b2f3ffdc6e7dfc9df4b6c43fc6226e2770cf18b9988df31c42f66aa1ebfd5f3dff2aa77e06b9f7af6fcf917dffe4dfe62027b7befb7bf7df9fac38b979f78edcd8fe5ef25b6217e310bf13b8ef8c52cc4ef38e217b31c217eabe7bfe555efc0575f7df6517d097ffc939fe6ef27b0ab1ffcf0478f0360fe4e623be217b310bfe3885fcc42fc8e237e31cb11e2b77afe5bde113af0e9e7bef0976f7de9edf77efe8b5fe6ef28b00b7dd7bef2b56ffcfa8fdefcd2dfe4ef234e43fce2da88dfcb217e716dc4efe510bfb8b6a3c4ef11f2dfd20ed1810f0faf3c3cfbc2f7f58554554b97c6007b51b55e3b5c7de7f4ddcb5f479c88f8c51511bf1746fce28a88df0b237e7145478adf43e4bf951da603dfff22aa9aa54b62d4f4700e3de1533f1342a38d367d97f4b021dd2fa8efd767dffaf2b7aa0f7e8742fcd2766cc4efce885fda8e8df8dd19f14bdbb11d357e0f93ff5675c40ed443399e7ce6f957f5f32034daa59a9e16ab870ee9bec1fc9dc3e510bfb43d1af17b1dc42f6d8f46fc5e07f14bdba31d317e8f98ff9642070200000000ee01f9ef203a1000000000700fc87f07d18100000000807b40fe3b880e0400000000dc03f2df41742000000000e01e90ff0ea2030100000000f780fc77101d0800000000b807e4bf83e84000000000c03d20ff1d44070200000000ee01f9ef203a1000000000700fc87f07d18100000000807b40fe3b880e0400000000dc03f2df41742000000000e01e90ff0ea2030100000000f780fc77101d0800000000b807e4bf83e84000000000c03d20ff1d44070200000000ee01f9ef203a1000000000700fc87f07d18100000000807b40fe3b880e0400000000dc03f2df41742000000000e01e90ff0ea2030100000000f780fc77101d0800000000b807e4bf83e84000000000c03d20ff1d44070200000000ee01f9ef203a1000000000700fc87f07d18100000000807b40fe3b880e0400000000dc03f2df41742000000000e01e90ff0ea2030100000000f780fc77101d0800000000b807e4bf83e84000000000c03d20ff1d44070200000000eec193cf3cff2af9ef000a0800000000807bf0dac38befa988905fc746af7dead9f3270fcf7f965f0700000000e0663c3cbcf2fac38b979f78edcd8fe549d8e8d5579f7d549df8e4c98b8fe7690000000000dc82274f9fbdaedc37bf8e133d79fafc5d5d85f0c94f3efc419e0600000000c09129d7d5ed0bdcbe7f090f0fafa8331fef0779faec75fd3bcf0200000000c0d1e86a7b9d3057be4bae7b29ef77a4aa31baa443cd0f97d0431669341a8d46a3d168341a8d463b4af3af2da870f078cbfed3e7ef523cd8891e2ae10ea7d168341a8d46a3d168341aed684d39ad7e3440cffdcb392f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008061afbefaeca3af7dead9f37ff5f4c5bfa3d168341a8d46a3d168341aeda8ed13afbdf9b19cf3e2121e1e5e79f2f4f9bbaf3fbc78f9e4e1f9cffef51fbef85f341a8d46a3d168341a8d46a31db1bdf6f0e27bca6fd5f46fe5bc390dc639deef4875ae0a074f9ebcf8789e0c00000000c0e13c9e287ff6baf25d358a0817e0eacc273ff9f007791a000000000047a65cf7f184f9d3e7efe66938d1e36d0b4f9fbd9e5f0700000000e016e86a7be5be7aee5f9e868df450097522977200000000006e99ae42d08f06e4d7b1919e4cf9782f080000000000374cb7ef3ff9ccf3afe6d7b1910a088f4fa40400000000e086a97840fe3b60ad80a0074d7cf013189dcb3cfc1f119fa510dfdf6b7aff29f3c7f7c5ed0000000000a0652dffc5066b1de8e7233c3e64f1e1f9cfd61e36a165683e2dcfafc5f7f79ade7fcafcf17d713b00000000006859cb7fb1c15a07e6847e693ee91510f4f7b5f6f813920f0fafe4d7e3329ebcf1c77f96a7f1d39300000000802dd6f25f6cb0d6814ede9fbcf1e60f3e48e29fbcf8789e4f7a058438efa9bc8cb8ec0f09c587b52b250000000000f7692dffc5066b1de80280a66bbec702c2c3f39fb57ef271560141ebf0ba63d32f4bc4e7310000000000eedb5afe8b0dd63a3016105434f09508ad0717ce2a20a8a0f1b84def6f9bb64bf3a878f0f85ae7b90d0000000080fbb196ff6283b50efcbd0242f8b79afe1ee79d5140d0ed142e14e4ab229e3c7dfeee3f1716fef8cfe2eb0000000080fbb496ff6283b50ecc05047162aeb3fc71de5e01c1cf27586a6b570a2c16109e3e7bddd3b85d0100000000b0662dffc5066b1dd82a203cdecaf0bbdb066242df2b20f45a2e0e446bf3fcde031e3ff3fcabf9ca080000000000642dffc5066b1dd82c20bcefb54f3d7bfe98b087670cf40a089abed6d6ae20582d203c79f1f15844f076e9d6057ee21100000000606bf92f3658ebc0a50282b860e069bd02c2bfbcf3746b0504d3ba5434c8c584b55b230000000000f7632dffc5066b1db85640d0d9fd0fcef83f79f1f1d9058428ae9787280200000000642dffc5066b1db8564010bdf731497f78fe33ff74e2350b088f571de8d687f40b0cf2c1b6bdf1e60ff23400000000c0fd59cb7fb1c15a07f60a088f0f544cb70c5cb380e05f84686d5feb8a0800000000c0fd5acb7fb1c15a07760b081ff9f02f2d5cb380900b18da4e1515fc9aae8c685d9d0000000000b83f6bf92f3658ebc02d0504f1950039c9dfbd80f0917f7e16839e73e05b281e0b076fbcf983c7072a3e79f1f13c3f00000000e03eade5bfd8e0a63af0e1e1157eba1100000000d07253f9ef0c742000000000e01e90ff0ea2030100000000f780fc77101d0800000000b807e4bf83e84000000000c03d20ff1d44070200000000ee01f9ef203a1000000000700fc87f07d18100000000807b40fe3b880e0400000000dc03f2df41742000000000e01e90ff0ea2030100000000f780fc77101d0800000000b807e4bf83e84000000000c03d20ff1d44070200000000ee01f9ef203a1000000000700fc87f07d18100000000807b40fe3b880e4425afbefaeca3af7dead9737d2f69b44bb74fbcf6e6c7f2770e9743fcd2f66cc4efbe885fda5a23fe704bf49d26ff1d4007a2848787579e7eee0b7ff9fac38b97cfbff8f66ffed37ffe2f2f69b44bb677befecd97fa7ea9fdd19b5ffa1b7de7f2d71067227e693b37e27747c42fadd3883fdc1af2df417420a67b7f47f4f0ec0bdf7feb4b6fbff7e39ffcf425b097f77efbdb973ff8e18f5e7ee56bdff8b5be731c045d00f18b2b217e7740fc6223e20fb784fc77101d88d954cdd60ee9e7bff865de5f01bbd0774d07cc9f7debcbdfcadf479c86f8c5b511bf9743fce254c41f6e01f9ef203a10b3e9923855b5816bd2d9367df774df6ffe4e623be2173310bf9741fce21cc41f8e8efc77101d8899f4501eed8474691c706dbadf570f0dcbdf4b6c43fc6226e2770cf18b11c41f8e8cfc77101d8899f4fdd3c3798019f470a84fbff1f977f2f712db10bf9889f81d43fc6204f1872323ff1d440762267dffb4130266f8f677befbf273ffe6dffeeffcbdc436c42f66227ec710bf1841fce1c8c87f07d18198890318ccf47fbef77f39001a40fc6226e2770cf18b11c41f8e8cfc77101d88993880c14c1c008d217e3113f13be6d4f8fdd5affee9e5dffedddf3ff63b8df65fffdb7f7ff987cfbef8fff43d52d33335f2770ca88afc77101d88994e3d80012e4907412420e7237e3113f13b666bfcea218b7ffe177ff5f8d4fdb7fee44f1fef7da7d162d3b334f4fd50d34f837ee4e1e195fc7d032a21ff1d440762a6ad0730c01e4840c610bf9889f81db3257e553c5072a8c2817eba0f58a2ef8a7e12f42b5ffbc6af1f9e7de1fb14115019f9ef203a10336d398001f642023286f8c54cc4ef982df1ebb3cb3fffc52ff324a049df95b7bef4f67b9f7debcbdfcadf39a00af2df41742066da720003ec8504640cf18b9988df315be25797a4ebac32700a5dada2efceabaf3efb68fede011590ff0ea20331d3960318602f242063885fcc44fc8ee9c5efcffee11f1f93405d9a0e9ceaf917dffecd6b9f7af63c7fef800ac87f07d18198a9770003ec8904640cf18b9988df31bdf855ffeaf605e01cfa6e7dfa8dcfbf93bf774005e4bf83e840ccd43b8001f644023286f8c54cc4ef985efcaa7fd7a6036bbefd9def129f288bfc77101d88997a0730c09e4840c610bf9889f81dd38b5f0a0818417ca232f2df41742066ea1dc0007be200670cf18b9988df31bdf83db580f0ab5ffdd3cbbffdbbbf7f7c1f8df65fffdb7f7ff987cfbef8fff43d53fbc46b6f7e2c7f078159c87f07d18198a9770003ec4907392420e7237e3113f13ba617bfeadfb5e9a6872cfef95ffcd5e30317dffa933f7d7c0f8d169b9ea5a1ef87da1fbdf9a5bff9c8c3c32bf9fb085c13f9ef203a1033f50e60803d91808c217e3113f13ba617bf5b0a082a1e283954e1403fdd072cd177453f09fa95af7de3d70fcfbef07d8a089889fc77101d88997a0730c09e4840c610bf9889f81dd38bdf2d05049f5dfef92f7e9927014dfaaebcf5a5b7dffbec5b5ffe56fe4e02d742fe3b880ec44cbd0318604f242063885fcc44fc8ee9c5ef9602822e49d75965e014ba5a45df9d575f7df6d1fcbd04ae81fc77101d88997a0730c09e4840c610bf9889f81dd38bdf5e01e167fff08f8f49a02e4d074ef5fc8b6fffe6b54f3d7b9ebf97c03590ff0ea2033153ef0006d81309c818e2173311bf637af1db2b2068ba6e5f00cea1efd6a7dff8fc3bf97b095c03f9ef203a1033f50e60803d91808c217e3113f13ba617bf5b0a086bd38135dffece77895f4c43fe3b880ec44cbd0318604f242063885fcc44fc8ee9c56faf40d09b0eac217e3113f9ef203a1033f50e60803d71003386f8c54cc4ef985efcf60a04bde9c01ae2173391ff0ea2033153ef0006d813073063885fcc44fc8ee9c56faf40d09b0eac217e3113f9ef203a1033f50e60803d71003386f8c54cc4ef985efcf60a04bde9c01ae2173391ff0ea2033153ef0006d813073063885fcc44fc8ee9c56faf40d09b0eac217e3113f9ef203a1033f50e60803d71003386f8c54cc4ef985efcf60a04bde9c01ae2173391ff0ea2033153ef0006d813073063885fcc44fc8ee9c56faf40d09b0eac217e3113f9ef203a1033f50e60803d71003386f8c54cc4ef985efcf60a04bde9c01ae2173391ff0ea2033153ef0006d813073063885fcc44fc8ee9c56faf40d09b0eac217e3113f9ef203a1033f50e60803d71003386f8c54cc4ef985efcf60a04bde9c01ae2173391ff0ea2033153ef0006d813073063885fcc44fc8ee9c56faf40d09b0eac217e3113f9ef203a1033f50e60803d71003386f8c54cc4ef985efcf60a04bde9c01ae2173391ff0ea2033153ef0006d813073063885fcc44fc8ee9c56faf40d09b0eac217e3113f9ef203a1033f50e60803d71003386f8c54cc4ef985efcf60a04bde9c01ae2173391ff0ea2033153ef0006d813073063ee3d7ef5d9d57efe8b5fe64925fde0873f7adc5efd790b88df31bdf8ed15087ad38135c42f6622ff1d440762a6de01cc25e8e0fe6fffeeef5ffef95ffcd5cbb7fee44f5fbefef0e2e53b5fffe6cb6f7fe7bb8f07d2bffad53fe5b7e04e700033e61af17b0d4eac4f691a573496a8fdec1ffe312fb2247ddfb5bd7ffd3ffe679e7448c4ef985efcf60a04bde9c01ae2173391ff0ea2033153ef00669412031fe42f3515158e72061197c501cc98bde3f75a9c589fd25434d0fbd48e5284f4e7bc85ff33217ec7f4e2b75720e84d07d610bf9889fc77101d88997a0730236252a0ab0de25942fd5d5725f88a048a08f7890398317bc6ef35a900a03121369da58fc582dcdefbed6ff362caa38080a817bfbd02416f3ab086f8c54ce4bf83e840ccd43b8039572c1eacddefaba2818b084bdbe1e4c2671bf5f77cc6d149457e3d5a4a3cb40dda462dfbc73ff9e9e232fcfed6bfb58cb87efda965aa4892d79769bad6ebcfb624afdfb786e8bd3d5a87dfdf2bd4681ef747af4f4771003366aff8ad208e214b96625ae2b8a1eff3526cb562b715535bc78948f3c7dbb4282020eac56faf40d09b0eac217e3113f9ef203a1033f50e60cea565ea4059cf3de8d1c1f552521baf52884daf699a693d6bebd301bfdfeb8379251ddecedc74f63327259e16efbd76df7939da21c733a7717939e1d0f2fffd7ff88f1f9a579fad758fb4a72bd9c9efd37bb4ee96d6f6b4e6d5e7d2b329f2bc4bf35f02073063f68adf0ab61410624c44f1bdb1296ef258136337c6959d3a4ee8df2a1ae418d5bc141010f5e2b75720e84d07d610bf9889fc77101d88997a0730e7f241733eb03f457c7e821364b55850f0d50d71de7c402f4ea2e3678d07f8bac542cbd69f7e2df74bdc16ffa9f9c50986a769d95a9e0b1b6ab1e021de2625ed9a57458e98a8e4b3a079fd7abf96193f472c52a81f624140dbaae6f9b52ef795fe8c9fcb7d1db7df9ff592388019b357fc56706e0121be4fdfdf3c6ee8cf18278eb9184723e3448c19c55f8cd1ded5564743fc8ee9c56faf40d09b0eac217e3113f9ef203a1033f50e60cea183791f408ff041b70ec86351407ff741bae6f16b3e386fdd3291a7c582439e3f6e7f4c34fc9adad259ccd6f25c28885747ac3d41dec949be0ac1f3e70448f2e7132f47494c9c5f7fcfdbeaa44bcbc99f4d0990a7b58a3323388019b347fc56714e01218e03397ee2153f5ab6c5d88dafcbda381163d83113632b2f2b7e9e5bf93f237ec7f4e2b75720e84d07d610bf9889fc77101d88997a0730e770c2b974e0ef7b935bcd096a2bc98d5ab724b412f53caf97dfbbe5c167ee6312e065b4de1393902c2621592e04c8d2fc6beb6f7d1e6f534e6444eb88f771b73e6fe475b7fe2f46700033668ff8ade29c02428cf5168f4d2e3c4a8cdd5c206bc55594e3a635d6449e762bff67c4ef985efcf60a04bde9c01ae2173391ff0ea2033153ef00e61c4b09b0c5c420372702f12a807c465c5a67f09792071716e21949270dfa53db939b1383f81e2fbb95447b79ad4463ed8a0c3f08d107826af1b683686dfdeed3f87fe9f9f3150e2d9e375ef21d5bebcced25700033668ff8ad228e134bf277dcef89b7e1c4169f0762712cc87ae3448efbb8fe16c7766b5d4744fc8ee9c5afbf634b7ad3d7c442fe2df2c3517b9f4f853ecd938f33e2fb7b4dcb3865fef8be99885fcc44fe3b880ec44cbd03987368c7e883f4d60e5209b09365b79c08f48a1092df234e74e3f3037c49737ccdefedb5ad09b93f8376c859ec0f53bfc4fba8975ab4b67e272ededed6e5d56bf27a975aebf38de00066cc1ef15b85bfd36a4b3cdd3111dfd36bb616bb1e3b7acdff07390e33af6b69fad110bf637af1db2b10f4a6afd9125f47d63aae68f1fe39f7637c7faf6919a7cc1fdf3713f18b99c87f07d18198a97700732eef20f3830097e41d6aaf08a1d7f27b243f3fc05725c44b96255e61e0b301ad1693efd6fa6c2d09899fc5e26d1edab6b89ea5e2c9dafa73e2b2d43f4b3caffa2ff7416cad5b2e4670003366aff8ad604b8293bfe37e8f12fffcddcdcdd662d7d37485417e7f6c8e5faf3f8f37460101512f7e7b0582def4355be2ebc86242af786c1d4788e2b715937ebfdeabbfaf35c5bff69df9f5fc60d6dcb614f7f744fc6226f2df41742066ea1dc09c4bcb5c3b90ce7222e09d7a7c2d5a9aee33efbe84d8972ce75f10f0f69df2d95beb332faf9584c46d35df5bdd7a6861eb326b595b7f2e2088cf9ee65f7f101502623160e9c1737be30066cc5ef15bc1960427c7442ccc6d2d76adc5aea7a9e0b8456f9b3ded56fecf88df31bdf8ed15087ad3d7f4beab47e7d8756bc5b7f40a084befdba27a1f13bf9889fc77101d88997a0730e78a49732f298dcf2e88c9b193dad60edc3be6d6bdc6beba40cbf5327232116f1f685122a2f7c7b316ad6db4b5838d5601c167265a7d132f9bdebafe5601c1458a5c3c91fc4c03cfbb54f0717f5c1a073063f68adf0ab61c7ce79888b7eeb49e15e2678ec4f1602d76e336b4ce606a1d6a9e16633d1706e383616fe5ff8cf81dd38bdf5e81a0377dcd96f86a8967dbb5afd59ffa77febe9bbef78a11edebb47fd1be26c7a079b99a5fd3fdf9b49e18675b38ae6341beb58d330b08fa3cfe8c6efa776b3bf740fc6226f2df41742066ea1dc08c883b6e1f00f8405f7ffa59089e470717f1a022ee7cb52c9f358fcb6deddce393d6bdee4c3b6e27ea3e60b1f8b3857bddc2e0cf163fb3b641ff8e0584d6731b5aebf7f2e2ff655caf5ed77a7cc0e2cfd74a7c34afd7a1e9eeef38ffa570003366cff89dad77f02dad98702ceafbaa65c4ef78ab28b916bb719cf01866f16a87384e78dcd1fb348f632ec6f5adfc9f11bf637af1ebe472496ffa9a2df195c5ef7c6efa7ee7a25d8c9f56cb4569c7a2f6394befdb9a5c7b598a7bffbdd557b30a08f10447ab6dfd9c23885fcc44fe3b880ec44cbd0398517107bad6e281bee9dff11709f2ce56db9ddf23f14c9f5a3ea831bd1e9799979fdfe7d75b09fcdac146ab8090b7d1ebd69f3a708845123fe17d6dfdeee7fc7f1997935bded6fc50c778ffa6b7ebd2388019b377fcceb476f06dad98d0f7348f1b31b6f3553f6bb12b799c8871a196c709fd3b4e8fdbe12b7d6ee5ff8cf81dd38bdf5e81a0377dcd96f88ae23e4b31e0efbd8a0a71ff15f7c9dea7c442b96275e9e7511d8b711d5a9e8beb7a7d294eb3584088db9e6fe99b5540886381fb269e20d1e76d1ddf5c12f18b99c87f07d18198a9770073093aa0d78e4a3bccf833663ab858ba9431d23c9a5f0727daa9eaeff920208b0f345adb093b59f7c182fed4b6b692652faf352d5e7a99697ebf37bfaef7e933a95fe265a0be7c53aff91684b5f5fb6a8ed6ed0a9aa6be6fad27f3c19de6537faff5c725700033e61af13b8bbfd36b9f6f29267cd65fd33c6e28ce9d90446bb16ba78c13a2b38b8a23ad5bb1a4f76adeb5383d22e2774c2f7e7b0582def4356bc96d4b4c6c33c5949715e3c83193af348845f5b8ff777ce5d7c5c588d615852d5e968b8bfebcb9c831ab80e0d75bc7327a5f6bacba34e2173391ff0ea2033153ef0006d813073063885fcc44fc8ee9c56faf40d09bbe662db96d89c5b3165f21b0b538e675b76e3f6a1529e2b392b6c80504f136c62b1f7a05049fb4586b4bd6fa385e1db856bcdc13f18b99c87f07d18198a9770003ec89039831c42f66227ec7f4e2b75720e84d5fb396dcb6f46e2158ba3d275e41e8f5c5d62a20b48a10ad5b01d7785971f971197ebd5740d8d296acf571eb3646f5e1358b09c42f6622ff1d440762a6de010cb0270e60c610bf9889f81dd38bdf5e81a0377dcd5a72dbe2795b97dc8b6f3188db1393f078263faebb55406815292e514090f85c06dd22d02b20e84a01cdb3d696f4fa584504dfc6e8f9d4544cc8b77dec81f8c54ce4bf83e840ccd43b8001f6c401cc18e2173311bf637af1db2b10f4a6afe925b7999f5fd4ba3a409c70fbf68098f0ebbdf17e7e3d0fc4d3ae5d4088bf0ca1f5f40a08ad6dd9ea943e569fc40752b66ee3b834e2173391ff0ea2033153ef0006d813073063885fcc44fc8ee9c56faf40d09bbee694e4569c502fad2f26e5e2e5ebf5fcb0d1b8ee6b1710243e4fc13f4d993fd7dab66c756a1f4bfc1597d6b65f12f18b99c87f07d18198a9770003ec89039831c42f66227ec7f4e2b75720e84d5f736a72ebc45605819cd8c6e4ded39c98e733e9ba6c3f5eb21f6f89584bda2f5940104f77e123f7e3dab66cb5d4c72a60f85791f22f2da87fbc4d4bdb7e29c42f6622ff1d440762a6de010cb0270e60c610bf9889f81dd38bdf5e81a0377d4d4c6ef5f7a51693d898f8ebd9004a849d68e7243c3e2450b7356839fee501ff44b0feae24daf7fbaf25ed972e20c4443d6fbbac6dcb564b0504893f69ade28cb6d33ff3dada9e3d10bf9889fc77101d88997a0730c09e38801943fc6226e2774c2f7e7b0582def43531b95d6b318156d2edc437371509f2d9743fb03036dfd2907f8540d692f64b171024f641eec7b56dd96aad80900b18b1a950b3b6dd9742fc6226f2df41742066ea1dc0007be200660cf18b9988df31bdf8ed15087ad3d728815592da6b9a2ff22f1768dd6aba05612dd9d5fc3ab3ae79f3b310f46faf27fe3baf53bcdeb575455e562e6a645ee6d2b6b5b665abd8c74bb41ef58faece505fe6edd813f18b99c87f07d18198a9770003ec89039831c42f66227ec7f4e2b75720e84d07d610bf9889fc77101d88997a0730d5c433236e5bcf10f872c2237dde5bc701cc98a3c56f16cf402e9d093c129dedd4775a9726abe9126e7da6a5cb988f8ef81dd38bdf5e81a0371d5843fc6226f2df41742066ea1dc05493ef1574d3c1baeec16c5d2669150b08474f9846710033e668f19b69db732cc7a698d6e5bd47e127cfbbe9f32d15107c79f39111bf637af1db2b10f4a6036b885fcc44fe3b880ec44cbd03986a7c20ae073969bbdd72e2117f1aaa2a9dad749271af38801973b4f8cd1cbb7a68588ce7fca0369dc93f027f1efdd9bb32ca0f983b32e2774c2f7e7b0582de74600df18b99c87f07d18198a97700538d138a7ce64ec9b87e0a4a672c3d8f1e4a5499cf4c1ea9ff2f8d039831478bdfcc09b7be07991270c7b3ae303a82b5cf9379de23237ec7f4e2b75720e84d07d610bf9889fc77101d88997a0730d52c151022ffd6743e73a9f76887192f89f66b7e9682feaef7c7db0a7c5fb39219f5959afebd76eb81a6691eb77c19b60ffcb48d3afbda9a47dba4edf73a7d8b467eaab4b7dbd3b41cbd2f2faf220e60c61c2d7eb35ec2ad385a8b797ff77b31228e11d1721d5b8af7d6b24d85c918877e5a7ae47144b1ac6df5762806637cc679fdb93cad77c54245c4ef985efcfabbbda4371d5843fc6226f2df41742066ea1dc054b3964c44ad33973e688f9fd7afc583ffb87c251af1f5d8b4ec5692aed75abfef1c7f273b4f538bdbd5fafd6c376d4f2c5ec47bace3fb9cb054c601cc98a3c56fd62b20c4ef764eb095d8b7e24ccdbf351fc5b8c8f3abe9b6896c2d0eb5ed8ee7a5656a9ef819d6e6ed8d691511bf637af1db2b10f4a6e3ffb3773faf9265d79de87ba099a6fe03f40fa81008deab2cc9a5942c65576659d52aa306d16a3f683c78089bc220f040d0fd061ef5c408e3811178e081fb0d8404ad59e3410b8c9f69cf0a21cc0309cd640f1e9246b27115e4cb75552b6be7d63e27e2de75e3aeb8199f0f1c326ffc387162dff38db3d68e1371d923bf74d2ff1619403a1d2a60cecdb1c5f6f8656679dbbd098458c62f62ccc620270fe2ba5c4f3432d9f8c432bedb393e6eac2b6e1b8d4e7ea6fbd08446181b8e78fc989088c7189b99f13ee3edf3ba58f7dc409d23054ccd7dcbef6c6f0261ccd9dcdc471e72f220aecb7d3db290998d7f477346c6b303f2f23133717d5e1e671d64ce23e3f9d8f3c7a4727bc7d7a7790221ad2ebb6fe4b7e6507e0f4d101cba1ef6c82f9df4bf4506904e870a98739345f7a10984b1683f7602613e2d796c2056cd784e0a8ccd4f5e36afebd8ed0979f6c4dc3485988c98b7695cf7ea3ee74c015373dff23bcb867bfe12c5bc3cf3319f7d90137571bfd5c715b2c11fcf10caf5cd130b21271dc6dc8e1f4798650ee371c6c73781c0751ccaefa1098243d7c31ef9a593feb7c800d2e95001736eb2e83e348110e6dbae1af67102616e5256b71fe51901797dfe558563b76f6bfdb98e71626294d767b3333628ab8f549c33054ccd7dcbef6c9c28582dd1a0c73e326733ef379f019072126ecc50ae73f5171df27b53c6b1ccdbc764c14a5e3f66dd0402d77128bf8726080e5d0f7be4974efadf220348a74305ccb95915ed2bc7bee3bfba2c8ddfa310d7cf4bbe4399f7dd6a14b66c3d76ae63ab71c9c7cde6687cdcd5bbb1e74c015373dff23bcb863bbfc8705c62326cfc738e63e6f3f2d5990bab6c86bd5ccd591cbfbc71fe93b1b9e4590ee3faf2f99840e01887f27b6882e0d0f5b0477ee9a4ff2d3280743a54c09c9b5533b1327e17419a9b84adcb5236038796fc5e83d563ee593df6316731e476ad2610ee1b054ccd7dcbef6cde975772226ffc28c19cc1ad651c9bbc6c95ab398b63a60e2de3b69b40e03a0ee5f7d004c1a1eb618ffcd249ff5b6400e974a88039375974af9a80d1ea7b04e62661ebb2b43aad79cf75cf04d87aec5cc7d6c711f29d4f1308dcb7fcce8e994018bff7233fca90f7dbfa08c34aae63f5da3167311e276fbf3a63618b0904aee3507e0f4d101cba1ef6c82f9df4bf4506904e870a9873b3d704a4f1af158c4df8dc246c5d365f776c917fe8ec81384321d699d76d3d769e7ebdfaacf6f818f9dcb61a94fb40015373dff23b3b6602613cb32727e6f27eab2f44dcb297cd5516f3f6ab1c6e3181c0751ccaefa1098243d7c31ef9a593feb7c800d2e95001736ef69a80f94fb2cd7f9160d524ac2e4b63e1bf3a1b20ce728865dc966c20e2f2f12c84715df92e6a3ef6bc9d79e6433447f397c78d7f1922afdb6a50ee03054ccd7dcbefecd00442ece3f97d07e34718f6fe424ae42ed63b67336fbf7aed58bd0ee4b6c5e3cf6714c563c6e531b950fd2b0cabedb92fe4b7e6507e0f4d101cba1ef6c82f9df4bf4506904e870a987393457734d971f0cb65fcb2b52cfae7c662d524ac2e1b654390b789d3996319bf60716cf2c7d3ad6302201a9d780735cf2a184fb91e1b8b6844b2891a4f9fce6fa19fbf506e6b3df78d02a6e6bee57796f98a7fc73cc79219cb65fe2841662af310d7474ec68f2f8dcd7dde76d5b0af5e0756398cfb6ee5395c6702619ca0c875df37f25b7328bf31be95eb618ffcd249ff5b6400e974a88039376343b15ab2d05f593509abcb46d180cc8dccb8cc9314617c77745ca25198cf2898d79da299c8ef3a9897685ab6ce6eb86f143035f72dbfb371826e6bc9bfd0301bcf4e989779622fe475ab756dbd0ec4a4c4560ee7db86eb4c208c97c7b2f5ba75cee4b7e6507e0f4d101cba1ef6c82f9df4bf4506904e870a98731307bcd512c5f87c9af12c6e13b71d3f8eb0ba6c25260af23b0ce2df5513328adbc73af3f6733393629b635db9ccd7450393cf31d6b39ab0c88f6ec472df28606aee5b7e67999139cbb96ce5669419decb48c8dbacd6b9f73a30e7306eb35a47c8e7335ebf97cfd8d6eb3cd77323bf3587f21be35bb91ef6c82f9df4bf4506904e870a183825054c8dfcd2497e6b0ee5f7d004c1a1eb618ffcd249ff5b6400e974a880815352c0d4c82f9de4b726f23b7f89eee8d004c1a1eb618ffcd249ff5b6400e9a401a19302a6467ee924bf350f1e3cf9787cffc5eafb71727cf7f27de87ad823bf74d2ff1619403a6940e8a480a9915f3ac96fdd273ff9f0377eebf1db7ff3f84b5ff9d7f80e8f797cf7f27de87ad823bf74d2ff1619403a6940e8a480a9915f3ac9efed79f85b6ffdc7fceb1e7936c2a1098243d7c31ef9a593feb7c800d249034227054c8dfcd2497e6f579c8df099475ffc87381b21c6f6d004c1a1eb618ffcd249ff5b6400e9a401a19302a6467ee924bfa79167237cf53f7d6d7782c0040215f24b27fd6f9101a49306844e0a981af9a593fc9ece2baf3cfae8eb5ff8edffb5976f130854c82f9df4bf4506904e1a103a29606ae4974ef27b5a87f26d02810af9a593feb7c800d2e9508102a7a480a9915f3ac9ef691dcab709042ae4974efadf220348a74305cacb200e92b1bcfb831fce57bde0a7fff84f57b78b7f537c1b76deff9825cc971d5a0e6dd7cb2c9ebf02e6e6e4f743f27bf7e2f9cbefe944bedff9fa37e6617f2ec6ff65cf3fa723bf74d2ff1619403a5d4203125f4695cbcf7efe8bf9eae7e2601ab7897f533423e3fd0f2d61beecd0f2b28fff1e054c8dfc7e487eef9efc9ed683074f3e1efb584c22e49f769cc7ff92f73f6ae4974efadf220348a74b6b400ebd9b13b7d96a40fee22fffea85771e574bae675e7ef7f7be76b58ef877bece3b980a989b92df0fc5be24bf772b9ebffc9e56fc69c7df7afcf6dfc49f76fcdbbffbfb5f1bff973dff9c8efcd249ff5b6400e974290d4814fe6ffece57affe3f1761290ea67b0dc8786af475c518c73a5ef6b1be2e054c8dfc7e487eef9efcde9dfcd38eb10fe6d9082610a8905f3ae97f8b0c209d2ea50189e7188d47fc3f1a91add3413b1a90f7de7fffea31b341ca256efba31fff64bef94b45015323bf1f92dfbb27bf772bce46f8cca32ffe439c8d10636f02810af9a593feb7c800d2e9521a9058429c021dffff933ffdf3e9567d0d486c4b3646717d9c6a9da74cc7b2f7b9effb4e015323bf1f92dfbb27bf3df26c84affea7af2df7493886fcd249ff5b6400e974690d4814f3f9f3fcee60470312efa46ead3f1a91b87cef73dff79d02a6467e3f24bf774f7efbbcf2caa38fbefe85dffe5ff33e09c7925f3ae97f8b0c209d2ead0109dffeeef7ae7e8e7709e3f4e374a8018953a8e3e7ad65ef9dc6ad06646c88e62f638b6ddb5be7cb40015323bff2db497e7b5d42fe391df9a593feb7c800d2e9120a90b90189c23e4f318e66241d6a400e2d7be3b8d5808cd7e563bfec4dc748015323bff2db497e7b5d42fe391df9a593feb7c800d2e9120a902cee4771fa735e9e05ffa106244e458eb1da5ac66666b6d780c463cc5fc0163fc7fa565f16f73251c0d4c8affc7692df5e91ff97f92332e7265e6be2b527f6fb5822e3f9ffd592673d9deba4a2fcd249ff5b6400e974a90d48c82f3fcb02ec500312ffbfa9bd0624c529d0b14d73335279dc73a780a9915ff9ed24bfbd1e3c78f2f1ccc0cb3e59d521c6342601f2b52697d8e78f597eebf1db3f1aef177f3d235e43e2fb517272a193fcd249ff5b6400e974c90d481407e3df96ef6e4046e33bac516cbcac143035f22bbf9de4b75ffc69c7678deadf44731a39a026270df22fbe7ce60b6ffd7f0f1f7de98f1fbcfee8b518eb79fc8f115f78f98957dff858ac235eb33fffe697bf354e2ec4eb4a64e9aecf54905f3ae97f8b0c209d2eb90109e3df96cf6f4dbfeb0624d63b7e195cca6d8b6575fdcb40015323bff2db497ecf47fe69c7d8479d8d707d91e33cd320260d3efdb927ffd74d270c8e15130b7116c9a73ffbc5773efb6fffddff7bf55af6f657de8bd7b2f9afcc9c82fcd249ff5b6400e974e90d48c8771a72b9cb0624bf517ef539d67c4775becfcb44015323bff2db497ecf4b34bc9f79f4c57f88b311c61cb016137bf1d1a3fc52d6cfbdf1f677a2a19fc7f5aec484c2ab9f7af4f8e1a3b7be1fdb939309a73a33417ee9a4ff2d328074d280bcf8a7d8eeba018902261ba07c17351e3f6f5f7ddc73a780a9915ff9ed24bfe729cf46887df365deff6e2a721bfb6e34e8314e71b64134eff33876cac98447bffde5ff277f9731d9719b6733c92f9df4bf4506904e1a905fc97712efba0109d100adbe7c2d0a8697fd33ad0a981af9fd15f9ed21bfe72b1ad078473df6c598d8bacdc6f3beca89833843e3eabb0d7eebadfff86f1e3efcc83c76e726ce2c89ef6178b6fc3ccf2eb98d8fa9c82f9df4bf4506904e97d080dc27511444a37329c59e02a6467ecf8bfc726ee28bfbe20bfb2ef9630df3c441bc6ede8789835ff36c9b63dbf30b186362a8329120bf74d2ff1619403a6940e8a480a9915f3ac9ef3df1acf18c77dbe31decfff07ffc9fefc7a9f097e0a59938588889a1f8be8bca4482fcd249ff5b6400e9a401a19302a6467ee924bff74b7cac213eef1f4d677c71e0cb3a91f0324f1ccc622221ff82c3753faa22bf74d2ff1619403a6940e8a480a9915f3ac9effd141309f199fa976d22e192260e6637f9a88afcd249ff5b6400e9a401a19302a6467ee924bff7db3891101f6d882ffdbcce3bd8e7e292270e5e307d54e5473ffec93c542f905f3ae97f8b0c209d34207452c0d4c82f9de4f7e570f5d186cf7ef19dfc96ff38153efebac8b98bcffdc75f60b9f88983c9383114c787adef47905f3ae97f8b0c209d34207452c0d4c82f9de4f725f3acf97ef5538f1ee767eaf34f916e35a05d6272232639621be3b4fdd8661307bfee13afbef1b167e3f3375b1f6b905f3ae97f8b0c209d34207452c0d4c82f9de4f7e515cd67bc8bfd992ffcbb9f45a3fe277ffae7ed9309f15d0d31a9f1c1c4c1dfc4e7fee7ede6d7c5c71a7242683cb3447ee9a4ff2d328074d280d049015323bf7492dfcbf0e0c1938f8f9309d188c64707e233f6a7fece84788c38db20de458fc78eed88c98d791bd9171f6bf8dc1b6f7f27c630cf46905f3ae97f8b0c209d34207452c0d4c82f9de4f7f244f31eaf3bf17b8f6634bf8031ce5088fd21ce12f8e93ffed38dcf548877c8631db1be9c3488b30d7c4ce176c438c6775dc424508cb1fcd245ff5b6400e9a401a19306a4467ee924bfc484c2074de91fc7be9067298c930bf11a75cc92f789757cfecd2f7f2b3ea210ef9ccf8f49cdafbe64f1adefc758cb2f5df4bf4506904e1a103a69406ae4974ef2cb969858c8c985789d5a2d0f7ef3f11fc45f7f78fef383271f3761707762cc3ffdf0cdff7bbe1cee82feb7c800d2490342270d488dfcd2497e01b809fd6f9101a49306844e1a901af9a593fc027013fadf220348270d089d342035f24b27f905e026f4bf4506904e1a103a69406ae4974ef20bc04de87f8b0c209d342074d280d4c82f9de417809bd0ff1619403a6940e8a401a9915f3ac92f0037a1ff2d328074d280d049035223bf74925f006e42ff5b6400e9a401a19306a4467ee924bf00dc84feb7c800d2490342270d488dfcd2497e01b809fd6f9101a49306844e1a901af9a593fc027013fadf220348270d089d342035f24b27f905e026f4bf4506904e1a103a69406ae4974ef20bc04de87f8b0c209d342074d280d4c82f9de417809bd0ff1619403a6940e8a401a9915f3ac92f0037a1ff2d328074d280d049035223bf74925f006e42ff5b6400e9a401a19306a4467ee924bf00dc84feb7c800d2490342270d488dfcd2497e01b809fd6f9101a49306844e1a901af9a593fc027013fadf220348270d089d342035f24b27f905e026f4bf4506904e1a103a69406ae4974ef20bc04de87f8b0c209d342074d280d4c82f9de417809bd0ff1619403a6940e8a401a9915f3ac92f0037a1ff2d328074d280d049035223bf74925f006e42ff5b6400e9a401a19306a4467ee924bf00dc84feb7c800d2490342270d488dfcd2497e01b809fd6f9101a49306844e1a901af9a593fc027013fadf220348270d089d342035f24b27f905e026f4bf4506904e1a103a69406ae4974ef20bc04de87f8b0c209d342074d280d4c82f9de417809bd0ff1619403a6940e8a401a9915f3ac92f0037a1ff2d328074d280d049035223bf74925f006e42ff5b6400e9a401a19306a4467ee924bf00dc84feb7c800d2490342a76f7ff77b1a9002f9a593fc027013fadf220348270d089ddef9fa379e7efab35f7c67de2f398efcd2497e01b809fd6f9101a4d3ab9f7af4f8f197bef2af736108a7f6defbef3f7dede193a79f78f58d8fcdfb25c7915fbac82f0037a5ff2d3280747ae595471f8d22f0473ffec95c1fc249bdfb831f5e3520f33ec9f1e4972ef20bc04de97f8b0c20dd5effc25b7ff6e6db5f79ef673fffc55c23c249c4bef6fb7ff847fff2b937defecebc3f723df2cb5d935f002af4bf450690760f1f7ee4e1a3b7be1f0561bcab14a7a6c2a9c4bbe5d1f0c63e17fbdebc3b724df2cb1d925f00aaf4bf450690b3f0ac108c7793e294d458e2cbb1e21bb6e3cf74592cd525f6a5f8b2bff8bc7eec5f9f7ff3cbdfd27cdc22f9b59c70915f006e93feb7c800726ee24bb11efce6e33f883fcf65b1dcd612dfd61e5ffa179fdb9ff7396e8ffc5a4eb1c82f00b745ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b8040f7ef3f11fe87f0b4c2000000070095e7df8e4af631261be9c23bdfaa9478f1f3c7cfcd3f97200000078693c7cf891d71e3e79fa8957dff8d87c15477ae595471f8d417cf0e0c9c7e7eb000000e065f0e0f547af45ef3b5fce353d78fdf137e32c844f7ef2e16fccd7010000c07d16bd6e7c7cc1c7f76fc3c3871f89c1bcfa3cc8eb8f5e8b9fe79b000000c07d1367dbc71be6d1efea756fcbb3818cd99838a52396fc7289f892458bc562b1582c168bc562b158eecb927f6d21260eae3eb2fffae36f9a3c3891f852891c708bc562b1582c168bc562b158eedb123d6dfcd180f8debfb9e7050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ca5e79e5d1475ffdd4a3c7ffdbeb4ffe83c562b1582c168bc562b1582cf775f9c4ab6f7c6cee79b90d0f1f7ee4c1eb8fbff9dac3274f1f3c7cfcd3fffd334ffe9bc562b1582c168bc562b1582cf77179f5e193bf8efe3696f8397adeb90de6269e0d640c6e4c1c3c78f0e4e3f3d500000070ef5cbd51fee8b5e877633189700b7276e6939f7cf81bf375000000709f45af7bf586f9eb8fbf395fc7355d7d6ce1f547afcd97030000c0cb20ceb68fde37bef76fbe8e23c5974ac4203a950300008097599c85107f3460be9c23c537535e7d16040000005e62f1f1fd07bff9f80fe6cb39524c205c7d2325000000bcc462f240ff5b70ec04427cd4e1c167ffed7f8eb315f2cf62c4cf17f7dd09f12d9e0f9e7c3c77bc589eff7d511f03010000385bc7f6bf6c383480f105130f3efbc6bbf9f733574bdcff3e7d11457cf3e64d4e5b89b19a9ffbbc744da8dcf4399dd2396e13000070b90ef5bf1c706800e32c83abc6f8ea4f5e3c7a2d270aaefe0cc6b39fe3f26c9eefc53bf0cfb631273de6abf6e438fc6a92e05963fce0c9c7630ce2cc83fc1e89e7d73fbb6ebeff49ddf0399dd4396e13000070d10ef5bf1cb037803159904df1d63755e65f71d8bb4d8adbaece5488cbae3e027084e7b7bde6644534fbf16f6eefd6735e8977d13f9c3cd83ec32027116252251f6f168f1fcbd6f55bb6c62e5ce7395dad633576cf2edb7b8cebca3f91b2b74df9bb3cf6770f000050b1d7ff7284bd01cc263096bd86f7eabaa9298d75c6fdf2bb13723d570df667df78379ac7b8df0befdc3fbb3cb6675ed7d5f70ec4bbfec3d90ed99cae262df2fa788c5c7fdc36b7695ee6fbcf3edcbe7ffb9fe7eb5ef06c3be33162c2e185463cb67f98841897d529fe79ddafcef278fccdf1f657eb1fce70d87b4ef9918b58478c538edfd8b0c7e3cf1f518931bbdaaef9f7f081d5ef73dc3f56db34ee635767ae4c8ff9ab335c1e7f73eb31010000aaf6fa5f8e706800b3d1db7b577d259bc86ce0f3cb17c786f2f93bf63139303494f3a44036df574d667c71e3d4f4ceef9a3f6f4a87db5c3dc6b3c635b721272bae262c76c473ce75dcb4b91d9ff7f3e7304c86cce3ff7c1c727c9e6debdcb4e7b6ec3da7e7130853b39ebfc79c20cae63d6effe2984d675b7c3041f2e1f58fbf394e00e5e3aeb629d715ff8ef7ffd5633e1b8fdccf0e4dd2000000dcd0a1fe97030e0de0f81185ab46339af77807fc4033fd62a3f961233a9ed5104de3fc6e78369679d9f8318ab9a1cd77e7e777f1f3f6799f7182219beabde73c8ac98cb87d34caf375c718c7ef85b32b9efd9bdb12cb380ef3f6e7e5719fd5e55bcf695c7f5cf7c24705e2ac880f9af6791225ef373fe71cefb8fc85dfdb30299093135bdbb4f53bbb3adbe28349957942080000e0361cea7f39e098019cdffdce251ac968b0570d5f4e20cc4de80b13025313998d683492e3edb73e279f4dea78fb306edf7879d86a6cb7e4733ff6f6b3e75f42f9ac599faf0bd9348f4dfcf3f1192652d287efec7ff84efdd6731a2710e6c997fcce83ab719d2683c6498ff1ac93e76709ccebfa37bf7aac580e4d20bcb05f1c9884020000b84dc7f4bfec387a00e31deb074f3e3e9e6e3e2ef3c70e9e37ba3b9ff19fff5ac1d8b88e97a7abef5378d6bc66b33a4e6c8cb7dbdaa6b0d5d86ec977cc8fbdfdecf9382c2603425e3faeffbadbbfba6cbc7c9e60995dbdfbffec7791e33a4e3c3c9fb819ce7e38e6a32c5bdb34fe8eafbe6b613a43040000e0548eee7f59bbe90046d317cddff81d09e33bcacf1be7c59f34fcb5e6f4035b1308e317006e2de3edb7d61fb61adb2d79fbd5d90cc7d8fa98405a6dcf75b77f75d9dee5e96a42663119342eb90d5bbf9b2d7b8fbdfa42c9d88eab091367250000002772d3fe970f5407706c2cc726392710568df3dc9ca65593fac297eec577303c78f2f1b85d2ce33be5e37ab6d61ff61adb9517b6e906cd6d7ec9e06a1c4236d3773d81307e39642cd1bce7b88e63be9c4038621cf61efbcab3755c4d0c4d7f6562eb4c0d000080aa6aff7bf1f606f0ea2c83670dfba153cc9f7fce7f3855fef626103e6c30e7db1ffa08c37cfb70b0b19d8cdfd9b07a2eb3586f2c799a7f8ec3d6e3e5f563e37cdded5f5db67779c82f875c3daff1badc86711c56db35db7bec95f17779687f030000b889bdfe97236c0ee0f02dfdbba7efc7ed3ef878c1f8c57eb73681901f91587c97c278fafd78f9d6fac3751bdb30fe19c9bdcfff8fa7e6e7633fbfefc69728e6edc7719ad7315a6dffeab2bdcbc3f81715b6ae9bb7e1f9feb0f86e86dc07f2bad563e71762aebe8471efaf42000000dc86cdfe97e3ec0de0d8d0c7e4c0dc3c5f7df9de46b3797b1308bffe57107ed584be78eafbb86d5beb0fe3bbeb47bfd33d4c92c4f2c25f9e88ebaebe0be2c377d0c706f985bf3af1ec3679bfabb33b36de75dfdbfe55633e7ee4605ccfeab6e985bf78f1c14712e66dcae79af7c9f5c57dc6dfebf3cb874992d5388f7faa71fe6e8cf1777ff4ef050000e01af6fa5f8e706800b339cc251ac0b8fdfca586e3d907e1b62610c6cfea0d6a4ca500006086494441545f7dd1de07df2970f5f3b326381f27aecbb314b6d69fc62f7e1c1f6b4f6cc7f8d85bcb3c0e61fed2c0793df318e5e5abeddf9a14583da7addb5e19cf1cf9e0773a3e8771b2e0f9fde37b0bc6f18f311ff683f9cc82e7dbf4c1bf57978ddf6911f77ffdf137e7c71ed7010000705b0ef5bf1c70cc00eefdf9c6f15df5d16d4d2084ab77f83f38e3201ad6f87fbe831df789c7ba9ad4387602e1eaf9bcf16ede6fbe7e4f8ed7af4da00cdbb412cf619e38b85acfe23e7bdbbf3529b07a4e5bb74d576772c4eff583e712b77b7ec6c1c3871fc975bd70ffab332e9e3dd761f2e16a1d8be7b1daa6905f9e388e618ccdd5be72c41734020000dcc431fd2f3beed300ae262a5a45b37b9386f726f739a51b3e8fdbf87d5c7df4e4068f0d0000705df7a9ff3d4b06100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c800724e5e79e5d1475ffdd4a3c7b15f5a2cb7bd7ce2d5373e36ef73dc1ef9b59c7291dfd3925fcb2917f9e59cc43ea9ff2d30809c85870f3ff2fa17defab3d71e3e79faf84b5ff9d7fff2c7fff5a9c5729bcb3b5fffc6d3d8bf62f9dc1b6f7f27f6b97937e486e4d772e2457e4f487e2d275ee49773a3ff2d3280b47b762079f8e8adefbff9f657defbd18f7ff2144ee5bdf7df7ffaee0f7ef8f4f7fff08ffe25f63945cc2d905fee88fc9e80fc7247e49773a2ff2d3280748bd9e838a0fcece7bf988f377012b1af45c1fcf937bffcad797fe47ae497bb26bfb7477eb96bf2cb39d0ff161940bac5296d312b0d7729de6d8b7d2f3ef73bef931c4f7ee920bfb7437ee920bf74d3ff1619403ac597eac441244e6d83bb169ff78d2f0d9bf74b8e23bf7492df1af9a593fcd249ff5b6400e914fb5f7cb90e74882f77faf467bff8cebc5f721cf9a593fcd6c82f9de4974efadf220348a7d8ffe220021dbefdddef3dfdc26ffffbff3eef971c477ee924bf35f24b27f9a593feb7c800d2490143a7fff1d7ff53015320bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d1430e7eda7fff84f5707f9777ff0c3ddcb3ac576c4f6c4765d9702a6467ee924bf35f24b27f9a593feb7c800d2a952c0c4fd564b3693efbdfffe7c17ae29c6f2b5874faec675efb24eb11db13db15dd7a580a9a9e4f7583ffbf92fae2689fee22fffeae9effeded79eef7bf1bbfbd18f7f22e7174c7e6bee22bff7cdb7bffbbdabd797f8f718f1fa34d720b9c46bd64d27b72f81fcd249ff5b6400e9542960a29138b4c4ba7ff9cb7f9eeffa5288a6eaa66377acd564c1b99d816002a14f25bfc7887d6ccef4bcbcf93b5fbd2ae2b93cf25b73eafcde47793c39765ce27838bf26ad96589f898417c92f9df4bf4506904e9502260fcc7ffb777f7f7560ce257efe933ffdf397bec1c842e794561308e7c604429f4a7e0f19270f22cf7956512c398915d9ce8c3b13e1f2c86fcd29f37b5f552610728220deb4c87a24ce64c8d7a938838a0fc92f9df4bf4506904e9502663e68cf62d2200fdcb1c4e9ce2b71b08febf254c3631b9158ffd6638f7252231e636b2223d795674bc436c4eda3899ab7278b937c6e59a8e4edf2e714eb5e3d6eac27d6bf7a8cb49a40c8c7cf7566437768598975c4f3cc49a043e2b1627bc7db9a40e853c9ef9ed82732b77b67ba8c197fe7ebdf98af7e2ef697dcd7c7ac8cd7c7b277b652de66be6fe6e1d0ebc79c83b85f66e83af95f89fb468656394fabc79fb3b42773be3746e1ba99ae90df9a53e5f73eab4c206c894c1caa432e91fcd249ff5b6400e95429600e4d2084bd06230ae22c16e665ebf38fb1befc1c762eb1de55e11ec5f93881b177fbb1098ec71e6f1feb88cf5266e19e4dfdbce4388c3fe7ffc7318eed9a9f43de661ecbd504c27cd9f8387bcb289e4b8cc37c9b58a2f198c5ef6a7cc73997fc1847fcdf04c2ddabe477cf758af86c5857e2f2799f89252e1b2726f2f1e24c87957142636cf2c7339dc625f23a1bef9fffcfe7774cfee78984f87995a1c8f6dee3475ee7fbc57db6f213eb9a1f6395d1eb66fa36c86fcda9f27b9f5de7b5271c338110321bab6c5e2af9a593feb7c800d2a952c0e4417b6e7a6763c33d36ee63131d457bdc6e6c08e64984b119897fc7dbcf0dc978fa75140e71dbb1018e7fc777f2b268c922230bfabc3c962cc2e3f98ecf29d79debcbcbc7fbe67389a623b721ae8f758ea758ce932cf938e3ef68be2c1e37b7615ec6e79bc66dc8719c9f6bfc3c1a7f5771bb719bf3dff93ec788fb28606eae92df3df9bbde9a1838c63cb11585fbdc0c6766c7bcaee4fdc67c8ccd72be7e8ceb9fc7252f9f5f7742eefb795de67f7c8cf94c8c9c68c8db4696c7dbcfaf8b79f998ff58c7b83de324c53cc19ae337de3f6f7f934cdf06f9ad39557eefb3dc678f1d1713083727bf74d2ff1619403a550a983c68cf85f26c7cf7306f3b360c7361be7ab731e46551748f8576360d63939c4579140d73519e85c43841314e5ccc8576360af338e5ed6779792cf3990eb9adabcf62aeee13db323ff6eab2956868725c56eb9c2f9faf4b5ba7b3c7588e0ddb3c6ec750c0d454f2bb653cddb7227316db37e638fe3f3709b12fe563ae262db2395e4d38ccaf1f6343b17afd88657ecdda6bb4731f1fcf8e18cf6298d795af1773a3b2f7f8f3f30bb99e18c731a7e363e7ed8fcdf47c164595fcd69c22bff7ddfcda70c83113083ec2b026bf74d2ff1619403a550a98ad6278b62ab6b348988bec941300f9aeff5e5393efc067313d1614ab82796cac5316d9abfb8c0df46875d978f9d6738bed1d1b9b940dd7d8c0ac260b5697cdc631981bacd5e38cf27e5968e5e3ad263dc6c7d95adf1e054c4d25bf5b321fab7d3bc4be1bbff7d592d95935b9a3710220b3908dfa9c9b317fb9fe9cf0dbfac8c36a1fcf75acc66b9c4098ed9d1db17aeddbba7d5eb6dae67cfcf1babc6c95ab788cb83cc76ef57c47f9d8abdf4585fcd69c22bff7dd6d4e20c475b18fe6045d2cf3f1fd92c92f9df4bf4506904e9502260fc8ab227a3436ff79dbf1ddc93888cd4b4e2064419c4dcdaa899d8d0dd0bcde58c677cd535c1e3fcf1f21085b05caeab2f1f2bd773a629db19db94de34702f23987dcaeeb4c208cdf3b317f0c24e4f6c538cc6313cb3cf6d9acad1e6f7ce778dcee63c57d14303757c9ef96431308b9ffad96ccf79899f91df1b07a4dc8898271622fac2616c62663de7f63599dae9c8fb76aa2570d7cdaca7f88063eb67b7cecadc988bdc7cf311d7f97f3f8ecc9dbc6f6cf6311cb9ce9db12eb93df9b3b457eefbbca04c2de329fc983fcd24bff5b6400e95429608e2d70c777e472f67f3eb86f2d59d0671371ccb666317ecc92db93f75915d85b0dc4eab2f1f2d5b84401337e4e7ab58cdbb06a2c5697a5f1231aab66687c67f8d092db9105dd6a6cc26ae2e3580a989a4a7eb78cfbfbeaddbac8733ce6b8e4ed739fdf7a177e34df27e4be344ebeed5d766839b621dfdbc7b7f27fccebcc68eff1e74c8f132cabb39566f3e36e2dabe75721bf35a7c8ef7d579940985f9762897d74356987fcd24bff5b6400e9542960f2a0bd2a8847f959def19dc56c00626220eebfb5e43b067381bd672cece7f5cdcb5d4f208c9307f9dc731b56a721af9ef7eab294670bccdffd30cac78ff5cce3312ed9b86441379f5a9ec6f55d9702a6a692df3df93bdd3b8b6634eff3636656fbe178e6ca9893f9fb03f2ac84f9cca3f10c8379bf1d97f11dc7d5e3a5eb4e208c1324f15a363ed6d6e4c9dee3cf995e7dec6b4fdef6d84cdf16f9ad39557eefb3ca0402d723bf74d2ff1619403a550a98630adcf174fad529c8ab77c957b68af295ad2f61dc731713086353308ff9d64701e6c662ebb2304ed4ec9daa998fb3fa78c3caded91fe33ba5abb13b44015353c9ef9ef12346c798f7f93133730ef6aecffd29271b73df9bf7d5eb361961f578e9ba1308e397aece595b7d442aec3dfe2ad3f9ba79cc9f60dcfbc8d229c96fcda9f27b9f5d37dbab7c721cf9a593feb7c800d2a952c0ec15c4619c3c887fc77722b3c89e3fef9ca2681ed73b36df73c13e161071bbb1a95d9dba18d7c7e5e3e4c2aa804f5b054a5e36bfc3ba352ef3768ec6498fb109586dd7eab27182657edcd97896c24a8cfd38c6f978f3f30fe376af9aaf4314303595fcee19f7d5434de9d664c05e03bcb74fe5e7f563dfca75cc79d96ad253e4613e7b62b58de9ba130839c1b21a9bf1cf328ef61e7f95e9cce9de63e4f65e37d3b7457e6b4e95dffbcc04c2dd915f3ae97f8b0c209d2a054c1eb4b3d9cf257ece2260bccd687e377e2caab7fefc60ae332ecff5c57af2f2f10c872ca8f36fb48fdf0e9fc5f7a1463d6d1528d9dccc4d47de76d528cc4d556c4fdc3f2f8f656c0256db355f364ed4cce3bc323e9f18b3b1391b2776f2f2f17715db16f78ff19cb77b1e876328606a2af93d647c973d1e231af2dc27629f8b9fc7db44ae569372b18f6406e7fd66b5cf8c5f989afbdc6ceff5639c4c3bd54718c63fb198af2df1b8f1f39889f1b15697a539d3617cdc18e778ce397e71d938293b677a7cdee39949f3444c95fcd69c32bff75566318f355bcb6adfe77ae4974efadf220348a74a019307edbd259a8055c11cc66f7bcf8261fc793e7b606c946399ff3f16c7e344415e3ffe3c3725ab023e6d1528f3f667f3913faf9ef7f8cee9bc3de39913b1bdf1f36abbe6cbf2e743cbd81c8ddb918f3ffe3cbf5b39df3ec73eee97f75d355f8728606a2af93dc6b1fb5634a9f39938f1f3b85fc53e334f94cdf709e3e4402c5b9362e34441ae6ffc797efdc8cb57b9bcee04c2bc8d6396636265cc4b4e6cee3dfe9ce934ae671cbb58e66dcd89825ce6f198337d1be4b7e6d4f9bd8f328b8796ccd12a9f1c477ee9a4ff2d328074aa143071bfd5927f4a2c0ae95583308a837fdc7e7cb7310ae155911da2708fa23a8be378bc683056efacc563c7bab22089023c1e2b6e3f6f577eb3fcea74e128bcf3b9cd627d795d362cf9f3aa608fc7cdc71ab73fb727c66cbcff6abbe6cbf2e743cbdc50e563e5d8c7ffe3f7b635f6715d8e7bdc27dfe9cc319ed77f0c054c4d25bfc71a7fc7e3c451e67cb59fa77cc73cee9b457eee677b62dfcafd76ceea281efbd8d78f5cdf6a7bf7f6e1adfcc7e5b19d3939393e6ebe4ec57d32a77b8f3f677a348e5f3ece6a1d6195e9adf1b80df25b7317f9bd6f328b8796ccc0563e394c7ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d2e9520b98d71e3eb95a7efa8fffb47b59a75ffef29fafb625979ffdfc17f34ddac43e13631505488502a6e6bee6f7bdf7dfbfdaa7631fe7fe92df9afb96dfdb7addbf34e7565b24f9a593feb7c800d2e9b60a98733d406e596defeab24eb93d5b4bfcdebab6f5b60a49054ccd6de5f72ec404d8b7bffbbda7ef7cfd1b2fecc76ffece579ffec99ffef9d3bffdbbbf9fef7267ce7132e31cb76926bf355df98ddfdb7c3c39b4c47d6eeb75ffd4f2f9758ced4a8e61d7f17a8bfcd249ff5b6400e9745b05ccb91e20b7acb637dfe98f7747cf416ee3effeded7ae0aa15ca2e11a0bbb68c8ee7a9b6fab9054c0d4dc567e4f2d260fc6fd36f6d9d8eed8b7c77df92ffef2afe6bb9e5c6cdb6deccbb7e91cb769457e6bbaf2fbee0f7ef8c231259671622ff3392e719fd82fefcbc4563c8f8eb15dc9713db7fa487ee9a4ff2d328074baad02e65c0f905beec3f6e636ae9a88983018df45baeb776f4d209c87dbcaef298d9307b1adf3645734239dfb723446b7b12fdfa6188373dba615f9ad39a7fcc6b1f03e1c178f6102e138f24b27fd6f9101a4d36d1530373d4046a11ca735c736c469cc71405bbdbb11457e5c97ef7ec4cff16e65dce7473ffec97cf317e4bb2df13879dbd5f6c6fac7c7cfc6268bf86884e2ffb9aed576866890c6ed8bffc76de7f51d92dbb877fb788cb84dbc6334cbc78b6d886d8edbc6cf730397e2f2f87dc4edc6dfc7ea776a02e13cdc567e4f25f6a99c3c88fd694feecb71fb395b99bd715f8e7d75de97e78cc5be9b991db39df2ba6c36e2e7c86b185f73e2f1331ba3f1b5285f170e7d57c9b88dab7ced6dd3b991df9a73caef3113086326e6cbf2181739998fb721ee93c7fab8cf9cdd9493e399a9adacef89fb677eae63f53a133fef3d76bec6e4b2caffd6b8e6739dc7f4aec82f9df4bf4506904eb755c06c1d20b7c48173fe2cf4b8cc057316d4abcf50c7124dc77ce08e75cca748c7928d4a2ce3f6ce978d0555be4b392f7343b1f5bc62fbf25dc563c73bef1b07f92d51a0e5edc62227b677fea8432e3126f358c5cfabeddeda86fc7dcc975f9702a6e6b6f27b2ae3fe3937efb3d87f579381d90cac96d867c77df998ccc66b489aaf8b25c7737ccd19b394c6c79a97b8fdcad6368d399aaf8be55c7fc7f25b734ef93d660261f5ba3f5eb63ae6c4716fb5dfc76de7c7899f57eb886575dcda92af19d719dbeb1e33c3d66bd37c16555e3e3edfb156e8f81862905f3ae97f8b0c209d6eab80591d20f78c4d7c1c6ce37ed160643112cb78c01e2f8f837c1cf8f2dd8e3ce88f07edb82e6f1f07ff280ee231c6f5ccdb3b5f36370871df5c4fbc4391978f07fef179c56df21d8db1303976bcf3f67b4d7a3437719bb161199ffb385679db58624c46e3b8c473c87756c7099871ac5685e44d28606a6e2bbfa77293427e346630731cfbe698b371ddabccc6656366c7acc4e559c4c73ae3e77cddc97d3c6e1f4bfc3ceeef79bfcc799ec193599f2741c7fcc563c57dc68ce5f3d8dba67323bf35e794df313be36bfd68f5ba3f1e3b56c7dacc436671dce7c7c9bcf16ca5f837261323eb719fbcfd7cdcda72ddd79dad63e6f83a333ff678dd78cccce730beb990b71bc7b57bf220c82f9df4bf4506904eb755c0ac0e905bc683f55c64872c3cc603f058a4ccc57416e6e369fc79705f1d9cc7427edcdef9b2b1a05a8dd1fc1cc602686eacc7e7bc5ad74ade7e5e578a756661358e553ef7b86e7eeeabb18ff1cccb56ef008fcd4c5a159237a180a9b9adfc9e4a753fc9a67ff5119df1ec867c4d18333beeaf21b2b0dacfb7b6717ccd9973118f17d7c7329f59b1d5bce4bae6d7bcf1ddd95cd7d6369d1bf9ad39a7fc562710e2d8371e6ff68e9f7936ded894e765b19ef9183f1e5be7fcac6c6570cb5ebdb03a3e8ec7d1795bf3b1c789ca795cf78ed177497ee9a4ff2d328074baad02663e40eec982793cc08ec64222659132bf0b10c6023ce5ed579fbb1e1b8f632710e65312c35c4c1d2ac0e6771a0fc975c5ede33162bbe3df58c6773f629cc6c7db7bee619ee4c8822796959c7019c77e7eee37a580a9b9adfc9ecabcaf5d57de7f7ca77294fb6de673cce0aad1c80cae1aa0791bc7c6e83a56af47e376adcc93105bdb746ee4b7e69cf27be8f81556fbe5def126d737efc7ab3ce464e13cf197f2faadd782511ed38e1ddbbde710e6e731d610b3981098f33c8e6b1ebb571325774d7ee9a4ff2d328074baad02663c401e9207f73880c663cfcbf859fc9407f85571b12a46721d73e112c6770ff62610c6771ee68220ccc5d4b81dab7715b2703876bc735d7b4bac6bdeb6434ddb3c96f9fb58bdcb1b72422796343ff79b52c0d4dc567e4fa5ba9fe47eb79a0c08f384c0a10caeb66775d978f95ec312d988a6637ccd1a97f176f1f36a0274656b9bce8dfcd69c537eab1308ab7d756b7dab6376ae273212ff9f97eb4cc0e731ed98db86dc96d57308b96df331f3baeb1f3ffad83d7910e4974efadf220348a7db2a60b60a85953cf81eb3a4bd2265558ce4cfabdb87bc7e6f0261bc6c65dea6d5768cf2791fdb44e4bae271e2beb9e47746ac1aa490f79b4fbb4ef344463e8fadfd60f5bce6e77e530a989adbcaefa9ccfbda756c4df48de6fd70b5af8ee6db6f5db67779183f3e144b4c20c4ed63594d805eb7e1d87bec7322bf35e794dfee09849cf83eb41c335ed7cddbd676a66cfc737df99cb7ce5898cdcf2196d59b21774d7ee9a4ff2d328074baad02e6d001789407f7eb9c1abc57a4ac8a91bd3310b60aa5bdcb56e66d1ad7bb6aeec7771f8e91b75d3d873dce40b81cb795df5319b3becac42cce3418cfa8c9fdee266720acacf6dbd5657b9787f17b54e626229ff3b80dab8f65edd97bec7322bf35e794dfade3e268b55fae2e4b5beb5be5743e2e5564068f1ddb638f997936d2a163e62c9f6b3ccef8fab0f5ba7657e4974efadf220348a7db2a60b60a8595f133c2c79ec6b757a4ec1523abe7363efe6ab26075d9cabc4d870ab0f11dcb63e46d57cf794f6ed7dcd8a479bdab8667347ec1549a9ffb4d29606a6e2bbfa7329e4570685f597d37496666f53182f14b1157df81b0b2da6f5797ed5d1e728272f5a56b79bf711b0e4d2ec663c4eb525eb7f7d8e7447e6bce29bf878e5f61b55fae2e4b5beb5be53427d88f3d436f4f1ed38e1ddbeb1e330f7d07c29ce7791cc6bf9e746c0d740af24b27fd6f9101a4d36d1530f30172cfd854acbe9c303f573cceceef1529ab6264fce2bff11dcd38a08f6702ac260b5697adacb669eb4bdfc66ddc5adf2c6fbb7ace7bb2789a9f7b18bfd7219fe7ea2f33a4182f7f85e17cdd567e4f29f7c7bdfd25f6c5ccceb89f8d935773e3bdfaeb05abd782d16abfcdcbe6773e57b74d998939e763966219b779eb5dcef14b65e70984799bce8dfcd69c537ebb27100ebdb110c7edc8c3eaba59bee61c3bb67bc7ccd5b88c9397f331735c579aef1ff62621ef8afcd249ff5b6400e9745b054c1e20e3001f07a5ad2565639007f938b0c6b2f52716f78a9455313216f279dae0f837da578fb177d9ea00bfdaa6f179450111c545de2e1ffbd87758723dabe7bc27b6351f2b9f7bbc63324e9cccbff3d5ef23ee378ed75858ad9efb4d28606a6e2bbfa7344e42c512ff8f9c4736e2dff9bb04c6acad721cfb72ee7fb18c4df6eab560b4da6fc7c625d69d0dcaeab669fc4842dc7fcc573c87cc4ddc2ed717ebc9c78975c77dc6d7a4f171c68f0d8ddb746ee4b7e69cf2bb6a9467ab4cac2e4b5bebdbcae9f85a90930563b6e2fad5b17896598bdbc7ffb79671426075cc1c5f67e6dfd3ea9819af67b99e716261350ef1dc5693a677497ee9a4ff2d328074baad02260f9087961407ecf1003c2ff3acfe5e91b2558c8c8dc1b844719e85ca6ab2e0d06569b54d73b3944b5c9645cdb1e39df75d3de743c6e2645ee2f1e7775962bbc762695c623df31732ae9efb4d28606a6e2bbf77619c1c5c2df13c56cd41ec7b5bfb723416e37db65e0bd2d67e3b6636c773ebb6211e736c7672c90990ccfab8beb0f59a374f9c8471fde7fa3b96df9a73caef394c20c4716b75fc8c255e038e9d481bf3b7b78cdb1cdbb4f53ab375cc1c27e5c765fe28445e3e8fc358a3cc35cf5d905f3ae97f8b0c209d6eab808903d131cb2c0a82b83c0aeb6830a259980be91007d7b8dd7c000e71603fb4fe788ee3fd737d635190eb387459dadba6b82c262ae23679dfb86d1623c7c8c75eadff18318ef92e6f1434b1ae79226016d7c7edf2f671ffebfe3eae23d6a180b9b9dbcaef5dc97714731f8b7d33fe7fa831880ccdfbf26adfdb7b2d085bfb6dbe4ee43ebf77db513c973ceb6abc5d64267e8e657e6e71593eff788d58bdb684d5369d1bf9ad39a7fc8ed9d9da275799585d96b6d67728a7f37168b5ee3d99b143cbbcdeea3173eb78998f378f4388d780b86ef571ce538bc7955fbae87f8b0c209dcea980791944d1bf6a1a42be53d351289c2b054c8dfcd2497e6be4974ef24b27fd6f9101a49302e6766d7dbb721ca8f3f4c8d5e4c2a552c0d4c82f9de4b7467ee924bf74d2ff1619403a29606e5f1c94f3738df179cef1b3cc711d1f52c0d4c82f9de4b7467ee924bf74d2ff1619403a29604e233e0b397ff3fcb97e8eb99302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d143074faf677bfa78029905f3ac96f8dfcd2497ee9a4ff2d32807452c0d0e99daf7fe3e9a73ffbc577e6fd92e3c82f9de4b7467ee924bf74d2ff1619403abdfaa9478f1f7fe92bff3a1f58e0d4de7bfffda7af3d7cf2f413afbef1b179bfe438f24b17f9ad935fbac82fddf4bf4506904eafbcf2e8a37110f9d18f7f321f5fe0a4defdc10faf0a98799fe478f24b17f9ad935fbac82fddf4bf4506906eaf7fe1ad3f7bf3edafbcf7b39fff623ec6c049c4bef6fb7ff847fff2b937defecebc3f723df2cb5d93dfdb23bfdc35f9e51ce87f8b0c20ed1e3efcc8c3476f7d3f0e28312b1da7b6c1a9c4bb6d5130c73e17fbdebc3b724df2cb1d92df5b26bfdc21f9e55ce87f8b0c2067e1d9812466a3e394b658e2cb75e21b7ae3cffc582cd525f6a5f8b2b0f8bc6fec5f9f7ff3cbdf52bcdc22f9b59c7091df13935fcb0917f9e51ce97f8b0c20e726be54e7c16f3efe83f8f33e16cb6d2df16dcff1a561f1b9df799fe3f6c8afe5148bfcde0df9b59c62915fce8dfeb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203080000c025d0ff1619400000002e81feb7c8000200007009f4bf4506100000804ba0ff2d32800000005c02fd6f9101040000e012e87f8b0c200000009740ff5b6400010000b804fadf2203c83979e595471f7df5538f1ec77e69b1dcf6f28957dff8d8bccf717be4d772ca457e4f4b7e2da75ce4977312fba4feb7c00072161e3efcc8eb5f78ebcf5e7bf8e4e9e32f7de55fffcb1fffd7a716cb6d2eef7cfd1b4f63ff8ae5736fbcfd9dd8e7e6dd901b925fcb8917f93d21f9b59c78915fce8dfeb7c800d2eed981e4e1a3b7beffe6db5f79ef473ffec9533895f7de7fffe9bb3ff8e1d3dfffc33ffa97d8e71431b7407eb923f27b02f2cb1d915fce89feb7c800d22d66a3e380f2b39fff623edec049c4be1605f3e7dffcf2b7e6fd91eb915fee9afcde1ef9e5aec92fe740ff5b6400e916a7b4c5ac34dca578b72df6bdf8dcefbc4f723cf9a583fcde0ef9a583fcd24dff5b6400e9145faa13079138b50dee5a7cde37be346cde2f398efcd2497e6be4974ef24b27fd6f9101a453ec7ff1e53ad021bedce9d39ffde23bf37ec971e4974ef25b23bf74925f3ae97f8b0c209d62ff8b830874f8f677bff7f40bbffdeffffbbc5f721cf9a593fcd6c82f9de4974efadf22034827050c9dfec75fff4f054c81fcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcd2497e6be4974ef24b27fd6f9101a49302864e0a981af9a593fcd6c82f9de4974efadf22034827050c9d143035f24b27f9ad915f3ac92f9df4bf4506904e0a183a29606ae4974ef25b23bf74925f3ae97f8b0c209d14307452c0d4c82f9de4b7467ee924bf74d2ff1619403a2960e8a480a9915f3ac96f8dfcd2497ee9a4ff2d32807452c0d049015323bf7492df1af9a593fcd249ff5b6400e9a480a19302a6467ee924bf35f24b27f9a593feb7c800d249014327054c8dfcf68ab18fe5673fffc57cd54590df9a73cd6feccfb15ddffeeef7e6ab7e4ddef62e9ec75d3ed625905f3ae97f8b0c209dcead8079f7073f3cba70e1fe53c0d474e6f7a7fff84f57398dc77ff377befaf4b5874f9efeeeef7dedeae7f8bdfef297ff3cdfe5a513cf3996188b4b24bf3587f29bf98ae5987dec2ffef2af6e65522b1e2bf6ebbd6d4b79db58ae2bb631b6395f3f6279e7ebdfb87adc1ffdf827f3cd4b8fc5af935f3ae97f8b0c209d0e1530772d0e68c7162edc7f0a989a8efcbef7fefb578d4d16f25b4b34057ffb777f3fdffd5ecad7a5b9898bcb2f65b264457e6b0ee537aecb3cfdc99ffef97cf50b621f1cf337efabd7b1358110db10978fe2713307d791ebca25261f63f260bc2c6e13af37e9a68fd5215f23cf99fcd249ff5b6400e974a880b96b26102e8b02a6a623bf635393ef8c66911f057ebc7318cd40dee63e14fb8764b35369ca5e46f25b7328bf63d662199be9591e3b73a9ecab5b130879a640d538d9118df6f8bce2ff31f198d71f9a383957f9bb3b67f24b27fd6f9101a4d3a102e6ae552610a2e889c223d611ff9fdf158cd325e3f2bd533bf336f37db3318a75c7c72cb6d691f79f1baad9b8bead758de236f1b8719f71ddab6d0d719b793cf68acf2e0a989abbceef780af1dec440ec6bd97447d331eea373c6e2dfd8b7635f3dd4f4c47d3337f1ef5676623de33e9f8f391b1f7bb5aecc584e88e436e67ae7c7198ddb1a8fb177bbf935236e1fcbeaf629b72d1e636bfb4f4d7e6b0ee5379bd0dcff629fd892b7c97fc7fd7dcedc288f15e3fe9339cf6dcb7d39b39ffbfd78ff55beb6c4c716725bb7e43bf8715642e6e0d063c5e5f33175ce57de6e1e9fc8d0ea583dcb6371dc7e359e99cb79ace2f10f6dffeaf7346f7ffe2e66719bd8ae7c6d5c6ddb4c7ee9a4ff2d3280743a54c0dcb59b4c20c401753ef53197b1c9c97735a2a159890374de6f2cd4b64ed78ec79c0fd259f0cdefc2a6b8fd78792eb14dabef7d886d5a3db7785e395673231785c4f899d2f131f60ad00e0a989abbceefd8a41c12fb7aee7bd130a4cc48fceeb3911897b16148f1f37cca732eb1bef9f6795decef9985719cb63212cf6bfcd845666c5eb201987f0e7bdb1acf6d6ef4c7d78c39ebb18de3d88558ff6adc6289ed9f5f934e497e6b0ee537f78d3c76c5feb1920d6b7e7f40fc7fdc27c7cccdc66677be2cb72def3f2f5bf73f24f3b1cafa9eadc78a4ccd798e9fe3f2d578e46de2b1e7fbcdaf0129f239df366f3f1e57f75e33b6b63fad7e4fe3ebc3f8f869affe59d51423f9a593feb7c800d2e9500173d7ae3b81301600f16f36d663019f07e3b1a159cde0471190d767513316eab1ce5cfff89863019407fbf1403f3e97bc3e0ef851a4e49746e66db79a8bf13ed9c465d130161b638112d7e7f68e05c6eab97751c0d4dc757e731f3a76222af3b3ca4066240bf671626d7e4e630ea2288efd669cd89b1babbc3cd7198f3516d37979643a73383e7e663af2343604f17ce2e76cd2f3f255b3968f1feb1f2fdb7acdc88cc6cf719f31b3e3fac7d7a9d8963c0361bcff5d91df9a43f9cd7d237effb97fae26883267e3beb6da27c763455a35b5f30442ec73e3b1308f2b5bf73f248e73799feb4c22ac1e2bc6633cde6646e3ffe3e5ab0984bc3eee33bf068ddb34d619f19a916720ac2654e3715663359f99b0b2fa3de565e36b48fe5e8ea97ff62611e4974efadf220348a74305cc5dcb03ffb1db34160a73f33d9e7190c5401e58e777f556d76dbd831a627d593c8cef568c07f9f95d8cf114d0795bb3e0181f677cfc715df1d863f3b42a365645d9784ae8b950c0d4dc657ec7a27f2cc6f78cf94c6346e6cf3f8ffb75364a63d13d4f5c8cef3cae1a84f9f2b07a5d48d984cf8fb3b5aef9f2715be7fc8f4de02ab3abf5e775631390db383706f15ce2bab8cfbc9e5391df9a43f9cddf7ffc3e73bf1df79d10bff7dc7f2233e37dd2aa314daba6362f1bb76d75bbbdcb0f199becc845fc1cb95b4d90a4d563e5b8c43ac6e73c9fedb77a7d88d780f9f1f2bad55988e34441cad79f71627eb59d7b97a7d5ef697c7d985f97f6ea9fb1de985fe792fcd249ff5b6400e974a880b96bd79d4058bd0b3fca03681edcf3ddbbb1a109631196b71d1b8d952c2ac66dcd83fdaa3949abcb578d565e16cb6c6ce6c6e73e3f87d1789fb9d8e8a280a9b9cbfc8ec5ef6a1f5e5915cc63413c1b73980df8a1d7849cf81bbf6c2dd7b12af8d3dc3884d5191321d73737e6f3e57b990df9dc57af19ab89bd55e392dbb86a1aee9afcd61cca6fee1bb17fe584f2bc4fe7312dd733de27e565abe3e42aa377318110cf27f6e56cc0c725cfb69b33ba7aac55a6d298c7d504c2fcc640c8f5adbebc71f5bab7bafd6a3bf72e4fabdf535e36ffdec7ebe6c9c494633b4f6626f9a593feb7c800d2e9500173d70e350bb3b11088fbcecbfc8edf78cadfd8646711361ea4735be2b279bdb18c5f1297f280be2a4c5214fd71408f837edc3e96f174e5343efe4ade3e9fdb3841b0351e79fddc0875896d52c0dcdc5de6772c7ee7c27ecbf82e58da2b8843e633f7ebbcfdf8919c71593510733656e2f9c4f5630ef3f5621ed3addccc976f4d40a455a6f79a8031b3693c3329c62aee17e3bc6a6e4e4d7e6b0ee537f78ddcbff298334e1ccd67cdccf7192f5be561d5d4562710e2b279d99bec8afd37f6e3f13898cbf85ab37aacbd3711c6db8fe39197cdefe887ccdceaf712eb88eb23e7f99a91af57371dabd1eaf7b4f7fa908f3d7ebc725ce6fa6726bf74d2ff1619403a1d2a60eedadec17b36be5b7968190fa0ab8f0b6461361ea4c7cf111e5ad2aa0048b1bdc7ac331d6a46e6c71a8b9343cb584c7552c0d4dc657ec7c6f5d8fd27f3bc6a98b7b67bdeaff3e743cbb8bebc6cf5cedb789af7d6326f5b5e3e3feff9f25cefd604e2f8fd05697ebea3add7c3f93b1b628966221ef7d8c99ddb20bf3587f29bfb46ee5fb9ffe4fe95991ccf789bef335eb6dac7564d6d5e366edbea765b978ffb652e7bcf7314cf63ccc998a5bdc75a657dac11561308739ec32a737b5f5498cb4dc76ab4fa3dad2e4bf3366c2dabfb06f9a593feb7c800d2e9500173d75607ef3de301320ece5bcb5854e7bba279e6c0fc19d2349e3e3caf6f5ed2dec13e9f5b2c518c8cefc88cd7cd976d8dc5fceec2589c4431356fe3d6787452c0d4dc757ec7bc1d6335099619d9daeef91dc5f1f6f37e3c2e639e723bc76ca671122ffe3f6661b5bd616b7df3e5b9eef9fe2933bd3a6b6935a6875e03e239cfefdc6eddf614e4b7e6507e73dfc8fd2bcfa0cbfd273f663736d9f37dc6cb56fbd8ea2ca1b8efbc2fe565e3edb62e8ffbcdcbea1df43d79fc8d2533ba7aacf98ca5d1d6f7b6ac2e4babcc8df99a27e9726c6f3a56a3f9b52fecfdee725d3156e36be1bc6c1defe5974efadf220348a74305cc5d5b1dbcf78c07d0ebc8e63b0a8c7cb763fe0c726e4b2cc79e1ebc77b0cfe2200a9ef9809ef71b0b8bf1f167e3bbc1f9d9cbd5e7c7cf9d02a6e6aef39bfbe9f88ee796711f1df7c7711d2b799fcc5036f55b1f7958c975ac1a846c38228ff373a87e842133bbb5adab098abdd78cebbc1e8eaf17f3769e8afcd61cca6fee1be3ef33f7a168fcf398324e9eadeeb3b78fe5fa6249d9e88edbb6d5fc6e5dbe255e17e2f560b52da3d5ebc7eab1f2b9adcefac90996793c5697a53973e37175ae11425e571dabf171c6b1d9fbdde5ef7ff5dc8f21bf74d2ff1619403a1d2a60eeda7cf03e24dff15b1dd843ac6ff58582f9ee46fc9beb983f0f391eec578546ac77fed6e8bd837d362eabe63e1f672c2cc6d338e746677cd7681cab6c80b6be002ab66befb3a8774d015373d7f91ddfd18b7d6cde2f535c9ec5eddc4c6746629927d2c6a621f7d3b109986f1f3287e3b6e4ede7dc8eeb9ff33e5e37bf9e6cad6fbe7c2fb36155f0efbd66ccaf8799e1d56b48c8fc6f5d7fdbe4b7e6507e579301795cca7d69de5757f7c9e3ddeab1c68fc2a4534e208cb7df9bf83ff60c849c0099c761bc2e96713c5697a53973e36bde7cec1cb7676bacc6d7817192607eecf1b5e3d80984ac5de6d7d814af03abfa27c92f9df4bf4506904e870a98bb9607ef2806e200bbb5ac0a8a781e79508e0375160fab774bb328188ba7f936210fde71bbd8b6bc4d3c4e4e088c8dc8dec17e2c74623db1c4013ed69deb8a258b94b83e2f8f7fe3b6e3f3ca652cc2e6cf8efeffeddd3faf246975c7f1848cd42f8037e01512c9ce625663cb5e318b8c8ce500c9222242462b4b480e7811082107c8120101113201a913270889c880c9406b3970088496415af31beb87cf1e4efde93eb7fb54ddfe7ea44733b76e7575f533f5ab3acfd3d53dee27fde97dab0aad2914303d13f98d85bd8e5d7f5c46c7a6fed4cff178ce05ac8f433fdef9d19f7e5c2c88630ee2fa122717e2f378592ed2c5cfef09100fca7d6bb81f1bcf073e4fe477faaae7f1ba715ff5fbf8baf74e3ae6c18cf6c9dbd7bf43dcc7f8d1acea5c760be4b7672bbf3e36f2711caf5b79b2a87a4c3c363cd9968f4935f37535eedbd2e45bbc06ef15cf21befdde7c0ef1efe36478f55c71323dd60df9b5c5e7a89659ce9cc4cc89fa42e78278ced09f16cf5939d79ef8d1f675ad8fe71fefd7de0984f86fa2f5fc7ab44df7b1b65b4dbc0af9c524c6bf4d7420266d1530f7e68bf7568b17d3588ca8c5e24a17cffcae81f942ae960706a6c7e6edc50b7d7edcdac53ebe93e16dc57dcc9fcd963821109b9e67e9b9f21735c6fdd7dfef35b8d88302a6672abf7992a06a3ad6aa02ddc7ad8ed39881f8d87c7780b613d789c774b5be9757cf1f3315b7e989bd6a709673e8cc55cf93f735bfb6a5015fceb15483997c1e89e7b1a5eddc0af9edd9caaf8f8d7c1cc7eb643e9f2f3d261f876ef1fa691ea8e77dcbd75a0fd6f3e3f7c8c76dd53cc8b6a5e7ca93ea6e31b79d0984d8dff99ca1c1795ce67ce75ac6b98c9323b1695bd5b9a05a16e5ede573e352fd23e4179318ff36d18198b455c0dc9b2ef81e1cafb53c605021a001892ec2ba986b1d5d1c9766de253e5755489867f3b59eb6ade7d0cff99d55f17a79ff4c1773ade3edc47dd49ffa59bf8f771568dfbc5d154afe2fdbf4f35261a175d41f2a26f45cfabb0a8db5fe984001d333995f1fafce9d8e451fa3be5ba6128f5be541ebebf13a567d7c57f47cce810beea51c6a3b5a6fa978d663fc0ea2d68bd950deb46ffa7dccb1b719f3ed9ff3f3f81dcab8afeaa7ea3cb376cef0392a9e0f44cfa77df4f663df2df5fb2d90df9eadfcfad8a88eafeab890a5c7e8b8f0f1e4e345c7a3d6f3716c5e96b7efeb4e3ceeabc7efe5eba1f6c70368b5a5acac3d975e9b33a7e3528fd7fe5603e9b8ffd952e67cced0b67c3d75d6f4bbea9ca1f5fc5c7179ac03f43b6fab3a1754cb32bd569f477d1ef6f9750df9c524c6bf4d7420266d1530382ebfeb51155a674101d373c6fc6a7f9726be702ee4b7e78cf93d13dfb1a06b25fe10f9c524c6bf4d74202651c01c57bc9d5aef2e44f1739f674601d373c6fc3281f07c90df9e33e6f768f42ebbaf85f15d7a5d3ffd71bef83d0af87fe4179318ff36d181984401736c2a8e7ca7816f4bf4004c2d7f9efa6c28607ace985f26109e0ff2db73c6fc1e91260e7c4dd42dff3a2e3df9aeebe7d6adfc8f8afc6212e3df263a109328608e2f4e22b8e9e7fc19cd33a280e939637efd39622610ce8ffcf69c31bf47152711dc3489507d470afe0ff9c524c6bf4d74202651c09c87bff1da5f0ef51c50c0f49c31bffa02381dc347fb424f5c8efcf69c31bf47a6eba2af91dc75b08dfc6212e3df263a10932860308902a687fc6212f9ed21bf98447e3189f16f131d88491430984401d3437e3189fcf6905f4c22bf98c4f8b7890ec4240a184ca280e921bf98447e7bc82f26915f4c62fcdb44076212050c2651c0f4905f4c22bf3de41793c82f2631fe6da203318902069328607ac82f2691df1ef28b49e4179318ff36d1819874960226fe0f045beddedfbeacfed37f19a5e7de43176dad7fcb7ef77f63b5779fa650c0f41c35bfca60cea51b9e0ff2db738ffc3a8bfe5f4ff2cf72693e63be6f45dbf675ec9eee717dcefc3fd3b876c93fdf0af9c524c6bf4d742026dda380790af9ff775e6bf77e3d479c40b8749fa650c0f41c35bf3efed6da7b5ff9eaebffbbfdb9fc97a48f88fcf6dc23bfcea2feadaa9f25e672eb9aa1bcc6f56fe51e1308d520fd1ed7e72c3f67fef956c82f2631fe6da20331e91e05cc5388830eedef5afbeef7be9f1f7e532ec8b60a2fbb477170e93e4da180e9396a7e7dfc7de18b5ffa837caa7de6affff64383102611ce89fcf6dc23bfcea2270cbef68d6f7ee8678959fcd6b7bff3fbe5154dfac5f56fe51e1308b7be0eef956b02ffac7fab5b22bf98c4f8b7890ec4a47b14304fc185c41107c4970ed673b1700b97eed3140a989ea3e6370f5a324d18c481c80f7ef8a3bc0a4e80fcf6dc23bfbede388bf967710e35b1a7b636a1a7497cafaf762bb79e40f0f66fddff7bf8dfc46f7e54ff46b7407e3189f16f131d8849f728609e820b894b07c45a5f1749fde9418b66f5f59a3568592a94f41944adab7763b4aefefcf92fde2fd78f83753515015aa6a6e7ce8f599b40c88fd7f36e0daeb49f5a57afcbaf890984c770d4fc6e4d20988e6fada74149a6e358c7b3f3ea3cc5635aeb68995afc4c77a45b94bd4eb4376bdaae1eab9c89ce033e2fe8f1d5f3aeed93cf49de5efe5db54ff91c7214e4b7e71ef9f5f5c6c776fe597c7d751eab6353743cebf7bab3c88fc974ac2a233e8e7d5d5acb82fed4efb59ef6c1dfb1b0f41c31d3f123087bce19a265be1343af25e6b1caa7fe9ef7d3cfb1f631ac9867fda97e116fcf7de2c9542d13adb7e7fcd9457e3189f16f131d8849f728609e820b895c086c71b1a48b7d7ee7c4cd1775d3453ddf621d5b2e84bc5d3f576eda562c72bc5eee771518f9b16e7a8e5ca468bf632117d7f53e5dda5ff74601d373d4fc6a9f74fc6d15c02e94d522e56529af6a71f0e30c2c7d74c983a2d84f4b5955cb59f34046395eca687e6e2faff2b794ff4bf6e928c86fcf3df2ebe3cac762fe597c9c398f4bb7cefbf88f39c89cfdaae5f381f745198dd732eddbd20482ce0dbe3ec78f5b5c72cec8bf53f3bf43954fbf26bdeeea39f2355ef47c793d354f3eeaeffe37f06b75ffe49f6f85fc6212e3df263a1093ee51c03c055f7cab827c4d2cca7591d7cf9a0050a1e44220174b7e6742fde2a220aebf345888cfa1c779e0e2a2c1aa0225164b5ad793142a42aa62c9ef04a9a9f0d2fe69597c4eb54bfbebde28607a8e9adfbd1308ce5ace601c84b8f0d700daebab399b2ed495838af313df61bc246b717dff4ecbf4fcde1f3d2ef2ba55fe2ec9bff6d9fb94fbe808c86fcf51f2eb634f3c90cf13e5e26351c77e7c8cc58f2579623ee62467345f9fb59e9629ebd50482967bff6246e592738654395c5a1eb71deb08bdde6a0233f68fafcf5a16b7a3569d1fee89fc6212e3df263a10938e52c06cf1055745832e7a6b2d163eb140c9771ab8d889c5bf6fff57cb17f76a7d8985457e97d0839bf898b502a51a24c442cadb8fdb5d7ace2314285b28607a8e9adfad09046534be8b196f178ec577752bb507041e40c4c9b4fc2e60bcc3c139599ab49098359f47f2e03e5a7a6e2fabf257e57f6d9f627f5483ba49e4b7e728f9f5f125ce657cc75e7cfdf3c78de263ccb7eb57b9f7faf13a1cafcff931790241f9f5247efec8d3a5e70ca972b8b43c0efcf339c6ebc77d721fe60993f83bb5eafc704fe4179318ff36d18198749402668b2fb87b5abc28c702250fb463d1b1e742beb4be9755fd188b203fa62a50bc4e55fc88dff9f1365cd06c3de7d2f68e8202a6e7a8f9f5f199ff1786ea23375a1eb3594dba45d51d077ebe7c7790efc8890387ad6ce4acc53ce54948f16b8a0320af5f9d57aafce7bb2432ffbe7afe49e4b7e728f9f5f12a9e14cb83740fc23db1101fb347fca89fc5eb739e1ccb130871f2205fcbaf396754395c5a1ecf6759f531acb5eb73bc4ba33a3fdc13f9c524c6bf4d7420261da580d9e20bae0a015d74d75a2c2eaa62205aba90eb7996063c797d2fcb8317d1bee4c7e47d8aeba8388a032e370f205cbcb940a99e53bcbd58ac1d11054ccf51f3ebe373ade958cfb913e743c77cce815afc0cb2b928cf057e1e785f923567270f6432bfd6ce04c2a5fb7414e4b7e728f9cdc7b733e677db636e7c7dcd8f311df39a6c88398dad9a40a8fa20e6ce1381d51d7772cd3963e9b9abe5ce78be0349aaf383d7aff21aef5aaace0ff7447e3189f16f131d8849472960b65c7bc1ad8a81286f37de26a9160bfaf859ca6a02a12a16243f26ef532c40b69a9f63ad4091bcfe5151c0f41c35bf6bc7a76fe15d1a0c2c7d5161d52c0e703ce8a92615aec95a354088aad7eaf5abf355ce7fbcb369ab55fd3989fcf61c25bff9f876763c41ed77f0e3476cf263c4c7b65a1ccceb71d59d3a390bd15256ab0c5c73ce587aee6a799571abce0f5b137e5ebf3a3fdc13f9c524c6bf4d7420261da580d972ed05b72a06a2bcdd787b61be5532deaa584d2054770354ef3654fbe475966e61ce5cd054ef88c4e75c2a608e8202a6e7a8f95d2bb8952917d8d567fe9d8fa5db9197f85d4ae7d0137e39979766ad1a2044d56bf5fad5f9cafbd9c9ff5190df9ea3e4371fdf9e90f3e49b8ff1f8119aa5c7a829bbf9bb02fcbb6b2610742df6ba6a3957d79c33969ebb5a5e65dcaaf383d7cfe71e59fa28e404f28b498c7f9be8404c3a4a01b3e5da0b6e550c4479bb6bb74afa77793fbcac7a8e585cf8339ed53e799d6a42a0e2c1d1d6735605cf9150c0f41c35bf6b05b7c4c9b8fcb9fef825a07910b2c6dbd4a0270e66f267ab2fcd5a354088aad7eaf5ab09014f9ec47f372fdbbb4f47417e7b8e92dfeaf8f6f5aeba9347f26362a6f371bf744daaae8556e5ced7bd7c7d8e13ff7bcf194bcf5d2daf326ed57e7afdbc6d89e7b7fc4595f7467e3189f16f131d8849472960b6f8827baf0984fc05521a84c4ef43a82610d4f260a5fa36e66a9ffcbc1efc442a88b43f5ac7db5f1a64e9b1f1a31655c1732414303d47cdef5ac16d5e270f06e21d345581ed6f7acf8314f140dcb9cb39964bb3560d10a2eab52eddfd10b7b537ffda8fbc4f47417e7b8e92dfeaf88e1372d5b19c1f132710e2719aaf497192acba165a953b6dcbfb131fb375d75d75cef073e77344b54f55c6addacff8a58db156d07e7a5b4bdbbb27f28b498c7f9be8404c3a4a01b325161fbae86db5b577fb236fd717f9787ba11eaba2284e2af8e2af82c88ff1faf15d443d2e7e97422c14aa7d8a0590b6a375b47d1522de4e2cbce23bacfe9d1ee37dd8fa0ce65168ff2860ae77d4fcae15dca6637ee9a30cce9c9a8e7f15feca54fcac73f54e63fe2c7435c990b3a6c7ac65ad1a2044d56bf53ba2dabecf07f1dd53fdb9967f3d666d9f8e82fcf61c25bf4bc7b78f55b53c79553dc6837b1deb3ae6e335c939d13afa9dae61d5b5d0967297afd116b3bfe79c11b7af75bcad6a9faa8c5bb59f31cf6af1fa1cfb344e2e4c20bf98c4f8b7890ec4a4a314305be2c5784ff385b92a06a2bcbec47708dc74d157f1118b05b5b80d152cb1385053b1a4e5f15dc5a57dd2b6f3e3ddf2bb24126fdb8c4ddb5f2b788e8402a6e7a8f9dd7bfcc53b69e2471994973c19105b353120f90b09f3bbf97649d6aa014254bdd6fc4ea39b96ade53fdee594f769e9b54c22bf3d47c9efd2f1bd76274ff59898e7d87c0dcc83e7a52cc85aeefc38359f37ae3967c4894a3f4fb54fde6e753e5bdacfaa1e701de19f9940c02363fcdb440762d2510a982dbad05dd2e2edc7fab92a1e24af6ffa59c5907e97dfe9d436dd246e43458cdf79597aceb57d8a8ff73a79df22ed9bf7537f7a5ff5382d9b2e50b6681f2960ae77d4fc5e72fcc5633d8bc7f7536c2fda9b352df33a95b5d71a1feb6c3e65fea7691fc9eff58e92dfa5e3dbc76f756c2f3d46c7b033e13b0dcc93f06a5abe9685addcf9bc901f7bc939c3fb10afe7d53e7959b5adb5fdf4f6e3b559cb3c81906b8b7bd33e935f4c61fcdb440762d2510a183c260a981ef28b49e4b787fc3e4f9a54f0a443be7348cb7d37c234f28b498c7f9be8404ca280c1240a981ef28b49e4b787fc3e4f9a40a8bebb449307d5f229e4179318ff36d181984401834914303de41793c86f0ff97dbe74e741fc22e5f87d08d5ffb63281fc6212e3df263a10932860308902a687fc6212f9ed21bfcf9b2609f43f52e8dfd8ff0383be90f128df6b427e3189f16f131d88491430984401d3437e3189fcf6905f4c22bf98c4f8b7890ec4240a184ca280e921bf98447e7bc82f26915f4c62fcdb44076212050c2651c0f4905f4c22bf3de41793c82f2631fe6da203318902069328607ac82f2691df1ef28b49e4179318ff36d181984401834914303de41793c86f0ff9c524f28b498c7f9be8404c3a5301a3ff4359ed08fffdd123ba45ff53c0f49c29bfd3aae3b75a86fdc86f0ff97d5afadf0d94e75ffeead7bf5fa6bf6bd951fee7832321bf98c4f8b7890ec4a46b0b185d8c75f151dbba30ebe2adf57efcd39fe55f5dc4ff87b2b6f768d477eeefadb6f5ef71ad5bf43f054ccfb5f93da29fffe2fd0fbef5edefbcfe2fcff4a78e8d1ffcf0474f36b8af8edf6a19f623bf3dcf25bf1aa4fbfab396d73dd7285feb743eb8941ea73cc73ed5dfb54cbfc387915f4c62fcdb440762d2b5058c0aeebdc5777551bf861eaf16df5d78142e82f6b4ad7f8f6bdd62fb14303dd7e6f7483449a0ff1f3d1fc76efadd358389ac3a7eab738a273cf320c793a679f92323bf3dcf21bfa24c385f4b59d572afb33698f73a3a2f5caaaa359e6a02c1131bcf09f9c524c6bf4d7420265d5bc04c4c203c321741fa53fdb9d66e35c0d9fbef7d090a989e6bf37b147ab7d293077a1d2ad2359857d3dfbff68d6fee1a74ecb1f7f8fdeef7be5faea7014db5fc9191df9eb3e737fac217bff43a1fca4fc5b9527bef2b5fcdbf7e2d4e325c731dab6a8da79a40d03e6b3bcf09f9c524c6bf4d7420265d5bc0dc6a02e14c77175cb3af2a8aae298c9ea208d2f35eb3cfb6f7dffb1214303dd7e6f7287c6ed0244275ebb396b970d73a5bd68ecdbdc7afb396d7d3c72aaae5d9b5193f23f2db73f6fc469e20d04442c5130cfeb3cafbd63694abea7156d51a7bae9d7b32ebf3c773427e3189f16f131d8849d71630dd0984b84c83daf84ea38a07bddb972fe8d5f3691d17f66e1a68687b1e2cfbb9b42cf3f356054bb5df7e5734de72ad9fabcf6abb70d1ef5418b970f2f35df29d107b8aa08af629f78f9a06657932c1ffa67a2ebd13949fd38fcdffde7a1dfeddd2edab4b28607aaecdef51787260ed35e818d63117cf07f158d571ec63d54d99cc79ac8edfb8cc79cfeddf7ef2ef7fb04c2d6651fb16cf616a3e0fe5fdf0f3e84f9d1b96262cce80fcf69c3dbfd1dadd03fe8883ae7bbe1e55d70a9f0fe25d0c5a4f398ad74fad575d73ab6b76be8e991ebb27b3f9dce2f61c905f4c62fcdb440762d2b5054c9c40c8c542565dd4bd4c85800b83f87735151a9197c742db03793d4e17fe38b8d7f65408b8b0a9dec18c1301f975c40900d140c5eb6adb7922214f08f8f12e8af2dfe3b6b72c15416be2ede16aeaa35c0cc5222e0ecae2bfc3da0442ec93bdaf25a280e9b936bf4711279f2e39b67daceaf88ee78078bce7dba4abe3372ed3bec47ce8f1faf9fdfff8cfd77fc6f38a7e76de9501ff4e7fea77713bdaaf786e89e7beb8bf4c203c9eb3e737f3f19caf85ceb9aee9fe7bfea88327196216e235cc930f6b3542556b54d74e3dd75a66b5cc99d57ec6ebb6d77d0ec82f2631fe6da20331e9da02264e206ca92eea5ea6a68b732c9efdbb7c5780d7f7ba2e4474b18fef18c4db9e5da4f8b1f9cbd2fc3cfa33173df9312e30968a963c60890589feeeede879fd9cf9352ea98aa02dde2ff54f7c6d7a7ef74fdce7f86fea3b24627ff977eeff3870ca7db217054ccfb5f93d923820505e35a995df59ccaa6355f4b8f859eb78dce7e3f79265e20ce6e57e4755598879d1dff37948f2b94faf374f5e9e05f9ed790ef98dfc8e7ebedbcfcb7dac3bb751bc9e9bb395d78d6f0ac47345556b54d7ce3d998dd7b44bea9d3321bf98c4f8b7890ec4a46b0b984b2ea8d5453d16d179e01edfd55e1bc0fa4bcd721120f9338d2e60e2bbe4de076fa72a1862e1a265d5472b96f6374e2064b1fff280a412272fb4fe5a336fbf9a7488cf1f2736bc2cbf3b24717fe33b38d74e1e08054ccfb5f93d121d4ff19d783715f255defc18af57ddf9e2bcc4814c3c7e2f5d26d504427cd7b4ba25bb1a14ad9dfbce86fcf63c87fc46d5f12eceb707fb1ea4c76c7b501f33abac29df398b5265a8aa35aa0984b5ccc68f62583cdf3c27e4179318ff36d18198746d0173c905b5baa8c722ba7ab7d1bf5b2bece3ed8d7e97bd1a6c8827096271e2c242dbd144419c2cf0fe550369d13eb8b968cafbebed57fdabe7ac1eb3244e466c3589dbaf8aa4eaf9e3bf69d58f717bd5bb34d7a080e9b936bf47a4e32adfa2eca6e32d9e27e2b15ae5c71386b16faaf5f72e936a0221ef476ef1dce0fdf7b9250fb2ce88fcf63ca7fc4a9c50cb13d3f16e37df251407ffbe962f4daa697bce95ce155e3f4e0c54b5469e408899d573e5ccc60904673d3ee63921bf98c4f8b7890ec4a46b0b987841ad069b91df59a82610f26dff962fe04bcb3c31109b0620f90b96aadb26e3f37bc0e1d7e2a2230ebef5bbad817cdc37afbb34c8f6636201b4c4dbd2feebef6b4de2bf4fbe3bc37201b65524f977f1dde2a56def4501d3736d7e8f4e5953711fbfe42ce664eb58adce2f5e7feb9c522d1367302e8f13045bcd8fab063967457e7b9e637e3db9ec3b833c5990bf1851cb3ca11fefe2cb1f47cc5f74985b670261ab318100dc0ee3df263a1093ae2d60e23b0d4bef1898df51ccb7266ad9d273e70bf8d232519111bfcddc2d7f3ed2fba17df7f3bba8f144845f4b2e16e2dd0e6a9a14d0c557dba9deb110efcfd25d0c7e4c750b76968ba02db1e0599ae0b9760241cd8f559f567790ec4501d3736d7ecfc4039298e7ad63b51a4878fdad734ab54cb62610f49c6bcd39acf6edacc86fcf73cc6f3ebe9d9b3819ef3be07c178eafbff90d85783792ea076d5bdb5106f3f54bf2734bbe76c67347ce686eceecd6f9e6acc82f2631fe6da20331a953c0c40bfb92f8ce42bcd03fe50442a4c2c445845a9cdc88b74dfaef2e6abc9ffe8e81fcbae24021df51b0358170c96b5c928ba02ddd8f3054e2eb8f132a6bfffe5b28607a3af93d0215e85b77b1c47388f31c8fd5eaf1d55d4ff978bf64995413085bfb51a9063967457e7bce9edf4accabf8ef79a2d979d2fad5f52d6e277f8429be81111f53652b6f3b6636677cc9d6b5f1acc82f2631fe6da20331a953c0f8c2bcf659def88de855e1bdf4dcd563f23215144b05806f7b8cef6878a0afc185dfd58c45895e87def1701112ef0cf0eba8de715f7a8dee1fb5ecd281472e82f6a8dea1b1aa88da2a92d6d6df7317458502a6a793df69f1e3474b3996b85efe5cb55a750794f372eb2f518c839c2a033a57e47c57839cb322bf3d67ceef1a5f7b7c6dac5ea373e03fd5625662eef35d74f9ce1fabb2555d3babc74639b35bd7c6b322bf98c4f8b7890ec4a44e0113df79f7c0db836bfd2e0ea0f3ad89be202f3db71fb756d87b12a0fa8840553488f7b5da274f3ac48f3a980b96781bb5fe5491133fda100711f1f5c7be89dfa510bf9361cdd2eb59e3624afb170759ea3ff75decffad22c9bf8bff264bc5df5e14303d9dfc1e41fc784275fc282bce57cc6b3c56b53c1f937e4cbcfba63a7eab654b136fce60be03c9cbf37e4875274435c8392bf2db73f6fc2ef171ef96b32431c36af95a1827e7e2b941d7b2f8d18698c72a5bd5b5d3fba7ede4cc7ad2239e6fe2bee4f5cf8cfc6212e3df263a1093ba054c2e147c518e3f578303170f79106fd5c53a2fd3365deceb3975e157ab3e336df10b99f2c4437cc7231733f1967dfd4eafdb3fabff5cb8f877e2c2c5ebe9cfdc5fd5bba795aa08da12f7594dfd122735d462ff5e338120eeefeaee8c2d14303dddfc4e8b1304ce8832aa1697abc5c9001fab3ae6620ee3b9279f5baae3b75a1627deb44d67344e986ab9cf1ff13c147fb73439520d72ce8afcf69c3dbf4b625672bea2989b3c31273143ca549cfcf7f5dae70c9d4baa6c55d7ce7cded1f6d6322bde8ef6417fcfbf3f23f28b498c7f9be8404c7a8a0226bf23109b3f2f9f5d3358ad96e53b1ddc5450549ffd8f9304f9f7f15d863cb920f9b93c59a0d7a7e6e2c3af29162eda5e1e28ed9d3c90aa08da43fb55f58ff63d1740d7fc9b482cc62efd3e040a989ea7c8ef341d877170109b8e2b1d23f91ce263d5857ccc9e97e7c754c76fb54c8f8bfb123317b314fb3dde5514f75d79c839ab063967457e7b9e437e2bca50ccc29238a19fafc7a2ecc475e26481c4df29c355b696ae9d4bd7462d5bda97b85ebe0e9e11f9c524c6bf4d7420263d7501a38baa5a2edeef4117f87b5cd4f77cf19b54858b1eb7e7b1b770affeb904054ccf53e7779a8fd1ade3d413081a5098ce3953e79e48fb3095f17b23bf3dcf2dbfb7e26cdfcae4757912f9c524c6bf4d74202651c0dc4e3581800fa380e979d4fc7a02410d73c86fcfa3e617c7407e3189f16f131d88491430b7c304c2360a989e47cd2f1308c7407e7b1e35bf3806f28b498c7f9be8404ca280b91d2610b651c0f43c6a7e99403806f2dbf3a8f9c531905f4c62fcdb4407621205ccede88be1d4b7977c59e2a3a180e979d4fceaf3ca7add8ff8da8f84fcf63c6a7e710ce4179318ff36d181984401834914303de41793c86f0ff9c524f28b498c7f9be8404ca280c1240a981ef28b49e4b787fc6212f9c524c6bf4d74202651c06012054c0ff9c524f2db437e3189fc6212e3df263a10932860308902a687fc6212f9ed21bf98447e3189f16f131d88491430984401d3437e3189fcf6905f4c22bf98c4f8b7890ec4240a184ca280e921bf98447e7bc82f26915f4c62fcdb44076212050c2651c0f4905f4c22bf3de41793c82f2631fe6da203318902069328607ac82f2691df1ef28b49e4179318ff36d181984401834914303de41793c86f0ff9c524f28b498c7f9be8404ca280c1240a981ef28b49e4b787fc6212f9c524c6bf4d74202651c06012054c0ff9c524f2db437e3189fc6212e3df263a10932860308902a687fc6212f9ed21bf98447e3189f16f131d88491430984401d3437e3189fcf6905f4c22bf98c4f8b7890ec4240a184ca280e921bf98447e7bc82f26915f4c62fcdb44076212050c2651c0f4905f4c22bf3de41793c82f2631fe6da203318902069328607ac82f2691df1ef28b49e4179318ff36d181984401834914303de41793c86f0ff9c524f28b498c7f9be8404ca280c1240a981ef28b49e4b787fc6212f9c524c6bf4d74202651c06012054c0ff9c524f2db437e3189fc6212e3df263a10932860308902a687fc6212f9ed21bf98447e3189f16f131d88491430984401d3437e3189fcf6905f4c22bf98c4f8b7890ec4240a184ca280e921bf98447e7bc82f26915f4c62fcdb44076212050c2651c0f4905f4c22bf3de41793c82f2631fe6da203318902069328607ac82f2691df1ef28b49e4179318ff36d181984401834914303de41793c86f0ff9c524f28b498c7f9be8404ca280c1240a981ef28b49e4b787fc6212f9c524c6bf4d74202651c06012054c0ff9c524f2db437e3189fc6212e3df263a10932860308902a687fc6212f9ed21bf98447e3189f16f131d88491430984401d3437e3189fcf6905f4c22bf98c4f8b7890ec4240a184ca280e921bf98447e7bc82f26915f4c62fcdb44076212050c2651c0f4905f4c22bf3de41793c82f2631fe6da203318902069328607ac82f2691df1ef28b49e4179318ff36d181984401834914303de41793c86f0ff9c524f28b498c7f9be8404ca280c1240a981ef28b49e4b787fc6212f9c524c6bf4d74202651c06012054c0ff9c524f2db437e3189fc6212e3df263a10932860308902a687fc6212f9ed21bf98447e3189f16f131d88491430984401d3437e3189fcf6905f4c22bf98c4f8b7890ec4240a184ca280e921bf98447e7bc82f26915f4c62fcdb44076212050c2651c0f4905f4c22bf3de41793c82f2631fe6da2033189020693befbbdef53c034905f4c22bf3de41793c82f2631fe6da2033189020693defbca573ff8933ffdcbf7f271897dc82f2691df1ef28b49e4179318ff36d18198f4e627df79f5eaaf3eff3ff9c202dcda6f7efbdb0fde7af9ee071f7ff3d31fcbc725f621bf98427efbc82fa6905f4c63fcdb440762d21b6fbcf3515d447efe8bf7f3f505b8a91ffff467af0b987c4c623ff28b29e4b78ffc620af9c534c6bf4d7420a6bdfd179ffdc7cf7ceef3bff9e5af7e9daf31c04de858fbbbbfff87fffeb34f7fee9ff3f188cb905fdc1bf97d3ae417f7467e71048c7f9be8408c7bf9f2232fdff9ecbfea82a25969ddda06dc8ade6d53c1ac634ec75e3e1c7121f28b3b22bf4f8cfce28ec82f8e82f16f131d8843f8dd8544b3d1baa54d4d5faea36fe8d57ff343a3759b8e257d59983eefabe3ebcf3ff337ff44f1f284c82fed868dfcde18f9a5ddb0915f1c11e3df263a1047a32fd579f1a9575fd67fef43a33d55d3b73deb4bc3f4b9df7ccce1e9905fda2d1af9bd0ff24bbb4523bf381ac6bf4d742000000000e01130fe6da20301000000008f80f16f131d080000000078048c7f9be84000000000c02360fcdb440702000000001e01e3df263a1000000000f00818ff36d181000000008047c0f8b7890e04000000003c02c6bf4d742000000000e01130fe6da20301000000008f80f16f131d080000000078048c7f9be84000000000c02360fcdb440702000000001e01e3df263a1000000000f00818ff36d181000000008047c0f8b7890e04000000003c02c6bf4d742000000000e01130fe6da20301000000008f80f16f131d080000000078048c7f9be84000000000c02360fcdb440702000000001e01e3df263a1000000000f00818ff36d181000000008047c0f8b7890e04000000003c02c6bf4d742000000000e01130fe6da20301000000008f80f16f131d080000000078048c7f9be84000000000c02360fcdb440702000000001e01e3df263a1000000000f00818ff36d181000000008047c0f8b7890e04000000003c02c6bf4d742000000000e011bcf8d4ab2f33fe6d60020100000000f008de7cf9eebf6812212fc74e6f7ef29d572f5ebefaafbc1c000000008067e3e5cb8fbcf5f2dd0f3efee6a73f967f859dde78e39d8faa135fbc78f78ff3ef0000000000780e5ebcfdce5b1afbe6e5b8d08bb75f7d5d77217ce2132fff28ff0e000000008033d358571f5fe0e3fb4fe1e5cb8fa8335f7f1ee4ed77ded2cf79150000000000ce4677dbeb0d738d7719eb3e95df75a46663744b879abf5c425fb248a3d168341a8d46a3d16834da599affb7054d1cbcfec8fedbafbecee4c18de84b25dce1341a8d46a3d168341a8d46a39dad694cabff3440dffb97c7bc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080c3fa5fb84c54a722ea50030000000049454e44ae426082" + } + ] +} \ No newline at end of file diff --git a/contracts/script/process_genesis_json.rb b/contracts/script/process_genesis_json.rb new file mode 100644 index 0000000..30ce413 --- /dev/null +++ b/contracts/script/process_genesis_json.rb @@ -0,0 +1,59 @@ +#!/usr/bin/env ruby + +require 'json' +require 'digest' +require 'base64' + +# Read the existing genesis JSON file +json_path = File.join(File.dirname(__FILE__), 'genesisEthscriptions.json') +data = JSON.parse(File.read(json_path)) + +# Process each ethscription +data['ethscriptions'].each do |ethscription| + content_uri = ethscription['content_uri'] + + # Calculate content URI hash (as hex string with 0x prefix for JSON) + content_uri_sha = '0x' + Digest::SHA256.hexdigest(content_uri) + ethscription['content_uri_sha'] = content_uri_sha + + # Parse the data URI + if content_uri.start_with?('data:') + # Remove 'data:' prefix + uri_without_prefix = content_uri[5..-1] + + # Find the comma that separates metadata from data + comma_index = uri_without_prefix.index(',') + + if comma_index + metadata = uri_without_prefix[0...comma_index] + data_part = uri_without_prefix[(comma_index + 1)..-1] + + # Check if it's base64 encoded + is_base64 = metadata.include?(';base64') + + # Get the content + if is_base64 + # Decode from base64 for storage + content = Base64.decode64(data_part) + # Store as hex string for JSON (with 0x prefix) + ethscription['content'] = '0x' + content.unpack1('H*') + else + # For non-base64, keep the original encoded form (preserves percent-encoding) + # Store as hex string for JSON (with 0x prefix) + ethscription['content'] = '0x' + data_part.unpack('H*')[0] + end + else + # Invalid data URI format, store empty content + ethscription['content'] = '0x' + end + else + # Not a data URI, store the whole thing as content + ethscription['content'] = '0x' + content_uri.unpack('H*')[0] + end +end + +# Write the updated JSON back +File.write(json_path, JSON.pretty_generate(data)) + +puts "Processed #{data['ethscriptions'].length} ethscriptions" +puts "Added content_uri_sha and content fields" \ No newline at end of file diff --git a/contracts/soldeer.lock b/contracts/soldeer.lock new file mode 100644 index 0000000..18cc8db --- /dev/null +++ b/contracts/soldeer.lock @@ -0,0 +1,34 @@ +[[dependencies]] +name = "@eth-optimism-contracts-bedrock" +version = "0.17.3" +url = "https://soldeer-revisions.s3.amazonaws.com/@eth-optimism-contracts-bedrock/0_17_3_06-06-2024_05:37:28_contracts-bedrock.zip" +checksum = "cd0264f2f2c9f0278f7f8259f4a2b230897262afe14eb8baef996274e53c23db" +integrity = "7646fa5b18fa27ac5e3de58996a778e2e3675a8d24d3fbf59a889f7397570a73" + +[[dependencies]] +name = "@openzeppelin-contracts" +version = "5.3.0" +url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/5_3_0_10-04-2025_10:51:50_contracts.zip" +checksum = "fa2bc3db351137c4d5eb32b738a814a541b78e87fbcbfeca825e189c4c787153" +integrity = "d69addf252dfe0688dcd893a7821cbee2421f8ce53d95ca0845a59530043cfd1" + +[[dependencies]] +name = "@openzeppelin-contracts-upgradeable" +version = "5.3.0" +url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts-upgradeable/5_3_0_10-04-2025_10:51:56_contracts-upgradeable.zip" +checksum = "4bd92f87af0cac7226b12ce367e7327f13431735fa6010508d8c8177f9d3d10f" +integrity = "fa195a69ef4dfec7fec7fbbb77f424258c821832fdd355b0a6e5fe34d2986a16" + +[[dependencies]] +name = "forge-std" +version = "1.10.0" +url = "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_10_0_02-08-2025_06:50:35_forge-std-1.10.zip" +checksum = "8cc59e92a7762c170c15f2102c4f3718106dfc78239978fe87c64f316740cc09" +integrity = "3fd441e2499c5cf04ef9b4126f52150a50dcef9cac1645ff7a955345e947cd04" + +[[dependencies]] +name = "solady" +version = "0.1.26" +url = "https://soldeer-revisions.s3.amazonaws.com/solady/0_1_26_25-08-2025_15:30:06_solady.zip" +checksum = "9872ac7cfd32c1eba32800508a1325c49f4a4aa8c6f670454db91971a583e26b" +integrity = "5da4b5ca9cbad98812a4b75ad528ff34c72a0b84433204be6d1420c81de1d6ff" diff --git a/contracts/src/ERC20FixedDenomination.sol b/contracts/src/ERC20FixedDenomination.sol new file mode 100644 index 0000000..02c69c8 --- /dev/null +++ b/contracts/src/ERC20FixedDenomination.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./ERC404NullOwnerCappedUpgradeable.sol"; +import "./libraries/Predeploys.sol"; +import "./Ethscriptions.sol"; +import "./ERC20FixedDenominationManager.sol"; +import {LibString} from "solady/utils/LibString.sol"; +import {Base64} from "solady/utils/Base64.sol"; + +/// @title ERC20FixedDenomination +/// @notice Hybrid ERC-20/ERC-721 proxy whose supply is managed in fixed denominations by the manager contract. +/// @dev User-initiated transfers/approvals are disabled; only the manager can mutate balances. +/// Each NFT represents a fixed denomination amount (e.g., 1 NFT = mintAmount tokens). +contract ERC20FixedDenomination is ERC404NullOwnerCappedUpgradeable { + using LibString for *; + + // ============================================================= + // CONSTANTS + // ============================================================= + + /// @notice The manager contract that controls this token + address public constant manager = Predeploys.ERC20_FIXED_DENOMINATION_MANAGER; + + // ============================================================= + // STATE VARIABLES + // ============================================================= + + /// @notice The ethscription ID that deployed this token + bytes32 public deployEthscriptionId; + + // ============================================================= + // CUSTOM ERRORS + // ============================================================= + + error OnlyManager(); + error TransfersOnlyViaEthscriptions(); + error ApprovalsNotAllowed(); + + // ============================================================= + // MODIFIERS + // ============================================================= + + modifier onlyManager() { + if (msg.sender != manager) revert OnlyManager(); + _; + } + + // ============================================================= + // EXTERNAL FUNCTIONS + // ============================================================= + + function initialize( + string memory name_, + string memory symbol_, + uint256 cap_, + uint256 mintAmount_, + bytes32 deployEthscriptionId_ + ) external initializer { + // cap_ is maxSupply * 10**18 + // mintAmount_ is the denomination amount (e.g., 1000 for 1000 tokens per NFT) + // units is mintAmount_ * 10**18 (amount of wei per NFT) + + uint256 units_ = mintAmount_ * (10 ** decimals()); + + __ERC404_init(name_, symbol_, cap_, units_); + deployEthscriptionId = deployEthscriptionId_; + } + + /// @notice Historical accessor for the fixed denomination (whole tokens per NFT) + function mintAmount() public view returns (uint256) { + return denomination(); + } + + /// @notice Mint one fixed-denomination note (manager only) + /// @param to The recipient address + /// @param nftId The specific NFT ID to mint (the mintId) + function mint(address to, uint256 nftId) external onlyManager { + // Mint the ERC20 tokens without triggering NFT creation + _mintERC20WithoutNFT(to, units()); + _mintERC721(to, nftId); + } + + /// @notice Force transfer the fixed-denomination NFT and its synced ERC20 lot (manager only) + /// @param from The sender address + /// @param to The recipient address + /// @param nftId The NFT ID to transfer (the mintId) + function forceTransfer(address from, address to, uint256 nftId) external onlyManager { + // Transfer the ERC20 tokens without triggering dynamic NFT logic + _transferERC20(from, to, units()); + + // Transfer the specific NFT using the proper function + _transferERC721(from, to, nftId); + } + + // ============================================================= + // DISABLED ERC20/721 FUNCTIONS + // ============================================================= + + /// @notice Regular transfers are disabled - only manager can transfer + function transfer(address, uint256) public pure override returns (bool) { + revert TransfersOnlyViaEthscriptions(); + } + + /// @notice Regular transferFrom is disabled - only manager can transfer + function transferFrom(address, address, uint256) public pure override returns (bool) { + revert TransfersOnlyViaEthscriptions(); + } + + /// @notice Approvals are disabled + function approve(address, uint256) public pure override returns (bool) { + revert ApprovalsNotAllowed(); + } + + /// @notice ERC721 approvals are disabled + function erc721Approve(address, uint256) public pure override { + revert ApprovalsNotAllowed(); + } + + /// @notice ERC20 approvals are disabled + function erc20Approve(address, uint256) public pure override returns (bool) { + revert ApprovalsNotAllowed(); + } + + /// @notice SetApprovalForAll is disabled + function setApprovalForAll(address, bool) public pure override { + revert ApprovalsNotAllowed(); + } + + /// @notice ERC721 transferFrom is disabled + function erc721TransferFrom(address, address, uint256) public pure override { + revert TransfersOnlyViaEthscriptions(); + } + + /// @notice ERC20 transferFrom is disabled + function erc20TransferFrom(address, address, uint256) public pure override returns (bool) { + revert TransfersOnlyViaEthscriptions(); + } + + /// @notice Safe transfers are disabled + function safeTransferFrom(address, address, uint256) public pure override { + revert TransfersOnlyViaEthscriptions(); + } + + /// @notice Safe transfers with data are disabled + function safeTransferFrom(address, address, uint256, bytes memory) public pure override { + revert TransfersOnlyViaEthscriptions(); + } + + // ============================================================= + // TOKEN URI + // ============================================================= + + /// @notice Returns metadata URI for NFT tokens + /// @dev Returns a data URI with JSON metadata fetched from the main Ethscriptions contract + function tokenURI(uint256 mintId) public view virtual override returns (string memory) { + ownerOf(mintId); // reverts on invalid / nonexistent + + // Get the ethscriptionId for this mintId from the manager + ERC20FixedDenominationManager mgr = ERC20FixedDenominationManager(manager); + bytes32 ethscriptionId = mgr.getMintEthscriptionId(deployEthscriptionId, mintId); + + // Get the ethscription data from the main contract + Ethscriptions ethscriptionsContract = Ethscriptions(Predeploys.ETHSCRIPTIONS); + Ethscriptions.Ethscription memory ethscription = ethscriptionsContract.getEthscription(ethscriptionId, false); + (string memory mediaType, string memory mediaUri) = ethscriptionsContract.getMediaUri(ethscriptionId); + + // Convert ethscriptionId to hex string (0x prefixed) + string memory ethscriptionIdHex = uint256(ethscriptionId).toHexString(32); + + // Build the JSON metadata + string memory jsonStart = string.concat( + '{"name":"', name().escapeJSON(), ' Token #', mintId.toString(), '"', + ',"description":"Fixed denomination token for ', mintAmount().toString(), ' ', symbol().escapeJSON(), ' tokens"' + ); + + // Add ethscription ID and number + string memory ethscriptionFields = string.concat( + ',"ethscription_id":"', ethscriptionIdHex, '"', + ',"ethscription_number":', ethscription.ethscriptionNumber.toString() + ); + + // Add media field + string memory mediaField = string.concat( + ',"', mediaType, '":"', mediaUri, '"' + ); + + string memory json = string.concat(jsonStart, ethscriptionFields, mediaField, '}'); + + return string.concat("data:application/json;base64,", Base64.encode(bytes(json))); + } +} diff --git a/contracts/src/ERC20FixedDenominationManager.sol b/contracts/src/ERC20FixedDenominationManager.sol new file mode 100644 index 0000000..8112bd3 --- /dev/null +++ b/contracts/src/ERC20FixedDenominationManager.sol @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "@openzeppelin/contracts/utils/Create2.sol"; +import {LibString} from "solady/utils/LibString.sol"; +import "./ERC20FixedDenomination.sol"; +import "./libraries/Proxy.sol"; +import "./Ethscriptions.sol"; +import "./libraries/Predeploys.sol"; +import "./interfaces/IProtocolHandler.sol"; + +/// @title ERC20FixedDenominationManager +/// @notice Manages ERC-20 tokens that move in a fixed denomination per mint/transfer lot. +/// @dev Deploys and controls ERC20FixedDenomination proxies; callable only by the Ethscriptions contract. +contract ERC20FixedDenominationManager is IProtocolHandler { + using LibString for string; + + // ============================================================= + // STRUCTS + // ============================================================= + + struct TokenInfo { + address tokenContract; + bytes32 deployEthscriptionId; + string tick; + uint256 maxSupply; + uint256 mintAmount; + uint256 totalMinted; + } + + struct TokenItem { + bytes32 deployEthscriptionId; // Which token this ethscription belongs to + uint256 amount; // How many tokens this ethscription represents + uint256 mintId; // Fixed denomination note identifier + } + + struct DeployOperation { + string tick; + uint256 maxSupply; + uint256 mintAmount; + } + + struct MintOperation { + string tick; + uint256 id; + uint256 amount; + } + + // ============================================================= + // CONSTANTS + // ============================================================= + + /// @dev Implementation contract used for proxy deployments + address public constant tokenImplementation = Predeploys.ERC20_FIXED_DENOMINATION_IMPLEMENTATION; + address public constant ethscriptions = Predeploys.ETHSCRIPTIONS; + + string public constant protocolName = "erc-20-fixed-denomination"; + + // ============================================================= + // STATE VARIABLES + // ============================================================= + + mapping(string => TokenInfo) internal tokensByTick; + mapping(bytes32 => string) internal deployToTick; // deployEthscriptionId => tick + mapping(bytes32 => TokenItem) internal tokenItems; + mapping(bytes32 => mapping(uint256 => bytes32)) internal mintIds; // deploy inscription => mint id => ethscriptionId + + // ============================================================= + // CUSTOM ERRORS + // ============================================================= + + error OnlyEthscriptions(); + error TokenAlreadyDeployed(); + error TokenNotDeployed(); + error MintAmountMismatch(); + error InvalidMintId(); + error InvalidMaxSupply(); + error InvalidMintAmount(); + error MaxSupplyNotDivisibleByMintAmount(); + + // ============================================================= + // EVENTS + // ============================================================= + + event ERC20FixedDenominationTokenDeployed( + bytes32 indexed deployEthscriptionId, + address indexed tokenAddress, + string tick, + uint256 maxSupply, + uint256 mintAmount + ); + + event ERC20FixedDenominationTokenMinted( + bytes32 indexed deployEthscriptionId, + address indexed to, + uint256 amount, + uint256 mintId, + bytes32 ethscriptionId + ); + + event ERC20FixedDenominationTokenTransferred( + bytes32 indexed deployEthscriptionId, + address indexed from, + address indexed to, + uint256 amount, + uint256 mintId, + bytes32 ethscriptionId + ); + + // ============================================================= + // MODIFIERS + // ============================================================= + + modifier onlyEthscriptions() { + if (msg.sender != ethscriptions) revert OnlyEthscriptions(); + _; + } + + // ============================================================= + // EXTERNAL FUNCTIONS + // ============================================================= + + /// @notice Handles a deploy inscription for a fixed-denomination ERC-20. + /// @param ethscriptionId The deploy inscription hash (also used as CREATE2 salt). + /// @param data ABI-encoded DeployOperation parameters (tick, maxSupply, mintAmount). + function op_deploy(bytes32 ethscriptionId, bytes calldata data) external virtual onlyEthscriptions { + DeployOperation memory deployOp = abi.decode(data, (DeployOperation)); + + TokenInfo storage token = tokensByTick[deployOp.tick]; + + if (token.deployEthscriptionId != bytes32(0)) revert TokenAlreadyDeployed(); + if (deployOp.maxSupply == 0) revert InvalidMaxSupply(); + if (deployOp.mintAmount == 0) revert InvalidMintAmount(); + if (deployOp.maxSupply % deployOp.mintAmount != 0) revert MaxSupplyNotDivisibleByMintAmount(); + + bytes32 erc20Salt = _getContractSalt(deployOp.tick, "erc20"); + Proxy tokenProxy = new Proxy{salt: erc20Salt}(address(this)); + + tokenProxy.upgradeToAndCall(tokenImplementation, abi.encodeWithSelector( + ERC20FixedDenomination.initialize.selector, + deployOp.tick, + deployOp.tick.upper(), + deployOp.maxSupply * 10**18, + deployOp.mintAmount, + ethscriptionId + ) + ); + + tokenProxy.changeAdmin(Predeploys.PROXY_ADMIN); + + tokensByTick[deployOp.tick] = TokenInfo({ + tokenContract: address(tokenProxy), + deployEthscriptionId: ethscriptionId, + tick: deployOp.tick, + maxSupply: deployOp.maxSupply, + mintAmount: deployOp.mintAmount, + totalMinted: 0 + }); + + deployToTick[ethscriptionId] = deployOp.tick; + + emit ERC20FixedDenominationTokenDeployed( + ethscriptionId, + address(tokenProxy), + deployOp.tick, + deployOp.maxSupply, + deployOp.mintAmount + ); + } + + /// @notice Processes a mint inscription and mints the fixed denomination to the inscription owner. + /// @param ethscriptionId The mint inscription hash. + /// @param data ABI-encoded MintOperation parameters (tick, id, amount). + function op_mint(bytes32 ethscriptionId, bytes calldata data) external virtual onlyEthscriptions { + MintOperation memory mintOp = abi.decode(data, (MintOperation)); + + TokenInfo storage token = tokensByTick[mintOp.tick]; + + if (token.deployEthscriptionId == bytes32(0)) revert TokenNotDeployed(); + if (mintOp.amount != token.mintAmount) revert MintAmountMismatch(); + + uint256 maxId = token.maxSupply / token.mintAmount; + if (mintOp.id < 1 || mintOp.id > maxId) revert InvalidMintId(); + + Ethscriptions ethscriptionsContract = Ethscriptions(ethscriptions); + Ethscriptions.Ethscription memory ethscription = ethscriptionsContract.getEthscription(ethscriptionId); + address initialOwner = ethscription.initialOwner; + address recipient = initialOwner == address(0) ? ethscription.creator : initialOwner; + + tokenItems[ethscriptionId] = TokenItem({ + deployEthscriptionId: token.deployEthscriptionId, + amount: mintOp.amount, + mintId: mintOp.id + }); + mintIds[token.deployEthscriptionId][mintOp.id] = ethscriptionId; + + // Mint ERC20 tokens and NFT with specific ID matching the mintId + ERC20FixedDenomination(token.tokenContract).mint({to: recipient, nftId: mintOp.id}); + + // If the initial owner is the null owner, mirror the ERC721 null-owner pattern: + // mint to creator, then move balances to address(0) (NFT will be burned via forceTransfer logic). + if (initialOwner == address(0)) { + ERC20FixedDenomination(token.tokenContract).forceTransfer({ + from: recipient, + to: address(0), + nftId: mintOp.id + }); + } + token.totalMinted += mintOp.amount; + + emit ERC20FixedDenominationTokenMinted(token.deployEthscriptionId, initialOwner, mintOp.amount, mintOp.id, ethscriptionId); + } + + /// @notice Mirrors ERC-20 balances and NFT when a mint inscription NFT transfers. + /// @param ethscriptionId The mint inscription hash being transferred. + /// @param from The previous owner of the inscription NFT. + /// @param to The new owner of the inscription NFT. + function onTransfer( + bytes32 ethscriptionId, + address from, + address to + ) external virtual override onlyEthscriptions { + TokenItem memory item = tokenItems[ethscriptionId]; + + if (item.deployEthscriptionId == bytes32(0)) return; + + string memory tick = deployToTick[item.deployEthscriptionId]; + TokenInfo storage token = tokensByTick[tick]; + + // Transfer both ERC20 tokens and the specific NFT with the mintId + ERC20FixedDenomination(token.tokenContract).forceTransfer({from: from, to: to, nftId: item.mintId}); + + emit ERC20FixedDenominationTokenTransferred(item.deployEthscriptionId, from, to, item.amount, item.mintId, ethscriptionId); + } + + // ============================================================= + // EXTERNAL VIEW FUNCTIONS + // ============================================================= + + function getTokenAddress(bytes32 deployEthscriptionId) external view returns (address) { + string memory tick = deployToTick[deployEthscriptionId]; + return tokensByTick[tick].tokenContract; + } + + function getTokenAddressByTick(string memory tick) external view returns (address) { + return tokensByTick[tick].tokenContract; + } + + function getTokenInfo(bytes32 deployEthscriptionId) external view returns (TokenInfo memory) { + string memory tick = deployToTick[deployEthscriptionId]; + return tokensByTick[tick]; + } + + function getTokenInfoByTick(string memory tick) external view returns (TokenInfo memory) { + return tokensByTick[tick]; + } + + function predictTokenAddressByTick(string memory tick) external view returns (address) { + if (tokensByTick[tick].tokenContract != address(0)) { + return tokensByTick[tick].tokenContract; + } + + bytes32 erc20Salt = _getContractSalt(tick, "erc20"); + bytes memory creationCode = abi.encodePacked(type(Proxy).creationCode, abi.encode(address(this))); + return Create2.computeAddress(erc20Salt, keccak256(creationCode), address(this)); + } + + function isTokenItem(bytes32 ethscriptionId) external view returns (bool) { + return tokenItems[ethscriptionId].deployEthscriptionId != bytes32(0); + } + + function getTokenItem(bytes32 ethscriptionId) external view returns (TokenItem memory) { + return tokenItems[ethscriptionId]; + } + + function getMintEthscriptionId(bytes32 deployEthscriptionId, uint256 mintId) external view returns (bytes32) { + return mintIds[deployEthscriptionId][mintId]; + } + + // ============================================================= + // PRIVATE FUNCTIONS + // ============================================================= + + function _getContractSalt(string memory tick, string memory contractType) private pure returns (bytes32) { + return keccak256(abi.encode(tick, contractType)); + } +} diff --git a/contracts/src/ERC404NullOwnerCappedUpgradeable.sol b/contracts/src/ERC404NullOwnerCappedUpgradeable.sol new file mode 100644 index 0000000..58db54a --- /dev/null +++ b/contracts/src/ERC404NullOwnerCappedUpgradeable.sol @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; + +/// @title ERC404NullOwnerCappedUpgradeable +/// @notice Hybrid ERC20/ERC721 implementation with null owner support, supply cap, and upgradeability +/// @dev Combines ERC404 NFT functionality with null owner semantics and EIP-7201 namespaced storage +abstract contract ERC404NullOwnerCappedUpgradeable is + Initializable, + ContextUpgradeable, + IERC165, + IERC20, + IERC20Metadata, + IERC20Errors +{ + struct TokenData { + address owner; // current owner (can be address(0) for null-owner) + uint88 index; // position in owned[owner] array + bool exists; // true if the token has been minted + } + + // ============================================================= + // STORAGE STRUCT + // ============================================================= + + /// @custom:storage-location erc7201:ethscriptions.storage.ERC404NullOwnerCapped + struct TokenStorage { + // === ERC20 State === + mapping(address => uint256) balances; + mapping(address => mapping(address => uint256)) allowances; + uint256 totalSupply; + uint256 cap; + + mapping(address => uint256[]) owned; + mapping(uint256 => TokenData) tokens; + mapping(uint256 => address) getApproved; + mapping(address => mapping(address => bool)) isApprovedForAll; + uint256 minted; // Number of NFTs minted + uint256 units; // Units for NFT minting (e.g., 1000 * 10^18) + + // === Metadata === + string name; + string symbol; + } + + // ============================================================= + // EVENTS + // ============================================================= + + // ERC20 Events are inherited from IERC20 (Transfer, Approval) + + // ERC721 Events (using different names to avoid conflicts with ERC20) + event ERC721Transfer(address indexed from, address indexed to, uint256 indexed id); + + // ============================================================= + // CUSTOM ERRORS + // ============================================================= + + error UnsafeUpdate(); + error ERC20ExceededCap(uint256 increasedSupply, uint256 cap); + error ERC20InvalidCap(uint256 cap); + error InvalidUnits(uint256 units); + error NotImplemented(); + error NotFound(); + error InvalidTokenId(); + error AlreadyExists(); + error InvalidRecipient(); + error Unauthorized(); + error OwnedIndexOverflow(); + + // ============================================================= + // STORAGE ACCESSOR + // ============================================================= + + function _getS() internal pure returns (TokenStorage storage $) { + bytes32 slot = keccak256("ethscriptions.storage.ERC404NullOwnerCapped"); + + assembly { + $.slot := slot + } + } + + // ============================================================= + // INITIALIZERS + // ============================================================= + + function __ERC404_init( + string memory name_, + string memory symbol_, + uint256 cap_, + uint256 units_ + ) internal onlyInitializing { + __Context_init(); + __ERC404_init_unchained(name_, symbol_, cap_, units_); + } + + function __ERC404_init_unchained( + string memory name_, + string memory symbol_, + uint256 cap_, + uint256 units_ + ) internal onlyInitializing { + TokenStorage storage $ = _getS(); + + if (cap_ == 0) revert ERC20InvalidCap(cap_); + uint256 base = 10 ** decimals(); + if (units_ == 0 || units_ % base != 0) revert InvalidUnits(units_); + + $.name = name_; + $.symbol = symbol_; + $.cap = cap_; + $.units = units_; + } + + // ============================================================= + // ERC20 METADATA VIEWS + // ============================================================= + + function name() public view virtual override(IERC20Metadata) returns (string memory) { + TokenStorage storage $ = _getS(); + return $.name; + } + + function symbol() public view virtual override(IERC20Metadata) returns (string memory) { + TokenStorage storage $ = _getS(); + return $.symbol; + } + + function decimals() public pure override(IERC20Metadata) returns (uint8) { + return 18; + } + + // ============================================================= + // ERC20 VIEWS + // ============================================================= + + function totalSupply() public view virtual override returns (uint256) { + TokenStorage storage $ = _getS(); + return $.totalSupply; + } + + function balanceOf(address account) public view virtual override returns (uint256) { + TokenStorage storage $ = _getS(); + return $.balances[account]; + } + + function balanceOf(address owner_, uint256 id_) + public + view + returns (uint256) + { + TokenStorage storage $ = _getS(); + TokenData storage t = $.tokens[id_]; + if (!t.exists) return 0; + return t.owner == owner_ ? 1 : 0; + } + + function allowance(address owner, address spender) public view virtual override returns (uint256) { + TokenStorage storage $ = _getS(); + return $.allowances[owner][spender]; + } + + function erc20TotalSupply() public view virtual returns (uint256) { + return totalSupply(); + } + + function erc20BalanceOf(address owner_) public view virtual returns (uint256) { + return balanceOf(owner_); + } + + // ============================================================= + // ERC721 VIEWS + // ============================================================= + + function erc721TotalSupply() public view virtual returns (uint256) { + TokenStorage storage $ = _getS(); + return $.minted; + } + + function erc721BalanceOf(address owner_) public view virtual returns (uint256) { + TokenStorage storage $ = _getS(); + return $.owned[owner_].length; + } + + function ownerOf(uint256 id_) public view virtual returns (address) { + _validateTokenId(id_); + TokenStorage storage $ = _getS(); + TokenData storage t = $.tokens[id_]; + + if (!t.exists) revert NotFound(); + + return t.owner; + } + + function owned(address owner_) public view virtual returns (uint256[] memory) { + TokenStorage storage $ = _getS(); + return $.owned[owner_]; + } + + function getApproved(uint256 id_) public view virtual returns (address) { + _validateTokenId(id_); + TokenStorage storage $ = _getS(); + if (!$.tokens[id_].exists) revert NotFound(); + + return $.getApproved[id_]; + } + + function isApprovedForAll(address owner_, address operator_) public view virtual returns (bool) { + TokenStorage storage $ = _getS(); + return $.isApprovedForAll[owner_][operator_]; + } + + // ============================================================= + // OTHER VIEWS + // ============================================================= + + function maxSupply() public view virtual returns (uint256) { + TokenStorage storage $ = _getS(); + return $.cap; + } + + function units() public view virtual returns (uint256) { + TokenStorage storage $ = _getS(); + return $.units; + } + + /// @notice Fixed denomination in whole-token units (e.g., 1000 if 1 NFT = 1000 tokens) + function denomination() public view virtual returns (uint256) { + return units() / (10 ** decimals()); + } + + /// @notice tokenURI must be implemented by child contract + function tokenURI(uint256 id_) public view virtual returns (string memory); + + // ============================================================= + // ERC20 OPERATIONS + // ============================================================= + + function transfer(address, uint256) public pure virtual override returns (bool) { + revert NotImplemented(); + } + + function approve(address, uint256) public pure virtual override returns (bool) { + revert NotImplemented(); + } + + function transferFrom(address, address, uint256) public pure virtual override returns (bool) { + revert NotImplemented(); + } + + function erc20Approve(address, uint256) public pure virtual returns (bool) { + revert NotImplemented(); + } + + function erc20TransferFrom(address, address, uint256) public pure virtual returns (bool) { + revert NotImplemented(); + } + + // ============================================================= + // ERC721 OPERATIONS + // ============================================================= + + function erc721Approve(address, uint256) public pure virtual { + revert NotImplemented(); + } + + function erc721TransferFrom(address, address, uint256) public pure virtual { + revert NotImplemented(); + } + + function setApprovalForAll(address, bool) public pure virtual { + revert NotImplemented(); + } + + function safeTransferFrom(address, address, uint256) public pure virtual { + revert NotImplemented(); + } + + function safeTransferFrom(address, address, uint256, bytes memory) public pure virtual { + revert NotImplemented(); + } + + /// @notice Low-level ERC20 transfer + /// @dev Supports transfers to/from address(0) for null owner support + function _transferERC20(address from_, address to_, uint256 value_) internal virtual { + TokenStorage storage $ = _getS(); + + if (from_ == address(0)) { + // Minting with cap enforcement + uint256 newSupply = $.totalSupply + value_; + if (newSupply > $.cap) { + revert ERC20ExceededCap(newSupply, $.cap); + } + $.totalSupply = newSupply; + } else { + // Transfer + uint256 fromBalance = $.balances[from_]; + if (fromBalance < value_) { + revert ERC20InsufficientBalance(from_, fromBalance, value_); + } + unchecked { + $.balances[from_] = fromBalance - value_; + } + } + + unchecked { + $.balances[to_] += value_; + } + + emit Transfer(from_, to_, value_); + } + + /// @notice Transfer an ERC721 token + function _transferERC721(address from_, address to_, uint256 id_) internal virtual { + TokenStorage storage $ = _getS(); + TokenData storage t = $.tokens[id_]; + + if (!t.exists) revert NotFound(); + if (from_ != t.owner) revert Unauthorized(); + + if (from_ != address(0)) { + // Clear approval + delete $.getApproved[id_]; + + // Remove from sender's owned list + uint256 lastTokenId = $.owned[from_][$.owned[from_].length - 1]; + if (lastTokenId != id_) { + uint256 updatedIndex = t.index; + $.owned[from_][updatedIndex] = lastTokenId; + $.tokens[lastTokenId].index = uint88(updatedIndex); + } + $.owned[from_].pop(); + } + + // Add to receiver's owned list (address(0) is a real owner in null-owner semantics) + uint256 newIndex = $.owned[to_].length; + if (newIndex > type(uint88).max) { + revert OwnedIndexOverflow(); + } + t.owner = to_; + t.index = uint88(newIndex); + $.owned[to_].push(id_); + + emit ERC721Transfer(from_, to_, id_); + } + + /// @notice Mint ERC20 tokens without triggering NFT creation + /// @dev Used for fixed denomination tokens where NFTs are explicitly minted + function _mintERC20WithoutNFT(address to_, uint256 value_) internal virtual { + // Direct ERC20 mint without NFT logic (cap enforced in _transferERC20) + _transferERC20(address(0), to_, value_); + } + + /// @notice Mint a specific NFT with a given ID + /// @dev Used for fixed denomination tokens to mint NFTs with specific mintIds + function _mintERC721(address to_, uint256 nftId_) internal virtual { + if (to_ == address(0)) { + revert InvalidRecipient(); + } + _validateTokenId(nftId_); + + TokenStorage storage $ = _getS(); + + TokenData storage t = $.tokens[nftId_]; + + // Check if this NFT already exists (including null-owner) + if (t.exists) { + revert AlreadyExists(); + } + + t.exists = true; + _transferERC721(address(0), to_, nftId_); + + // Increment minted supply counter + $.minted++; + } + + // ============================================================= + // HELPER FUNCTIONS + // ============================================================= + + /// @dev Simple tokenId validation: nonzero and not max uint256. + function _validateTokenId(uint256 id_) internal pure { + if (id_ == 0 || id_ == type(uint256).max) { + revert InvalidTokenId(); + } + } + + // ============================================================= + // ERC165 SUPPORT + // ============================================================= + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return + interfaceId == type(IERC165).interfaceId || + interfaceId == type(IERC20).interfaceId || + interfaceId == type(IERC20Metadata).interfaceId; + } +} diff --git a/contracts/src/ERC721EthscriptionsCollection.sol b/contracts/src/ERC721EthscriptionsCollection.sol new file mode 100644 index 0000000..e656b7b --- /dev/null +++ b/contracts/src/ERC721EthscriptionsCollection.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./ERC721EthscriptionsEnumerableUpgradeable.sol"; +import "./Ethscriptions.sol"; +import "./libraries/Predeploys.sol"; +import {LibString} from "solady/utils/LibString.sol"; +import {Base64} from "solady/utils/Base64.sol"; +import {JSONParserLib} from "solady/utils/JSONParserLib.sol"; +import "./ERC721EthscriptionsCollectionManager.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +/// @title ERC721EthscriptionsCollection +/// @notice Thin ERC-721 wrapper for Ethscription collections where the manager controls mint/burn +contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeable, OwnableUpgradeable { + using LibString for *; + + Ethscriptions public constant ethscriptions = Ethscriptions(Predeploys.ETHSCRIPTIONS); + + /// @notice Manager contract that deployed and controls this collection + ERC721EthscriptionsCollectionManager public manager; + + /// @notice Collection ID stored locally to avoid callback to manager + bytes32 public collectionId; + + // Events + event MemberAdded(bytes32 indexed ethscriptionId, uint256 indexed tokenId); + event MemberRemoved(bytes32 indexed ethscriptionId, uint256 indexed tokenId); + + // Errors + error NotFactory(); + error UnknownCollection(); + error TransferNotAllowed(); + + modifier onlyFactory() { + if (msg.sender != address(manager)) revert NotFactory(); + _; + } + + function initialize( + string memory name_, + string memory symbol_, + address initialOwner_, + bytes32 collectionId_ + ) external initializer { + __ERC721_init(name_, symbol_); + + if (initialOwner_ == address(0)) { + __Ownable_init(address(1)); + _transferOwnership(address(0)); + } else { + __Ownable_init(initialOwner_); + } + + manager = ERC721EthscriptionsCollectionManager(msg.sender); + collectionId = collectionId_; + } + + function addMember(bytes32 ethscriptionId, uint256 tokenId) external onlyFactory { + address owner = ethscriptions.ownerOf(ethscriptionId); + + // Handle minting to address(0) - mint to creator first then transfer + if (owner == address(0)) { + Ethscriptions.Ethscription memory ethscription = ethscriptions.getEthscription(ethscriptionId, false); + address creator = ethscription.creator; + _mint(creator, tokenId); + _transfer(creator, address(0), tokenId); + } else { + _mint(owner, tokenId); + } + + emit MemberAdded(ethscriptionId, tokenId); + } + + function removeMember(bytes32 ethscriptionId, uint256 tokenId) external onlyFactory { + require(_tokenExists(tokenId), "Token does not exist"); + address owner = ownerOf(tokenId); + // Mark token as non-existent (handles enumeration cleanup) + _setTokenExists(tokenId, false); + + // Emit burn-style transfer for indexers + emit Transfer(owner, address(0), tokenId); + + emit MemberRemoved(ethscriptionId, tokenId); + } + + /// @notice Called by the manager to mirror Ethscription transfers + function forceTransfer(address from, address to, uint256 tokenId) external onlyFactory { + require(_ownerOf(tokenId) == from, "Unexpected owner"); + _transfer(from, to, tokenId); + } + + /// @notice Let the manager update Ownable owner to match inscription holder + function factoryTransferOwnership(address newOwner) external onlyFactory { + _transferOwnership(newOwner); + } + + function tokenURI(uint256 tokenId) + public + view + override(ERC721EthscriptionsUpgradeable) + returns (string memory) + { + if (!_tokenExists(tokenId)) revert("Token does not exist"); + + ERC721EthscriptionsCollectionManager.CollectionItem memory item = + manager.getCollectionItem(collectionId, tokenId); + if (item.ethscriptionId == bytes32(0)) revert("Token not in collection"); + + // Get the ethscription data to extract the ethscription number + Ethscriptions.Ethscription memory ethscription = ethscriptions.getEthscription(item.ethscriptionId, false); + + (string memory mediaType, string memory mediaUri) = ethscriptions.getMediaUri(item.ethscriptionId); + + // Convert ethscriptionId to hex string (0x prefixed) + string memory ethscriptionIdHex = uint256(item.ethscriptionId).toHexString(32); + + string memory jsonStart = string.concat('{"name":"', item.name.escapeJSON(), '"'); + if (bytes(item.description).length > 0) { + jsonStart = string.concat(jsonStart, ',"description":"', item.description.escapeJSON(), '"'); + } + + // Add ethscription ID and number + string memory ethscriptionFields = string.concat( + ',"ethscription_id":"', ethscriptionIdHex, '"', + ',"ethscription_number":', ethscription.ethscriptionNumber.toString() + ); + + string memory mediaField = string.concat( + ',"', + mediaType, + '":"', + mediaUri, + '"' + ); + + string memory bgColor = ""; + if (bytes(item.backgroundColor).length > 0) { + bgColor = string.concat(',"background_color":"', item.backgroundColor.escapeJSON(), '"'); + } + + string memory attributesJson = ',"attributes":['; + for (uint256 i = 0; i < item.attributes.length; i++) { + if (i > 0) attributesJson = string.concat(attributesJson, ','); + attributesJson = string.concat( + attributesJson, + '{"trait_type":"', + item.attributes[i].traitType.escapeJSON(), + '","value":"', + item.attributes[i].value.escapeJSON(), + '"}' + ); + } + attributesJson = string.concat(attributesJson, ']'); + + string memory json = string.concat(jsonStart, ethscriptionFields, mediaField, bgColor, attributesJson, '}'); + + return string.concat("data:application/json;base64,", Base64.encode(bytes(json))); + } + + // --- Transfer/approvals blocked externally --------------------------------- + + function transferFrom(address, address, uint256) + public + pure + override(ERC721EthscriptionsUpgradeable, IERC721) + { + revert TransferNotAllowed(); + } + + /// @notice OpenSea collection-level metadata + /// @return JSON string with collection metadata + function contractURI() external view returns (string memory) { + ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = + manager.getCollectionByAddress(address(this)); + + // Resolve URIs (handles esc://ethscriptions/{id}/data references) + string memory image = _resolveEthscriptionURI(metadata.logoImageUri); + string memory bannerImage = _resolveEthscriptionURI(metadata.bannerImageUri); + + // Build JSON with OpenSea fields + string memory json = string.concat( + '{"name":"', metadata.name.escapeJSON(), + '","description":"', metadata.description.escapeJSON(), + '","image":"', image.escapeJSON(), + '","banner_image":"', bannerImage.escapeJSON(), + '","external_link":"', metadata.websiteLink.escapeJSON(), + '"}' + ); + + return string.concat("data:application/json;base64,", Base64.encode(bytes(json))); + } + + function safeTransferFrom(address, address, uint256, bytes memory) + public + pure + override(ERC721EthscriptionsUpgradeable, IERC721) + { + revert TransferNotAllowed(); + } + + function approve(address, uint256) + public + pure + override(ERC721EthscriptionsUpgradeable, IERC721) + { + revert TransferNotAllowed(); + } + + function setApprovalForAll(address, bool) + public + pure + override(ERC721EthscriptionsUpgradeable, IERC721) + { + revert TransferNotAllowed(); + } + + // -------------------- URI Resolution Helpers -------------------- + + /// @notice Resolve URI, handling esc://ethscriptions/{id}/data format + /// @dev Returns empty string if esc:// reference not found (doesn't revert) + /// @param uri The URI to resolve (can be regular URI, data URI, or esc:// reference) + /// @return The resolved URI (or empty string if esc:// reference not found) + function _resolveEthscriptionURI(string memory uri) private view returns (string memory) { + // Check if it's an ethscription reference + if (!uri.startsWith("esc://ethscriptions/")) { + return uri; // Regular URI or data URI, pass through + } + + // Format: esc://ethscriptions/0x{64 hex chars}/data + // Split by "/" to extract parts: ["esc:", "", "ethscriptions", "0x{id}", "data"] + string[] memory parts = uri.split("/"); + + if (parts.length != 5 || !parts[4].eq("data")) { + return ""; // Invalid format + } + + // The ID should be at index 3 (after esc: / / ethscriptions /) + string memory hexId = parts[3]; + + // Validate hex ID format before parsing + if (bytes(hexId).length != 66) { + return ""; // Must be 0x + 64 hex chars + } + + // Parse hex string to bytes32 using JSONParserLib (reverts on invalid) + bytes32 ethscriptionId; + try this._parseHexToBytes32(hexId) returns (bytes32 parsed) { + ethscriptionId = parsed; + } catch { + return ""; // Invalid hex format + } + + // Try to get the ethscription's media URI + try ethscriptions.getMediaUri(ethscriptionId) returns (string memory, string memory mediaUri) { + return mediaUri; // Return the data URI from the referenced ethscription + } catch { + return ""; // Ethscription doesn't exist, return empty (don't revert) + } + } + + /// @notice Parse hex string to bytes32 (external for try/catch) + /// @dev Must be external to allow try/catch usage + function _parseHexToBytes32(string calldata hexStr) external pure returns (bytes32) { + return bytes32(JSONParserLib.parseUintFromHex(hexStr)); + } +} diff --git a/contracts/src/ERC721EthscriptionsCollectionManager.sol b/contracts/src/ERC721EthscriptionsCollectionManager.sol new file mode 100644 index 0000000..ae0b4cc --- /dev/null +++ b/contracts/src/ERC721EthscriptionsCollectionManager.sol @@ -0,0 +1,531 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "@openzeppelin/contracts/utils/Create2.sol"; +import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import {LibString} from "solady/utils/LibString.sol"; +import "./ERC721EthscriptionsCollection.sol"; +import "./libraries/Proxy.sol"; +import "./libraries/DedupedBlobStore.sol"; +import "./Ethscriptions.sol"; +import "./libraries/Predeploys.sol"; +import "./interfaces/IProtocolHandler.sol"; + +contract ERC721EthscriptionsCollectionManager is IProtocolHandler { + using LibString for *; + + struct Attribute { + string traitType; + string value; + } + + struct CollectionParams { + string name; + string symbol; + uint256 maxSupply; + string description; + string logoImageUri; + string bannerImageUri; + string backgroundColor; + string websiteLink; + string twitterLink; + string discordLink; + bytes32 merkleRoot; + address initialOwner; + } + + struct CollectionRecord { + address collectionContract; + bool locked; + bytes32 nameRef; // DedupedBlobStore reference + bytes32 symbolRef; // DedupedBlobStore reference + uint256 maxSupply; + bytes32 descriptionRef; // DedupedBlobStore reference + bytes32 logoImageRef; // DedupedBlobStore reference + bytes32 bannerImageRef; // DedupedBlobStore reference + bytes32 backgroundColorRef; // DedupedBlobStore reference + bytes32 websiteLinkRef; // DedupedBlobStore reference + bytes32 twitterLinkRef; // DedupedBlobStore reference + bytes32 discordLinkRef; // DedupedBlobStore reference + bytes32 merkleRoot; + } + + /// @notice View struct for external consumption with decoded strings + struct CollectionMetadata { + address collectionContract; + bool locked; + string name; + string symbol; + uint256 maxSupply; + string description; + string logoImageUri; + string bannerImageUri; + string backgroundColor; + string websiteLink; + string twitterLink; + string discordLink; + bytes32 merkleRoot; + } + + struct CollectionItem { + uint256 itemIndex; + string name; + bytes32 ethscriptionId; + string backgroundColor; + string description; + Attribute[] attributes; + } + + struct ItemData { + bytes32 contentHash; // keccak256 of ethscription content (known ahead of time) + uint256 itemIndex; + string name; + string backgroundColor; + string description; + Attribute[] attributes; + bytes32[] merkleProof; + } + + struct Membership { + bytes32 collectionId; + uint256 tokenIdPlusOne; // 0 means not a member + } + + struct RemoveItemsOperation { + bytes32 collectionId; + bytes32[] ethscriptionIds; + } + + struct EditCollectionOperation { + bytes32 collectionId; + string description; + string logoImageUri; + string bannerImageUri; + string backgroundColor; + string websiteLink; + string twitterLink; + string discordLink; + bytes32 merkleRoot; + } + + struct EditCollectionItemOperation { + bytes32 collectionId; + uint256 itemIndex; + string name; + string backgroundColor; + string description; + Attribute[] attributes; + } + + struct CreateAndAddSelfParams { + CollectionParams metadata; + ItemData item; + } + + struct AddSelfToCollectionParams { + bytes32 collectionId; + ItemData item; + } + + address public constant collectionsImplementation = Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_IMPLEMENTATION; + Ethscriptions public constant ethscriptions = Ethscriptions(Predeploys.ETHSCRIPTIONS); + string public constant protocolName = "erc-721-ethscriptions-collection"; + + mapping(bytes32 => CollectionRecord) internal collectionStore; + mapping(bytes32 => mapping(uint256 => CollectionItem)) internal collectionItems; + mapping(bytes32 => Membership) public membershipOfEthscription; + mapping(address => bytes32) internal collectionAddressToId; + + /// @dev Deduplicated storage for collection string fields (name, description, URIs, etc.) + mapping(bytes32 => bytes32) internal collectionBlobStorage; + + bytes32[] public collectionIds; + + event CollectionCreated( + bytes32 indexed collectionId, + address indexed collectionContract, + string name, + string symbol, + uint256 maxSupply + ); + + event ItemsAdded(bytes32 indexed collectionId, uint256 count, bytes32 updateTxHash); + event ItemsRemoved(bytes32 indexed collectionId, uint256 count, bytes32 updateTxHash); + event CollectionEdited(bytes32 indexed collectionId); + event CollectionLocked(bytes32 indexed collectionId); + event OwnershipTransferred(bytes32 indexed collectionId, address indexed previousOwner, address indexed newOwner); + + modifier onlyEthscriptions() { + require(msg.sender == address(ethscriptions), "Only Ethscriptions contract"); + _; + } + + function collectionExists(bytes32 collectionId) public view returns (bool) { + return collectionStore[collectionId].collectionContract != address(0); + } + + function collectionIdForAddress(address collectionAddress) public view returns (bytes32) { + return collectionAddressToId[collectionAddress]; + } + + function op_create_collection(bytes32 ethscriptionId, bytes calldata data) public onlyEthscriptions { + CollectionParams memory metadata = abi.decode(data, (CollectionParams)); + _createCollection(ethscriptionId, metadata); + } + + function op_create_collection_and_add_self(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { + CreateAndAddSelfParams memory op = abi.decode(data, (CreateAndAddSelfParams)); + + _createCollection(ethscriptionId, op.metadata); + _addSingleItem(ethscriptionId, ethscriptionId, op.item); + } + + function op_add_self_to_collection(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { + AddSelfToCollectionParams memory op = abi.decode(data, (AddSelfToCollectionParams)); + + _addSingleItem(op.collectionId, ethscriptionId, op.item); + } + + function op_transfer_ownership(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { + (bytes32 collectionId, address newOwner) = abi.decode(data, (bytes32, address)); + require(newOwner != address(0), "New owner cannot be zero address"); + _transferCollectionOwnership(ethscriptionId, collectionId, newOwner); + } + + function op_renounce_ownership(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { + bytes32 collectionId = abi.decode(data, (bytes32)); + _transferCollectionOwnership(ethscriptionId, collectionId, address(0)); + } + + function op_remove_items(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { + RemoveItemsOperation memory removeOp = abi.decode(data, (RemoveItemsOperation)); + CollectionRecord storage collection = collectionStore[removeOp.collectionId]; + require(collection.collectionContract != address(0), "Collection does not exist"); + require(!collection.locked, "Collection is locked"); + _requireCollectionOwner(ethscriptionId, removeOp.collectionId, "Only collection owner can remove"); + + ERC721EthscriptionsCollection collectionContract = + ERC721EthscriptionsCollection(collection.collectionContract); + + for (uint256 i = 0; i < removeOp.ethscriptionIds.length; i++) { + bytes32 itemId = removeOp.ethscriptionIds[i]; + Membership storage membership = membershipOfEthscription[itemId]; + require(membership.collectionId == removeOp.collectionId, "Ethscription not in collection"); + + uint256 tokenIdPlusOne = membership.tokenIdPlusOne; + require(tokenIdPlusOne != 0, "Token missing"); + uint256 tokenId = tokenIdPlusOne - 1; + + delete membershipOfEthscription[itemId]; + delete collectionItems[removeOp.collectionId][tokenId]; + + collectionContract.removeMember(itemId, tokenId); + } + + emit ItemsRemoved(removeOp.collectionId, removeOp.ethscriptionIds.length, ethscriptionId); + } + + function op_edit_collection(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { + EditCollectionOperation memory editOp = abi.decode(data, (EditCollectionOperation)); + + CollectionRecord storage collection = collectionStore[editOp.collectionId]; + require(collection.collectionContract != address(0), "Collection does not exist"); + require(!collection.locked, "Collection is locked"); + _requireCollectionOwner(ethscriptionId, editOp.collectionId, "Only collection owner can edit"); + + // Update fields (empty strings allowed to clear fields) + (, collection.descriptionRef) = DedupedBlobStore.storeMemory(bytes(editOp.description), collectionBlobStorage); + (, collection.logoImageRef) = DedupedBlobStore.storeMemory(bytes(editOp.logoImageUri), collectionBlobStorage); + (, collection.bannerImageRef) = DedupedBlobStore.storeMemory(bytes(editOp.bannerImageUri), collectionBlobStorage); + (, collection.backgroundColorRef) = DedupedBlobStore.storeMemory(bytes(editOp.backgroundColor), collectionBlobStorage); + (, collection.websiteLinkRef) = DedupedBlobStore.storeMemory(bytes(editOp.websiteLink), collectionBlobStorage); + (, collection.twitterLinkRef) = DedupedBlobStore.storeMemory(bytes(editOp.twitterLink), collectionBlobStorage); + (, collection.discordLinkRef) = DedupedBlobStore.storeMemory(bytes(editOp.discordLink), collectionBlobStorage); + collection.merkleRoot = editOp.merkleRoot; + + emit CollectionEdited(editOp.collectionId); + } + + function op_edit_collection_item(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { + EditCollectionItemOperation memory editOp = abi.decode(data, (EditCollectionItemOperation)); + + CollectionRecord storage collection = collectionStore[editOp.collectionId]; + require(collection.collectionContract != address(0), "Collection does not exist"); + require(!collection.locked, "Collection is locked"); + _requireCollectionOwner(ethscriptionId, editOp.collectionId, "Only collection owner can edit items"); + + CollectionItem storage item = collectionItems[editOp.collectionId][editOp.itemIndex]; + require(item.ethscriptionId != bytes32(0), "Item does not exist"); + + if (bytes(editOp.name).length > 0) item.name = editOp.name; + if (bytes(editOp.backgroundColor).length > 0) item.backgroundColor = editOp.backgroundColor; + if (bytes(editOp.description).length > 0) item.description = editOp.description; + if (editOp.attributes.length > 0) { + delete item.attributes; + for (uint256 i = 0; i < editOp.attributes.length; i++) { + item.attributes.push(editOp.attributes[i]); + } + } + } + + function op_lock_collection(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { + bytes32 collectionId = abi.decode(data, (bytes32)); + CollectionRecord storage collection = collectionStore[collectionId]; + require(collection.collectionContract != address(0), "Collection does not exist"); + _requireCollectionOwner(ethscriptionId, collectionId, "Only collection owner can lock"); + + collection.locked = true; + emit CollectionLocked(collectionId); + } + + function onTransfer(bytes32 ethscriptionId, address from, address to) external override onlyEthscriptions { + Membership storage membership = membershipOfEthscription[ethscriptionId]; + + if (!collectionExists(membership.collectionId)) { + return; + } + + ERC721EthscriptionsCollection c = ERC721EthscriptionsCollection(collectionStore[membership.collectionId].collectionContract); + + ERC721EthscriptionsCollection(c).forceTransfer(from, to, membership.tokenIdPlusOne - 1); + } + + // -------------------- Views -------------------- + + function getCollectionAddress(bytes32 collectionId) external view returns (address) { + return collectionStore[collectionId].collectionContract; + } + + function getCollection(bytes32 collectionId) public view returns (CollectionMetadata memory) { + CollectionRecord storage record = collectionStore[collectionId]; + require(record.collectionContract != address(0), "Collection does not exist"); + + return CollectionMetadata({ + collectionContract: record.collectionContract, + locked: record.locked, + name: DedupedBlobStore.readString(record.nameRef), + symbol: DedupedBlobStore.readString(record.symbolRef), + maxSupply: record.maxSupply, + description: DedupedBlobStore.readString(record.descriptionRef), + logoImageUri: DedupedBlobStore.readString(record.logoImageRef), + bannerImageUri: DedupedBlobStore.readString(record.bannerImageRef), + backgroundColor: DedupedBlobStore.readString(record.backgroundColorRef), + websiteLink: DedupedBlobStore.readString(record.websiteLinkRef), + twitterLink: DedupedBlobStore.readString(record.twitterLinkRef), + discordLink: DedupedBlobStore.readString(record.discordLinkRef), + merkleRoot: record.merkleRoot + }); + } + + function getCollectionItem(bytes32 collectionId, uint256 itemIndex) external view returns (CollectionItem memory) { + return collectionItems[collectionId][itemIndex]; + } + + function isInCollection(bytes32 ethscriptionId, bytes32 collectionId) external view returns (bool) { + return membershipOfEthscription[ethscriptionId].collectionId == collectionId; + } + + function getEthscriptionTokenId(bytes32 ethscriptionId) external view returns (uint256) { + uint256 tokenIdPlusOne = membershipOfEthscription[ethscriptionId].tokenIdPlusOne; + require(tokenIdPlusOne != 0, "Not in collection"); + return tokenIdPlusOne - 1; + } + + function predictCollectionAddress(bytes32 collectionId) external view returns (address) { + if (collectionStore[collectionId].collectionContract != address(0)) { + return collectionStore[collectionId].collectionContract; + } + + bytes memory creationCode = abi.encodePacked(type(Proxy).creationCode, abi.encode(address(this))); + return Create2.computeAddress(collectionId, keccak256(creationCode), address(this)); + } + + function getAllCollections() external view returns (bytes32[] memory) { + return collectionIds; + } + + // -------------------- Helpers -------------------- + + function _initializeCollection(Proxy collectionProxy, bytes32 collectionId, CollectionParams memory metadata) private { + + collectionProxy.upgradeToAndCall(collectionsImplementation, abi.encodeWithSelector( + ERC721EthscriptionsCollection.initialize.selector, + metadata.name, + metadata.symbol, + metadata.initialOwner, + collectionId + )); + + collectionProxy.changeAdmin(Predeploys.PROXY_ADMIN); + } + + function _storeCollectionData(bytes32 collectionId, address collectionContract, CollectionParams memory metadata) private { + // Store string fields using DedupedBlobStore + (, bytes32 nameRef) = DedupedBlobStore.storeMemory(bytes(metadata.name), collectionBlobStorage); + (, bytes32 symbolRef) = DedupedBlobStore.storeMemory(bytes(metadata.symbol), collectionBlobStorage); + (, bytes32 descriptionRef) = DedupedBlobStore.storeMemory(bytes(metadata.description), collectionBlobStorage); + (, bytes32 logoImageRef) = DedupedBlobStore.storeMemory(bytes(metadata.logoImageUri), collectionBlobStorage); + (, bytes32 bannerImageRef) = DedupedBlobStore.storeMemory(bytes(metadata.bannerImageUri), collectionBlobStorage); + (, bytes32 backgroundColorRef) = DedupedBlobStore.storeMemory(bytes(metadata.backgroundColor), collectionBlobStorage); + (, bytes32 websiteLinkRef) = DedupedBlobStore.storeMemory(bytes(metadata.websiteLink), collectionBlobStorage); + (, bytes32 twitterLinkRef) = DedupedBlobStore.storeMemory(bytes(metadata.twitterLink), collectionBlobStorage); + (, bytes32 discordLinkRef) = DedupedBlobStore.storeMemory(bytes(metadata.discordLink), collectionBlobStorage); + + collectionStore[collectionId] = CollectionRecord({ + collectionContract: collectionContract, + locked: false, + nameRef: nameRef, + symbolRef: symbolRef, + maxSupply: metadata.maxSupply, + descriptionRef: descriptionRef, + logoImageRef: logoImageRef, + bannerImageRef: bannerImageRef, + backgroundColorRef: backgroundColorRef, + websiteLinkRef: websiteLinkRef, + twitterLinkRef: twitterLinkRef, + discordLinkRef: discordLinkRef, + merkleRoot: metadata.merkleRoot + }); + } + + function _createCollection(bytes32 collectionId, CollectionParams memory metadata) internal { + require(!collectionExists(collectionId), "Collection already exists"); + + Proxy collectionProxy = new Proxy{salt: collectionId}(address(this)); + + // Initialize the collection + _initializeCollection(collectionProxy, collectionId, metadata); + + // Store collection metadata + _storeCollectionData(collectionId, address(collectionProxy), metadata); + + collectionAddressToId[address(collectionProxy)] = collectionId; + collectionIds.push(collectionId); + + emit CollectionCreated(collectionId, address(collectionProxy), metadata.name, metadata.symbol, metadata.maxSupply); + } + + function _addSingleItem( + bytes32 collectionId, + bytes32 ethscriptionId, + ItemData memory item + ) internal { + CollectionRecord storage collection = collectionStore[collectionId]; + Ethscriptions.Ethscription memory ethscription = ethscriptions.getEthscription(ethscriptionId, false); + + require(ethscription.contentHash == item.contentHash, "Content hash mismatch"); + require(collection.collectionContract != address(0), "Collection does not exist"); + require(!collection.locked, "Collection is locked"); + + address sender = ethscription.creator; + + ERC721EthscriptionsCollection collectionContract = + ERC721EthscriptionsCollection(collection.collectionContract); + address collectionOwner = collectionContract.owner(); + bool senderIsCollectionOwner = sender == collectionOwner; + + if (collection.maxSupply > 0) { + uint256 supply = collectionContract.totalSupply(); + require(supply + 1 <= collection.maxSupply, "Exceeds max supply"); + } + + Membership storage membership = membershipOfEthscription[ethscriptionId]; + require(membership.collectionId == bytes32(0), "Ethscription already in collection"); + require(collectionItems[collectionId][item.itemIndex].ethscriptionId == bytes32(0), "Item slot taken"); + + if (!senderIsCollectionOwner && _shouldEnforceMerkleProof(sender)) { + _verifyItemMerkleProof(item, collection.merkleRoot); + } + + _storeCollectionItem(collectionId, ethscriptionId, item); + membership.collectionId = collectionId; + membership.tokenIdPlusOne = item.itemIndex + 1; + collectionContract.addMember(ethscriptionId, item.itemIndex); + + emit ItemsAdded(collectionId, 1, ethscriptionId); + } + + function _shouldEnforceMerkleProof(address sender) internal view returns (bool) { + bool senderIsForceMerkle = sender == 0x0000000000000000000000000000000000000042; + + return !_inImportMode() || senderIsForceMerkle; + } + + function _storeCollectionItem(bytes32 collectionId, bytes32 ethscriptionId, ItemData memory item) private { + CollectionItem storage newItem = collectionItems[collectionId][item.itemIndex]; + newItem.itemIndex = item.itemIndex; + newItem.name = item.name; + newItem.ethscriptionId = ethscriptionId; + newItem.backgroundColor = item.backgroundColor; + newItem.description = item.description; + + for (uint256 j = 0; j < item.attributes.length; j++) { + newItem.attributes.push(item.attributes[j]); + } + } + + function _getEthscriptionCreator(bytes32 ethscriptionId) private view returns (address) { + Ethscriptions.Ethscription memory operation = ethscriptions.getEthscription(ethscriptionId, false); + return operation.creator; + } + + function _requireCollectionOwner(bytes32 ethscriptionId, bytes32 collectionId, string memory errorMessage) private view { + address sender = _getEthscriptionCreator(ethscriptionId); + CollectionRecord storage collection = collectionStore[collectionId]; + require(collection.collectionContract != address(0), "Collection does not exist"); + ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract); + address currentOwner = collectionContract.owner(); + require(currentOwner == sender, errorMessage); + } + + function _transferCollectionOwnership(bytes32 ethscriptionId, bytes32 collectionId, address newOwner) private { + CollectionRecord storage collection = collectionStore[collectionId]; + require(collection.collectionContract != address(0), "Collection does not exist"); + + address sender = _getEthscriptionCreator(ethscriptionId); + ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract); + address currentOwner = collectionContract.owner(); + require(currentOwner == sender, "Only collection owner can transfer"); + + if (newOwner == currentOwner) { + revert("New owner must differ"); + } + + collectionContract.factoryTransferOwnership(newOwner); + emit OwnershipTransferred(collectionId, currentOwner, newOwner); + } + + function _verifyItemMerkleProof(ItemData memory item, bytes32 merkleRoot) private pure { + require(merkleRoot != bytes32(0), "Merkle proof required"); + + // Compute leaf from item data (excluding merkleProof itself) + // Includes contentHash to ensure the ethscription content matches what was specified + bytes32 leaf = keccak256(abi.encode( + item.contentHash, + item.itemIndex, + item.name, + item.backgroundColor, + item.description, + item.attributes + )); + + require(MerkleProof.verify(item.merkleProof, merkleRoot, leaf), "Invalid Merkle proof"); + } + + function _inImportMode() private view returns (bool) { + return block.timestamp < Constants.historicalBackfillApproxDoneAt; + } + + function getMembershipOfEthscription(bytes32 ethscriptionId) external view returns (Membership memory) { + return membershipOfEthscription[ethscriptionId]; + } + + /// @notice Get collection metadata by address + /// @param collectionAddress The collection contract address + /// @return metadata The collection metadata with decoded strings + function getCollectionByAddress(address collectionAddress) external view returns (CollectionMetadata memory) { + bytes32 collectionId = collectionAddressToId[collectionAddress]; + require(collectionId != bytes32(0), "Collection not found"); + return getCollection(collectionId); + } +} diff --git a/contracts/src/ERC721EthscriptionsEnumerableUpgradeable.sol b/contracts/src/ERC721EthscriptionsEnumerableUpgradeable.sol new file mode 100644 index 0000000..a3cde2b --- /dev/null +++ b/contracts/src/ERC721EthscriptionsEnumerableUpgradeable.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./ERC721EthscriptionsUpgradeable.sol"; +import {IERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Enumerable.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +/** + * @dev Enumerable mixin for collections that require general token ID tracking and burns. + */ +abstract contract ERC721EthscriptionsEnumerableUpgradeable is ERC721EthscriptionsUpgradeable, IERC721Enumerable { + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC721EthscriptionsUpgradeable, IERC165) + returns (bool) + { + return interfaceId == type(IERC721Enumerable).interfaceId || super.supportsInterface(interfaceId); + } + + /// @inheritdoc IERC721Enumerable + function tokenOfOwnerByIndex(address owner, uint256 index) + public + view + virtual + override(IERC721Enumerable, ERC721EthscriptionsUpgradeable) + returns (uint256) + { + return super.tokenOfOwnerByIndex(owner, index); + } + + /// @inheritdoc IERC721Enumerable + function totalSupply() public view virtual override returns (uint256) { + ERC721EnumerableStorage storage $ = _getERC721EnumerableStorage(); + return $._allTokens.length; + } + + /// @inheritdoc IERC721Enumerable + function tokenByIndex(uint256 index) public view virtual override returns (uint256) { + ERC721EnumerableStorage storage $ = _getERC721EnumerableStorage(); + if (index >= $._allTokens.length) { + revert ERC721OutOfBoundsIndex(address(0), index); + } + return $._allTokens[index]; + } + + function _afterTokenMint(uint256 tokenId) internal virtual override { + ERC721EnumerableStorage storage $ = _getERC721EnumerableStorage(); + $._allTokensIndex[tokenId] = $._allTokens.length; + $._allTokens.push(tokenId); + } + + function _beforeTokenRemoval(uint256 tokenId, address) internal virtual override { + ERC721EnumerableStorage storage $ = _getERC721EnumerableStorage(); + uint256 tokenIndex = $._allTokensIndex[tokenId]; + uint256 lastTokenIndex = $._allTokens.length - 1; + uint256 lastTokenId = $._allTokens[lastTokenIndex]; + + $._allTokens[tokenIndex] = lastTokenId; + $._allTokensIndex[lastTokenId] = tokenIndex; + + delete $._allTokensIndex[tokenId]; + $._allTokens.pop(); + } +} diff --git a/contracts/src/ERC721EthscriptionsSequentialEnumerableUpgradeable.sol b/contracts/src/ERC721EthscriptionsSequentialEnumerableUpgradeable.sol new file mode 100644 index 0000000..d3d2d4a --- /dev/null +++ b/contracts/src/ERC721EthscriptionsSequentialEnumerableUpgradeable.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./ERC721EthscriptionsUpgradeable.sol"; +import {IERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Enumerable.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +/** + * @dev Enumerable mixin for Ethscriptions-style collections where token IDs are + * sequential, start at zero, and tokens are never burned. + */ +abstract contract ERC721EthscriptionsSequentialEnumerableUpgradeable is ERC721EthscriptionsUpgradeable, IERC721Enumerable { + /// @dev Raised when a mint attempts to skip or reuse a token ID. + error ERC721SequentialEnumerableInvalidTokenId(uint256 expected, uint256 actual); + /// @dev Raised if a contract attempts to remove a token from supply. + error ERC721SequentialEnumerableTokenRemoval(uint256 tokenId); + + /// @custom:storage-location erc7201:ethscriptions.storage.ERC721SequentialEnumerable + struct ERC721SequentialEnumerableStorage { + uint256 _mintCount; + } + + // keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ERC721SequentialEnumerableStorageLocation")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ERC721SequentialEnumerableStorageLocation = 0x154e8d00bf5f00755eebdfa0d432d05cad242742a46a00bbdb15798f33342700; + + function _getERC721SequentialEnumerableStorage() + private + pure + returns (ERC721SequentialEnumerableStorage storage $) + { + assembly { + $.slot := ERC721SequentialEnumerableStorageLocation + } + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC721EthscriptionsUpgradeable, IERC165) + returns (bool) + { + return interfaceId == type(IERC721Enumerable).interfaceId || super.supportsInterface(interfaceId); + } + + /// @inheritdoc IERC721Enumerable + function tokenOfOwnerByIndex(address owner, uint256 index) + public + view + virtual + override(IERC721Enumerable, ERC721EthscriptionsUpgradeable) + returns (uint256) + { + return super.tokenOfOwnerByIndex(owner, index); + } + + /// @inheritdoc IERC721Enumerable + function totalSupply() public view virtual override returns (uint256) { + ERC721SequentialEnumerableStorage storage $ = _getERC721SequentialEnumerableStorage(); + return $._mintCount; + } + + /// @inheritdoc IERC721Enumerable + function tokenByIndex(uint256 index) public view virtual override returns (uint256) { + if (index >= totalSupply()) { + revert ERC721OutOfBoundsIndex(address(0), index); + } + return index; + } + + function _afterTokenMint(uint256 tokenId) internal virtual override { + ERC721SequentialEnumerableStorage storage $ = _getERC721SequentialEnumerableStorage(); + + uint256 expectedId = $._mintCount; + if (tokenId != expectedId) { + revert ERC721SequentialEnumerableInvalidTokenId(expectedId, tokenId); + } + + unchecked { + $._mintCount = expectedId + 1; + } + } + + function _beforeTokenRemoval(uint256 tokenId, address) internal virtual override { + revert ERC721SequentialEnumerableTokenRemoval(tokenId); + } +} diff --git a/contracts/src/ERC721EthscriptionsUpgradeable.sol b/contracts/src/ERC721EthscriptionsUpgradeable.sol new file mode 100644 index 0000000..79eff70 --- /dev/null +++ b/contracts/src/ERC721EthscriptionsUpgradeable.sol @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC721Metadata} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; +import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {IERC721Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +/** + * @dev Minimal ERC-721 implementation that supports null address ownership. + * Unlike standard ERC-721, tokens can be owned by address(0) and still exist. + * This is required for Ethscriptions protocol compatibility. + * + * Simplifications from standard ERC-721: + * - No safe transfer functionality (no onERC721Received checks) + * - No approval functionality (approve, getApproved, setApprovalForAll removed) + * - No tokenURI implementation (must be overridden by child) + * - No burn function (transfer to address(0) instead) + * - Keeps only core transfer and ownership logic + */ +abstract contract ERC721EthscriptionsUpgradeable is Initializable, ContextUpgradeable, ERC165Upgradeable, IERC721, IERC721Metadata, IERC721Errors { + // Errors for enumerable functionality + error ERC721OutOfBoundsIndex(address owner, uint256 index); + error ERC721EnumerableForbiddenBatchMint(); + + /// @custom:storage-location erc7201:ethscriptions.storage.ERC721 + struct ERC721Storage { + string _name; + string _symbol; + + // Token owners (can be address(0) for null-owned tokens) + mapping(uint256 tokenId => address) _owners; + // Balance per address (including null address) + mapping(address owner => uint256) _balances; + + // === Ethscriptions-specific storage === + // Explicit existence tracking (true = token exists) + mapping(uint256 tokenId => bool) _existsFlag; + } + + // keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ERC721.ethscriptions")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ERC721StorageLocation = 0x03f081da1bf59345b57bd2323b19ea3e2315141ee27bd283a32089733412b400; + + function _getERC721Storage() private pure returns (ERC721Storage storage $) { + assembly { + $.slot := ERC721StorageLocation + } + } + + /// @custom:storage-location erc7201:openzeppelin.storage.ERC721Enumerable + struct ERC721EnumerableStorage { + mapping(address owner => mapping(uint256 index => uint256)) _ownedTokens; + mapping(uint256 tokenId => uint256) _ownedTokensIndex; + uint256[] _allTokens; + mapping(uint256 tokenId => uint256) _allTokensIndex; + } + + // keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ERC721Enumerable")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ERC721EnumerableStorageLocation = 0x645e039705490088daad89bae25049a34f4a9072d398537b1ab2425f24cbed00; + + function _getERC721EnumerableStorage() internal pure returns (ERC721EnumerableStorage storage $) { + assembly { + $.slot := ERC721EnumerableStorageLocation + } + } + + /** + * @dev Initializes the contract. + */ + function __ERC721_init(string memory name_, string memory symbol_) internal onlyInitializing { + __ERC721_init_unchained(name_, symbol_); + } + + function __ERC721_init_unchained(string memory name_, string memory symbol_) internal onlyInitializing { + ERC721Storage storage $ = _getERC721Storage(); + $._name = name_; + $._symbol = symbol_; + } + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165Upgradeable, IERC165) returns (bool) { + return + interfaceId == type(IERC721).interfaceId || + interfaceId == type(IERC721Metadata).interfaceId || + super.supportsInterface(interfaceId); + } + + /** + * @dev See {IERC721-balanceOf}. + * Modified to support null address balance queries. + */ + function balanceOf(address owner) public view virtual returns (uint256) { + ERC721Storage storage $ = _getERC721Storage(); + return $._balances[owner]; + } + + /** + * @dev See {IERC721-ownerOf}. + */ + function ownerOf(uint256 tokenId) public view virtual returns (address) { + return _requireOwned(tokenId); + } + + /** + * @dev See {IERC721Metadata-name}. + * Must be overridden by child contract. + */ + function name() public view virtual returns (string memory) { + ERC721Storage storage $ = _getERC721Storage(); + return $._name; + } + + /** + * @dev See {IERC721Metadata-symbol}. + * Must be overridden by child contract. + */ + function symbol() public view virtual returns (string memory) { + ERC721Storage storage $ = _getERC721Storage(); + return $._symbol; + } + + /** + * @dev See {IERC721Metadata-tokenURI}. + * Must be overridden by child contract. + */ + function tokenURI(uint256 tokenId) public view virtual returns (string memory); + + /// @dev Return token owned by `owner` at `index`. + function tokenOfOwnerByIndex(address owner, uint256 index) public view virtual returns (uint256) { + ERC721EnumerableStorage storage $ = _getERC721EnumerableStorage(); + if (index >= balanceOf(owner)) { + revert ERC721OutOfBoundsIndex(owner, index); + } + return $._ownedTokens[owner][index]; + } + + /** + * @dev Approval functions removed - not needed for Ethscriptions. + * These can be added back in child contracts if needed. + */ + function approve(address, uint256) public virtual { + revert("Approvals not supported"); + } + + function getApproved(uint256 tokenId) public view virtual returns (address) { + if (!_tokenExists(tokenId)) { + revert ERC721NonexistentToken(tokenId); + } + return address(0); + } + + function setApprovalForAll(address, bool) public virtual { + revert("Approvals not supported"); + } + + function isApprovedForAll(address, address) public view virtual returns (bool) { + return false; + } + + /** + * @dev See {IERC721-transferFrom}. + * Modified to allow transfers to address(0) (not burns, just transfers). + */ + function transferFrom(address from, address to, uint256 tokenId) public virtual { + // Removed check for to == address(0) to allow null transfers + address previousOwner = _update(to, tokenId, _msgSender()); + if (previousOwner != from) { + revert ERC721IncorrectOwner(from, tokenId, previousOwner); + } + } + + /** + * @dev Safe transfer functions removed - not needed for Ethscriptions. + */ + function safeTransferFrom(address, address, uint256) public virtual { + revert("Safe transfers not supported"); + } + + function safeTransferFrom(address, address, uint256, bytes memory) public virtual { + revert("Safe transfers not supported"); + } + + /** + * @dev Returns the owner of the `tokenId`. Does NOT revert if token doesn't exist. + * Modified to check existence flag instead of owner == address(0). + */ + function _ownerOf(uint256 tokenId) internal view virtual returns (address) { + ERC721Storage storage $ = _getERC721Storage(); + return $._owners[tokenId]; // May be address(0) for null-owned + } + + /** + * @dev Simplified authorization - only owner can transfer. + */ + function _checkAuthorized(address owner, address spender, uint256 tokenId) internal view virtual { + ERC721Storage storage $ = _getERC721Storage(); + + if (!$._existsFlag[tokenId]) { + revert ERC721NonexistentToken(tokenId); + } + + // Only the owner can transfer (no approvals) + // Since spender (msg.sender) can never be address(0), null-owned tokens are automatically protected + if (owner != spender) { + revert ERC721InsufficientApproval(spender, tokenId); + } + } + + /** + * @dev Transfers `tokenId` from its current owner to `to`. + * Modified to handle null address as a valid owner. + */ + function _update(address to, uint256 tokenId, address auth) internal virtual returns (address) { + ERC721Storage storage $ = _getERC721Storage(); + + bool existed = $._existsFlag[tokenId]; + address from = _ownerOf(tokenId); + + // Perform authorization check if needed + if (auth != address(0)) { + _checkAuthorized(from, auth, tokenId); + } + + // Handle transfer/mint - enumeration helpers handle balance updates + if (existed) { + // This is a transfer + if (from != to) { + // Remove from old owner (also decrements from's balance) + _removeTokenFromOwnerEnumeration(from, tokenId); + // Add to new owner (also increments to's balance) + _addTokenToOwnerEnumeration(to, tokenId); + } + } else { + // This is a mint + $._existsFlag[tokenId] = true; + + // Allow derived contracts to adjust enumeration state for newly minted token + _afterTokenMint(tokenId); + + // Add to owner enumeration (also increments balance) + _addTokenToOwnerEnumeration(to, tokenId); + } + + // Update owner and emit + $._owners[tokenId] = to; + emit Transfer(from, to, tokenId); + + return from; + } + + /** + * @dev Mints `tokenId` and transfers it to `to`. + * Routes through _update to ensure consistent behavior. + * Does not allow minting directly to address(0) - mint then transfer instead. + */ + function _mint(address to, uint256 tokenId) internal { + ERC721Storage storage $ = _getERC721Storage(); + + // Check if token already exists + if ($._existsFlag[tokenId]) { + revert ERC721InvalidSender(address(0)); + } + + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + + // Mint the token via _update + _update(to, tokenId, address(0)); + } + + /** + * @dev Transfers `tokenId` from `from` to `to`. + * Modified to allow transfers to address(0) (not burns). + */ + function _transfer(address from, address to, uint256 tokenId) internal { + address previousOwner = _update(to, tokenId, address(0)); + if (!_tokenExists(tokenId)) { + revert ERC721NonexistentToken(tokenId); + } else if (previousOwner != from) { + revert ERC721IncorrectOwner(from, tokenId, previousOwner); + } + } + + /** + * @dev Reverts if the `tokenId` doesn't have a current owner (it hasn't been minted, or it has been burned). + * Modified to use existence flag instead of owner == address(0). + */ + function _requireOwned(uint256 tokenId) internal view returns (address) { + ERC721Storage storage $ = _getERC721Storage(); + if (!$._existsFlag[tokenId]) { + revert ERC721NonexistentToken(tokenId); + } + return $._owners[tokenId]; // May be address(0) for null-owned + } + + /** + * @dev Returns whether `tokenId` exists. + * Tokens start existing when they are minted (`_mint`). + */ + function _tokenExists(uint256 tokenId) internal view returns (bool) { + ERC721Storage storage $ = _getERC721Storage(); + return $._existsFlag[tokenId]; + } + + /** + * @dev Sets the existence flag for a token. Used by child contracts for removal. + */ + function _setTokenExists(uint256 tokenId, bool exists) internal { + ERC721Storage storage $ = _getERC721Storage(); + + // If removing a token, also remove from enumeration and update balance + if (!exists && $._existsFlag[tokenId]) { + address owner = $._owners[tokenId]; + + _beforeTokenRemoval(tokenId, owner); + + // Remove from enumerations (balance is decremented inside _removeTokenFromOwnerEnumeration) + _removeTokenFromOwnerEnumeration(owner, tokenId); + + // Clear owner storage for cleanliness + delete $._owners[tokenId]; + } + + $._existsFlag[tokenId] = exists; + } + + /** + * @dev Override to forbid batch minting which would break enumeration. + */ + function _increaseBalance(address account, uint128 amount) internal virtual { + if (amount > 0) { + revert ERC721EnumerableForbiddenBatchMint(); + } + // Note: We don't have a parent _increaseBalance to call since we're not inheriting from ERC721Upgradeable + // This function exists just to prevent batch minting attempts + } + + /** + * @dev Private function to add a token to this extension's ownership-tracking data structures. + * Also increments the owner's balance. + * @param to address representing the new owner of the given token ID + * @param tokenId uint256 ID of the token to be added to the tokens list of the given address + */ + function _addTokenToOwnerEnumeration(address to, uint256 tokenId) private { + ERC721Storage storage $ = _getERC721Storage(); + ERC721EnumerableStorage storage $enum = _getERC721EnumerableStorage(); + + // Use current balance as the index for the new token + uint256 length = $._balances[to]; + $enum._ownedTokens[to][length] = tokenId; + $enum._ownedTokensIndex[tokenId] = length; + + // Now increment the balance + unchecked { + $._balances[to] += 1; + } + } + + /** + * @dev Private function to remove a token from this extension's ownership-tracking data structures. + * Also decrements the owner's balance. + * Note that while the token is not assigned a new owner, the `_ownedTokensIndex` mapping is _not_ updated: this allows for + * gas optimizations e.g. when performing a transfer operation (avoiding double writes). + * This has O(1) time complexity, but alters the order of the _ownedTokens array. + * @param from address representing the previous owner of the given token ID + * @param tokenId uint256 ID of the token to be removed from the tokens list of the given address + */ + function _removeTokenFromOwnerEnumeration(address from, uint256 tokenId) private { + ERC721Storage storage $ = _getERC721Storage(); + ERC721EnumerableStorage storage $enum = _getERC721EnumerableStorage(); + + // To prevent a gap in from's tokens array, we store the last token in the index of the token to delete, and + // then delete the last slot (swap and pop). + + // First decrement the balance + unchecked { + $._balances[from] -= 1; + } + + // Now use the updated balance as the last index + uint256 lastTokenIndex = $._balances[from]; + uint256 tokenIndex = $enum._ownedTokensIndex[tokenId]; + + mapping(uint256 index => uint256) storage _ownedTokensByOwner = $enum._ownedTokens[from]; + + // When the token to delete is the last token, the swap operation is unnecessary + if (tokenIndex != lastTokenIndex) { + uint256 lastTokenId = _ownedTokensByOwner[lastTokenIndex]; + + _ownedTokensByOwner[tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token + $enum._ownedTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index + } + + // This also deletes the contents at the last position of the array + delete $enum._ownedTokensIndex[tokenId]; + delete _ownedTokensByOwner[lastTokenIndex]; + } + + /** + * @dev Hook for derived contracts to react to token minting. + */ + function _afterTokenMint(uint256) internal virtual {} + + /** + * @dev Hook for derived contracts to react to token removal. + */ + function _beforeTokenRemoval(uint256, address) internal virtual {} +} diff --git a/contracts/src/Ethscriptions.sol b/contracts/src/Ethscriptions.sol new file mode 100644 index 0000000..6444708 --- /dev/null +++ b/contracts/src/Ethscriptions.sol @@ -0,0 +1,889 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./ERC721EthscriptionsSequentialEnumerableUpgradeable.sol"; +import {LibString} from "solady/utils/LibString.sol"; +import "./libraries/DedupedBlobStore.sol"; +import "./libraries/MetaStoreLib.sol"; +import "./libraries/EthscriptionsRendererLib.sol"; +import "./EthscriptionsProver.sol"; +import "./libraries/Predeploys.sol"; +import "./L2/L1Block.sol"; +import "./interfaces/IProtocolHandler.sol"; +import "./libraries/Constants.sol"; + +/// @title Ethscriptions ERC-721 Contract +/// @notice Mints Ethscriptions as ERC-721 tokens based on L1 transaction data +/// @dev Uses ethscription number as token ID and name, while transaction hash remains the primary identifier for function calls +contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { + using LibString for *; + + // ============================================================= + // STRUCTS + // ============================================================= + + /// @notice Internal storage struct for ethscriptions (optimized for storage) + struct EthscriptionStorage { + // Full slots + bytes32 contentUriSha; // sha256 of content URI (for protocol uniqueness check) + bytes32 contentHash; // keccak256 of content (for deduplication) + bytes32 l1BlockHash; + // Packed slot (32 bytes) + address creator; + uint48 createdAt; + uint48 l1BlockNumber; + // Metadata reference (replaces dynamic mimetype string) + bytes32 metaRef; // Reference to deduplicated metadata (mimetype, protocol, operation) + // Packed slot (27 bytes used, 5 free) + address initialOwner; + uint48 ethscriptionNumber; + bool esip6; + // Packed slot (26 bytes used, 6 free) + address previousOwner; + uint48 l2BlockNumber; + } + + struct ProtocolParams { + string protocolName; // Protocol identifier (e.g., "erc-20-fixed-denomination", "erc-721-ethscriptions-collection", etc.) + string operation; // Operation to perform (e.g., "mint", "deploy", "create_collection", etc.) + bytes data; // ABI-encoded parameters specific to the protocol/operation + } + + struct CreateEthscriptionParams { + bytes32 ethscriptionId; + bytes32 contentUriSha; // sha256 of content URI (for protocol uniqueness) + address initialOwner; + bytes content; // Raw decoded bytes (not Base64) + string mimetype; + bool esip6; + ProtocolParams protocolParams; // Protocol operation data (optional) + } + + /// @notice Paginated result for batch queries + struct PaginatedEthscriptionsResponse { + Ethscription[] items; + uint256 total; // total items available (totalSupply or balanceOf(owner)) + uint256 start; // start index used for this page + uint256 limit; // effective limit used for this page (after clamping) + uint256 nextStart; // next page start index (end of this page) + bool hasMore; // true if nextStart < total + } + + /// @notice Complete denormalized ethscription data for external/off-chain consumption + /// @dev Includes all EthscriptionStorage fields plus owner and content + struct Ethscription { + // Identity + bytes32 ethscriptionId; // L1 tx hash (the key) + uint256 ethscriptionNumber; // Token ID + + // Core metadata + bytes32 contentUriSha; // sha256 of content URI (protocol) + bytes32 contentHash; // keccak256 of content + string mimetype; + bytes content; // Full content bytes (empty when includeContent=false) + + // Ownership + address currentOwner; // Current owner from ERC721 storage + address creator; + address initialOwner; + address previousOwner; + + // Block/time data + bytes32 l1BlockHash; + uint256 l1BlockNumber; + uint256 l2BlockNumber; + uint256 createdAt; + + // Protocol + bool esip6; + string protocolName; // Protocol identifier (empty if none) + string operation; // Operation name (empty if none) + } + + // ============================================================= + // CONSTANTS & IMMUTABLES + // ============================================================= + + /// @dev L1Block predeploy for getting L1 block info + L1Block constant l1Block = L1Block(Predeploys.L1_BLOCK_ATTRIBUTES); + + /// @dev Ethscriptions Prover contract (pre-deployed at known address) + EthscriptionsProver public constant prover = EthscriptionsProver(Predeploys.ETHSCRIPTIONS_PROVER); + + // ============================================================= + // PAGINATION CONSTANTS + // ============================================================= + + /// @dev Maximum page sizes for pagination helpers + uint256 private constant MAX_PAGE_WITH_CONTENT = 20; + uint256 private constant MAX_PAGE_WITHOUT_CONTENT = 50; + + // ============================================================= + // STATE VARIABLES + // ============================================================= + + /// @dev Ethscription ID (L1 tx hash) => Ethscription data + mapping(bytes32 => EthscriptionStorage) internal ethscriptions; + + /// @dev Content hash (keccak256) => packed content (for <32 bytes) or SSTORE2 pointer (for >=32 bytes) + mapping(bytes32 => bytes32) internal contentStorage; + + /// @dev Metadata blob hash (keccak256) => packed metadata or SSTORE2 pointer (for deduplicated metadata storage) + mapping(bytes32 => bytes32) internal metadataStorage; + + /// @dev Content URI hash => first ethscription tx hash that used it (for protocol uniqueness check) + /// @dev bytes32(0) means unused, non-zero means the content URI has been used + mapping(bytes32 => bytes32) public firstEthscriptionByContentUri; + + /// @dev Mapping from token ID (ethscription number) to ethscription ID (L1 tx hash) + mapping(uint256 => bytes32) public tokenIdToEthscriptionId; + + /// @dev Protocol registry - maps protocol names to handler addresses + mapping(string => address) public protocolHandlers; + + /// @dev Array of genesis ethscription transaction hashes that need events emitted + /// @notice This array is populated during genesis and cleared (by popping) when events are emitted + bytes32[] internal pendingGenesisEvents; + + // ============================================================= + // CUSTOM ERRORS + // ============================================================= + + error DuplicateContentUri(); + error InvalidCreator(); + error EthscriptionAlreadyExists(); + error EthscriptionDoesNotExist(); + error OnlyDepositor(); + error InvalidHandler(); + error ProtocolAlreadyRegistered(); + error PreviousOwnerMismatch(); + error NoSuccessfulTransfers(); + error TokenDoesNotExist(); + error InvalidPaginationLimit(); + + // ============================================================= + // EVENTS + // ============================================================= + + /// @notice Emitted when a new ethscription is created + event EthscriptionCreated( + bytes32 indexed ethscriptionId, + address indexed creator, + address indexed initialOwner, + bytes32 contentUriSha, + bytes32 contentHash, + uint256 ethscriptionNumber + ); + + /// @notice Emitted when an ethscription is transferred (Ethscriptions protocol semantics) + /// @dev This event matches the Ethscriptions protocol transfer semantics where 'from' is the initiator + /// For creations, this shows transfer from creator to initial owner (not from address(0)) + event EthscriptionTransferred( + bytes32 indexed ethscriptionId, + address indexed from, + address indexed to, + uint256 ethscriptionNumber + ); + + /// @notice Emitted when a protocol handler is registered + event ProtocolRegistered(string indexed protocol, address indexed handler); + + /// @notice Emitted when a protocol handler operation fails but ethscription continues + event ProtocolHandlerFailed( + bytes32 indexed ethscriptionId, + string protocol, + bytes revertData + ); + + /// @notice Emitted when a protocol handler operation succeeds + event ProtocolHandlerSuccess( + bytes32 indexed ethscriptionId, + string protocol, + bytes returnData + ); + + // ============================================================= + // MODIFIERS + // ============================================================= + + /// @notice Modifier to emit pending genesis events on first real creation + modifier emitGenesisEvents() { + _emitPendingGenesisEvents(); + _; + } + + /// @notice Resolve and validate an ethscription (by ID) or revert + function _getEthscriptionOrRevert(bytes32 ethscriptionId) internal view returns (EthscriptionStorage storage ethscription) { + if (!_ethscriptionExists(ethscriptionId)) revert EthscriptionDoesNotExist(); + ethscription = ethscriptions[ethscriptionId]; + } + + /// @notice Resolve and validate an ethscription (by tokenId) or revert + function _getEthscriptionOrRevert(uint256 tokenId) internal view returns (EthscriptionStorage storage ethscription) { + bytes32 id = tokenIdToEthscriptionId[tokenId]; + ethscription = _getEthscriptionOrRevert(id); + } + + // ============================================================= + // ADMIN/SETUP FUNCTIONS + // ============================================================= + + /// @notice Register a protocol handler + /// @param protocol The protocol identifier (e.g., "erc-20-fixed-denomination", "erc-721-ethscriptions-collection") + /// @param handler The address of the handler contract + /// @dev Only callable by the depositor address (used during genesis setup) + /// @dev Protocol names should already be normalized (lowercase) by the caller + function registerProtocol(string calldata protocol, address handler) external { + if (msg.sender != Predeploys.DEPOSITOR_ACCOUNT) revert OnlyDepositor(); + if (handler == address(0)) revert InvalidHandler(); + if (protocolHandlers[protocol] != address(0)) revert ProtocolAlreadyRegistered(); + + protocolHandlers[protocol] = handler; + + emit ProtocolRegistered(protocol, handler); + } + + // ============================================================= + // CORE EXTERNAL FUNCTIONS + // ============================================================= + + /// @notice Create (mint) a new ethscription token + /// @dev Called via system transaction with msg.sender spoofed as the actual creator + /// @param params Struct containing all ethscription creation parameters + function createEthscription( + CreateEthscriptionParams calldata params + ) external emitGenesisEvents returns (uint256 tokenId) { + address creator = msg.sender; + + if (creator == address(0)) revert InvalidCreator(); + if (_ethscriptionExists(params.ethscriptionId)) revert EthscriptionAlreadyExists(); + + bool contentUriAlreadySeen = firstEthscriptionByContentUri[params.contentUriSha] != bytes32(0); + + if (contentUriAlreadySeen) { + if (!params.esip6) revert DuplicateContentUri(); + } else { + firstEthscriptionByContentUri[params.contentUriSha] = params.ethscriptionId; + } + + // Store content and get content hash (keccak256 of raw bytes) + bytes32 contentHash = _storeContent(params.content); + + // Store metadata (mimetype, protocol, operation) + bytes32 metaRef = MetaStoreLib.store( + params.mimetype, + params.protocolParams.protocolName, + params.protocolParams.operation, + metadataStorage + ); + + ethscriptions[params.ethscriptionId] = EthscriptionStorage({ + contentUriSha: params.contentUriSha, + contentHash: contentHash, + l1BlockHash: l1Block.hash(), + creator: creator, + createdAt: uint48(block.timestamp), + l1BlockNumber: uint48(l1Block.number()), + metaRef: metaRef, + initialOwner: params.initialOwner, + ethscriptionNumber: uint48(totalSupply()), + esip6: params.esip6, + previousOwner: creator, + l2BlockNumber: uint48(block.number) + }); + + // Use ethscription number as token ID + tokenId = totalSupply(); + + // Store the mapping from token ID to ethscription ID + tokenIdToEthscriptionId[tokenId] = params.ethscriptionId; + + // Mint to initial owner (if address(0), mint to creator then transfer) + if (params.initialOwner == address(0)) { + _mint(creator, tokenId); + _transfer(creator, address(0), tokenId); + } else { + _mint(params.initialOwner, tokenId); + } + + emit EthscriptionCreated( + params.ethscriptionId, + creator, + params.initialOwner, + params.contentUriSha, + contentHash, + tokenId + ); + + // Handle protocol operations (if any) + _callProtocolOperation(params.ethscriptionId, params.protocolParams); + } + + /// @notice Transfer an ethscription + /// @dev Called via system transaction with msg.sender spoofed as 'from' + /// @param to The recipient address (can be address(0) for burning) + /// @param ethscriptionId The ethscription to transfer (used to find token ID) + function transferEthscription( + address to, + bytes32 ethscriptionId + ) external { + // Load and validate + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + uint256 tokenId = ethscription.ethscriptionNumber; + // Standard ERC721 transfer will handle authorization + transferFrom(msg.sender, to, tokenId); + } + + /// @notice Transfer an ethscription with previous owner validation (ESIP-2) + /// @dev Called via system transaction with msg.sender spoofed as 'from' + /// @param to The recipient address (can be address(0) for burning) + /// @param ethscriptionId The ethscription to transfer + /// @param previousOwner The required previous owner for validation + function transferEthscriptionForPreviousOwner( + address to, + bytes32 ethscriptionId, + address previousOwner + ) external { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + + // Verify the previous owner matches + if (ethscription.previousOwner != previousOwner) { + revert PreviousOwnerMismatch(); + } + + // Use transferFrom which now handles burns when to == address(0) + transferFrom(msg.sender, to, ethscription.ethscriptionNumber); + } + + /// @notice Transfer multiple ethscriptions to a single recipient + /// @dev Continues transferring even if individual transfers fail due to wrong ownership + /// @param ethscriptionIds Array of ethscription IDs to transfer + /// @param to The recipient address (can be address(0) for burning) + /// @return successCount Number of successful transfers + function transferEthscriptions( + address to, + bytes32[] calldata ethscriptionIds + ) external returns (uint256 successCount) { + for (uint256 i = 0; i < ethscriptionIds.length; i++) { + // Get the ethscription to find its token ID + if (!_ethscriptionExists(ethscriptionIds[i])) continue; // Skip non-existent ethscriptions + EthscriptionStorage storage ethscription = ethscriptions[ethscriptionIds[i]]; + + uint256 tokenId = ethscription.ethscriptionNumber; + + // Check if sender owns this token before attempting transfer + // This prevents reverts and allows us to continue + if (_ownerOf(tokenId) == msg.sender) { + // Perform the transfer directly using internal _update + _update(to, tokenId, msg.sender); + successCount++; + } + // If sender doesn't own the token, just continue to next one + } + + if (successCount == 0) revert NoSuccessfulTransfers(); + } + + // ============================================================= + // VIEW FUNCTIONS + // ============================================================= + + // ---------------------- Token Metadata ---------------------- + + function name() public pure override returns (string memory) { + return "Ethscriptions"; + } + + function symbol() public pure override returns (string memory) { + return "ETHSCRIPTIONS"; + } + + // ---------------------- Token URI & Media ---------------------- + + /// @notice Returns the full data URI for a token + function tokenURI(uint256 tokenId) public view override returns (string memory) { + // Find the ethscription for this token ID (ethscription number) + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(tokenId); + bytes32 id = tokenIdToEthscriptionId[tokenId]; + + // Get content + bytes memory content = _getEthscriptionContent(id); + + // Decode metadata from reference + (string memory mimetype, string memory protocolName, string memory operation) = + MetaStoreLib.decode(ethscription.metaRef); + + // Build complete token URI using the library + return EthscriptionsRendererLib.buildTokenURI(ethscription, id, mimetype, protocolName, operation, content); + } + + /// @notice Get the media URI for an ethscription (image or animation_url) + /// @param ethscriptionId The ethscription ID (L1 tx hash) of the ethscription + /// @return mediaType Either "image" or "animation_url" + /// @return mediaUri The data URI for the media + function getMediaUri(bytes32 ethscriptionId) external view returns (string memory mediaType, string memory mediaUri) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + bytes memory content = _getEthscriptionContent(ethscriptionId); + + // Decode mimetype from metadata reference + string memory mimetype = MetaStoreLib.getMimetype(ethscription.metaRef); + + return EthscriptionsRendererLib.getMediaUri(mimetype, content); + } + + // -------------------- Data Retrieval -------------------- + + /// @notice Internal helper to build complete ethscription data + /// @param ethscriptionId The ethscription ID + /// @param includeContent Whether to include content bytes + /// @return complete The complete ethscription data + function _buildEthscription(bytes32 ethscriptionId, bool includeContent) internal view returns (Ethscription memory) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + + // Decode metadata from reference + (string memory mimetype, string memory protocolName, string memory operation) = + MetaStoreLib.decode(ethscription.metaRef); + + return Ethscription({ + // Identity + ethscriptionId: ethscriptionId, + ethscriptionNumber: uint256(ethscription.ethscriptionNumber), + + // Core metadata + contentUriSha: ethscription.contentUriSha, + contentHash: ethscription.contentHash, + mimetype: mimetype, + content: includeContent ? _getEthscriptionContent(ethscriptionId) : bytes(""), + + // Ownership + currentOwner: _ownerOf(uint256(ethscription.ethscriptionNumber)), + creator: ethscription.creator, + initialOwner: ethscription.initialOwner, + previousOwner: ethscription.previousOwner, + + // Block/time data + l1BlockHash: ethscription.l1BlockHash, + l1BlockNumber: uint256(ethscription.l1BlockNumber), + l2BlockNumber: uint256(ethscription.l2BlockNumber), + createdAt: uint256(ethscription.createdAt), + + // Protocol + esip6: ethscription.esip6, + protocolName: protocolName, + operation: operation + }); + } + + /// @notice Get complete ethscription data (includes content by default) + /// @param ethscriptionId The ethscription ID to look up + /// @return The complete ethscription data with content + function getEthscription(bytes32 ethscriptionId) external view returns (Ethscription memory) { + return _buildEthscription(ethscriptionId, true); + } + + /// @notice Get complete ethscription data with option to exclude content + /// @param ethscriptionId The ethscription ID to look up + /// @param includeContent Whether to include content (false for gas efficiency) + /// @return The complete ethscription data + function getEthscription(bytes32 ethscriptionId, bool includeContent) external view returns (Ethscription memory) { + return _buildEthscription(ethscriptionId, includeContent); + } + + /// @notice Get complete ethscription data by tokenId (includes content by default) + /// @param tokenId The token ID to look up + /// @return The complete ethscription data with content + function getEthscription(uint256 tokenId) external view returns (Ethscription memory) { + bytes32 ethscriptionId = tokenIdToEthscriptionId[tokenId]; + // _buildEthscription calls _getEthscriptionOrRevert which handles existence check + return _buildEthscription(ethscriptionId, true); + } + + /// @notice Get complete ethscription data by tokenId with option to exclude content + /// @param tokenId The token ID to look up + /// @param includeContent Whether to include content (false for gas efficiency) + /// @return The complete ethscription data + function getEthscription(uint256 tokenId, bool includeContent) external view returns (Ethscription memory) { + bytes32 ethscriptionId = tokenIdToEthscriptionId[tokenId]; + // _buildEthscription calls _getEthscriptionOrRevert which handles existence check + return _buildEthscription(ethscriptionId, includeContent); + } + + /// @notice Paginate all ethscriptions by global tokenId range + /// @param start Starting tokenId (inclusive) + /// @param limit Maximum number of items to return (clamped by includeContent) + /// @param includeContent Whether to include content bytes in the returned structs + /// @return page Paginated result containing items and metadata + function getEthscriptions(uint256 start, uint256 limit, bool includeContent) + external + view + returns (PaginatedEthscriptionsResponse memory page) + { + return _createPaginatedEthscriptionsResponse({ + byOwner: false, + owner: address(0), + start: start, + limit: limit, + includeContent: includeContent + }); + } + + /// @notice Overload with includeContent defaulting to true + function getEthscriptions(uint256 start, uint256 limit) + external + view + returns (PaginatedEthscriptionsResponse memory) + { + return _createPaginatedEthscriptionsResponse({ + byOwner: false, + owner: address(0), + start: start, + limit: limit, + includeContent: true + }); + } + + /// @notice Paginate ethscriptions owned by a specific address + /// @param owner The owner address to filter by + /// @param start Start index within the owner's token set (inclusive) + /// @param limit Maximum number of items to return (clamped by includeContent) + /// @param includeContent Whether to include content bytes in the returned structs + /// @return page Paginated result containing items and metadata + function getOwnerEthscriptions(address owner, uint256 start, uint256 limit, bool includeContent) + external + view + returns (PaginatedEthscriptionsResponse memory page) + { + return _createPaginatedEthscriptionsResponse({ + byOwner: true, + owner: owner, + start: start, + limit: limit, + includeContent: includeContent + }); + } + + /// @notice Overload with includeContent defaulting to true + function getOwnerEthscriptions(address owner, uint256 start, uint256 limit) + external + view + returns (PaginatedEthscriptionsResponse memory) + { + return _createPaginatedEthscriptionsResponse({ + byOwner: true, + owner: owner, + start: start, + limit: limit, + includeContent: true + }); + } + + /// @notice Internal generic paginator shared by global and owner-scoped pagination + function _createPaginatedEthscriptionsResponse( + bool byOwner, + address owner, + uint256 start, + uint256 limit, + bool includeContent + ) internal view returns (PaginatedEthscriptionsResponse memory page) { + if (limit == 0) revert InvalidPaginationLimit(); + + uint256 totalCount = byOwner ? balanceOf(owner) : totalSupply(); + page.total = totalCount; + page.start = start; + + uint256 maxPerPage = includeContent ? MAX_PAGE_WITH_CONTENT : MAX_PAGE_WITHOUT_CONTENT; + uint256 effectiveLimit = limit > maxPerPage ? maxPerPage : limit; + + uint256 endExclusive = start >= totalCount ? start : start + effectiveLimit; + if (endExclusive > totalCount) endExclusive = totalCount; + uint256 resultsCount = start >= totalCount ? 0 : (endExclusive - start); + + Ethscription[] memory items = new Ethscription[](resultsCount); + for (uint256 index = 0; index < resultsCount;) { + uint256 tokenId = byOwner ? tokenOfOwnerByIndex(owner, start + index) : (start + index); + bytes32 id = tokenIdToEthscriptionId[tokenId]; + items[index] = _buildEthscription(id, includeContent); + unchecked { ++index; } + } + + page.items = items; + // `limit` reflects the effective (clamped) page size requested, + // while the actual number of returned items is `items.length`. + page.limit = effectiveLimit; + page.nextStart = start + resultsCount; + page.hasMore = page.nextStart < totalCount; + } + + // -------------------- Internal helper for content retrieval -------------------- + + /// @notice Internal: Get content for an ethscription + /// @dev Kept as internal for tokenURI and other internal uses + function _getEthscriptionContent(bytes32 ethscriptionId) internal view returns (bytes memory) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + // Use shared retrieval logic + return DedupedBlobStore.readByHash(ethscription.contentHash, contentStorage); + } + + // ---------------- Ownership & Existence Checks ---------------- + + /// @notice Check if an ethscription exists + /// @param ethscriptionId The ethscription ID to check + /// @return true if the ethscription exists + function exists(bytes32 ethscriptionId) external view returns (bool) { + return _ethscriptionExists(ethscriptionId); + } + + function exists(uint256 tokenId) external view returns (bool) { + return _ethscriptionExists(tokenIdToEthscriptionId[tokenId]); + } + + /// @notice Get owner of an ethscription by transaction hash + /// @dev Overload of ownerOf that accepts transaction hash instead of token ID + function ownerOf(bytes32 ethscriptionId) external view returns (address) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + uint256 tokenId = ethscription.ethscriptionNumber; + + return ownerOf(tokenId); + } + + /// @notice Get the token ID (ethscription number) for a given transaction hash + /// @param ethscriptionId The ethscription ID to look up + /// @return The token ID (ethscription number) + function getTokenId(bytes32 ethscriptionId) external view returns (uint256) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + return ethscription.ethscriptionNumber; + } + + /// @notice Get the ethscription ID (bytes32) for a given tokenId + /// @dev Reverts if tokenId does not exist + function getEthscriptionId(uint256 tokenId) external view returns (bytes32) { + bytes32 id = tokenIdToEthscriptionId[tokenId]; + if (!_ethscriptionExists(id)) revert TokenDoesNotExist(); + return id; + } + + // -------------------- Metadata Helpers -------------------- + + /// @notice Get the MIME type of an ethscription + /// @param ethscriptionId The ethscription ID to query + /// @return mimetype The MIME type string + function getMimetype(bytes32 ethscriptionId) external view returns (string memory) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + return MetaStoreLib.getMimetype(ethscription.metaRef); + } + + /// @notice Get the protocol information for an ethscription + /// @param ethscriptionId The ethscription ID to query + /// @return protocolName The protocol identifier (empty if none) + /// @return operation The operation name (empty if none) + function getProtocol(bytes32 ethscriptionId) external view returns (string memory protocolName, string memory operation) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + return MetaStoreLib.getProtocol(ethscription.metaRef); + } + + /// @notice Get complete metadata for an ethscription + /// @param ethscriptionId The ethscription ID to query + /// @return mimetype The MIME type + /// @return protocolName The protocol identifier (empty if none) + /// @return operation The operation name (empty if none) + function getMetadata(bytes32 ethscriptionId) external view returns ( + string memory mimetype, + string memory protocolName, + string memory operation + ) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + return MetaStoreLib.decode(ethscription.metaRef); + } + + // ============================================================= + // INTERNAL FUNCTIONS + // ============================================================= + + /// @dev Override _update to track previous owner and handle token transfers + function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address from) { + // Find the ethscription ID for this token ID (ethscription number) + bytes32 id = tokenIdToEthscriptionId[tokenId]; + EthscriptionStorage storage ethscription = ethscriptions[id]; + + // Call parent implementation first to handle the actual update + from = super._update(to, tokenId, auth); + + if (from == address(0)) { + // Mint: emit once when minted directly to initial owner + if (to == ethscription.initialOwner) { + emit EthscriptionTransferred(id, ethscription.creator, to, tokenId); + } + // no previousOwner update or tokenManager call on mint + } else { + // Transfers (including creator -> address(0)) + emit EthscriptionTransferred(id, from, to, tokenId); + ethscription.previousOwner = from; + + // Notify protocol handler about the transfer if this ethscription has a protocol + _notifyProtocolTransfer(id, from, to); + } + + // Queue ethscription for batch proving at block boundary once proving is live + _queueForProving(id); + } + + /// @notice Check if an ethscription exists + /// @dev An ethscription exists if it has been created (has a creator set) + /// @param ethscriptionId The ethscription ID to check + /// @return True if the ethscription exists + function _ethscriptionExists(bytes32 ethscriptionId) internal view returns (bool) { + // Check if this ethscription has been created + // We can't use _tokenExists here because we need the tokenId first + // Instead, check if creator is set (ethscriptions are never created with zero creator) + return ethscriptions[ethscriptionId].creator != address(0); + } + + /// @notice Internal helper to store content and return its hash + /// @param content The raw content bytes to store + /// @return contentHash The keccak256 hash of the content + function _storeContent(bytes calldata content) internal returns (bytes32 contentHash) { + // Use shared deduplication logic with keccak256 + (contentHash,) = DedupedBlobStore.storeCalldata(content, contentStorage); + return contentHash; + } + + function _queueForProving(bytes32 ethscriptionId) internal { + if (block.timestamp >= Constants.historicalBackfillApproxDoneAt) { + prover.queueEthscription(ethscriptionId); + } + } + + /// @notice Call a protocol handler operation during ethscription creation + /// @param ethscriptionId The ethscription ID (L1 tx hash) + /// @param protocolParams The protocol parameters struct + function _callProtocolOperation( + bytes32 ethscriptionId, + ProtocolParams calldata protocolParams + ) internal { + // Skip if no protocol specified + if (bytes(protocolParams.protocolName).length == 0) { + return; + } + + address handler = protocolHandlers[protocolParams.protocolName]; + + // Skip if no handler is registered + if (handler == address(0)) { + return; + } + + // Encode the function call with operation name + bytes memory callData = abi.encodeWithSignature( + string.concat("op_", protocolParams.operation, "(bytes32,bytes)"), + ethscriptionId, + protocolParams.data + ); + + // Call the handler - failures don't revert ethscription creation + (bool success, bytes memory returnData) = handler.call(callData); + + if (!success) { + emit ProtocolHandlerFailed(ethscriptionId, protocolParams.protocolName, returnData); + } else { + emit ProtocolHandlerSuccess(ethscriptionId, protocolParams.protocolName, returnData); + } + } + + /// @notice Notify protocol handler about an ethscription transfer + /// @param ethscriptionId The ethscription ID (L1 tx hash) + /// @param from The address transferring from + /// @param to The address transferring to + function _notifyProtocolTransfer( + bytes32 ethscriptionId, + address from, + address to + ) internal { + // Get protocol from metadata + EthscriptionStorage storage etsc = ethscriptions[ethscriptionId]; + (string memory protocolName,) = MetaStoreLib.getProtocol(etsc.metaRef); + + // Skip if no protocol assigned + if (bytes(protocolName).length == 0) { + return; + } + + // Protocol names are stored normalized (lowercase) + address handler = protocolHandlers[protocolName]; + + // Skip if no handler is registered + if (handler == address(0)) { + return; + } + + // Use try/catch for cleaner error handling + try IProtocolHandler(handler).onTransfer(ethscriptionId, from, to) { + // onTransfer doesn't return data, so pass empty bytes + emit ProtocolHandlerSuccess(ethscriptionId, protocolName, ""); + } catch (bytes memory revertData) { + emit ProtocolHandlerFailed(ethscriptionId, protocolName, revertData); + } + } + + // ============================================================= + // PRIVATE FUNCTIONS + // ============================================================= + + /// @notice Emit all pending genesis events + /// @dev Emits events in chronological order then clears the array + function _emitPendingGenesisEvents() private { + // Store the length before we start popping + uint256 count = pendingGenesisEvents.length; + + // Emit events in the order they were created (FIFO) + for (uint256 i = 0; i < count; i++) { + bytes32 ethscriptionId = pendingGenesisEvents[i]; + + // Get the ethscription data + EthscriptionStorage storage ethscription = ethscriptions[ethscriptionId]; + uint256 tokenId = ethscription.ethscriptionNumber; + + // Emit events in the same order as live mints: + // 1. Transfer (mint), 2. EthscriptionTransferred, 3. EthscriptionCreated + + if (ethscription.initialOwner == address(0)) { + // Token was minted to creator then burned + // First emit mint to creator + emit Transfer(address(0), ethscription.creator, tokenId); + // Then emit burn from creator to null address + emit Transfer(ethscription.creator, address(0), tokenId); + // Emit Ethscriptions transfer event for the burn + emit EthscriptionTransferred( + ethscriptionId, + ethscription.creator, + address(0), + ethscription.ethscriptionNumber + ); + } else { + // Token was minted directly to initial owner + emit Transfer(address(0), ethscription.initialOwner, tokenId); + // Emit Ethscriptions transfer event + emit EthscriptionTransferred( + ethscriptionId, + ethscription.creator, + ethscription.initialOwner, + ethscription.ethscriptionNumber + ); + } + + // Finally emit the creation event (matching the order of live mints) + emit EthscriptionCreated( + ethscriptionId, + ethscription.creator, + ethscription.initialOwner, + ethscription.contentUriSha, + ethscription.contentHash, + ethscription.ethscriptionNumber + ); + } + + // Pop the array until it's empty + while (pendingGenesisEvents.length > 0) { + pendingGenesisEvents.pop(); + } + } +} diff --git a/contracts/src/EthscriptionsProver.sol b/contracts/src/EthscriptionsProver.sol new file mode 100644 index 0000000..231a324 --- /dev/null +++ b/contracts/src/EthscriptionsProver.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./Ethscriptions.sol"; +import "./L2/L2ToL1MessagePasser.sol"; +import "./L2/L1Block.sol"; +import "./libraries/Predeploys.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +/// @title EthscriptionsProver +/// @notice Proves Ethscription ownership and token balances to L1 via OP Stack +/// @dev Uses L2ToL1MessagePasser to send provable messages to L1 +contract EthscriptionsProver { + using EnumerableSet for EnumerableSet.Bytes32Set; + + // ============================================================= + // STRUCTS + // ============================================================= + + /// @notice Info stored when an ethscription is queued for proving + struct QueuedProof { + bytes32 l1BlockHash; + uint48 l2BlockNumber; + uint48 l2BlockTimestamp; + uint48 l1BlockNumber; + } + + /// @notice Struct for ethscription data proof + struct EthscriptionDataProof { + bytes32 ethscriptionId; + bytes32 contentHash; + bytes32 contentUriSha; + bytes32 l1BlockHash; + address creator; + address currentOwner; + address previousOwner; + bool esip6; + uint48 ethscriptionNumber; + uint48 l1BlockNumber; + uint48 l2BlockNumber; + uint48 l2Timestamp; + } + + // ============================================================= + // CONSTANTS + // ============================================================= + + /// @notice L1Block contract address for access control + address constant L1_BLOCK = Predeploys.L1_BLOCK_ATTRIBUTES; + + /// @notice L2ToL1MessagePasser predeploy address on OP Stack + L2ToL1MessagePasser constant L2_TO_L1_MESSAGE_PASSER = + L2ToL1MessagePasser(Predeploys.L2_TO_L1_MESSAGE_PASSER); + + /// @notice The Ethscriptions contract (pre-deployed at known address) + Ethscriptions constant ethscriptions = Ethscriptions(Predeploys.ETHSCRIPTIONS); + + // ============================================================= + // STATE VARIABLES + // ============================================================= + + /// @notice Set of all ethscription transaction hashes queued for proving + EnumerableSet.Bytes32Set private queuedEthscriptions; + + /// @notice Mapping from ethscription tx hash to its queued proof info + mapping(bytes32 => QueuedProof) private queuedProofInfo; + + // ============================================================= + // CUSTOM ERRORS + // ============================================================= + + error OnlyEthscriptions(); + error OnlyL1Block(); + + // ============================================================= + // EVENTS + // ============================================================= + + /// @notice Emitted when an ethscription data proof is sent to L1 + event EthscriptionDataProofSent( + bytes32 indexed ethscriptionId, + uint256 indexed l2BlockNumber, + uint256 l2Timestamp + ); + + // ============================================================= + // EXTERNAL FUNCTIONS + // ============================================================= + + /// @notice Queue an ethscription for proving + /// @dev Only callable by the Ethscriptions contract + /// @param ethscriptionId The ID of the ethscription (L1 tx hash) + function queueEthscription(bytes32 ethscriptionId) external virtual { + if (msg.sender != address(ethscriptions)) revert OnlyEthscriptions(); + + // Add to the set (deduplicates automatically) + if (queuedEthscriptions.add(ethscriptionId)) { + // Only store info if this is the first time we're queueing this ID + // Capture the L1 block hash and number at the time of queuing + L1Block l1Block = L1Block(L1_BLOCK); + queuedProofInfo[ethscriptionId] = QueuedProof({ + l1BlockHash: l1Block.hash(), + l2BlockNumber: uint48(block.number), + l2BlockTimestamp: uint48(block.timestamp), + l1BlockNumber: uint48(l1Block.number()) + }); + } + } + + /// @notice Flush all queued proofs + /// @dev Only callable by the L1Block contract at the start of each new block + function flushAllProofs() external { + if (msg.sender != L1_BLOCK) revert OnlyL1Block(); + + uint256 count = queuedEthscriptions.length(); + + // Process and remove each ethscription from the set + // We iterate backwards to avoid index shifting during removal + for (uint256 i = count; i > 0; i--) { + bytes32 ethscriptionId = queuedEthscriptions.at(i - 1); + + // Create and send proof for current state with stored block info + _createAndSendProof(ethscriptionId, queuedProofInfo[ethscriptionId]); + + // Clean up: remove from set and delete the proof info + queuedEthscriptions.remove(ethscriptionId); + delete queuedProofInfo[ethscriptionId]; + } + } + + // ============================================================= + // INTERNAL FUNCTIONS + // ============================================================= + + /// @notice Internal function to create and send proof for an ethscription + /// @param ethscriptionId The Ethscription ID (L1 tx hash) + /// @param proofInfo The queued proof info containing block data + function _createAndSendProof(bytes32 ethscriptionId, QueuedProof memory proofInfo) internal { + // Get ethscription data including previous owner (without content for gas efficiency) + Ethscriptions.Ethscription memory ethscription = ethscriptions.getEthscription(ethscriptionId, false); + // currentOwner is already in the struct now + address currentOwner = ethscription.currentOwner; + + // Create proof struct with all ethscription data + EthscriptionDataProof memory proof = EthscriptionDataProof({ + ethscriptionId: ethscriptionId, + contentHash: ethscription.contentHash, + contentUriSha: ethscription.contentUriSha, + l1BlockHash: proofInfo.l1BlockHash, + creator: ethscription.creator, + currentOwner: currentOwner, + previousOwner: ethscription.previousOwner, + esip6: ethscription.esip6, + ethscriptionNumber: uint48(ethscription.ethscriptionNumber), + l1BlockNumber: proofInfo.l1BlockNumber, + l2BlockNumber: proofInfo.l2BlockNumber, + l2Timestamp: proofInfo.l2BlockTimestamp + }); + + // Encode and send to L1 with zero address and gas (only for state recording) + bytes memory proofData = abi.encode(proof); + L2_TO_L1_MESSAGE_PASSER.initiateWithdrawal(address(0), 0, proofData); + + emit EthscriptionDataProofSent(ethscriptionId, proofInfo.l2BlockNumber, proofInfo.l2BlockTimestamp); + } +} diff --git a/contracts/src/L2/L1Block.sol b/contracts/src/L2/L1Block.sol new file mode 100644 index 0000000..59577f8 --- /dev/null +++ b/contracts/src/L2/L1Block.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { Constants } from "../libraries/Constants.sol"; +import { Predeploys } from "../libraries/Predeploys.sol"; + +interface IEthscriptionsProver { + function flushAllProofs() external; +} + +/// @custom:proxied +/// @custom:predeploy 0x4200000000000000000000000000000000000015 +/// @title L1Block +/// @notice The L1Block predeploy gives users access to information about the last known L1 block. +/// Values within this contract are updated once per epoch (every L1 block) and can only be +/// set by the "depositor" account, a special system address. Depositor account transactions +/// are created by the protocol whenever we move to a new epoch. +contract L1Block { + /// @notice Address of the special depositor account. + function DEPOSITOR_ACCOUNT() public pure returns (address addr_) { + addr_ = Constants.DEPOSITOR_ACCOUNT; + } + + /// @notice The latest L1 block number known by the L2 system. + uint64 public number; + + /// @notice The latest L1 timestamp known by the L2 system. + uint64 public timestamp; + + /// @notice The latest L1 base fee. + uint256 public basefee; + + /// @notice The latest L1 blockhash. + bytes32 public hash; + + /// @notice The number of L2 blocks in the same epoch. + uint64 public sequenceNumber; + + /// @notice The scalar value applied to the L1 blob base fee portion of the blob-capable L1 cost func. + uint32 public blobBaseFeeScalar; + + /// @notice The scalar value applied to the L1 base fee portion of the blob-capable L1 cost func. + uint32 public baseFeeScalar; + + /// @notice The versioned hash to authenticate the batcher by. + bytes32 public batcherHash; + + /// @notice The overhead value applied to the L1 portion of the transaction fee. + /// @custom:legacy + uint256 public l1FeeOverhead; + + /// @notice The scalar value applied to the L1 portion of the transaction fee. + /// @custom:legacy + uint256 public l1FeeScalar; + + /// @notice The latest L1 blob base fee. + uint256 public blobBaseFee; + + /// @notice Updates the L1 block values for an Ecotone upgraded chain. + /// Params are packed and passed in as raw msg.data instead of ABI to reduce calldata size. + /// Params are expected to be in the following order: + /// 1. _baseFeeScalar L1 base fee scalar + /// 2. _blobBaseFeeScalar L1 blob base fee scalar + /// 3. _sequenceNumber Number of L2 blocks since epoch start. + /// 4. _timestamp L1 timestamp. + /// 5. _number L1 blocknumber. + /// 6. _basefee L1 base fee. + /// 7. _blobBaseFee L1 blob base fee. + /// 8. _hash L1 blockhash. + /// 9. _batcherHash Versioned hash to authenticate batcher by. + function setL1BlockValuesEcotone() external { + address depositor = DEPOSITOR_ACCOUNT(); + assembly { + // Revert if the caller is not the depositor account. + if xor(caller(), depositor) { + mstore(0x00, 0x3cc50b45) // 0x3cc50b45 is the 4-byte selector of "NotDepositor()" + revert(0x1C, 0x04) // returns the stored 4-byte selector from above + } + // sequencenum (uint64), blobBaseFeeScalar (uint32), baseFeeScalar (uint32) + sstore(sequenceNumber.slot, shr(128, calldataload(4))) + // number (uint64) and timestamp (uint64) + sstore(number.slot, shr(128, calldataload(20))) + sstore(basefee.slot, calldataload(36)) // uint256 + sstore(blobBaseFee.slot, calldataload(68)) // uint256 + sstore(hash.slot, calldataload(100)) // bytes32 + sstore(batcherHash.slot, calldataload(132)) // bytes32 + } + + _flushProofsIfLive(); + } + + function _flushProofsIfLive() internal { + if (block.timestamp >= Constants.historicalBackfillApproxDoneAt) { + // Each proof includes its own block number and timestamp from when it was queued + IEthscriptionsProver(Predeploys.ETHSCRIPTIONS_PROVER).flushAllProofs(); + } + } +} diff --git a/contracts/src/L2/L2ToL1MessagePasser.sol b/contracts/src/L2/L2ToL1MessagePasser.sol new file mode 100644 index 0000000..bde4bc1 --- /dev/null +++ b/contracts/src/L2/L2ToL1MessagePasser.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +/// @custom:proxied true +/// @custom:predeploy 0x4200000000000000000000000000000000000016 +/// @title L2ToL1MessagePasser +/// @notice The L2ToL1MessagePasser is a dedicated contract where messages that are being sent from +/// L2 to L1 can be stored. The storage root of this contract is pulled up to the top level +/// of the L2 output to reduce the cost of proving the existence of sent messages. +contract L2ToL1MessagePasser { + /// @notice Struct representing a withdrawal transaction. + /// @custom:field nonce Nonce of the withdrawal transaction + /// @custom:field sender Address of the sender of the transaction. + /// @custom:field target Address of the recipient of the transaction. + /// @custom:field value Value to send to the recipient. + /// @custom:field gasLimit Gas limit of the transaction. + /// @custom:field data Data of the transaction. + struct WithdrawalTransaction { + uint256 nonce; + address sender; + address target; + uint256 value; + uint256 gasLimit; + bytes data; + } + + /// @notice The current message version identifier. + uint16 public constant MESSAGE_VERSION = 1; + + /// @notice Includes the message hashes for all withdrawals + mapping(bytes32 => bool) public sentMessages; + + /// @notice A unique value hashed with each withdrawal. + uint240 internal msgNonce; + + /// @notice Emitted any time a withdrawal is initiated. + /// @param nonce Unique value corresponding to each withdrawal. + /// @param sender The L2 account address which initiated the withdrawal. + /// @param target The L1 account address the call will be send to. + /// @param value The ETH value submitted for withdrawal, to be forwarded to the target. + /// @param gasLimit The minimum amount of gas that must be provided when withdrawing. + /// @param data The data to be forwarded to the target on L1. + /// @param withdrawalHash The hash of the withdrawal. + event MessagePassed( + uint256 indexed nonce, + address indexed sender, + address indexed target, + uint256 value, + uint256 gasLimit, + bytes data, + bytes32 withdrawalHash + ); + + /// @notice Sends a message from L2 to L1. + /// @param _target Address to call on L1 execution. + /// @param _gasLimit Minimum gas limit for executing the message on L1. + /// @param _data Data to forward to L1 target. + function initiateWithdrawal(address _target, uint256 _gasLimit, bytes memory _data) public payable { + bytes32 withdrawalHash = hashWithdrawal( + WithdrawalTransaction({ + nonce: messageNonce(), + sender: msg.sender, + target: _target, + value: msg.value, + gasLimit: _gasLimit, + data: _data + }) + ); + + sentMessages[withdrawalHash] = true; + + emit MessagePassed(messageNonce(), msg.sender, _target, msg.value, _gasLimit, _data, withdrawalHash); + + unchecked { + ++msgNonce; + } + } + + /// @notice Retrieves the next message nonce. Message version will be added to the upper two + /// bytes of the message nonce. Message version allows us to treat messages as having + /// different structures. + /// @return Nonce of the next message to be sent, with added message version. + function messageNonce() public view returns (uint256) { + return encodeVersionedNonce(msgNonce, MESSAGE_VERSION); + } + + /// @notice Derives the withdrawal hash according to the encoding in the L2 Withdrawer contract + /// @param _tx Withdrawal transaction to hash. + /// @return Hashed withdrawal transaction. + function hashWithdrawal(WithdrawalTransaction memory _tx) internal pure returns (bytes32) { + return keccak256(abi.encode(_tx.nonce, _tx.sender, _tx.target, _tx.value, _tx.gasLimit, _tx.data)); + } + + /// @notice Adds a version number into the first two bytes of a message nonce. + /// @param _nonce Message nonce to encode into. + /// @param _version Version number to encode into the message nonce. + /// @return Message nonce with version encoded into the first two bytes. + function encodeVersionedNonce(uint240 _nonce, uint16 _version) internal pure returns (uint256) { + uint256 nonce; + assembly { + nonce := or(shl(240, _version), _nonce) + } + return nonce; + } +} diff --git a/contracts/src/L2/ProxyAdmin.sol b/contracts/src/L2/ProxyAdmin.sol new file mode 100644 index 0000000..c95c82d --- /dev/null +++ b/contracts/src/L2/ProxyAdmin.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { Proxy } from "../libraries/Proxy.sol"; + +// Define interfaces locally since they're not in separate files +interface IStaticERC1967Proxy { + function implementation() external view returns (address); + function admin() external view returns (address); +} + +/// @title ProxyAdmin +/// @notice This is an auxiliary contract meant to be assigned as the admin of an ERC1967 Proxy, +/// based on the OpenZeppelin implementation. +contract ProxyAdmin is Ownable { + /// @param _owner Address of the initial owner of this contract. + constructor(address _owner) Ownable(_owner) { } + + /// @notice Returns the implementation of the given proxy address. + /// @param _proxy Address of the proxy to get the implementation of. + /// @return Address of the implementation of the proxy. + function getProxyImplementation(address _proxy) external view returns (address) { + return IStaticERC1967Proxy(_proxy).implementation(); + } + + /// @notice Returns the admin of the given proxy address. + /// @param _proxy Address of the proxy to get the admin of. + /// @return Address of the admin of the proxy. + function getProxyAdmin(address _proxy) external view returns (address) { + return IStaticERC1967Proxy(_proxy).admin(); + } + + /// @notice Updates the admin of the given proxy address. + /// @param _proxy Address of the proxy to update. + /// @param _newAdmin Address of the new proxy admin. + function changeProxyAdmin(address _proxy, address _newAdmin) external onlyOwner { + Proxy(_proxy).changeAdmin(_newAdmin); + } + + /// @notice Changes a proxy's implementation contract. + /// @param _proxy Address of the proxy to upgrade. + /// @param _implementation Address of the new implementation address. + function upgrade(address _proxy, address _implementation) public onlyOwner { + Proxy(_proxy).upgradeTo(_implementation); + } + + /// @notice Changes a proxy's implementation contract and delegatecalls the new implementation + /// with some given data. Useful for atomic upgrade-and-initialize calls. + /// @param _proxy Address of the proxy to upgrade. + /// @param _implementation Address of the new implementation address. + /// @param _data Data to trigger the new implementation with. + function upgradeAndCall( + address _proxy, + address _implementation, + bytes memory _data + ) external onlyOwner { + Proxy(_proxy).upgradeToAndCall(_implementation, _data); + } +} diff --git a/contracts/src/interfaces/IProtocolHandler.sol b/contracts/src/interfaces/IProtocolHandler.sol new file mode 100644 index 0000000..62f2e39 --- /dev/null +++ b/contracts/src/interfaces/IProtocolHandler.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +/// @title IProtocolHandler +/// @notice Interface that all protocol handlers must implement +/// @dev Handlers process protocol-specific logic for Ethscriptions lifecycle events +interface IProtocolHandler { + /// @notice Called when an Ethscription with this protocol is transferred + /// @param ethscriptionId The Ethscription ID (L1 tx hash) + /// @param from The address transferring the Ethscription + /// @param to The address receiving the Ethscription + function onTransfer( + bytes32 ethscriptionId, + address from, + address to + ) external; + + /// @notice Returns human-readable protocol name + /// @return The protocol name (e.g., "erc-20-fixed-denomination", "erc-721-ethscriptions-collection") + function protocolName() external pure returns (string memory); +} diff --git a/contracts/src/libraries/BytePackLib.sol b/contracts/src/libraries/BytePackLib.sol new file mode 100644 index 0000000..1c6f4aa --- /dev/null +++ b/contracts/src/libraries/BytePackLib.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +/// @title BytePackLib +/// @notice Library for packing small byte arrays (0-31 bytes) into a single bytes32 slot +/// @dev Uses a tag byte (length + 1) to distinguish packed data from regular addresses/data +library BytePackLib { + error ContentTooLarge(uint256 size); + error NotPackedData(); + + /// @notice Pack bytes calldata up to 31 bytes into a bytes32 + /// @dev Calldata version for gas optimization when called with external data + /// @param data The data to pack (must be <= 31 bytes) + /// @return packed The packed bytes32 value + function packCalldata(bytes calldata data) internal pure returns (bytes32 packed) { + uint256 len = data.length; + if (len >= 32) revert ContentTooLarge(len); + + assembly { + // Pack: tag byte (len+1) | first 31 bytes of data + packed := or( + shl(248, add(len, 1)), // Tag in first byte + shr(8, calldataload(data.offset)) // Data in remaining 31 bytes + ) + } + } + + /// @notice Pack bytes memory up to 31 bytes into a bytes32 + /// @dev Memory version for when data is in memory + /// @param data The data to pack (must be <= 31 bytes) + /// @return packed The packed bytes32 value + function pack(bytes memory data) internal pure returns (bytes32 packed) { + uint256 len = data.length; + if (len >= 32) revert ContentTooLarge(len); + + assembly { + // Pack: tag byte (len+1) | first 31 bytes of data + packed := or( + shl(248, add(len, 1)), // Tag in first byte + shr(8, mload(add(data, 0x20))) // Data in remaining 31 bytes (skip length prefix) + ) + } + } + + /// @notice Unpack a bytes32 value into bytes + /// @dev Extracts the data based on the tag byte (length + 1) + /// @param packed The packed bytes32 value + /// @return data The unpacked bytes data + function unpack(bytes32 packed) internal pure returns (bytes memory data) { + uint256 tag = uint8(uint256(packed >> 248)); + if (tag == 0 || tag > 32) revert NotPackedData(); + + uint256 len = tag - 1; + data = new bytes(len); + + if (len > 0) { + assembly { + // Store the data (shift left by 8 to remove tag byte) + mstore(add(data, 0x20), shl(8, packed)) + // Note: No need to zero memory after the data since new bytes() already zeroes it + // and we're only writing up to 31 bytes into a 32-byte word + } + } + } + + /// @notice Check if a bytes32 value is packed data + /// @dev Returns true if the first byte indicates packed data (tag between 1-32) + /// @param value The bytes32 value to check + /// @return True if the value is packed data, false otherwise + function isPacked(bytes32 value) internal pure returns (bool) { + // Packed data has a tag byte between 1-32 in the first byte + uint256 tag = uint8(uint256(value >> 248)); + return tag > 0 && tag <= 32; + } + + /// @notice Get the length of packed data without unpacking + /// @dev Returns the length stored in the tag byte + /// @param packed The packed bytes32 value + /// @return The length of the packed data (0-31) + function packedLength(bytes32 packed) internal pure returns (uint256) { + uint256 tag = uint8(uint256(packed >> 248)); + if (tag == 0 || tag > 32) revert NotPackedData(); + return tag - 1; + } +} \ No newline at end of file diff --git a/contracts/src/libraries/Constants.sol b/contracts/src/libraries/Constants.sol new file mode 100644 index 0000000..027a85c --- /dev/null +++ b/contracts/src/libraries/Constants.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +/// @title Constants +/// @notice Constants is a library for storing constants. Simple! Don't put everything in here, just +/// the stuff used in multiple contracts. Constants that only apply to a single contract +/// should be defined in that contract instead. +library Constants { + /// @notice The storage slot that holds the address of a proxy implementation. + /// @dev `bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)` + bytes32 internal constant PROXY_IMPLEMENTATION_ADDRESS = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + /// @notice The storage slot that holds the address of the owner. + /// @dev `bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)` + bytes32 internal constant PROXY_OWNER_ADDRESS = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + + /// @notice The address that represents the system caller responsible for L1 attributes transactions. + address internal constant DEPOSITOR_ACCOUNT = 0xDeaDDEaDDeAdDeAdDEAdDEaddeAddEAdDEAd0001; + + /// @notice Storage slot for Initializable contract's initialized flag + /// @dev This is the keccak256 of "eip1967.proxy.initialized" - 1 + bytes32 internal constant INITIALIZABLE_STORAGE = 0xf0c57e16840df040f15088dc2f81fe391c3923bec73e23a9662efc9c229c6a00; + + uint256 internal constant historicalBackfillApproxDoneAt = 1764024440; +} diff --git a/contracts/src/libraries/DedupedBlobStore.sol b/contracts/src/libraries/DedupedBlobStore.sol new file mode 100644 index 0000000..a260c5b --- /dev/null +++ b/contracts/src/libraries/DedupedBlobStore.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./BytePackLib.sol"; +import "./SSTORE2Unlimited.sol"; + +/// @title DedupedBlobStore +/// @notice Shared library for deduplicated blob storage using inline packing or SSTORE2 +/// @dev Used by both content storage and metadata storage to eliminate code duplication +library DedupedBlobStore { + + /// @notice Store calldata blob with deduplication using keccak256 + /// @dev Uses keccak256 for dedup key, stores either packed (≤31 bytes) or SSTORE2 pointer + /// @param data The calldata to store + /// @param store The storage mapping (hash => ref) + /// @return hash The keccak256 hash of the data (dedup key) + /// @return ref The storage reference (packed or SSTORE2 pointer) + function storeCalldata( + bytes calldata data, + mapping(bytes32 => bytes32) storage store + ) internal returns (bytes32 hash, bytes32 ref) { + hash = keccak256(data); + + // Check if already stored + bytes32 existing = store[hash]; + if (existing != bytes32(0)) { + return (hash, existing); + } + + // Store based on size - use calldata packing for efficiency + ref = data.length <= 31 ? BytePackLib.packCalldata(data) : _deploySST0RE2Calldata(data); + + // Store the mapping: hash -> reference + store[hash] = ref; + return (hash, ref); + } + + /// @notice Store memory blob with deduplication using keccak256 + /// @dev Uses keccak256 for dedup key, stores either packed (≤31 bytes) or SSTORE2 pointer + /// @param data The memory data to store + /// @param store The storage mapping (hash => ref) + /// @return hash The keccak256 hash of the data (dedup key) + /// @return ref The storage reference (packed or SSTORE2 pointer) + function storeMemory( + bytes memory data, + mapping(bytes32 => bytes32) storage store + ) internal returns (bytes32 hash, bytes32 ref) { + hash = keccak256(data); + + // Check if already stored + bytes32 existing = store[hash]; + if (existing != bytes32(0)) { + return (hash, existing); + } + + // Store based on size - use memory packing + ref = data.length <= 31 ? BytePackLib.pack(data) : _deploySST0RE2Memory(data); + + // Store the mapping: hash -> reference + store[hash] = ref; + return (hash, ref); + } + + /// @notice Deploy SSTORE2 contract and return reference + /// @param data The data to deploy (calldata or memory) + /// @return ref The SSTORE2 pointer as bytes32 + function _deploySST0RE2Calldata(bytes calldata data) private returns (bytes32 ref) { + address pointer = SSTORE2Unlimited.write(data); + return bytes32(uint256(uint160(pointer))); + } + + /// @notice Deploy SSTORE2 contract and return reference + /// @param data The data to deploy (calldata or memory) + /// @return ref The SSTORE2 pointer as bytes32 + function _deploySST0RE2Memory(bytes memory data) private returns (bytes32 ref) { + address pointer = SSTORE2Unlimited.write(data); + return bytes32(uint256(uint160(pointer))); + } + + /// @notice Read blob from storage reference + /// @dev Automatically detects packed vs SSTORE2 and retrieves accordingly + /// @param ref The storage reference (packed or SSTORE2 pointer) + /// @return data The retrieved blob + function read(bytes32 ref) internal view returns (bytes memory) { + // Check if it's inline packed content + if (BytePackLib.isPacked(ref)) { + return BytePackLib.unpack(ref); + } + + // It's a pointer to SSTORE2 contract + address pointer = address(uint160(uint256(ref))); + return SSTORE2Unlimited.read(pointer); + } + + /// @notice Read blob from storage reference and convert to string + /// @dev Convenience wrapper to avoid repetitive string() casting + /// @param ref The storage reference (packed or SSTORE2 pointer) + /// @return str The retrieved data as string + function readString(bytes32 ref) internal view returns (string memory) { + return string(read(ref)); + } + + /// @notice Read blob from storage mapping by hash + /// @dev Looks up reference in mapping, then reads + /// @param hash The hash key + /// @param store The storage mapping + /// @return data The retrieved blob + function readByHash( + bytes32 hash, + mapping(bytes32 => bytes32) storage store + ) internal view returns (bytes memory) { + bytes32 ref = store[hash]; + return read(ref); + } +} diff --git a/contracts/src/libraries/EthscriptionsRendererLib.sol b/contracts/src/libraries/EthscriptionsRendererLib.sol new file mode 100644 index 0000000..8b067e9 --- /dev/null +++ b/contracts/src/libraries/EthscriptionsRendererLib.sol @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Base64} from "solady/utils/Base64.sol"; +import {LibString} from "solady/utils/LibString.sol"; +import {Ethscriptions} from "../Ethscriptions.sol"; + +/// @title EthscriptionsRendererLib +/// @notice Library for rendering Ethscription metadata and media URIs +/// @dev Contains all token URI generation, media handling, and metadata formatting logic +library EthscriptionsRendererLib { + using LibString for *; + + /// @notice Build attributes JSON array from ethscription data + /// @param etsc Storage pointer to the ethscription + /// @param ethscriptionId The ethscription ID (L1 tx hash) + /// @param mimetype The MIME type string (decoded from metadata) + /// @param protocolName The protocol name (empty if none) + /// @param operation The operation name (empty if none) + /// @return JSON string of attributes array + function buildAttributes( + Ethscriptions.EthscriptionStorage storage etsc, + bytes32 ethscriptionId, + string memory mimetype, + string memory protocolName, + string memory operation + ) + internal + view + returns (string memory) + { + // Build in chunks to avoid stack too deep + string memory part1 = string.concat( + '[{"trait_type":"Ethscription ID","value":"', + uint256(ethscriptionId).toHexString(32), + '"},{"trait_type":"Ethscription Number","display_type":"number","value":', + etsc.ethscriptionNumber.toString(), + '},{"trait_type":"Creator","value":"', + etsc.creator.toHexString(), + '"},{"trait_type":"Initial Owner","value":"', + etsc.initialOwner.toHexString() + ); + + string memory part2 = string.concat( + '"},{"trait_type":"Content Hash","value":"', + uint256(etsc.contentHash).toHexString(32), + '"},{"trait_type":"Content URI SHA","value":"', + uint256(etsc.contentUriSha).toHexString(32), + '"},{"trait_type":"MIME Type","value":"', + mimetype.escapeJSON(), + '"},{"trait_type":"ESIP-6","value":"', + etsc.esip6 ? "true" : "false" + ); + + // Add protocol info if present + string memory protocolAttrs = ""; + if (bytes(protocolName).length > 0) { + protocolAttrs = string.concat( + '"},{"trait_type":"Protocol Name","value":"', + protocolName.escapeJSON() + ); + if (bytes(operation).length > 0) { + protocolAttrs = string.concat( + protocolAttrs, + '"},{"trait_type":"Protocol Operation","value":"', + operation.escapeJSON() + ); + } + } + + string memory part3 = string.concat( + protocolAttrs, + '"},{"trait_type":"L1 Block Number","display_type":"number","value":', + uint256(etsc.l1BlockNumber).toString(), + '},{"trait_type":"L2 Block Number","display_type":"number","value":', + uint256(etsc.l2BlockNumber).toString(), + '},{"trait_type":"Created At","display_type":"date","value":', + etsc.createdAt.toString(), + '}]' + ); + + return string.concat(part1, part2, part3); + } + + /// @notice Generate the media URI for an ethscription + /// @param mimetype The MIME type string + /// @param content The content bytes + /// @return mediaType Either "image" or "animation_url" + /// @return mediaUri The data URI for the media + function getMediaUri(string memory mimetype, bytes memory content) + internal + pure + returns (string memory mediaType, string memory mediaUri) + { + if (mimetype.startsWith("image/")) { + // Image content: wrap in SVG for pixel-perfect rendering + string memory imageDataUri = constructDataURI(mimetype, content); + string memory svg = wrapImageInSVG(imageDataUri); + mediaUri = constructDataURI("image/svg+xml", bytes(svg)); + return ("image", mediaUri); + } else { + // Non-image content: use animation_url + if (mimetype.startsWith("video/") || + mimetype.startsWith("audio/") || + mimetype.eq("text/html")) { + // Video, audio, and HTML pass through directly as data URIs + mediaUri = constructDataURI(mimetype, content); + } else { + // Everything else (text/plain, application/json, etc.) uses the HTML viewer + mediaUri = createTextViewerDataURI(mimetype, content); + } + return ("animation_url", mediaUri); + } + } + + /// @notice Build complete token URI JSON + /// @param etsc Storage pointer to the ethscription + /// @param ethscriptionId The ethscription ID (L1 tx hash) + /// @param mimetype The MIME type string (decoded from metadata) + /// @param protocolName The protocol name (empty if none) + /// @param operation The operation name (empty if none) + /// @param content The content bytes + /// @return The complete base64-encoded data URI + function buildTokenURI( + Ethscriptions.EthscriptionStorage storage etsc, + bytes32 ethscriptionId, + string memory mimetype, + string memory protocolName, + string memory operation, + bytes memory content + ) internal view returns (string memory) { + // Get media URI + (string memory mediaType, string memory mediaUri) = getMediaUri(mimetype, content); + + // Build attributes + string memory attributes = buildAttributes(etsc, ethscriptionId, mimetype, protocolName, operation); + + // Build JSON + string memory json = string.concat( + '{"name":"Ethscription #', + etsc.ethscriptionNumber.toString(), + '","description":"Ethscription #', + etsc.ethscriptionNumber.toString(), + ' created by ', + etsc.creator.toHexString(), + '","', + mediaType, + '":"', + mediaUri, + '","attributes":', + attributes, + '}' + ); + + return string.concat( + "data:application/json;base64,", + Base64.encode(bytes(json)) + ); + } + + /// @notice Construct a base64-encoded data URI + /// @param mimetype The MIME type + /// @param content The content bytes + /// @return The complete data URI + function constructDataURI(string memory mimetype, bytes memory content) + internal + pure + returns (string memory) + { + return string.concat( + "data:", + mimetype.escapeJSON(), + ";base64,", + Base64.encode(content) + ); + } + + /// @notice Wrap an image in SVG for pixel-perfect rendering + /// @param imageDataUri The image data URI to wrap + /// @return The SVG markup + function wrapImageInSVG(string memory imageDataUri) + internal + pure + returns (string memory) + { + // SVG wrapper that enforces pixelated/nearest-neighbor scaling for pixel art + return string.concat( + '' + ); + } + + /// @notice Create an HTML viewer data URI for text content + /// @param mimetype The MIME type of the content + /// @param content The content bytes + /// @return The HTML viewer data URI + function createTextViewerDataURI(string memory mimetype, bytes memory content) + internal + pure + returns (string memory) + { + // Base64 encode the content for embedding in HTML + string memory encodedContent = Base64.encode(content); + + // Generate HTML with embedded content + string memory html = generateTextViewerHTML(encodedContent, mimetype); + + // Return as base64-encoded HTML data URI + return constructDataURI("text/html", bytes(html)); + } + + /// @notice Generate minimal HTML viewer for text content + /// @param encodedPayload Base64-encoded content + /// @param mimetype The MIME type + /// @return The complete HTML string + function generateTextViewerHTML(string memory encodedPayload, string memory mimetype) + internal + pure + returns (string memory) + { + // Ultra-minimal HTML with inline styles optimized for iframe display + return string.concat( + '', + '', + '
'
+        );
+    }
+}
diff --git a/contracts/src/libraries/MetaStoreLib.sol b/contracts/src/libraries/MetaStoreLib.sol
new file mode 100644
index 0000000..55f5883
--- /dev/null
+++ b/contracts/src/libraries/MetaStoreLib.sol
@@ -0,0 +1,231 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.24;
+
+import {LibBytes} from "solady/utils/LibBytes.sol";
+import "./DedupedBlobStore.sol";
+
+/// @title MetaStoreLib
+/// @notice Library for deduplicated storage of ethscription metadata (mimetype, protocol, operation)
+/// @dev Encodes metadata as: mimetype\x00protocol\x00operation, stores once per unique combination
+library MetaStoreLib {
+    using LibBytes for bytes;
+
+    /// @dev Null byte (0x00) used to separate metadata components
+    /// @dev Safe to use as Ruby indexer strips all null bytes from input strings
+    bytes1 constant SEPARATOR = 0x00;
+
+    /// @dev Sentinel value for "text/plain" with no protocol (most common case)
+    bytes32 constant EMPTY_REF = bytes32(0);
+
+    // Custom errors
+    error InvalidSeparatorInInput();
+    error InvalidMetadataRef();
+    error MetadataNotStored();
+    error InvalidFormat();
+
+    /// @notice Store metadata components (encode + deduplicate)
+    /// @dev High-level API for callers - combines encode() and intern()
+    /// @param mimetype MIME type string (preserve case for standards compliance)
+    /// @param protocolName Protocol identifier (should already be normalized by Ruby)
+    /// @param operation Operation to perform (should already be normalized by Ruby)
+    /// @param metaStore Storage mapping for metadata blobs
+    /// @return metaRef The metadata reference (bytes32(0), packed, or SSTORE2 pointer)
+    function store(
+        string memory mimetype,
+        string memory protocolName,
+        string memory operation,
+        mapping(bytes32 => bytes32) storage metaStore
+    ) internal returns (bytes32 metaRef) {
+        bytes memory blob = encode(mimetype, protocolName, operation);
+        return intern(blob, metaStore);
+    }
+
+    /// @notice Encode metadata components into a blob
+    /// @dev Lower-level API - most callers should use store() instead
+    /// @param mimetype MIME type string (not normalized - preserve case for standards compliance)
+    /// @param protocolName Protocol identifier (should already be normalized by Ruby)
+    /// @param operation Operation name (should already be normalized by Ruby)
+    /// @return blob The encoded metadata blob (empty if all components empty/default)
+    function encode(
+        string memory mimetype,
+        string memory protocolName,
+        string memory operation
+    ) internal pure returns (bytes memory blob) {
+        // Validate inputs don't contain separator
+        if (_containsByte(bytes(mimetype), SEPARATOR)) revert InvalidSeparatorInInput();
+        if (_containsByte(bytes(protocolName), SEPARATOR)) revert InvalidSeparatorInInput();
+        if (_containsByte(bytes(operation), SEPARATOR)) revert InvalidSeparatorInInput();
+
+        // Note: normalization (lowercase, trim) is handled by Ruby indexer before submission
+
+        // Normalize "text/plain" to empty string (convention: empty = text/plain)
+        if (keccak256(bytes(mimetype)) == keccak256(bytes("text/plain"))) {
+            mimetype = "";
+        }
+
+        // Special case: empty mimetype + no protocol → empty blob (most common case!)
+        if (bytes(mimetype).length == 0 && bytes(protocolName).length == 0 && bytes(operation).length == 0) {
+            return bytes("");  // Will map to EMPTY_REF (bytes32(0))
+        }
+
+        // Always encode in same format: mimetype\x1Fprotocol\x1Foperation
+        // Any component can be empty string
+        return abi.encodePacked(mimetype, SEPARATOR, protocolName, SEPARATOR, operation);
+    }
+
+    /// @notice Decode a metadata reference into components
+    /// @param metaRef The metadata reference (bytes32(0), packed, or SSTORE2 pointer)
+    /// @return mimetype The MIME type
+    /// @return protocolName The protocol identifier (normalized)
+    /// @return operation The operation name (normalized)
+    function decode(bytes32 metaRef) internal view returns (
+        string memory mimetype,
+        string memory protocolName,
+        string memory operation
+    ) {
+        bytes[] memory parts = _getParts(metaRef);
+        return _partsToStrings(parts);
+    }
+
+    /// @notice Get only the mimetype from a metadata reference (gas-optimized)
+    /// @param metaRef The metadata reference
+    /// @return mimetype The MIME type
+    function getMimetype(bytes32 metaRef) internal view returns (string memory mimetype) {
+        bytes[] memory parts = _getParts(metaRef);
+
+        // First part is always mimetype (empty = text/plain)
+        string memory mime = string(parts[0]);
+        return bytes(mime).length == 0 ? "text/plain" : mime;
+    }
+
+    /// @notice Get protocol information from a metadata reference
+    /// @param metaRef The metadata reference
+    /// @return protocolName The protocol identifier (normalized, empty if none)
+    /// @return operation The operation name (normalized, empty if none)
+    function getProtocol(bytes32 metaRef) internal view returns (
+        string memory protocolName,
+        string memory operation
+    ) {
+        bytes[] memory parts = _getParts(metaRef);
+
+        // parts[0] = mimetype, parts[1] = protocol, parts[2] = operation
+        protocolName = string(parts[1]);
+        operation = string(parts[2]);
+        return (protocolName, operation);
+    }
+
+
+    /// @notice Intern a metadata blob (deduplicate and store)
+    /// @dev Lower-level API - most callers should use store() instead
+    /// @param blob The encoded metadata blob
+    /// @param metaStore Storage mapping for metadata blobs
+    /// @return metaRef The metadata reference (bytes32(0), packed, or SSTORE2 pointer)
+    function intern(
+        bytes memory blob,
+        mapping(bytes32 => bytes32) storage metaStore
+    ) internal returns (bytes32 metaRef) {
+        // Special case: empty blob = EMPTY_REF sentinel
+        if (blob.length == 0) {
+            return EMPTY_REF;
+        }
+
+        // Use shared deduplication logic with keccak256
+        (, metaRef) = DedupedBlobStore.storeMemory(blob, metaStore);
+        return metaRef;
+    }
+
+
+    // =============================================================
+    //                     INTERNAL HELPERS
+    // =============================================================
+
+    /// @notice Retrieve a blob from storage
+    /// @param metaRef The metadata reference
+    /// @return blob The retrieved blob
+    function _retrieve(bytes32 metaRef) private view returns (bytes memory blob) {
+        if (metaRef == EMPTY_REF) {
+            return bytes("");
+        }
+
+        // Use shared read logic
+        return DedupedBlobStore.read(metaRef);
+    }
+
+    /// @notice Get parts array from metadata reference (single point for blob.length check)
+    /// @param metaRef The metadata reference
+    /// @return parts Array of 3 byte parts [mimetype, protocol, operation]
+    function _getParts(bytes32 metaRef) private view returns (bytes[] memory parts) {
+        bytes memory blob = _retrieve(metaRef);
+
+        // Single check for empty blob (text/plain + no protocol case)
+        if (blob.length == 0) {
+            parts = new bytes[](3);
+            parts[0] = bytes("");  // Empty = text/plain
+            parts[1] = bytes("");  // No protocol
+            parts[2] = bytes("");  // No operation
+            return parts;
+        }
+
+        // Split keeping empty parts - always get 3 parts
+        return _splitKeepEmpty(blob, SEPARATOR);
+    }
+
+    /// @notice Convert parts array to strings with text/plain default
+    /// @param parts Array of 3 byte parts [mimetype, protocol, operation]
+    /// @return mimetype The MIME type
+    /// @return protocolName The protocol identifier
+    /// @return operation The operation name
+    function _partsToStrings(bytes[] memory parts) private pure returns (
+        string memory mimetype,
+        string memory protocolName,
+        string memory operation
+    ) {
+        // Extract mimetype (empty = text/plain)
+        mimetype = string(parts[0]);
+        if (bytes(mimetype).length == 0) {
+            mimetype = "text/plain";
+        }
+
+        // Extract protocol and operation (may be empty)
+        protocolName = string(parts[1]);
+        operation = string(parts[2]);
+
+        return (mimetype, protocolName, operation);
+    }
+
+    /// @notice Split a blob by single-byte delimiter, keeping empty parts
+    /// @dev Enforces exactly 2 separators (3 parts): [mimetype, protocol, operation]
+    /// @param subject The blob to split
+    /// @param delim The single-byte delimiter
+    /// @return out Array with exactly 3 parts (some may be empty)
+    function _splitKeepEmpty(bytes memory subject, bytes1 delim)
+        private
+        pure
+        returns (bytes[] memory out)
+    {
+        // Find first separator
+        uint256 a = subject.indexOfByte(delim, 0);
+        if (a == LibBytes.NOT_FOUND) revert InvalidFormat();
+
+        // Find second separator
+        uint256 b = subject.indexOfByte(delim, a + 1);
+        if (b == LibBytes.NOT_FOUND) revert InvalidFormat();
+
+        // Ensure no third separator (enforce format)
+        if (subject.indexOfByte(delim, b + 1) != LibBytes.NOT_FOUND) revert InvalidFormat();
+
+        out = new bytes[](3);
+        out[0] = subject.slice(0, a);           // mimetype (may be empty)
+        out[1] = subject.slice(a + 1, b);       // protocol (may be empty)
+        out[2] = subject.slice(b + 1, subject.length);  // operation (may be empty)
+    }
+
+    /// @notice Check if bytes contains a specific byte
+    /// @dev Custom helper since LibBytes.contains requires bytes memory, not bytes1
+    /// @param data The data to search
+    /// @param target The byte to find
+    /// @return True if found
+    function _containsByte(bytes memory data, bytes1 target) private pure returns (bool) {
+        return data.indexOfByte(target) != LibBytes.NOT_FOUND;
+    }
+}
diff --git a/contracts/src/libraries/Predeploys.sol b/contracts/src/libraries/Predeploys.sol
new file mode 100644
index 0000000..2c8814b
--- /dev/null
+++ b/contracts/src/libraries/Predeploys.sol
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.24;
+
+/// @title Predeploys
+/// @notice Defines all predeploy addresses for the L2 chain
+library Predeploys {
+    // ============ OP Stack Predeploys ============
+
+    /// @notice L1Block predeploy (stores L1 block information)
+    address constant L1_BLOCK_ATTRIBUTES = 0x4200000000000000000000000000000000000015;
+
+    /// @notice Depositor Account (system address that can make deposits)
+    address constant DEPOSITOR_ACCOUNT = 0xDeaDDEaDDeAdDeAdDEAdDEaddeAddEAdDEAd0001;
+    
+    /// @notice L2ToL1MessagePasser predeploy (for L2->L1 messages)
+    address constant L2_TO_L1_MESSAGE_PASSER = 0x4200000000000000000000000000000000000016;
+    
+    /// @notice ProxyAdmin predeploy (manages all proxy upgrades)
+    address constant PROXY_ADMIN = 0x4200000000000000000000000000000000000018;
+    
+    address constant MultiCall3 = 0xcA11bde05977b3631167028862bE2a173976CA11;
+    
+    // ============ Ethscriptions System Predeploys ============
+    // Using 0x3300… namespace for Ethscriptions contracts
+    
+    /// @notice Ethscriptions NFT contract
+    /// @dev Moved to the 0x3300… namespace to align with other Ethscriptions predeploys
+    address constant ETHSCRIPTIONS = 0x3300000000000000000000000000000000000001;
+    
+    /// @notice ERC20 fixed denomination manager for managed ERC-20 semantics
+    address constant ERC20_FIXED_DENOMINATION_MANAGER = 0x3300000000000000000000000000000000000002;
+    
+    /// @notice EthscriptionsProver for L1 provability
+    address constant ETHSCRIPTIONS_PROVER = 0x3300000000000000000000000000000000000003;
+
+    /// @notice Implementation address for the ERC20 fixed denomination template (actual logic contract)
+    address constant ERC20_FIXED_DENOMINATION_IMPLEMENTATION = 0xc0D3c0D3c0D3c0d3c0d3C0d3C0d3c0D3C0D30004;
+
+    /// @notice Implementation address for the ERC721 Ethscriptions collection template (actual logic contract)
+    address constant ERC721_ETHSCRIPTIONS_COLLECTION_IMPLEMENTATION = 0xc0d3C0d3c0D3c0d3C0D3C0D3c0D3C0D3c0d30005;
+
+    /// @notice ERC721 Ethscriptions collection manager
+    address constant ERC721_ETHSCRIPTIONS_COLLECTION_MANAGER = 0x3300000000000000000000000000000000000006;
+    
+    // ============ Helper Functions ============
+    
+    /// @notice Returns true if the address is an OP Stack predeploy (0x4200… namespace)
+    function isOPPredeployNamespace(address _addr) internal pure returns (bool) {
+        return uint160(_addr) >> 11 == uint160(0x4200000000000000000000000000000000000000) >> 11;
+    }
+
+    /// @notice Returns true if the address is an Ethscriptions predeploy (0x3300… namespace)
+    function isEthscriptionsPredeployNamespace(address _addr) internal pure returns (bool) {
+        return uint160(_addr) >> 11 == uint160(0x3300000000000000000000000000000000000000) >> 11;
+    }
+
+    /// @notice Returns true if the address is a recognized predeploy (OP or Ethscriptions)
+    function isPredeployNamespace(address _addr) internal pure returns (bool) {
+        return isOPPredeployNamespace(_addr) || isEthscriptionsPredeployNamespace(_addr);
+    }
+    
+    /// @notice Converts a predeploy address to its code namespace equivalent
+    function predeployToCodeNamespace(address _addr) internal pure returns (address) {
+        require(
+            isPredeployNamespace(_addr), 
+            "Predeploys: can only derive code-namespace address for predeploy addresses"
+        );
+        return address(
+            uint160(uint256(uint160(_addr)) & 0xffff | uint256(uint160(0xc0D3C0d3C0d3C0D3c0d3C0d3c0D3C0d3c0d30000)))
+        );
+    }
+    
+    bytes internal constant MultiCall3Code =
+        hex"6080604052600436106100f35760003560e01c80634d2301cc1161008a578063a8b0574e11610059578063a8b0574e1461025a578063bce38bd714610275578063c3077fa914610288578063ee82ac5e1461029b57600080fd5b80634d2301cc146101ec57806372425d9d1461022157806382ad56cb1461023457806386d516e81461024757600080fd5b80633408e470116100c65780633408e47014610191578063399542e9146101a45780633e64a696146101c657806342cbb15c146101d957600080fd5b80630f28c97d146100f8578063174dea711461011a578063252dba421461013a57806327e86d6e1461015b575b600080fd5b34801561010457600080fd5b50425b6040519081526020015b60405180910390f35b61012d610128366004610a85565b6102ba565b6040516101119190610bbe565b61014d610148366004610a85565b6104ef565b604051610111929190610bd8565b34801561016757600080fd5b50437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0140610107565b34801561019d57600080fd5b5046610107565b6101b76101b2366004610c60565b610690565b60405161011193929190610cba565b3480156101d257600080fd5b5048610107565b3480156101e557600080fd5b5043610107565b3480156101f857600080fd5b50610107610207366004610ce2565b73ffffffffffffffffffffffffffffffffffffffff163190565b34801561022d57600080fd5b5044610107565b61012d610242366004610a85565b6106ab565b34801561025357600080fd5b5045610107565b34801561026657600080fd5b50604051418152602001610111565b61012d610283366004610c60565b61085a565b6101b7610296366004610a85565b610a1a565b3480156102a757600080fd5b506101076102b6366004610d18565b4090565b60606000828067ffffffffffffffff8111156102d8576102d8610d31565b60405190808252806020026020018201604052801561031e57816020015b6040805180820190915260008152606060208201528152602001906001900390816102f65790505b5092503660005b8281101561047757600085828151811061034157610341610d60565b6020026020010151905087878381811061035d5761035d610d60565b905060200281019061036f9190610d8f565b6040810135958601959093506103886020850185610ce2565b73ffffffffffffffffffffffffffffffffffffffff16816103ac6060870187610dcd565b6040516103ba929190610e32565b60006040518083038185875af1925050503d80600081146103f7576040519150601f19603f3d011682016040523d82523d6000602084013e6103fc565b606091505b50602080850191909152901515808452908501351761046d577f08c379a000000000000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060445260846000fd5b5050600101610325565b508234146104e6576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f4d756c746963616c6c333a2076616c7565206d69736d6174636800000000000060448201526064015b60405180910390fd5b50505092915050565b436060828067ffffffffffffffff81111561050c5761050c610d31565b60405190808252806020026020018201604052801561053f57816020015b606081526020019060019003908161052a5790505b5091503660005b8281101561068657600087878381811061056257610562610d60565b90506020028101906105749190610e42565b92506105836020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166105a66020850185610dcd565b6040516105b4929190610e32565b6000604051808303816000865af19150503d80600081146105f1576040519150601f19603f3d011682016040523d82523d6000602084013e6105f6565b606091505b5086848151811061060957610609610d60565b602090810291909101015290508061067d576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060448201526064016104dd565b50600101610546565b5050509250929050565b43804060606106a086868661085a565b905093509350939050565b6060818067ffffffffffffffff8111156106c7576106c7610d31565b60405190808252806020026020018201604052801561070d57816020015b6040805180820190915260008152606060208201528152602001906001900390816106e55790505b5091503660005b828110156104e657600084828151811061073057610730610d60565b6020026020010151905086868381811061074c5761074c610d60565b905060200281019061075e9190610e76565b925061076d6020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166107906040850185610dcd565b60405161079e929190610e32565b6000604051808303816000865af19150503d80600081146107db576040519150601f19603f3d011682016040523d82523d6000602084013e6107e0565b606091505b506020808401919091529015158083529084013517610851577f08c379a000000000000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060445260646000fd5b50600101610714565b6060818067ffffffffffffffff81111561087657610876610d31565b6040519080825280602002602001820160405280156108bc57816020015b6040805180820190915260008152606060208201528152602001906001900390816108945790505b5091503660005b82811015610a105760008482815181106108df576108df610d60565b602002602001015190508686838181106108fb576108fb610d60565b905060200281019061090d9190610e42565b925061091c6020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff1661093f6020850185610dcd565b60405161094d929190610e32565b6000604051808303816000865af19150503d806000811461098a576040519150601f19603f3d011682016040523d82523d6000602084013e61098f565b606091505b506020830152151581528715610a07578051610a07576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060448201526064016104dd565b506001016108c3565b5050509392505050565b6000806060610a2b60018686610690565b919790965090945092505050565b60008083601f840112610a4b57600080fd5b50813567ffffffffffffffff811115610a6357600080fd5b6020830191508360208260051b8501011115610a7e57600080fd5b9250929050565b60008060208385031215610a9857600080fd5b823567ffffffffffffffff811115610aaf57600080fd5b610abb85828601610a39565b90969095509350505050565b6000815180845260005b81811015610aed57602081850181015186830182015201610ad1565b81811115610aff576000602083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0169290920160200192915050565b600082825180855260208086019550808260051b84010181860160005b84811015610bb1578583037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe001895281518051151584528401516040858501819052610b9d81860183610ac7565b9a86019a9450505090830190600101610b4f565b5090979650505050505050565b602081526000610bd16020830184610b32565b9392505050565b600060408201848352602060408185015281855180845260608601915060608160051b870101935082870160005b82811015610c52577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa0888703018452610c40868351610ac7565b95509284019290840190600101610c06565b509398975050505050505050565b600080600060408486031215610c7557600080fd5b83358015158114610c8557600080fd5b9250602084013567ffffffffffffffff811115610ca157600080fd5b610cad86828701610a39565b9497909650939450505050565b838152826020820152606060408201526000610cd96060830184610b32565b95945050505050565b600060208284031215610cf457600080fd5b813573ffffffffffffffffffffffffffffffffffffffff81168114610bd157600080fd5b600060208284031215610d2a57600080fd5b5035919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff81833603018112610dc357600080fd5b9190910192915050565b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe1843603018112610e0257600080fd5b83018035915067ffffffffffffffff821115610e1d57600080fd5b602001915036819003821315610a7e57600080fd5b8183823760009101908152919050565b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc1833603018112610dc357600080fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa1833603018112610dc357600080fdfea2646970667358221220bb2b5c71a328032f97c676ae39a1ec2148d3e5d6f73d95e9b17910152d61f16264736f6c634300080c0033";
+}
diff --git a/contracts/src/libraries/Proxy.sol b/contracts/src/libraries/Proxy.sol
new file mode 100644
index 0000000..b54230a
--- /dev/null
+++ b/contracts/src/libraries/Proxy.sol
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.24;
+
+import { Constants } from "./Constants.sol";
+
+/// @title Proxy
+/// @notice Proxy is a transparent proxy that passes through the call if the caller is the owner or
+///         if the caller is address(0), meaning that the call originated from an off-chain
+///         simulation.
+contract Proxy {
+    /// @notice An event that is emitted each time the implementation is changed. This event is part
+    ///         of the EIP-1967 specification.
+    /// @param implementation The address of the implementation contract
+    event Upgraded(address indexed implementation);
+
+    /// @notice An event that is emitted each time the owner is upgraded. This event is part of the
+    ///         EIP-1967 specification.
+    /// @param previousAdmin The previous owner of the contract
+    /// @param newAdmin      The new owner of the contract
+    event AdminChanged(address previousAdmin, address newAdmin);
+
+    /// @notice A modifier that reverts if not called by the owner or by address(0) to allow
+    ///         eth_call to interact with this proxy without needing to use low-level storage
+    ///         inspection. We assume that nobody is able to trigger calls from address(0) during
+    ///         normal EVM execution.
+    modifier proxyCallIfNotAdmin() {
+        if (msg.sender == _getAdmin() || msg.sender == address(0)) {
+            _;
+        } else {
+            // This WILL halt the call frame on completion.
+            _doProxyCall();
+        }
+    }
+
+    /// @notice Sets the initial admin during contract deployment. Admin address is stored at the
+    ///         EIP-1967 admin storage slot so that accidental storage collision with the
+    ///         implementation is not possible.
+    /// @param _admin Address of the initial contract admin. Admin has the ability to access the
+    ///               transparent proxy interface.
+    constructor(address _admin) {
+        _changeAdmin(_admin);
+    }
+
+    // slither-disable-next-line locked-ether
+    fallback() external {
+        // Proxy call by default.
+        _doProxyCall();
+    }
+
+    /// @notice Set the implementation contract address. The code at the given address will execute
+    ///         when this contract is called.
+    /// @param _implementation Address of the implementation contract.
+    function upgradeTo(address _implementation) public virtual proxyCallIfNotAdmin {
+        _setImplementation(_implementation);
+    }
+
+    /// @notice Set the implementation and call a function in a single transaction. Useful to ensure
+    ///         atomic execution of initialization-based upgrades.
+    /// @param _implementation Address of the implementation contract.
+    /// @param _data           Calldata to delegatecall the new implementation with.
+    function upgradeToAndCall(
+        address _implementation,
+        bytes calldata _data
+    )
+        public
+        virtual
+        proxyCallIfNotAdmin
+        returns (bytes memory)
+    {
+        _setImplementation(_implementation);
+        (bool success, bytes memory returndata) = _implementation.delegatecall(_data);
+        require(success, "Proxy: delegatecall to new implementation contract failed");
+        return returndata;
+    }
+
+    /// @notice Changes the owner of the proxy contract. Only callable by the owner.
+    /// @param _admin New owner of the proxy contract.
+    function changeAdmin(address _admin) public virtual proxyCallIfNotAdmin {
+        _changeAdmin(_admin);
+    }
+
+    /// @notice Gets the owner of the proxy contract.
+    /// @return Owner address.
+    function admin() public virtual proxyCallIfNotAdmin returns (address) {
+        return _getAdmin();
+    }
+
+    //// @notice Queries the implementation address.
+    /// @return Implementation address.
+    function implementation() public virtual proxyCallIfNotAdmin returns (address) {
+        return _getImplementation();
+    }
+
+    /// @notice Sets the implementation address.
+    /// @param _implementation New implementation address.
+    function _setImplementation(address _implementation) internal {
+        bytes32 proxyImplementation = Constants.PROXY_IMPLEMENTATION_ADDRESS;
+        assembly {
+            sstore(proxyImplementation, _implementation)
+        }
+        emit Upgraded(_implementation);
+    }
+
+    /// @notice Changes the owner of the proxy contract.
+    /// @param _admin New owner of the proxy contract.
+    function _changeAdmin(address _admin) internal {
+        address previous = _getAdmin();
+        bytes32 proxyOwner = Constants.PROXY_OWNER_ADDRESS;
+        assembly {
+            sstore(proxyOwner, _admin)
+        }
+        emit AdminChanged(previous, _admin);
+    }
+
+    /// @notice Performs the proxy call via a delegatecall.
+    function _doProxyCall() internal {
+        address impl = _getImplementation();
+        require(impl != address(0), "Proxy: implementation not initialized");
+
+        assembly {
+            // Copy calldata into memory at 0x0....calldatasize.
+            calldatacopy(0x0, 0x0, calldatasize())
+
+            // Perform the delegatecall, make sure to pass all available gas.
+            let success := delegatecall(gas(), impl, 0x0, calldatasize(), 0x0, 0x0)
+
+            // Copy returndata into memory at 0x0....returndatasize. Note that this *will*
+            // overwrite the calldata that we just copied into memory but that doesn't really
+            // matter because we'll be returning in a second anyway.
+            returndatacopy(0x0, 0x0, returndatasize())
+
+            // Success == 0 means a revert. We'll revert too and pass the data up.
+            if iszero(success) { revert(0x0, returndatasize()) }
+
+            // Otherwise we'll just return and pass the data up.
+            return(0x0, returndatasize())
+        }
+    }
+
+    /// @notice Queries the implementation address.
+    /// @return Implementation address.
+    function _getImplementation() internal view returns (address) {
+        address impl;
+        bytes32 proxyImplementation = Constants.PROXY_IMPLEMENTATION_ADDRESS;
+        assembly {
+            impl := sload(proxyImplementation)
+        }
+        return impl;
+    }
+
+    /// @notice Queries the owner of the proxy contract.
+    /// @return Owner address.
+    function _getAdmin() internal view returns (address) {
+        address owner;
+        bytes32 proxyOwner = Constants.PROXY_OWNER_ADDRESS;
+        assembly {
+            owner := sload(proxyOwner)
+        }
+        return owner;
+    }
+}
diff --git a/contracts/src/libraries/SSTORE2Unlimited.sol b/contracts/src/libraries/SSTORE2Unlimited.sol
new file mode 100644
index 0000000..1e67f32
--- /dev/null
+++ b/contracts/src/libraries/SSTORE2Unlimited.sol
@@ -0,0 +1,96 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.4;
+
+/// @notice Read and write to persistent storage at a fraction of the cost.
+/// @notice Modified to support unlimited content size (up to 4GB) using PUSH4
+/// @author Modified from Solady (https://github.com/vectorized/solady/blob/main/src/utils/SSTORE2.sol)
+library SSTORE2Unlimited {
+
+
+    /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
+    /*                        CUSTOM ERRORS                       */
+    /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
+
+    /// @dev Unable to deploy the storage contract.
+    error DeploymentFailed();
+
+    /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
+    /*                         WRITE LOGIC                        */
+    /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
+
+    /// @dev Writes `data` into the bytecode of a storage contract and returns its address.
+    /// Uses a simpler approach with abi.encodePacked for clarity
+    function write(bytes memory data) internal returns (address pointer) {
+        // Prefix the bytecode with a STOP opcode to ensure it cannot be called.
+        bytes memory runtimeCode = abi.encodePacked(hex"00", data);
+
+        bytes memory creationCode = abi.encodePacked(
+            //---------------------------------------------------------------------------------------------------------------//
+            // Opcode  | Opcode + Arguments  | Description  | Stack View                                                     //
+            //---------------------------------------------------------------------------------------------------------------//
+            // 0x60    |  0x600B             | PUSH1 11     | codeOffset                                                     //
+            // 0x59    |  0x59               | MSIZE        | 0 codeOffset                                                   //
+            // 0x81    |  0x81               | DUP2         | codeOffset 0 codeOffset                                        //
+            // 0x38    |  0x38               | CODESIZE     | codeSize codeOffset 0 codeOffset                               //
+            // 0x03    |  0x03               | SUB          | (codeSize - codeOffset) 0 codeOffset                           //
+            // 0x80    |  0x80               | DUP          | (codeSize - codeOffset) (codeSize - codeOffset) 0 codeOffset   //
+            // 0x92    |  0x92               | SWAP3        | codeOffset (codeSize - codeOffset) 0 (codeSize - codeOffset)   //
+            // 0x59    |  0x59               | MSIZE        | 0 codeOffset (codeSize - codeOffset) 0 (codeSize - codeOffset) //
+            // 0x39    |  0x39               | CODECOPY     | 0 (codeSize - codeOffset)                                      //
+            // 0xf3    |  0xf3               | RETURN       |                                                                //
+            //---------------------------------------------------------------------------------------------------------------//
+            hex"60_0B_59_81_38_03_80_92_59_39_F3", // Returns all code in the contract except for the first 11 (0B in hex) bytes.
+            runtimeCode // The bytecode we want the contract to have after deployment.
+        );
+
+        /// @solidity memory-safe-assembly
+        assembly {
+            // Deploy a new contract with the generated creation code.
+            // We start 32 bytes into the code to avoid copying the byte length.
+            pointer := create(0, add(creationCode, 32), mload(creationCode))
+
+            if iszero(pointer) {
+                mstore(0x00, 0x30116425) // `DeploymentFailed()`.
+                revert(0x1c, 0x04)
+            }
+        }
+    }
+
+
+    /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
+    /*                         READ LOGIC                         */
+    /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
+
+    /// @dev The offset of the data in the bytecode (skipping the STOP opcode).
+    uint256 private constant DATA_OFFSET = 1;
+
+    /// @dev Reads all data from the storage contract, skipping the initial STOP opcode.
+    function read(address pointer) internal view returns (bytes memory) {
+        return readBytecode(pointer, DATA_OFFSET, pointer.code.length - DATA_OFFSET);
+    }
+
+    /// @dev Reads bytecode from a contract at a specific offset and size.
+    function readBytecode(
+        address pointer,
+        uint256 start,
+        uint256 size
+    ) private view returns (bytes memory data) {
+        /// @solidity memory-safe-assembly
+        assembly {
+            // Get a pointer to some free memory.
+            data := mload(0x40)
+
+            // Update the free memory pointer to prevent overriding our data.
+            // We use and(x, not(31)) as a cheaper equivalent to sub(x, mod(x, 32)).
+            // Adding 31 to size and running the result through the logic above ensures
+            // the memory pointer remains word-aligned, following the Solidity convention.
+            mstore(0x40, add(data, and(add(add(size, 32), 31), not(31))))
+
+            // Store the size of the data in the first 32 byte chunk of free memory.
+            mstore(data, size)
+
+            // Copy the code into memory right after the 32 bytes we used to store the size.
+            extcodecopy(pointer, add(data, 32), start, size)
+        }
+    }
+}
diff --git a/contracts/test/AddressPrediction.t.sol b/contracts/test/AddressPrediction.t.sol
new file mode 100644
index 0000000..95d1911
--- /dev/null
+++ b/contracts/test/AddressPrediction.t.sol
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "forge-std/Test.sol";
+import "../src/libraries/Predeploys.sol";
+import "../src/libraries/Proxy.sol";
+import "../src/ERC20FixedDenominationManager.sol";
+import "../src/ERC721EthscriptionsCollectionManager.sol";
+import "../src/Ethscriptions.sol";
+import "@openzeppelin/contracts/utils/Create2.sol";
+import "./TestSetup.sol";
+
+contract AddressPredictionTest is TestSetup {
+    // Test predictable address for ERC20FixedDenominationManager token proxies
+    function testPredictERC20FixedDenominationTokenAddress() public {
+        // Arrange
+        string memory tick = "eths";
+        bytes32 deployTxHash = keccak256("deploy-eths");
+
+        // Prepare deploy op data
+        ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({
+            tick: tick,
+            maxSupply: 1_000_000,
+            mintAmount: 1_000
+        });
+        bytes memory data = abi.encode(deployOp);
+
+        // Prediction via contract helper
+        address predicted = fixedDenominationManager.predictTokenAddressByTick(tick);
+
+        // Act: call deploy as Ethscriptions (authorized)
+        vm.prank(Predeploys.ETHSCRIPTIONS);
+        fixedDenominationManager.op_deploy(deployTxHash, data);
+
+        // Assert actual matches predicted
+        address actual = fixedDenominationManager.getTokenAddressByTick(tick);
+        assertEq(actual, predicted, "Predicted token address should match actual deployed proxy");
+    }
+
+    // Test predictable address for ERC721EthscriptionsCollectionManager collection proxies
+    function testPredictCollectionsAddress() public {
+        // Arrange
+        bytes32 collectionId = keccak256("collection-1");
+        address creator = makeAddr("creator");
+
+        // First, create the ethscription that will represent this collection
+        Ethscriptions.CreateEthscriptionParams memory ethscriptionParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: collectionId,
+            contentUriSha: keccak256("collection-content"),
+            initialOwner: creator,
+            content: bytes("collection-content"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "",
+                operation: "",
+                data: ""
+            })
+        });
+
+        vm.prank(creator);
+        ethscriptions.createEthscription(ethscriptionParams);
+
+        ERC721EthscriptionsCollectionManager.CollectionParams memory metadata =
+            ERC721EthscriptionsCollectionManager.CollectionParams({
+                name: "My Collection",
+                symbol: "MYC",
+                maxSupply: 1000,
+                description: "A test collection",
+                logoImageUri: "data:,logo",
+                bannerImageUri: "data:,banner",
+                backgroundColor: "#000000",
+                websiteLink: "https://example.com",
+                twitterLink: "",
+                discordLink: "",
+                merkleRoot: bytes32(0),
+                initialOwner: address(this)  // Use test contract as owner
+            });
+
+        // Manually compute predicted proxy address
+        bytes memory creationCode = abi.encodePacked(type(Proxy).creationCode, abi.encode(address(collectionsHandler)));
+        address predicted = Create2.computeAddress(collectionId, keccak256(creationCode), address(collectionsHandler));
+
+        // Act: create collection as Ethscriptions (authorized)
+        vm.prank(Predeploys.ETHSCRIPTIONS);
+        collectionsHandler.op_create_collection(collectionId, abi.encode(metadata));
+
+        // Assert deployed matches predicted
+        ERC721EthscriptionsCollectionManager.CollectionMetadata memory collection =
+            collectionsHandler.getCollection(collectionId);
+        address actual = collection.collectionContract;
+        assertEq(actual, predicted, "Predicted collection address should match actual deployed proxy");
+    }
+}
diff --git a/contracts/test/BytePackLib.t.sol b/contracts/test/BytePackLib.t.sol
new file mode 100644
index 0000000..41de402
--- /dev/null
+++ b/contracts/test/BytePackLib.t.sol
@@ -0,0 +1,203 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "forge-std/Test.sol";
+import "forge-std/console2.sol";
+import "../src/libraries/BytePackLib.sol";
+
+/// @title TestWrapper
+/// @notice Wrapper contract to expose internal library functions for testing
+contract TestWrapper {
+    function packCalldata(bytes calldata data) external pure returns (bytes32) {
+        return BytePackLib.packCalldata(data);
+    }
+
+    function unpack(bytes32 packed) external pure returns (bytes memory) {
+        return BytePackLib.unpack(packed);
+    }
+
+    function isPacked(bytes32 value) external pure returns (bool) {
+        return BytePackLib.isPacked(value);
+    }
+
+    function packedLength(bytes32 packed) external pure returns (uint256) {
+        return BytePackLib.packedLength(packed);
+    }
+}
+
+contract BytePackLibTest is Test {
+    TestWrapper wrapper;
+
+    function setUp() public {
+        wrapper = new TestWrapper();
+    }
+
+    /// @notice Test packing and unpacking for all valid sizes (0-31 bytes)
+    function test_AllValidSizes() public {
+        for (uint256 i = 0; i <= 31; i++) {
+            // Test with incrementing pattern
+            bytes memory data = new bytes(i);
+            for (uint256 j = 0; j < i; j++) {
+                data[j] = bytes1(uint8(j % 256));
+            }
+            this.helperPackUnpack(data);
+
+            // Also test with all zeros
+            bytes memory zeros = new bytes(i);
+            this.helperPackUnpack(zeros);
+
+            // Also test with all 0xFF
+            bytes memory ones = new bytes(i);
+            for (uint256 j = 0; j < i; j++) {
+                ones[j] = bytes1(0xFF);
+            }
+            this.helperPackUnpack(ones);
+        }
+    }
+
+    function helperPackUnpack(bytes calldata data) external view {
+        require(data.length < 32, "Data must be less than 32 bytes");
+
+        bytes32 packed = wrapper.packCalldata(data);
+
+        // Verify it's marked as packed
+        assertTrue(wrapper.isPacked(packed), "Should be marked as packed");
+
+        // Verify the length is correct
+        assertEq(wrapper.packedLength(packed), data.length, "Length should match");
+
+        // Verify unpacking gives back the original data
+        bytes memory unpacked = wrapper.unpack(packed);
+        assertEq(unpacked, data, "Unpacked data should match original");
+
+        // Verify the tag byte is correct (length + 1)
+        uint8 tag = uint8(uint256(packed >> 248));
+        assertEq(tag, data.length + 1, "Tag should be length + 1");
+    }
+
+    /// @notice Test that packing 32+ bytes reverts
+    function test_PackingTooLarge_Reverts() public {
+        for (uint256 size = 32; size <= 40; size++) {
+            bytes memory data = new bytes(size);
+
+            vm.expectRevert(abi.encodeWithSelector(BytePackLib.ContentTooLarge.selector, size));
+            this.helperPackLarge(data);
+        }
+    }
+
+    function helperPackLarge(bytes calldata data) external view {
+        wrapper.packCalldata(data);
+    }
+
+    /// @notice Test that unpacking non-packed data reverts
+    function test_UnpackNonPacked_Reverts() public {
+        // Test with zero bytes32 (tag = 0)
+        bytes32 zero = bytes32(0);
+        assertFalse(wrapper.isPacked(zero), "Zero should not be packed");
+        vm.expectRevert(BytePackLib.NotPackedData.selector);
+        wrapper.unpack(zero);
+
+        // Test with an address-like value (no tag byte)
+        bytes32 addressLike = bytes32(uint256(uint160(address(0x1234567890123456789012345678901234567890))));
+        assertFalse(wrapper.isPacked(addressLike), "Address should not be packed");
+        vm.expectRevert(BytePackLib.NotPackedData.selector);
+        wrapper.unpack(addressLike);
+
+        // Test with tag byte > 32 (e.g., 0x21 = 33)
+        bytes32 invalidTag = bytes32(uint256(0x21) << 248);
+        assertFalse(wrapper.isPacked(invalidTag), "Tag > 32 should not be packed");
+        vm.expectRevert(BytePackLib.NotPackedData.selector);
+        wrapper.unpack(invalidTag);
+
+        // Test with tag byte = 255 (maximum uint8)
+        bytes32 maxTag = bytes32(uint256(0xFF) << 248);
+        assertFalse(wrapper.isPacked(maxTag), "Tag = 255 should not be packed");
+        vm.expectRevert(BytePackLib.NotPackedData.selector);
+        wrapper.unpack(maxTag);
+
+        // Test getting length of non-packed data
+        vm.expectRevert(BytePackLib.NotPackedData.selector);
+        wrapper.packedLength(zero);
+
+        vm.expectRevert(BytePackLib.NotPackedData.selector);
+        wrapper.packedLength(addressLike);
+
+        vm.expectRevert(BytePackLib.NotPackedData.selector);
+        wrapper.packedLength(invalidTag);
+
+        vm.expectRevert(BytePackLib.NotPackedData.selector);
+        wrapper.packedLength(maxTag);
+    }
+
+    /// @notice Test isPacked detection
+    function test_IsPacked_Detection() public {
+        // Pack some data and verify detection
+        bytes memory testData = hex"74657374"; // "test"
+        bytes32 packed = wrapper.packCalldata(testData);
+        assertTrue(wrapper.isPacked(packed), "Packed data should be detected");
+
+        // Regular addresses should not be detected as packed
+        address addr = address(0x1234567890123456789012345678901234567890);
+        bytes32 addrBytes = bytes32(uint256(uint160(addr)));
+        assertFalse(wrapper.isPacked(addrBytes), "Address should not be detected as packed");
+
+        // Zero should not be detected as packed
+        assertFalse(wrapper.isPacked(bytes32(0)), "Zero should not be detected as packed");
+
+        // Random data without tag byte (first byte is 0x00) should not be detected as packed
+        bytes32 randomData = bytes32(uint256(0x00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff));
+        assertFalse(wrapper.isPacked(randomData), "Random data should not be detected as packed");
+    }
+
+    /// @notice Test the exact packed format
+    function test_PackedFormat() public {
+        // Test empty bytes
+        bytes memory empty = "";
+        bytes32 packedEmpty = wrapper.packCalldata(empty);
+        assertEq(uint256(packedEmpty), uint256(bytes32(bytes1(0x01))), "Empty should pack to 0x01 followed by zeros");
+
+        // Test single byte 'A' (0x41)
+        bytes memory single = hex"41";
+        bytes32 packedSingle = wrapper.packCalldata(single);
+        // Should be: tag=0x02, data=0x41, rest zeros
+        assertEq(uint8(uint256(packedSingle >> 248)), 0x02, "Tag for 1 byte should be 2");
+        assertEq(uint8(uint256(packedSingle >> 240)), 0x41, "Data should be 0x41");
+
+        // Test "ABC" (0x414243)
+        bytes memory abc = hex"414243";
+        bytes32 packedAbc = wrapper.packCalldata(abc);
+        // Should be: tag=0x04, data=0x414243, rest zeros
+        assertEq(uint8(uint256(packedAbc >> 248)), 0x04, "Tag for 3 bytes should be 4");
+        assertEq(uint8(uint256(packedAbc >> 240)), 0x41, "First byte should be 0x41");
+        assertEq(uint8(uint256(packedAbc >> 232)), 0x42, "Second byte should be 0x42");
+        assertEq(uint8(uint256(packedAbc >> 224)), 0x43, "Third byte should be 0x43");
+    }
+
+    /// @notice Test edge cases
+    function test_EdgeCases() public {
+        // Test 31 bytes (maximum packable)
+        bytes memory max = new bytes(31);
+        for (uint i = 0; i < 31; i++) {
+            max[i] = bytes1(uint8(i));
+        }
+
+        bytes32 packed = wrapper.packCalldata(max);
+        assertTrue(wrapper.isPacked(packed), "31 bytes should be packed");
+        assertEq(wrapper.packedLength(packed), 31, "Length should be 31");
+
+        bytes memory unpacked = wrapper.unpack(packed);
+        assertEq(unpacked, max, "31 bytes should unpack correctly");
+
+        // Verify tag is 32 (31 + 1) - the maximum valid tag
+        assertEq(uint8(uint256(packed >> 248)), 32, "Tag for 31 bytes should be 32");
+
+        // Manually create a bytes32 with tag = 32 (boundary case) and verify it's valid
+        bytes32 dataBytes;
+        assembly {
+            dataBytes := mload(add(max, 0x20))
+        }
+        bytes32 boundaryTag = bytes32(uint256(32) << 248) | (dataBytes >> 8);
+        assertTrue(wrapper.isPacked(boundaryTag), "Tag = 32 should be valid packed data");
+        assertEq(wrapper.packedLength(boundaryTag), 31, "Tag = 32 means 31 bytes of data");
+    }
+}
\ No newline at end of file
diff --git a/contracts/test/CollectionURIResolution.t.sol b/contracts/test/CollectionURIResolution.t.sol
new file mode 100644
index 0000000..2a6ee99
--- /dev/null
+++ b/contracts/test/CollectionURIResolution.t.sol
@@ -0,0 +1,201 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+import {LibString} from "solady/utils/LibString.sol";
+import {Base64} from "solady/utils/Base64.sol";
+
+contract CollectionURIResolutionTest is TestSetup {
+    using LibString for *;
+    bytes32 constant COLLECTION_TX_HASH = keccak256("collection_uri_test");
+    bytes32 constant IMAGE_ETSC_TX_HASH = keccak256("image_ethscription");
+
+    address alice = makeAddr("alice");
+
+    function setUp() public override {
+        super.setUp();
+    }
+
+    function test_RegularHTTPURIPassesThrough() public {
+        // Create collection with regular HTTP URI
+        string memory regularUri = "https://example.com/logo.png";
+
+        bytes32 collectionId = _createCollectionWithLogo(regularUri);
+
+        // Get collection metadata
+        ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata =
+            collectionsHandler.getCollection(collectionId);
+
+        assertEq(metadata.logoImageUri, regularUri, "Should preserve regular URI");
+
+        // contractURI should also pass it through
+        address collectionAddr = metadata.collectionContract;
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr);
+        string memory contractUri = collection.contractURI();
+
+        assertTrue(bytes(contractUri).length > 0, "Should have contractURI");
+
+        // contractURI returns base64-encoded JSON, decode it
+        // Check it starts with data URI prefix
+        string memory prefix = "data:application/json;base64,";
+        assertTrue(contractUri.startsWith(prefix), "Should be a data URI");
+
+        // Extract and decode the base64 part
+        string memory base64Part = contractUri.slice(bytes(prefix).length);
+        bytes memory decodedBytes = Base64.decode(base64Part);
+        string memory decodedJson = string(decodedBytes);
+
+        assertTrue(decodedJson.contains(regularUri), "Should contain original URI");
+    }
+
+    function test_DataURIPassesThrough() public {
+        // Create collection with data URI
+        string memory dataUri = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iMTAiPjwvc3ZnPg==";
+
+        bytes32 collectionId = _createCollectionWithLogo(dataUri);
+
+        ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata =
+            collectionsHandler.getCollection(collectionId);
+
+        assertEq(metadata.logoImageUri, dataUri, "Should preserve data URI");
+    }
+
+    function test_EthscriptionReferenceResolvesToMediaURI() public {
+        // First create an ethscription with image content
+        string memory imageContent = "data:image/png;base64,iVBORw0KGgo=";
+
+        Ethscriptions.CreateEthscriptionParams memory imageParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: IMAGE_ETSC_TX_HASH,
+            contentUriSha: sha256(bytes(imageContent)),
+            initialOwner: alice,
+            content: bytes(imageContent),
+            mimetype: "image/png",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "",
+                operation: "",
+                data: ""
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(imageParams);
+
+        // Create collection with esc:// reference to the image
+        string memory escUri = string.concat(
+            "esc://ethscriptions/",
+            uint256(IMAGE_ETSC_TX_HASH).toHexString(32),
+            "/data"
+        );
+
+        bytes32 collectionId = _createCollectionWithLogo(escUri);
+
+        // Get collection and check contractURI resolves the reference
+        ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata =
+            collectionsHandler.getCollection(collectionId);
+
+        // Stored value should be the esc:// URI
+        assertEq(metadata.logoImageUri, escUri, "Should store esc:// URI");
+
+        // contractURI should resolve it to the media URI
+        address collectionAddr = metadata.collectionContract;
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr);
+        string memory contractUri = collection.contractURI();
+
+        // Should contain a data URI (resolved from the referenced ethscription)
+        assertTrue(contractUri.contains("data:"), "Should contain resolved data URI");
+    }
+
+    function test_InvalidEthscriptionReferenceReturnsEmpty() public {
+        // Reference to non-existent ethscription
+        bytes32 fakeId = keccak256("nonexistent");
+        string memory escUri = string.concat(
+            "esc://ethscriptions/",
+            uint256(fakeId).toHexString(32),
+            "/data"
+        );
+
+        bytes32 collectionId = _createCollectionWithLogo(escUri);
+
+        ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata =
+            collectionsHandler.getCollection(collectionId);
+        address collectionAddr = metadata.collectionContract;
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr);
+
+        // Should not revert, just return empty/placeholder
+        string memory contractUri = collection.contractURI();
+        assertTrue(bytes(contractUri).length > 0, "Should return contractURI without reverting");
+    }
+
+    function test_MalformedEscURIReturnsEmpty() public {
+        // Various malformed esc:// URIs
+        string[] memory badUris = new string[](4);
+        badUris[0] = "esc://ethscriptions/notahexid/data";
+        badUris[1] = "esc://ethscriptions/0x123/data";  // Too short
+        badUris[2] = "esc://ethscriptions/";  // Incomplete
+        badUris[3] = "esc://wrong/0x1234567890123456789012345678901234567890123456789012345678901234/data";
+
+        for (uint i = 0; i < badUris.length; i++) {
+            // Use unique collection ID for each iteration
+            bytes32 uniqueCollectionId = keccak256(abi.encodePacked("malformed_test", i));
+            bytes32 collectionId = _createCollectionWithLogoAndId(badUris[i], uniqueCollectionId);
+
+            ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata =
+                collectionsHandler.getCollection(collectionId);
+            address collectionAddr = metadata.collectionContract;
+            ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr);
+
+            // Should not revert
+            string memory contractUri = collection.contractURI();
+            assertTrue(bytes(contractUri).length > 0, "Should return contractURI without reverting");
+        }
+    }
+
+    // -------------------- Helpers --------------------
+
+    function _createCollectionWithLogo(string memory logoUri) private returns (bytes32) {
+        return _createCollectionWithLogoAndId(logoUri, COLLECTION_TX_HASH);
+    }
+
+    function _createCollectionWithLogoAndId(string memory logoUri, bytes32 collectionId) private returns (bytes32) {
+        ERC721EthscriptionsCollectionManager.CollectionParams memory metadata =
+            ERC721EthscriptionsCollectionManager.CollectionParams({
+                name: "Test Collection",
+                symbol: "TEST",
+                maxSupply: 100,
+                description: "Test collection",
+                logoImageUri: logoUri,
+                bannerImageUri: "",
+                backgroundColor: "",
+                websiteLink: "",
+                twitterLink: "",
+                discordLink: "",
+                merkleRoot: bytes32(0),
+                initialOwner: alice  // Use alice as owner
+            });
+
+        string memory collectionContent = string.concat(
+            'data:application/json,',
+            '{"p":"erc-721-ethscriptions-collection","op":"create_collection"}'
+        );
+
+        Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: collectionId,
+            contentUriSha: sha256(bytes(collectionContent)),
+            initialOwner: alice,
+            content: bytes(collectionContent),
+            mimetype: "application/json",
+            esip6: true,  // Allow duplicate content URI
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "create_collection",
+                data: abi.encode(metadata)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(params);
+
+        return collectionId;
+    }
+}
diff --git a/contracts/test/CollectionsManager.t.sol b/contracts/test/CollectionsManager.t.sol
new file mode 100644
index 0000000..10345a2
--- /dev/null
+++ b/contracts/test/CollectionsManager.t.sol
@@ -0,0 +1,1434 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+import "../src/ERC721EthscriptionsCollectionManager.sol";
+import "../src/ERC721EthscriptionsCollection.sol";
+import "../src/libraries/Constants.sol";
+import {LibString} from "solady/utils/LibString.sol";
+
+contract ERC721EthscriptionsCollectionManagerTest is TestSetup {
+    using LibString for *;
+    address alice = address(0xa11ce);
+    address bob = address(0xb0b);
+    address charlie = address(0xc0ffee);
+
+    bytes32 constant COLLECTION_TX_HASH = bytes32(uint256(0x1234));
+    bytes32 constant ITEM1_TX_HASH = bytes32(uint256(0x5678));
+    bytes32 constant ITEM2_TX_HASH = bytes32(uint256(0x9ABC));
+    bytes32 constant ITEM3_TX_HASH = bytes32(uint256(0xDEF0));
+
+    function setUp() public override {
+        super.setUp();
+    }
+
+    function testCreateCollection() public {
+        // Create a collection as Alice
+        vm.prank(alice);
+
+        string memory collectionContent = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test Collection","symbol":"TEST","max_supply":"100"}';
+
+        ERC721EthscriptionsCollectionManager.CollectionParams memory metadata =
+            ERC721EthscriptionsCollectionManager.CollectionParams({
+                name: "Test Collection",
+                symbol: "TEST",
+                maxSupply: 100,
+                description: "A test collection for unit tests",
+                logoImageUri: "esc://ethscriptions/0x123/data",
+                bannerImageUri: "esc://ethscriptions/0x456/data",
+                backgroundColor: "#FF5733",
+                websiteLink: "https://example.com",
+                twitterLink: "https://twitter.com/test",
+                discordLink: "https://discord.gg/test",
+                merkleRoot: bytes32(0),
+                initialOwner: alice  // Use alice as owner
+            });
+
+        Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: COLLECTION_TX_HASH,
+            contentUriSha: sha256(bytes(collectionContent)),
+            initialOwner: alice,
+            content: bytes(collectionContent),
+            mimetype: "application/json",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "create_collection",
+                data: abi.encode(metadata)
+            })
+        });
+
+        ethscriptions.createEthscription(params);
+
+        // Verify collection was created
+        address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        assertTrue(collectionAddress != address(0));
+
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+        assertEq(collection.name(), "Test Collection");
+        assertEq(collection.symbol(), "TEST");
+        // Collection owner is tracked through the original ethscription ownership
+
+        // Verify metadata was stored
+        ERC721EthscriptionsCollectionManager.CollectionMetadata memory stored = collectionsHandler.getCollection(COLLECTION_TX_HASH);
+        assertEq(stored.name, "Test Collection");
+        assertEq(stored.symbol, "TEST");
+        assertEq(stored.maxSupply, 100);
+        assertEq(stored.description, "A test collection for unit tests");
+        assertEq(stored.backgroundColor, "#FF5733");
+    }
+
+    function testCreateCollectionAndAddSelf() public {
+        // Create ethscription that both creates a collection and adds itself as the first item
+        bytes32 collectionAndItemId = bytes32(uint256(0xC0FFEE));
+
+        // Prepare collection metadata
+        ERC721EthscriptionsCollectionManager.CollectionParams memory metadata =
+            ERC721EthscriptionsCollectionManager.CollectionParams({
+                name: "Self Collection",
+                symbol: "SELF",
+                maxSupply: 100,
+                description: "A collection where creator is first item",
+                logoImageUri: "esc://ethscriptions/0x123/data",
+                bannerImageUri: "esc://ethscriptions/0x456/data",
+                backgroundColor: "#112233",
+                websiteLink: "https://example.com",
+                twitterLink: "",
+                discordLink: "",
+                merkleRoot: bytes32(0),
+                initialOwner: alice  // Use alice as owner
+            });
+
+        // Prepare item data
+        ERC721EthscriptionsCollectionManager.Attribute[] memory attributes =
+            new ERC721EthscriptionsCollectionManager.Attribute[](2);
+        attributes[0] = ERC721EthscriptionsCollectionManager.Attribute({
+            traitType: "Type",
+            value: "Genesis"
+        });
+        attributes[1] = ERC721EthscriptionsCollectionManager.Attribute({
+            traitType: "Creator",
+            value: "Alice"
+        });
+
+        // Define content for the ethscription
+        bytes memory itemContent = bytes("collection and item content");
+        bytes32 itemContentHash = keccak256(itemContent);
+
+        ERC721EthscriptionsCollectionManager.ItemData memory itemData =
+            ERC721EthscriptionsCollectionManager.ItemData({
+                contentHash: itemContentHash,  // keccak256 of the ethscription content
+                itemIndex: 0,
+                name: "Genesis Item #0",
+                backgroundColor: "#445566",
+                description: "The first item in this collection",
+                attributes: attributes,
+                merkleProof: new bytes32[](0)
+            });
+
+        ERC721EthscriptionsCollectionManager.CreateAndAddSelfParams memory params =
+            ERC721EthscriptionsCollectionManager.CreateAndAddSelfParams({
+                metadata: metadata,
+                item: itemData
+            });
+
+        // Create the ethscription
+        Ethscriptions.CreateEthscriptionParams memory ethscriptionParams =
+            Ethscriptions.CreateEthscriptionParams({
+                ethscriptionId: collectionAndItemId,
+                contentUriSha: sha256(itemContent),
+                initialOwner: alice,
+                content: itemContent,
+                mimetype: "text/plain",
+                esip6: false,
+                protocolParams: Ethscriptions.ProtocolParams({
+                    protocolName: "erc-721-ethscriptions-collection",
+                    operation: "create_collection_and_add_self",
+                    data: abi.encode(params)
+                })
+            });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(ethscriptionParams);
+
+        // Verify collection was created
+        address collectionAddress = collectionsHandler.getCollectionAddress(collectionAndItemId);
+        assertTrue(collectionAddress != address(0), "Collection should be created");
+
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+        assertEq(collection.name(), "Self Collection");
+        assertEq(collection.symbol(), "SELF");
+
+        // Verify item was added as token ID 0
+        assertEq(collection.ownerOf(0), alice);
+
+        // Verify item metadata
+        ERC721EthscriptionsCollectionManager.CollectionItem memory item =
+            collectionsHandler.getCollectionItem(collectionAndItemId, 0);
+        assertEq(item.name, "Genesis Item #0");
+        assertEq(item.description, "The first item in this collection");
+        assertEq(item.backgroundColor, "#445566");
+        assertEq(item.attributes.length, 2);
+        assertEq(item.attributes[0].traitType, "Type");
+        assertEq(item.attributes[0].value, "Genesis");
+    }
+
+    function testAddToCollection() public {
+        // First create a collection
+        testCreateCollection();
+
+        address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+
+        // Create an ethscription that adds itself to the collection at creation time
+        string memory itemContent = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_self","collection":"0x1234","item":"artwork1"}';
+        bytes32 itemContentHash = keccak256(bytes(itemContent));
+
+        // Create item data with attributes
+        ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = new ERC721EthscriptionsCollectionManager.Attribute[](3);
+        attributes[0] = ERC721EthscriptionsCollectionManager.Attribute({
+            traitType: "Type",
+            value: "Artwork"
+        });
+        attributes[1] = ERC721EthscriptionsCollectionManager.Attribute({
+            traitType: "Rarity",
+            value: "Common"
+        });
+        attributes[2] = ERC721EthscriptionsCollectionManager.Attribute({
+            traitType: "Color",
+            value: "Blue"
+        });
+
+        ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({
+            contentHash: itemContentHash,  // keccak256 of the ethscription content
+            itemIndex: 0,
+            name: "Test Item #0",
+            backgroundColor: "#0000FF",
+            description: "First test item",
+            attributes: attributes,
+            merkleProof: new bytes32[](0)
+        });
+
+        ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams =
+            ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({
+                collectionId: COLLECTION_TX_HASH,
+                item: itemData
+            });
+
+        // Create the ethscription with protocol set to add itself to the collection
+        Ethscriptions.CreateEthscriptionParams memory itemParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: ITEM1_TX_HASH,
+            contentUriSha: sha256(bytes(itemContent)),
+            initialOwner: alice,
+            content: bytes(itemContent),
+            mimetype: "application/json",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "add_self_to_collection",
+                data: abi.encode(addSelfParams)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(itemParams);
+
+        // Verify item was added with metadata
+        ERC721EthscriptionsCollectionManager.CollectionItem memory item = collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 0);
+        assertEq(item.name, "Test Item #0");
+        assertEq(item.ethscriptionId, ITEM1_TX_HASH);
+        assertEq(item.backgroundColor, "#0000FF");
+        assertEq(item.description, "First test item");
+        assertEq(item.attributes.length, 3);
+        assertEq(item.attributes[0].traitType, "Type");
+        assertEq(item.attributes[0].value, "Artwork");
+        assertEq(item.attributes[1].traitType, "Rarity");
+        assertEq(item.attributes[1].value, "Common");
+        assertEq(item.attributes[2].traitType, "Color");
+        assertEq(item.attributes[2].value, "Blue");
+
+        // Verify item was added to collection
+        // Token ID is the item index (0 for the first item)
+        uint256 tokenId = 0;
+        assertEq(collection.ownerOf(tokenId), alice);
+        // Verify item is in collection via ERC721EthscriptionsCollectionManager
+        ERC721EthscriptionsCollectionManager.CollectionItem memory item2 = collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, tokenId);
+        assertEq(item2.ethscriptionId, ITEM1_TX_HASH);
+    }
+
+    function testTransferCollectionItem() public {
+        // Setup: Create collection and add item
+        testAddToCollection();
+
+        address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+
+        // Transfer the ethscription NFT
+        vm.prank(alice);
+        ethscriptions.transferEthscription(bob, ITEM1_TX_HASH);
+
+        // Verify ownership synced in collection
+        // Token ID is the item index (0 for the first item)
+        uint256 tokenId = 0;
+        assertEq(collection.ownerOf(tokenId), bob);
+    }
+
+    function testBurnCollectionItem() public {
+        // Setup: Create collection and add item
+        testAddToCollection();
+
+        address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+
+        uint256 tokenId = collectionsHandler.getEthscriptionTokenId(ITEM1_TX_HASH);
+
+        // Burn the ethscription (transfer to address(0))
+        vm.prank(alice);
+        ethscriptions.transferEthscription(address(0), ITEM1_TX_HASH);
+
+        // Verify item is still in collection but owned by address(0)
+        // Token ID is the item index (0 for the first item)
+        assertEq(collection.ownerOf(tokenId), address(0));
+    }
+
+    function testRemoveFromCollection() public {
+        // Setup: Create collection and add item
+        testAddToCollection();
+
+        address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+
+        // Remove item from collection (only collection owner can do this)
+        vm.prank(alice);
+
+        string memory removeContent = 'data:,{"p":"erc-721-ethscriptions-collection","op":"remove","collection":"0x1234","item":"0x5678"}';
+
+        bytes32[] memory itemsToRemove = new bytes32[](1);
+        itemsToRemove[0] = ITEM1_TX_HASH;
+
+        ERC721EthscriptionsCollectionManager.RemoveItemsOperation memory removeOp = ERC721EthscriptionsCollectionManager.RemoveItemsOperation({
+            collectionId: COLLECTION_TX_HASH,
+            ethscriptionIds: itemsToRemove
+        });
+
+        Ethscriptions.CreateEthscriptionParams memory removeParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: bytes32(uint256(0xFEED)),
+            contentUriSha: sha256(bytes(removeContent)),
+            initialOwner: alice,
+            content: bytes(removeContent),
+            mimetype: "application/json",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "remove_items",
+                data: abi.encode(removeOp)
+            })
+        });
+
+        // Check token exists before removal
+        uint256 tokenId = 0;
+        address ownerBefore = collection.ownerOf(tokenId);
+        assertEq(ownerBefore, alice, "Should own token before removal");
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(removeParams);
+
+        // Check membership was removed from manager
+        (bytes32 collId, uint256 tokenIdPlusOne) = collectionsHandler.membershipOfEthscription(ITEM1_TX_HASH);
+        assertEq(collId, bytes32(0), "Collection ID should be zero after removal");
+        assertEq(tokenIdPlusOne, 0, "Token ID plus one should be zero after removal");
+
+        // Verify item was removed - token should no longer exist
+        // This should revert with ERC721NonexistentToken
+        vm.expectRevert(abi.encodeWithSignature("ERC721NonexistentToken(uint256)", tokenId));
+        collection.ownerOf(tokenId);
+    }
+
+    function testOnlyOwnerCanRemove() public {
+        // Setup: Create collection and add item
+        testAddToCollection();
+
+        address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+
+        // Try to remove item as non-owner (should fail silently)
+        vm.prank(bob);
+
+        bytes32[] memory itemsToRemove = new bytes32[](1);
+        itemsToRemove[0] = ITEM1_TX_HASH;
+
+        ERC721EthscriptionsCollectionManager.RemoveItemsOperation memory removeOp = ERC721EthscriptionsCollectionManager.RemoveItemsOperation({
+            collectionId: COLLECTION_TX_HASH,
+            ethscriptionIds: itemsToRemove
+        });
+
+        Ethscriptions.CreateEthscriptionParams memory removeParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: bytes32(uint256(0xBAD)),
+            contentUriSha: sha256(bytes("data:,remove")),
+            initialOwner: bob,
+            content: bytes("remove"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "remove_items",
+                data: abi.encode(removeOp)
+            })
+        });
+
+        vm.prank(bob);
+        ethscriptions.createEthscription(removeParams);
+
+        // Verify item is still in collection (remove failed)
+        // Token ID is the item index (0 for the first item)
+        uint256 tokenId = 0;
+        assertEq(collection.ownerOf(tokenId), alice);
+    }
+
+    function testMultipleItemsInCollection() public {
+        // Create collection
+        testCreateCollection();
+
+        address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+
+        // Add multiple items (each adds itself at creation time)
+        bytes32[3] memory itemHashes = [ITEM1_TX_HASH, ITEM2_TX_HASH, ITEM3_TX_HASH];
+        address[3] memory owners = [alice, bob, charlie];
+
+        for (uint i = 0; i < 3; i++) {
+            // Create item data for self-add
+            ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = new ERC721EthscriptionsCollectionManager.Attribute[](1);
+            attributes[0] = ERC721EthscriptionsCollectionManager.Attribute({
+                traitType: "Type",
+                value: "Test"
+            });
+
+            string memory itemName = i == 0 ? "Item #0" : i == 1 ? "Item #1" : "Item #2";
+            bytes32 itemContentHash = keccak256(abi.encodePacked("item", i));
+
+            ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({
+                contentHash: itemContentHash,
+                itemIndex: uint256(i),
+                name: itemName,
+                backgroundColor: "#000000",
+                description: "Test item",
+                attributes: attributes,
+                merkleProof: new bytes32[](0)
+            });
+
+            ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams =
+                ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({
+                    collectionId: COLLECTION_TX_HASH,
+                    item: itemData
+                });
+
+            // Create ethscription that adds itself to the collection
+            Ethscriptions.CreateEthscriptionParams memory itemParams = Ethscriptions.CreateEthscriptionParams({
+                ethscriptionId: itemHashes[i],
+                contentUriSha: sha256(abi.encodePacked("item", i)),
+                initialOwner: owners[i],
+                content: abi.encodePacked("item", i),
+                mimetype: "text/plain",
+                esip6: false,
+                protocolParams: Ethscriptions.ProtocolParams({
+                    protocolName: "erc-721-ethscriptions-collection",
+                    operation: "add_self_to_collection",
+                    data: abi.encode(addSelfParams)
+                })
+            });
+
+            vm.prank(alice);
+            ethscriptions.createEthscription(itemParams);
+        }
+
+        // Verify all items are in collection with correct owners
+        for (uint i = 0; i < 3; i++) {
+            uint256 tokenId = uint256(i); // Token ID matches the item index
+            assertEq(collection.ownerOf(tokenId), owners[i]);
+        }
+
+        // Collection has 3 items
+    }
+
+    function testTokenURIGeneration() public {
+        // First create a collection with metadata
+        testCreateCollection();
+
+        address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+
+        // Create an ethscription with image content to add
+        vm.prank(alice);
+
+        string memory imageContent = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
+        bytes32 imageContentHash = keccak256(bytes(imageContent));
+
+        // Create item data with attributes
+        ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = new ERC721EthscriptionsCollectionManager.Attribute[](4);
+        attributes[0] = ERC721EthscriptionsCollectionManager.Attribute({
+            traitType: "Type",
+            value: "Female"
+        });
+        attributes[1] = ERC721EthscriptionsCollectionManager.Attribute({
+            traitType: "Hair",
+            value: "Blonde Bob"
+        });
+        attributes[2] = ERC721EthscriptionsCollectionManager.Attribute({
+            traitType: "Eyes",
+            value: "Green Eye Shadow"
+        });
+        attributes[3] = ERC721EthscriptionsCollectionManager.Attribute({
+            traitType: "Rarity",
+            value: "Rare"
+        });
+
+        ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({
+            contentHash: imageContentHash,
+            itemIndex: 0,
+            name: "Ittybit #0000",
+            backgroundColor: "#648595",
+            description: "A rare ittybit with green eye shadow",
+            attributes: attributes,
+            merkleProof: new bytes32[](0)
+        });
+
+        ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams =
+            ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({
+                collectionId: COLLECTION_TX_HASH,
+                item: itemData
+            });
+
+        // Create the ethscription with image content
+        Ethscriptions.CreateEthscriptionParams memory itemParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: ITEM1_TX_HASH,
+            contentUriSha: sha256(bytes(imageContent)),
+            initialOwner: alice,
+            content: bytes(imageContent),
+            mimetype: "image/png",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "add_self_to_collection",
+                data: abi.encode(addSelfParams)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(itemParams);
+
+        // Get the token URI and verify it contains the expected data
+        // Get tokenId from ERC721EthscriptionsCollectionManager (it should be 0)
+        uint256 tokenId = 0;
+        string memory tokenUri = collection.tokenURI(tokenId);
+
+        // The URI should be a base64-encoded JSON data URI
+        assertTrue(bytes(tokenUri).length > 0);
+        // Should start with data:application/json;base64,
+        assertTrue(LibString.startsWith(tokenUri, "data:application/json;base64,"));
+
+        // Verify the item metadata was stored correctly
+        ERC721EthscriptionsCollectionManager.CollectionItem memory item = collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 0);
+        assertEq(item.name, "Ittybit #0000");
+        assertEq(item.backgroundColor, "#648595");
+        assertEq(item.attributes.length, 4);
+        assertEq(item.attributes[1].traitType, "Hair");
+        assertEq(item.attributes[1].value, "Blonde Bob");
+    }
+
+    function testCollectionAddressIsPredictable() public {
+        // Predict the collection address before deployment
+        address predictedAddress = collectionsHandler.predictCollectionAddress(COLLECTION_TX_HASH);
+
+        // Create the collection
+        testCreateCollection();
+
+        // Verify the actual address matches prediction
+        address actualAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        assertEq(actualAddress, predictedAddress);
+    }
+
+    function testEditCollectionItem() public {
+        // Setup: Create collection and add item
+        testAddToCollection();
+
+        // Edit item 0 - update name, description, and attributes
+        vm.prank(alice);
+
+        ERC721EthscriptionsCollectionManager.Attribute[] memory newAttributes = new ERC721EthscriptionsCollectionManager.Attribute[](3);
+        newAttributes[0] = ERC721EthscriptionsCollectionManager.Attribute({traitType: "Color", value: "Blue"});
+        newAttributes[1] = ERC721EthscriptionsCollectionManager.Attribute({traitType: "Size", value: "Large"});
+        newAttributes[2] = ERC721EthscriptionsCollectionManager.Attribute({traitType: "Rarity", value: "Epic"});
+
+        ERC721EthscriptionsCollectionManager.EditCollectionItemOperation memory editOp = ERC721EthscriptionsCollectionManager.EditCollectionItemOperation({
+            collectionId: COLLECTION_TX_HASH,
+            itemIndex: 0,
+            name: "Updated Item Name",
+            backgroundColor: "#0000FF",
+            description: "This item has been updated",
+            attributes: newAttributes
+        });
+
+        Ethscriptions.CreateEthscriptionParams memory editParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: bytes32(uint256(0xED171)),
+            contentUriSha: sha256(bytes("edit")),
+            initialOwner: alice,
+            content: bytes("edit"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "edit_collection_item",
+                data: abi.encode(editOp)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(editParams);
+
+        // Verify item was updated
+        ERC721EthscriptionsCollectionManager.CollectionItem memory item = collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 0);
+        assertEq(item.name, "Updated Item Name");
+        assertEq(item.backgroundColor, "#0000FF");
+        assertEq(item.description, "This item has been updated");
+        assertEq(item.attributes.length, 3);
+        assertEq(item.attributes[0].traitType, "Color");
+        assertEq(item.attributes[0].value, "Blue");
+        assertEq(item.attributes[1].traitType, "Size");
+        assertEq(item.attributes[1].value, "Large");
+        assertEq(item.attributes[2].traitType, "Rarity");
+        assertEq(item.attributes[2].value, "Epic");
+    }
+
+    function testEditCollectionItemPartialUpdate() public {
+        // Setup: Create collection and add item with attributes
+        testCreateCollection();
+
+        // Create ethscription that adds itself to the collection with attributes
+        ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = new ERC721EthscriptionsCollectionManager.Attribute[](2);
+        attributes[0] = ERC721EthscriptionsCollectionManager.Attribute({traitType: "Hair Color", value: "Brown"});
+        attributes[1] = ERC721EthscriptionsCollectionManager.Attribute({traitType: "Hair", value: "Blonde Bob"});
+
+        bytes32 itemContentHash = keccak256(bytes("item content"));
+
+        ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({
+            contentHash: itemContentHash,
+            itemIndex: 0,
+            name: "Test Item #0",
+            backgroundColor: "#FF5733",
+            description: "First item description",
+            attributes: attributes,
+            merkleProof: new bytes32[](0)
+        });
+
+        ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams =
+            ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({
+                collectionId: COLLECTION_TX_HASH,
+                item: itemData
+            });
+
+        Ethscriptions.CreateEthscriptionParams memory itemParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: ITEM1_TX_HASH,
+            contentUriSha: sha256(bytes("item content")),
+            initialOwner: alice,
+            content: bytes("item content"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "add_self_to_collection",
+                data: abi.encode(addSelfParams)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(itemParams);
+
+        // Edit item 0 - only update name and description, keep existing attributes
+        vm.prank(alice);
+
+        ERC721EthscriptionsCollectionManager.EditCollectionItemOperation memory editOp = ERC721EthscriptionsCollectionManager.EditCollectionItemOperation({
+            collectionId: COLLECTION_TX_HASH,
+            itemIndex: 0,
+            name: "Partially Updated",
+            backgroundColor: "", // Empty string - don't update
+            description: "Only name and description changed",
+            attributes: new ERC721EthscriptionsCollectionManager.Attribute[](0) // Empty array - keep existing
+        });
+
+        Ethscriptions.CreateEthscriptionParams memory editParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: bytes32(uint256(0xED172)),
+            contentUriSha: sha256(bytes("partial-edit")),
+            initialOwner: alice,
+            content: bytes("partial-edit"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "edit_collection_item",
+                data: abi.encode(editOp)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(editParams);
+
+        // Verify partial update
+        ERC721EthscriptionsCollectionManager.CollectionItem memory item = collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 0);
+        assertEq(item.name, "Partially Updated");
+        assertEq(item.description, "Only name and description changed");
+        assertEq(item.backgroundColor, "#FF5733"); // Original value preserved
+        assertEq(item.attributes.length, 2); // Original attributes preserved
+        assertEq(item.attributes[0].traitType, "Hair Color");
+        assertEq(item.attributes[0].value, "Brown");
+    }
+
+    function testOnlyOwnerCanEditItem() public {
+        // Setup: Create collection and add item
+        testAddToCollection();
+
+        // Try to edit item as non-owner (should revert)
+        vm.prank(bob);
+
+        ERC721EthscriptionsCollectionManager.EditCollectionItemOperation memory editOp = ERC721EthscriptionsCollectionManager.EditCollectionItemOperation({
+            collectionId: COLLECTION_TX_HASH,
+            itemIndex: 0,
+            name: "Unauthorized Edit",
+            backgroundColor: "#000000",
+            description: "This should not work",
+            attributes: new ERC721EthscriptionsCollectionManager.Attribute[](0)
+        });
+
+        Ethscriptions.CreateEthscriptionParams memory editParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: bytes32(uint256(0xBADED17)),
+            contentUriSha: sha256(bytes("bad-edit")),
+            initialOwner: bob,
+            content: bytes("bad-edit"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "edit_collection_item",
+                data: abi.encode(editOp)
+            })
+        });
+
+        vm.prank(bob);
+        ethscriptions.createEthscription(editParams);
+
+        // Verify item was not changed
+        ERC721EthscriptionsCollectionManager.CollectionItem memory item = collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 0);
+        assertEq(item.name, "Test Item #0"); // Original name preserved
+    }
+
+    function testEditNonExistentItem() public {
+        // Setup: Create collection
+        testCreateCollection();
+
+        // Try to edit non-existent item (should revert)
+        vm.prank(alice);
+
+        ERC721EthscriptionsCollectionManager.EditCollectionItemOperation memory editOp = ERC721EthscriptionsCollectionManager.EditCollectionItemOperation({
+            collectionId: COLLECTION_TX_HASH,
+            itemIndex: 999, // Non-existent index
+            name: "Should Fail",
+            backgroundColor: "#000000",
+            description: "This item doesn't exist",
+            attributes: new ERC721EthscriptionsCollectionManager.Attribute[](0)
+        });
+
+        Ethscriptions.CreateEthscriptionParams memory editParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: bytes32(uint256(0x901743)),
+            contentUriSha: sha256(bytes("no-item")),
+            initialOwner: alice,
+            content: bytes("no-item"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "edit_collection_item",
+                data: abi.encode(editOp)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(editParams);
+
+        // The operation should fail silently (no revert in createEthscription)
+        // Verify by checking that getting the item returns default values
+        ERC721EthscriptionsCollectionManager.CollectionItem memory item = collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 999);
+        assertEq(item.ethscriptionId, bytes32(0)); // Default value for non-existent item
+    }
+
+    function testSyncOwnership() public {
+        // Setup: Create collection and add items
+        testAddToCollection();
+
+        // Get the collection contract
+        address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+
+        // Initially Alice owns the token
+        assertEq(collection.ownerOf(0), alice);
+
+        // Now transfer the underlying ethscription to Bob (simulating a transfer outside the ERC721)
+        // We need to mock this transfer in the Ethscriptions contract
+        vm.prank(alice);
+        ethscriptions.transferEthscription(bob, ITEM1_TX_HASH);
+
+        // Verify the ethscription is now owned by Bob
+        // Note: ERC721's ownerOf always returns the current ethscription owner
+        assertEq(ethscriptions.ownerOf(ITEM1_TX_HASH), bob);
+        assertEq(collection.ownerOf(0), bob); // Immediately reflects the new owner
+
+        // Now sync the ownership
+        vm.prank(charlie); // Anyone can trigger sync
+        bytes32[] memory ethscriptionIds = new bytes32[](1);
+        ethscriptionIds[0] = ITEM1_TX_HASH;
+
+        Ethscriptions.CreateEthscriptionParams memory syncParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: bytes32(uint256(0x5914C)),
+            contentUriSha: sha256(bytes("sync")),
+            initialOwner: charlie,
+            content: bytes("sync"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "sync_ownership",
+                data: abi.encode(COLLECTION_TX_HASH, ethscriptionIds)
+            })
+        });
+
+        vm.prank(charlie);
+        ethscriptions.createEthscription(syncParams);
+
+        // Verify the ERC721 ownership is now synced
+        assertEq(collection.ownerOf(0), bob);
+    }
+
+    function testSyncOwnershipMultipleItems() public {
+        // Setup: Create collection with multiple items
+        testMultipleItemsInCollection();
+
+        address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+
+        // Transfer multiple ethscriptions to different owners
+        vm.prank(alice);
+        ethscriptions.transferEthscription(charlie, ITEM1_TX_HASH);
+
+        vm.prank(bob);
+        ethscriptions.transferEthscription(alice, ITEM2_TX_HASH);
+
+        // Verify ethscriptions have new owners
+        assertEq(ethscriptions.ownerOf(ITEM1_TX_HASH), charlie);
+        assertEq(ethscriptions.ownerOf(ITEM2_TX_HASH), alice);
+
+        // Ownership should sync automatically via onTransfer callback
+        // since these ethscriptions have the collection protocol set
+        assertEq(collection.ownerOf(0), charlie); // Should be synced automatically
+        assertEq(collection.ownerOf(1), alice);   // Should be synced automatically
+    }
+
+    function testSyncOwnershipNonExistentItem() public {
+        // Setup: Create collection
+        testCreateCollection();
+
+        // Try to sync an ethscription that's not in the collection
+        bytes32[] memory ethscriptionIds = new bytes32[](1);
+        ethscriptionIds[0] = bytes32(uint256(0x999999)); // Non-existent in collection
+
+        Ethscriptions.CreateEthscriptionParams memory syncParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: bytes32(uint256(0x5914CE)),
+            contentUriSha: sha256(bytes("sync-nonexistent")),
+            initialOwner: alice,
+            content: bytes("sync-nonexistent"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "sync_ownership",
+                data: abi.encode(COLLECTION_TX_HASH, ethscriptionIds)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(syncParams);
+
+        // Should complete without error (non-existent items are skipped)
+        // No assertion needed - just verifying no revert
+    }
+
+    function testSyncOwnershipNonExistentCollection() public {
+        bytes32 fakeCollectionId = bytes32(uint256(0xFABE));
+        bytes32[] memory ethscriptionIds = new bytes32[](1);
+        ethscriptionIds[0] = ITEM1_TX_HASH;
+
+        Ethscriptions.CreateEthscriptionParams memory syncParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: bytes32(uint256(0x5914CF)),
+            contentUriSha: sha256(bytes("sync-fake")),
+            initialOwner: alice,
+            content: bytes("sync-fake"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "sync_ownership",
+                data: abi.encode(fakeCollectionId, ethscriptionIds)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(syncParams);
+
+        // The operation should fail silently (protocol handler catches the require)
+        // No assertion needed - just verifying completion
+    }
+
+    function testEditLockedCollection() public {
+        // Setup: Create collection and add item
+        testAddToCollection();
+
+        // Lock the collection
+        vm.prank(alice);
+
+        Ethscriptions.CreateEthscriptionParams memory lockParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: bytes32(uint256(0x10CCC)),
+            contentUriSha: sha256(bytes("lock")),
+            initialOwner: alice,
+            content: bytes("lock"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "lock_collection",
+                data: abi.encode(COLLECTION_TX_HASH)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(lockParams);
+
+        // Try to edit item in locked collection (should fail)
+        vm.prank(alice);
+
+        ERC721EthscriptionsCollectionManager.EditCollectionItemOperation memory editOp = ERC721EthscriptionsCollectionManager.EditCollectionItemOperation({
+            collectionId: COLLECTION_TX_HASH,
+            itemIndex: 0,
+            name: "Should not update",
+            backgroundColor: "#000000",
+            description: "Collection is locked",
+            attributes: new ERC721EthscriptionsCollectionManager.Attribute[](0)
+        });
+
+        Ethscriptions.CreateEthscriptionParams memory editParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: bytes32(uint256(0x10C3ED)),
+            contentUriSha: sha256(bytes("locked-edit")),
+            initialOwner: alice,
+            content: bytes("locked-edit"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "edit_collection_item",
+                data: abi.encode(editOp)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(editParams);
+
+        // Verify item was not changed
+        ERC721EthscriptionsCollectionManager.CollectionItem memory item = collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 0);
+        assertEq(item.name, "Test Item #0"); // Original name preserved
+    }
+
+    function testNonOwnerCanAddItemWithValidMerkleProof() public {
+        _exitImportMode();
+        bytes memory allowlistedContent = bytes("allowlisted-item");
+        bytes memory siblingContent = bytes("sibling-item");
+
+        ERC721EthscriptionsCollectionManager.Attribute[] memory allowlistedAttributes =
+            _attributeArray("Tier", "Founder");
+        ERC721EthscriptionsCollectionManager.Attribute[] memory siblingAttributes =
+            _attributeArray("Tier", "Guest");
+
+        bytes32 allowlistedLeaf = _computeLeafHash(
+            keccak256(allowlistedContent),
+            0,
+            "Allowlisted Item",
+            "#111111",
+            "Reserved for the allowlist",
+            allowlistedAttributes
+        );
+        bytes32 siblingLeaf = _computeLeafHash(
+            keccak256(siblingContent),
+            1,
+            "Sibling Item",
+            "#222222",
+            "Another whitelisted entry",
+            siblingAttributes
+        );
+
+        bytes32 merkleRoot = _hashPair(allowlistedLeaf, siblingLeaf);
+        _createCollectionWithMerkleRoot(COLLECTION_TX_HASH, merkleRoot);
+
+        ERC721EthscriptionsCollectionManager.ItemData memory itemData =
+            ERC721EthscriptionsCollectionManager.ItemData({
+                contentHash: keccak256(allowlistedContent),
+                itemIndex: 0,
+                name: "Allowlisted Item",
+                backgroundColor: "#111111",
+                description: "Reserved for the allowlist",
+                attributes: allowlistedAttributes,
+                merkleProof: _singleProofArray(siblingLeaf)
+            });
+
+        ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams =
+            ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({
+                collectionId: COLLECTION_TX_HASH,
+                item: itemData
+            });
+
+        Ethscriptions.CreateEthscriptionParams memory addParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: ITEM1_TX_HASH,
+            contentUriSha: sha256(allowlistedContent),
+            initialOwner: bob,
+            content: allowlistedContent,
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "add_self_to_collection",
+                data: abi.encode(addSelfParams)
+            })
+        });
+
+        vm.prank(bob);
+        ethscriptions.createEthscription(addParams);
+
+        address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        assertTrue(collectionAddress != address(0));
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+
+        assertEq(collection.ownerOf(0), bob);
+        ERC721EthscriptionsCollectionManager.CollectionItem memory stored = collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 0);
+        assertEq(stored.ethscriptionId, ITEM1_TX_HASH);
+        assertEq(stored.name, "Allowlisted Item");
+    }
+
+    function testNonOwnerCannotAddItemWithoutMerkleRoot() public {
+        _exitImportMode();
+        _createCollectionWithMerkleRoot(COLLECTION_TX_HASH, bytes32(0));
+
+        bytes memory allowlistedContent = bytes("allowlisted-item");
+        ERC721EthscriptionsCollectionManager.Attribute[] memory attributes =
+            _attributeArray("Tier", "Founder");
+
+        ERC721EthscriptionsCollectionManager.ItemData memory itemData =
+            ERC721EthscriptionsCollectionManager.ItemData({
+                contentHash: keccak256(allowlistedContent),
+                itemIndex: 0,
+                name: "Allowlisted Item",
+                backgroundColor: "#111111",
+                description: "Reserved for the allowlist",
+                attributes: attributes,
+                merkleProof: new bytes32[](0)
+            });
+
+        ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams =
+            ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({
+                collectionId: COLLECTION_TX_HASH,
+                item: itemData
+            });
+
+        Ethscriptions.CreateEthscriptionParams memory addParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: ITEM1_TX_HASH,
+            contentUriSha: sha256(allowlistedContent),
+            initialOwner: bob,
+            content: allowlistedContent,
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "add_self_to_collection",
+                data: abi.encode(addSelfParams)
+            })
+        });
+
+        vm.recordLogs();
+        vm.prank(bob);
+        ethscriptions.createEthscription(addParams);
+
+        _assertProtocolFailure(ITEM1_TX_HASH, "Merkle proof required");
+
+        ERC721EthscriptionsCollectionManager.CollectionItem memory stored =
+            collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 0);
+        assertEq(stored.ethscriptionId, bytes32(0));
+
+        ERC721EthscriptionsCollectionManager.Membership memory membership =
+            collectionsHandler.getMembershipOfEthscription(ITEM1_TX_HASH);
+        assertEq(membership.collectionId, bytes32(0));
+    }
+
+    function testNonOwnerCannotAddItemWithInvalidMerkleProof() public {
+        _exitImportMode();
+        bytes memory allowlistedContent = bytes("allowlisted-item");
+        bytes memory siblingContent = bytes("sibling-item");
+
+        ERC721EthscriptionsCollectionManager.Attribute[] memory allowlistedAttributes =
+            _attributeArray("Tier", "Founder");
+        ERC721EthscriptionsCollectionManager.Attribute[] memory siblingAttributes =
+            _attributeArray("Tier", "Guest");
+
+        bytes32 allowlistedLeaf = _computeLeafHash(
+            keccak256(allowlistedContent),
+            0,
+            "Allowlisted Item",
+            "#111111",
+            "Reserved for the allowlist",
+            allowlistedAttributes
+        );
+        bytes32 siblingLeaf = _computeLeafHash(
+            keccak256(siblingContent),
+            1,
+            "Sibling Item",
+            "#222222",
+            "Another whitelisted entry",
+            siblingAttributes
+        );
+
+        bytes32 merkleRoot = _hashPair(allowlistedLeaf, siblingLeaf);
+        _createCollectionWithMerkleRoot(COLLECTION_TX_HASH, merkleRoot);
+
+        bytes32[] memory invalidProof = new bytes32[](1);
+        invalidProof[0] = bytes32(uint256(0xdeadbeef));
+
+        ERC721EthscriptionsCollectionManager.ItemData memory itemData =
+            ERC721EthscriptionsCollectionManager.ItemData({
+                contentHash: keccak256(allowlistedContent),
+                itemIndex: 0,
+                name: "Allowlisted Item",
+                backgroundColor: "#111111",
+                description: "Reserved for the allowlist",
+                attributes: allowlistedAttributes,
+                merkleProof: invalidProof
+            });
+
+        ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams =
+            ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({
+                collectionId: COLLECTION_TX_HASH,
+                item: itemData
+            });
+
+        Ethscriptions.CreateEthscriptionParams memory addParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: ITEM1_TX_HASH,
+            contentUriSha: sha256(allowlistedContent),
+            initialOwner: bob,
+            content: allowlistedContent,
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "add_self_to_collection",
+                data: abi.encode(addSelfParams)
+            })
+        });
+
+        vm.recordLogs();
+        vm.prank(bob);
+        ethscriptions.createEthscription(addParams);
+
+        _assertProtocolFailure(ITEM1_TX_HASH, "Invalid Merkle proof");
+
+        ERC721EthscriptionsCollectionManager.CollectionItem memory stored =
+            collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 0);
+        assertEq(stored.ethscriptionId, bytes32(0));
+
+        ERC721EthscriptionsCollectionManager.Membership memory membership =
+            collectionsHandler.getMembershipOfEthscription(ITEM1_TX_HASH);
+        assertEq(membership.collectionId, bytes32(0));
+    }
+
+    function testCollectionOwnerBypassesMerkleProof() public {
+        _exitImportMode();
+        bytes32 enforcedRoot = keccak256("allowlist-root");
+        _createCollectionWithMerkleRoot(COLLECTION_TX_HASH, enforcedRoot);
+
+        bytes memory itemContent = bytes("owner-merkle-item");
+        ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = _attributeArray("Tier", "Owner");
+
+        ERC721EthscriptionsCollectionManager.ItemData memory itemData =
+            ERC721EthscriptionsCollectionManager.ItemData({
+                contentHash: keccak256(itemContent),
+                itemIndex: 0,
+                name: "Owner Item",
+                backgroundColor: "#010101",
+                description: "Owner should bypass proof enforcement",
+                attributes: attributes,
+                merkleProof: new bytes32[](0)
+            });
+
+        ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams =
+            ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({
+                collectionId: COLLECTION_TX_HASH,
+                item: itemData
+            });
+
+        Ethscriptions.CreateEthscriptionParams memory addParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: ITEM1_TX_HASH,
+            contentUriSha: sha256(itemContent),
+            initialOwner: alice,
+            content: itemContent,
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "add_self_to_collection",
+                data: abi.encode(addSelfParams)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(addParams);
+
+        address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH);
+        ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress);
+        assertEq(collection.ownerOf(0), alice);
+
+        ERC721EthscriptionsCollectionManager.CollectionItem memory stored =
+            collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 0);
+        assertEq(stored.ethscriptionId, ITEM1_TX_HASH);
+    }
+
+    function testEditingMerkleRootChangesNonOwnerAccess() public {
+        _exitImportMode();
+
+        bytes memory allowlistedContent = bytes("allowlisted-item");
+        bytes memory siblingContent = bytes("sibling-item");
+
+        ERC721EthscriptionsCollectionManager.Attribute[] memory allowlistedAttributes =
+            _attributeArray("Tier", "Founder");
+        ERC721EthscriptionsCollectionManager.Attribute[] memory siblingAttributes =
+            _attributeArray("Tier", "Guest");
+
+        bytes32 allowlistedLeaf = _computeLeafHash(
+            keccak256(allowlistedContent),
+            0,
+            "Allowlisted Item",
+            "#111111",
+            "Reserved for the allowlist",
+            allowlistedAttributes
+        );
+        bytes32 siblingLeaf = _computeLeafHash(
+            keccak256(siblingContent),
+            1,
+            "Sibling Item",
+            "#222222",
+            "Another whitelisted entry",
+            siblingAttributes
+        );
+
+        bytes32 merkleRoot = _hashPair(allowlistedLeaf, siblingLeaf);
+        _createCollectionWithMerkleRoot(COLLECTION_TX_HASH, bytes32(0));
+
+        ERC721EthscriptionsCollectionManager.ItemData memory itemData =
+            ERC721EthscriptionsCollectionManager.ItemData({
+                contentHash: keccak256(allowlistedContent),
+                itemIndex: 0,
+                name: "Allowlisted Item",
+                backgroundColor: "#111111",
+                description: "Reserved for the allowlist",
+                attributes: allowlistedAttributes,
+                merkleProof: _singleProofArray(siblingLeaf)
+            });
+
+        ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams =
+            ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({
+                collectionId: COLLECTION_TX_HASH,
+                item: itemData
+            });
+
+        Ethscriptions.CreateEthscriptionParams memory firstAttempt = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: ITEM1_TX_HASH,
+            contentUriSha: sha256(allowlistedContent),
+            initialOwner: bob,
+            content: allowlistedContent,
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "add_self_to_collection",
+                data: abi.encode(addSelfParams)
+            })
+        });
+
+        vm.recordLogs();
+        vm.prank(bob);
+        ethscriptions.createEthscription(firstAttempt);
+        _assertProtocolFailure(ITEM1_TX_HASH, "Merkle proof required");
+
+        ERC721EthscriptionsCollectionManager.CollectionItem memory emptySlot =
+            collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 0);
+        assertEq(emptySlot.ethscriptionId, bytes32(0));
+
+        _editCollectionMerkleRoot(bytes32(uint256(0xED111)), COLLECTION_TX_HASH, merkleRoot);
+
+        Ethscriptions.CreateEthscriptionParams memory secondAttempt = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: ITEM2_TX_HASH,
+            contentUriSha: sha256(allowlistedContent),
+            initialOwner: bob,
+            content: allowlistedContent,
+            mimetype: "text/plain",
+            esip6: true,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "add_self_to_collection",
+                data: abi.encode(addSelfParams)
+            })
+        });
+
+        vm.prank(bob);
+        ethscriptions.createEthscription(secondAttempt);
+
+        ERC721EthscriptionsCollectionManager.CollectionItem memory stored =
+            collectionsHandler.getCollectionItem(COLLECTION_TX_HASH, 0);
+        assertEq(stored.ethscriptionId, ITEM2_TX_HASH);
+
+        ERC721EthscriptionsCollectionManager.Membership memory membership =
+            collectionsHandler.getMembershipOfEthscription(ITEM2_TX_HASH);
+        assertEq(membership.collectionId, COLLECTION_TX_HASH);
+        assertEq(membership.tokenIdPlusOne, 1);
+
+        ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata =
+            collectionsHandler.getCollection(COLLECTION_TX_HASH);
+        assertEq(metadata.merkleRoot, merkleRoot);
+    }
+
+    function _createCollectionWithMerkleRoot(bytes32 collectionId, bytes32 merkleRoot) private {
+        ERC721EthscriptionsCollectionManager.CollectionParams memory metadata =
+            ERC721EthscriptionsCollectionManager.CollectionParams({
+                name: "Merkle Collection",
+                symbol: "MRKL",
+                maxSupply: 100,
+                description: "Collection that requires proofs",
+                logoImageUri: "",
+                bannerImageUri: "",
+                backgroundColor: "",
+                websiteLink: "",
+                twitterLink: "",
+                discordLink: "",
+                merkleRoot: merkleRoot,
+                initialOwner: alice  // Use alice as owner
+            });
+
+        string memory collectionContent =
+            'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Merkle Collection"}';
+
+        Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: collectionId,
+            contentUriSha: sha256(bytes(collectionContent)),
+            initialOwner: alice,
+            content: bytes(collectionContent),
+            mimetype: "application/json",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "create_collection",
+                data: abi.encode(metadata)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(params);
+    }
+
+    function _attributeArray(string memory trait, string memory value)
+        private
+        pure
+        returns (ERC721EthscriptionsCollectionManager.Attribute[] memory attrs)
+    {
+        attrs = new ERC721EthscriptionsCollectionManager.Attribute[](1);
+        attrs[0] = ERC721EthscriptionsCollectionManager.Attribute({traitType: trait, value: value});
+    }
+
+    function _computeLeafHash(
+        bytes32 contentHash,
+        uint256 itemIndex,
+        string memory name,
+        string memory backgroundColor,
+        string memory description,
+        ERC721EthscriptionsCollectionManager.Attribute[] memory attributes
+    ) private pure returns (bytes32) {
+        return keccak256(abi.encode(contentHash, itemIndex, name, backgroundColor, description, attributes));
+    }
+
+    function _hashPair(bytes32 a, bytes32 b) private pure returns (bytes32) {
+        return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a));
+    }
+
+    function _singleProofArray(bytes32 sibling) private pure returns (bytes32[] memory proof) {
+        proof = new bytes32[](1);
+        proof[0] = sibling;
+    }
+
+    function _assertProtocolFailure(bytes32 ethscriptionId, string memory expectedMessage) private {
+        Vm.Log[] memory logs = vm.getRecordedLogs();
+        bytes32 failureTopic = keccak256("ProtocolHandlerFailed(bytes32,string,bytes)");
+        bool found;
+        string memory protocol;
+        bytes memory revertData;
+
+        for (uint256 i = 0; i < logs.length; i++) {
+            Vm.Log memory entry = logs[i];
+            if (entry.topics.length >= 2 && entry.topics[0] == failureTopic && entry.topics[1] == ethscriptionId) {
+                (protocol, revertData) = abi.decode(entry.data, (string, bytes));
+                found = true;
+                break;
+            }
+        }
+
+        assertTrue(found, "Expected ProtocolHandlerFailed event");
+        assertEq(protocol, "erc-721-ethscriptions-collection");
+
+        bytes memory expected = abi.encodeWithSignature("Error(string)", expectedMessage);
+        assertEq(keccak256(revertData), keccak256(expected));
+    }
+
+    function _exitImportMode() private {
+        vm.warp(Constants.historicalBackfillApproxDoneAt + 1);
+    }
+
+    function _editCollectionMerkleRoot(bytes32 editEthscriptionId, bytes32 collectionId, bytes32 newRoot) private {
+        ERC721EthscriptionsCollectionManager.EditCollectionOperation memory editOp =
+            ERC721EthscriptionsCollectionManager.EditCollectionOperation({
+                collectionId: collectionId,
+                description: "",
+                logoImageUri: "",
+                bannerImageUri: "",
+                backgroundColor: "",
+                websiteLink: "",
+                twitterLink: "",
+                discordLink: "",
+                merkleRoot: newRoot
+            });
+
+        Ethscriptions.CreateEthscriptionParams memory editParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: editEthscriptionId,
+            contentUriSha: sha256(bytes("edit-merkle")),
+            initialOwner: alice,
+            content: bytes("edit-merkle"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "edit_collection",
+                data: abi.encode(editOp)
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(editParams);
+    }
+}
diff --git a/contracts/test/CollectionsProtocol.t.sol b/contracts/test/CollectionsProtocol.t.sol
new file mode 100644
index 0000000..b2d8b11
--- /dev/null
+++ b/contracts/test/CollectionsProtocol.t.sol
@@ -0,0 +1,215 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "forge-std/Test.sol";
+import "../src/ERC721EthscriptionsCollectionManager.sol";
+import "../src/ERC721EthscriptionsCollection.sol";
+import "../src/Ethscriptions.sol";
+import "../src/libraries/Predeploys.sol";
+import "./TestSetup.sol";
+
+contract CollectionsProtocolTest is TestSetup {
+    address alice = makeAddr("alice");
+    
+    function test_CreateCollection() public {
+        // Encode collection metadata as ABI tuple
+        ERC721EthscriptionsCollectionManager.CollectionParams memory metadata =
+            ERC721EthscriptionsCollectionManager.CollectionParams({
+                name: "Test Collection",
+                symbol: "TEST",
+                maxSupply: 100,
+                description: "A test collection",
+                logoImageUri: "https://example.com/logo.png",
+                bannerImageUri: "",
+                backgroundColor: "",
+                websiteLink: "",
+                twitterLink: "",
+                discordLink: "",
+                merkleRoot: bytes32(0),
+                initialOwner: alice  // Use alice as owner
+            });
+
+        bytes memory encodedMetadata = abi.encode(metadata);
+
+        // Create the ethscription
+        bytes32 txHash = keccak256("create_collection_tx");
+
+        // First, create the ethscription that will represent this collection
+        Ethscriptions.CreateEthscriptionParams memory ethscriptionParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: txHash,
+            contentUriSha: keccak256("test-collection-content"),
+            initialOwner: alice,
+            content: bytes("test-collection-content"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "",
+                operation: "",
+                data: ""
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(ethscriptionParams);
+
+        vm.prank(address(ethscriptions));
+        collectionsHandler.op_create_collection(txHash, encodedMetadata);
+
+        // Verify collection was created
+        bytes32 collectionId = txHash;
+
+        // Use the getter functions instead of direct mapping access
+        ERC721EthscriptionsCollectionManager.CollectionMetadata memory collection = collectionsHandler.getCollection(collectionId);
+        assertNotEq(collection.collectionContract, address(0), "Collection contract should be deployed");
+        assertEq(collection.locked, false, "Should not be locked");
+
+        ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract);
+        assertEq(collectionContract.totalSupply(), 0, "Initial size should be 0");
+
+        // Verify metadata
+        ERC721EthscriptionsCollectionManager.CollectionMetadata memory stored = collectionsHandler.getCollection(collectionId);
+        assertEq(stored.name, "Test Collection", "Name should match");
+        assertEq(stored.symbol, "TEST", "Symbol should match");
+        assertEq(stored.maxSupply, 100, "Max supply should match");
+        assertEq(stored.description, "A test collection", "Description should match");
+    }
+
+    function test_CreateCollectionEndToEnd() public {
+        // Full end-to-end test: create ethscription with JSON, let it call the protocol handler
+
+        // The JSON data
+        string memory json = '{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test NFTs","symbol":"TEST","maxSupply":"100","description":"","logoImageUri":"","bannerImageUri":"","backgroundColor":"","websiteLink":"","twitterLink":"","discordLink":""}';
+
+        // Encode the metadata as the protocol handler expects
+        ERC721EthscriptionsCollectionManager.CollectionParams memory metadata =
+            ERC721EthscriptionsCollectionManager.CollectionParams({
+                name: "Test NFTs",
+                symbol: "TEST",
+                maxSupply: 100,
+                description: "",
+                logoImageUri: "",
+                bannerImageUri: "",
+                backgroundColor: "",
+                websiteLink: "",
+                twitterLink: "",
+                discordLink: "",
+                merkleRoot: bytes32(0),
+                initialOwner: alice  // Use alice as owner
+            });
+
+        bytes memory encodedProtocolData = abi.encode(metadata);
+
+        // Create the ethscription with protocol params
+        bytes32 txHash = keccak256(abi.encodePacked("test_collection_tx", block.timestamp));
+
+        Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: txHash,
+            contentUriSha: keccak256(bytes(json)),
+            initialOwner: alice,
+            content: bytes(json),
+            mimetype: "application/json",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "erc-721-ethscriptions-collection",
+                operation: "create_collection",
+                data: encodedProtocolData
+            })
+        });
+
+        // Create the ethscription - this will call the protocol handler automatically
+        vm.prank(alice);
+        ethscriptions.createEthscription(params);
+
+        bytes32 collectionId = txHash;
+
+        // Read back the state
+        ERC721EthscriptionsCollectionManager.CollectionMetadata memory collection = collectionsHandler.getCollection(collectionId);
+
+        console.log("Collection exists:", collection.collectionContract != address(0));
+        console.log("Collection contract:", collection.collectionContract);
+        ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract);
+        console.log("Current size:", collectionContract.totalSupply());
+
+        // Verify the collection was created
+        assertTrue(collection.collectionContract != address(0), "Collection should exist");
+        assertEq(collection.locked, false);
+        assertEq(collectionContract.totalSupply(), 0);
+
+        // Read metadata
+        ERC721EthscriptionsCollectionManager.CollectionMetadata memory stored = collectionsHandler.getCollection(collectionId);
+        assertEq(stored.name, "Test NFTs");
+        assertEq(stored.symbol, "TEST");
+        assertEq(stored.maxSupply, 100);
+    }
+
+    function test_ReadCollectionStateViaEthCall() public {
+        // Create a collection first
+        ERC721EthscriptionsCollectionManager.CollectionParams memory metadata =
+            ERC721EthscriptionsCollectionManager.CollectionParams({
+                name: "Call Test",
+                symbol: "CALL",
+                maxSupply: 50,
+                description: "",
+                logoImageUri: "",
+                bannerImageUri: "",
+                backgroundColor: "",
+                websiteLink: "",
+                twitterLink: "",
+                discordLink: "",
+                merkleRoot: bytes32(0),
+                initialOwner: alice  // Use alice as owner
+            });
+
+        bytes32 txHash = keccak256("call_test_tx");
+
+        // First, create the ethscription that will represent this collection
+        Ethscriptions.CreateEthscriptionParams memory ethscriptionParams = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: txHash,
+            contentUriSha: keccak256("call-test-content"),
+            initialOwner: alice,
+            content: bytes("call-test-content"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "",
+                operation: "",
+                data: ""
+            })
+        });
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(ethscriptionParams);
+
+        vm.prank(address(ethscriptions));
+        collectionsHandler.op_create_collection(txHash, abi.encode(metadata));
+
+        // Now simulate an eth_call to read the state
+        bytes32 collectionId = txHash;
+
+        // Encode the function call: getCollection(bytes32)
+        bytes memory callData = abi.encodeWithSelector(
+            collectionsHandler.getCollection.selector,
+            collectionId
+        );
+
+        console.log("Call data:");
+        console.logBytes(callData);
+
+        // Make the call
+        (bool success, bytes memory result) = address(collectionsHandler).staticcall(callData);
+        assertTrue(success, "Static call should succeed");
+
+        console.log("Result:");
+        console.logBytes(result);
+
+        // Decode the result
+        ERC721EthscriptionsCollectionManager.CollectionMetadata memory collection = abi.decode(result, (ERC721EthscriptionsCollectionManager.CollectionMetadata));
+
+        assertTrue(collection.collectionContract != address(0), "Should have collection contract");
+        assertEq(collection.locked, false);
+        ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract);
+        assertEq(collectionContract.totalSupply(), 0);
+
+        console.log("Successfully read collection state via eth_call!");
+    }
+}
diff --git a/contracts/test/CompressionCPUGasTest.t.sol b/contracts/test/CompressionCPUGasTest.t.sol
new file mode 100644
index 0000000..4f1a3e3
--- /dev/null
+++ b/contracts/test/CompressionCPUGasTest.t.sol
@@ -0,0 +1,198 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "forge-std/Test.sol";
+import "solady/utils/LibZip.sol";
+import "forge-std/console.sol";
+
+contract CompressionCPUGasTest is Test {
+    using LibZip for bytes;
+    
+    function testIsolatedCompressionDecompressionGas() public {
+        console.log("\n=== Isolated CPU Gas Cost for Compression/Decompression ===\n");
+        
+        // Load the actual example ethscription content
+        string memory json = vm.readFile("test/example_ethscription.json");
+        bytes memory contentUri = bytes(vm.parseJsonString(json, ".result.content_uri"));
+        
+        console.log("Testing with real content:", contentUri.length, "bytes");
+        
+        // Test compression CPU cost
+        uint256 compressionGas = _measureCompressionGas(contentUri);
+        
+        // Compress once to get compressed data for decompression test
+        bytes memory compressed = LibZip.flzCompress(contentUri);
+        console.log("Compressed size:", compressed.length, "bytes");
+        console.log("Compression ratio:", (compressed.length * 100) / contentUri.length, "%");
+        
+        // Test decompression CPU cost
+        uint256 decompressionGas = _measureDecompressionGas(compressed, contentUri);
+        
+        // Test with different sizes to see scaling
+        console.log("\n=== Gas Scaling Analysis ===\n");
+        _testScaling(contentUri);
+        
+        // Summary
+        console.log("\n=== Summary for Full Content ===");
+        console.log("Compression CPU gas:", compressionGas);
+        console.log("Decompression CPU gas:", decompressionGas);
+        console.log("Gas per input KB (compression):", compressionGas / (contentUri.length / 1024));
+        console.log("Gas per output KB (decompression):", decompressionGas / (contentUri.length / 1024));
+        
+        // Compare to storage costs
+        uint256 storageGasPerByte = 640; // Approximate gas per byte for SSTORE2
+        uint256 savedBytes = contentUri.length - compressed.length;
+        uint256 storageSavings = savedBytes * storageGasPerByte;
+        
+        console.log("\n=== Compression Economics ===");
+        console.log("Bytes saved:", savedBytes);
+        console.log("Storage gas saved:", storageSavings);
+        console.log("Compression gas cost:", compressionGas);
+        
+        if (storageSavings > compressionGas) {
+            uint256 netSavings = storageSavings - compressionGas;
+            console.log("Net savings on write:", netSavings);
+            console.log("ROI on compression:", (netSavings * 100) / compressionGas, "%");
+        } else {
+            uint256 netCost = compressionGas - storageSavings;
+            console.log("Net cost on write:", netCost);
+            console.log("Compression is not economical for storage alone");
+        }
+    }
+    
+    function _measureCompressionGas(bytes memory data) internal returns (uint256) {
+        // Warm up (first call might have different gas due to memory expansion)
+        LibZip.flzCompress(data);
+        
+        // Measure actual compression gas
+        uint256 gasBefore = gasleft();
+        bytes memory compressed = LibZip.flzCompress(data);
+        uint256 gasUsed = gasBefore - gasleft();
+        
+        // Verify it worked
+        require(compressed.length > 0, "Compression failed");
+        
+        console.log("Compression gas:", gasUsed);
+        return gasUsed;
+    }
+    
+    function _measureDecompressionGas(bytes memory compressed, bytes memory expected) internal returns (uint256) {
+        // Warm up
+        LibZip.flzDecompress(compressed);
+        
+        // Measure actual decompression gas
+        uint256 gasBefore = gasleft();
+        bytes memory decompressed = LibZip.flzDecompress(compressed);
+        uint256 gasUsed = gasBefore - gasleft();
+        
+        // Verify correctness
+        assertEq(decompressed, expected, "Decompression mismatch");
+        
+        console.log("Decompression gas:", gasUsed);
+        return gasUsed;
+    }
+    
+    function _testScaling(bytes memory fullData) internal {
+        uint256[] memory sizes = new uint256[](5);
+        sizes[0] = 1024;   // 1 KB
+        sizes[1] = 5120;   // 5 KB
+        sizes[2] = 10240;  // 10 KB
+        sizes[3] = 25600;  // 25 KB
+        sizes[4] = 51200;  // 50 KB
+        
+        console.log("Size (KB) | Compress Gas | Decompress Gas | Gas/KB Compress | Gas/KB Decompress");
+        console.log("----------|--------------|----------------|-----------------|------------------");
+        
+        for (uint i = 0; i < sizes.length; i++) {
+            if (sizes[i] > fullData.length) continue;
+            
+            // Create test data of specific size
+            bytes memory testData = new bytes(sizes[i]);
+            for (uint j = 0; j < sizes[i]; j++) {
+                testData[j] = fullData[j % fullData.length];
+            }
+            
+            // Compress to get compressed version
+            bytes memory compressed = LibZip.flzCompress(testData);
+            
+            // Measure compression
+            uint256 compressGasBefore = gasleft();
+            LibZip.flzCompress(testData);
+            uint256 compressGas = compressGasBefore - gasleft();
+            
+            // Measure decompression
+            uint256 decompressGasBefore = gasleft();
+            LibZip.flzDecompress(compressed);
+            uint256 decompressGas = decompressGasBefore - gasleft();
+            
+            uint256 sizeKB = sizes[i] / 1024;
+            console.log(
+                string.concat(
+                    vm.toString(sizeKB), " KB      | ",
+                    vm.toString(compressGas), " | ",
+                    vm.toString(decompressGas), " | ",
+                    vm.toString(compressGas / sizeKB), " | ",
+                    vm.toString(decompressGas / sizeKB)
+                )
+            );
+        }
+    }
+    
+    function testCompressionWithDifferentDataTypes() public {
+        console.log("\n=== Compression CPU Gas by Data Type ===\n");
+        
+        // Test different types of data
+        bytes memory types = new bytes(5);
+        
+        // 1. Highly repetitive data (best case)
+        bytes memory repetitive = new bytes(10240);
+        for (uint i = 0; i < repetitive.length; i++) {
+            repetitive[i] = bytes1(uint8(65)); // All 'A's
+        }
+        
+        // 2. Base64 data (typical case)
+        bytes memory base64 = new bytes(10240);
+        bytes memory base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+        for (uint i = 0; i < base64.length; i++) {
+            base64[i] = base64Chars[i % 64];
+        }
+        
+        // 3. Random data (worst case)
+        bytes memory random = new bytes(10240);
+        for (uint i = 0; i < random.length; i++) {
+            random[i] = bytes1(uint8(uint256(keccak256(abi.encode(i))) % 256));
+        }
+        
+        console.log("Data Type    | Size | Compressed | Ratio | Compress Gas | Decompress Gas");
+        console.log("-------------|------|------------|-------|--------------|---------------");
+        
+        _measureDataType("Repetitive", repetitive);
+        _measureDataType("Base64", base64);
+        _measureDataType("Random", random);
+    }
+    
+    function _measureDataType(string memory label, bytes memory data) internal {
+        bytes memory compressed = LibZip.flzCompress(data);
+        
+        // Measure compression
+        uint256 compressGasBefore = gasleft();
+        LibZip.flzCompress(data);
+        uint256 compressGas = compressGasBefore - gasleft();
+        
+        // Measure decompression  
+        uint256 decompressGasBefore = gasleft();
+        LibZip.flzDecompress(compressed);
+        uint256 decompressGas = decompressGasBefore - gasleft();
+        
+        console.log(
+            string.concat(
+                label, " | ",
+                vm.toString(data.length), " | ",
+                vm.toString(compressed.length), " | ",
+                vm.toString((compressed.length * 100) / data.length), "% | ",
+                vm.toString(compressGas), " | ",
+                vm.toString(decompressGas)
+            )
+        );
+    }
+}
\ No newline at end of file
diff --git a/contracts/test/CompressionGasTest.t.sol b/contracts/test/CompressionGasTest.t.sol
new file mode 100644
index 0000000..368876f
--- /dev/null
+++ b/contracts/test/CompressionGasTest.t.sol
@@ -0,0 +1,199 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "forge-std/Test.sol";
+import "solady/utils/LibZip.sol";
+import "solady/utils/SSTORE2.sol";
+
+contract CompressionGasTest is Test {
+    using LibZip for bytes;
+    
+    // Test different types of data
+    bytes constant JSON_DATA = 'data:application/json,{"p":"erc-20","op":"mint","tick":"eths","amt":"1000"}';
+    bytes constant TEXT_DATA = 'data:text/plain,Hello World! This is a longer text message that might benefit from compression. Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
+    bytes constant REPETITIVE_DATA = 'data:text/plain,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
+    bytes constant BASE64_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
+    
+    // Larger JSON for better compression ratio testing
+    bytes constant LARGE_JSON = 'data:application/json,{"protocol":"ethscriptions","operation":"deploy","ticker":"TEST","maxSupply":"21000000","mintLimit":"1000","decimals":"18","metadata":{"name":"Test Token","description":"This is a test token for compression analysis","website":"https://example.com","twitter":"@example","discord":"https://discord.gg/example"}}';
+    
+    function testCompressionEfficiency() public {
+        console.log("=== SSTORE2 Compression Gas Analysis ===\n");
+        
+        // Test JSON data
+        _testDataType("JSON (small)", JSON_DATA);
+        _testDataType("JSON (large)", LARGE_JSON);
+        
+        // Test text data
+        _testDataType("Text", TEXT_DATA);
+        
+        // Test repetitive data (should compress well)
+        _testDataType("Repetitive", REPETITIVE_DATA);
+        
+        // Test base64 image (may not compress well)
+        _testDataType("Base64 Image", BASE64_IMAGE);
+        
+        // Test chunked storage scenarios
+        console.log("\n=== Chunked Storage Analysis (24KB chunks) ===\n");
+        _testChunkedStorage();
+    }
+    
+    function _testDataType(string memory label, bytes memory data) internal {
+        console.log(string.concat("Testing: ", label));
+        console.log("Original size:", data.length, "bytes");
+        
+        // Compress the data
+        bytes memory compressed = LibZip.flzCompress(data);
+        console.log("Compressed size:", compressed.length, "bytes");
+        
+        uint256 compressionRatio = (compressed.length * 100) / data.length;
+        console.log("Compression ratio:", compressionRatio, "%");
+        
+        // Test SSTORE2 write gas costs
+        uint256 gasUncompressed = gasleft();
+        address ptrUncompressed = SSTORE2.write(data);
+        gasUncompressed = gasUncompressed - gasleft();
+        
+        uint256 gasCompressed = gasleft();
+        address ptrCompressed = SSTORE2.write(compressed);
+        gasCompressed = gasCompressed - gasleft();
+        
+        console.log("SSTORE2 write gas (uncompressed):", gasUncompressed);
+        console.log("SSTORE2 write gas (compressed):", gasCompressed);
+        
+        int256 gasSavings = int256(gasUncompressed) - int256(gasCompressed);
+        if (gasSavings > 0) {
+            console.log("Gas saved by compression:", uint256(gasSavings));
+        } else {
+            console.log("Extra gas cost for compression:", uint256(-gasSavings));
+        }
+        
+        // Test read + decompress gas costs
+        uint256 gasReadUncompressed = gasleft();
+        bytes memory readUncompressed = SSTORE2.read(ptrUncompressed);
+        gasReadUncompressed = gasReadUncompressed - gasleft();
+        
+        uint256 gasReadCompressed = gasleft();
+        bytes memory readCompressed = SSTORE2.read(ptrCompressed);
+        bytes memory decompressed = LibZip.flzDecompress(readCompressed);
+        gasReadCompressed = gasReadCompressed - gasleft();
+        
+        console.log("Read gas (uncompressed):", gasReadUncompressed);
+        console.log("Read + decompress gas:", gasReadCompressed);
+        
+        // Verify decompression is correct
+        assertEq(decompressed, data, "Decompression failed");
+        
+        console.log("---\n");
+    }
+    
+    function _testChunkedStorage() internal {
+        // Create a large dataset that would be split into chunks
+        bytes memory largeData = new bytes(48000); // ~48KB
+        
+        // Fill with semi-realistic JSON data pattern
+        bytes memory pattern = bytes('{"id":1234567890,"data":"');
+        for (uint i = 0; i < largeData.length; i++) {
+            largeData[i] = pattern[i % pattern.length];
+        }
+        
+        console.log("Large dataset size:", largeData.length, "bytes");
+        
+        // Compress the entire dataset
+        bytes memory compressed = LibZip.flzCompress(largeData);
+        console.log("Compressed size:", compressed.length, "bytes");
+        console.log("Compression ratio:", (compressed.length * 100) / largeData.length, "%");
+        
+        // Calculate chunks needed
+        uint256 chunkSize = 24575; // Max SSTORE2 chunk size
+        uint256 chunksUncompressed = (largeData.length + chunkSize - 1) / chunkSize;
+        uint256 chunksCompressed = (compressed.length + chunkSize - 1) / chunkSize;
+        
+        console.log("Chunks needed (uncompressed):", chunksUncompressed);
+        console.log("Chunks needed (compressed):", chunksCompressed);
+        
+        // Estimate total storage cost
+        uint256 totalGasUncompressed = 0;
+        uint256 totalGasCompressed = 0;
+        
+        // Store uncompressed chunks
+        for (uint i = 0; i < chunksUncompressed; i++) {
+            uint256 start = i * chunkSize;
+            uint256 end = start + chunkSize;
+            if (end > largeData.length) end = largeData.length;
+            
+            bytes memory chunk = new bytes(end - start);
+            for (uint j = 0; j < chunk.length; j++) {
+                chunk[j] = largeData[start + j];
+            }
+            
+            uint256 gas = gasleft();
+            SSTORE2.write(chunk);
+            totalGasUncompressed += gas - gasleft();
+        }
+        
+        // Store compressed as single chunk (if it fits) or multiple
+        for (uint i = 0; i < chunksCompressed; i++) {
+            uint256 start = i * chunkSize;
+            uint256 end = start + chunkSize;
+            if (end > compressed.length) end = compressed.length;
+            
+            bytes memory chunk = new bytes(end - start);
+            for (uint j = 0; j < chunk.length; j++) {
+                chunk[j] = compressed[start + j];
+            }
+            
+            uint256 gas = gasleft();
+            SSTORE2.write(chunk);
+            totalGasCompressed += gas - gasleft();
+        }
+        
+        console.log("Total storage gas (uncompressed):", totalGasUncompressed);
+        console.log("Total storage gas (compressed):", totalGasCompressed);
+        
+        int256 totalSavings = int256(totalGasUncompressed) - int256(totalGasCompressed);
+        if (totalSavings > 0) {
+            console.log("Total gas saved:", uint256(totalSavings));
+            console.log("Percentage saved:", (uint256(totalSavings) * 100) / totalGasUncompressed, "%");
+        } else {
+            console.log("Extra gas cost:", uint256(-totalSavings));
+        }
+    }
+    
+    function testDecompressionGasCost() public {
+        console.log("=== Decompression Gas Cost Analysis ===\n");
+        
+        bytes memory testData = LARGE_JSON;
+        bytes memory compressed = LibZip.flzCompress(testData);
+        
+        // Write compressed data
+        address ptr = SSTORE2.write(compressed);
+        
+        // Measure decompression cost at different sizes
+        uint256[] memory sizes = new uint256[](5);
+        sizes[0] = 100;
+        sizes[1] = 500;
+        sizes[2] = 1000;
+        sizes[3] = 5000;
+        sizes[4] = 10000;
+        
+        for (uint i = 0; i < sizes.length; i++) {
+            if (sizes[i] > testData.length) continue;
+            
+            bytes memory data = new bytes(sizes[i]);
+            for (uint j = 0; j < sizes[i]; j++) {
+                data[j] = testData[j % testData.length];
+            }
+            
+            bytes memory compressedData = LibZip.flzCompress(data);
+            address dataPtr = SSTORE2.write(compressedData);
+            
+            uint256 gas = gasleft();
+            bytes memory read = SSTORE2.read(dataPtr);
+            LibZip.flzDecompress(read);
+            gas = gas - gasleft();
+            
+            console.log("Size:", sizes[i], "bytes - Decompression gas:", gas);
+        }
+    }
+}
\ No newline at end of file
diff --git a/contracts/test/DataURIEdgeCase.t.sol b/contracts/test/DataURIEdgeCase.t.sol
new file mode 100644
index 0000000..e6c6908
--- /dev/null
+++ b/contracts/test/DataURIEdgeCase.t.sol
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+import {Base64} from "solady/utils/Base64.sol";
+
+contract DataURIEdgeCaseTest is TestSetup {
+    address creator = address(0x1234);
+
+    function testDataURIAsContentTokenURI() public {
+        // The content is literally a data URI string
+        string memory dataURIContent = "data:EthscriptionsApe3332;image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQAQMAAAC032DuAAAABlBMVEXWtYs0MlAmBrhqAAABEklEQVQoz+3SP0vDUBAA8EszJINgJsGpq26ZxK1fwcW9bo7FyUGaFkEHQQTX0tXRjiKiLRV0c3QQbIKuJYOir/QlJ3p/XoZ+BN/04zi4u3cHqA/+WeHXgzI9Fs4+ZsI3TITfL8/Ciae5JXjMZQswJwZmd3NE9FuDrfs/lt2d2lGXovAerZwz03gjI4ZZHI+JUQrAuWettXWfc23NC4id9vXnBXFc9PdybsdkJ9LZfDjS2TqHShg6Nt0Uyj40E+ap46PjFUQNqeZ4A1Fdo9BYxFBYICZaeH/BArAI3LJ84R3KV+Nrqbx0xAp7oFxyXBVaHAD3YBMjNO1im3lg3ZWY6u3kxAni7ZT4hPUeX0le+uEvfwAZzbx2rozFmgAAAABJRU5ErkJggg==";
+
+        // Create an ethscription with this content as a data URI (text/plain with base64 encoded content)
+        bytes32 txHash = keccak256(abi.encodePacked("test_datauri_edge_case"));
+
+        // Create params with the data URI content
+        // The content itself is a data URI string, stored as text/plain
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            creator,
+            string(abi.encodePacked("data:text/plain;base64,", Base64.encode(bytes(dataURIContent)))),
+            false
+        );
+
+        // Create the ethscription
+        vm.prank(creator);
+        uint256 tokenId = ethscriptions.createEthscription(params);
+
+        // Get the tokenURI
+        string memory tokenURI = ethscriptions.tokenURI(tokenId);
+
+        console.log("\n=== DATA URI EDGE CASE TEST ===\n");
+        console.log("Original content (a data URI string):");
+        console.log(dataURIContent);
+        console.log("\nThis content is stored as text/plain in the ethscription.");
+        console.log("\nGenerated tokenURI (paste this into your browser):\n");
+        console.log(tokenURI);
+        console.log("\n=== EXPECTED BEHAVIOR ===");
+        console.log("The viewer should display the full data URI string as text.");
+        console.log("You should NOT see an image, but rather the data URI text itself.");
+        console.log("\n=============================\n");
+    }
+
+    function testBinaryPNGTokenURI() public {
+        // Create actual binary PNG content (tiny 1x1 pixel PNG)
+        bytes memory pngBytes = hex"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c636080000000050001d507211200000000";
+
+        bytes32 txHash = keccak256(abi.encodePacked("test_binary_png"));
+
+        // Create params with binary PNG data as base64 data URI
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            creator,
+            string(abi.encodePacked("data:image/png;base64,", Base64.encode(pngBytes))),
+            false
+        );
+
+        // Create the ethscription
+        vm.prank(creator);
+        uint256 tokenId = ethscriptions.createEthscription(params);
+
+        // Get the tokenURI
+        string memory tokenURI = ethscriptions.tokenURI(tokenId);
+
+        console.log("\n=== BINARY PNG TEST ===\n");
+        console.log("Binary PNG data stored with mimetype: image/png");
+        console.log("\nGenerated tokenURI (paste this into your browser):\n");
+        console.log(tokenURI);
+        console.log("\n=== EXPECTED BEHAVIOR ===");
+        console.log("The viewer should display a data URI starting with 'data:image/png;base64,...'");
+        console.log("This is because binary content cannot be decoded as UTF-8.");
+        console.log("\n=============================\n");
+    }
+}
\ No newline at end of file
diff --git a/contracts/test/ERC404FixedDenominationNullOwner.t.sol b/contracts/test/ERC404FixedDenominationNullOwner.t.sol
new file mode 100644
index 0000000..90a7c33
--- /dev/null
+++ b/contracts/test/ERC404FixedDenominationNullOwner.t.sol
@@ -0,0 +1,153 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+import "../src/ERC20FixedDenominationManager.sol";
+import "../src/ERC20FixedDenomination.sol";
+import {LibString} from "solady/utils/LibString.sol";
+
+contract ERC404FixedDenominationNullOwnerTest is TestSetup {
+    using LibString for uint256;
+
+    string constant CANONICAL_PROTOCOL = "erc-20-fixed-denomination";
+    address alice = address(0x1);
+    address bob = address(0x2);
+
+    error NotImplemented();
+
+    function setUp() public override {
+        super.setUp();
+    }
+
+    function createTokenParams(
+        bytes32 transactionHash,
+        address initialOwner,
+        string memory contentUri,
+        string memory protocol,
+        string memory operation,
+        bytes memory data
+    ) internal pure returns (Ethscriptions.CreateEthscriptionParams memory) {
+        bytes memory contentUriBytes = bytes(contentUri);
+        bytes32 contentUriSha = sha256(contentUriBytes);
+        bytes memory content;
+        if (contentUriBytes.length > 6) {
+            content = new bytes(contentUriBytes.length - 6);
+            for (uint256 i = 0; i < content.length; i++) {
+                content[i] = contentUriBytes[i + 6];
+            }
+        }
+        return Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: transactionHash,
+            contentUriSha: contentUriSha,
+            initialOwner: initialOwner,
+            content: content,
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: protocol,
+                operation: operation,
+                data: data
+            })
+        });
+    }
+
+    function deployToken(string memory tick, uint256 maxSupply, uint256 mintAmount, bytes32 deployId, address initialOwner)
+        internal
+        returns (address tokenAddr)
+    {
+        ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({
+            tick: tick,
+            maxSupply: maxSupply,
+            mintAmount: mintAmount
+        });
+        string memory deployContent =
+            string(abi.encodePacked('data:,{"p":"erc-20","op":"deploy","tick":"', tick, '","max":"', maxSupply.toString(), '","lim":"', mintAmount.toString(), '"}'));
+
+        vm.prank(initialOwner);
+        ethscriptions.createEthscription(
+            createTokenParams(
+                deployId,
+                initialOwner,
+                deployContent,
+                CANONICAL_PROTOCOL,
+                "deploy",
+                abi.encode(deployOp)
+            )
+        );
+        tokenAddr = fixedDenominationManager.getTokenAddressByTick(tick);
+    }
+
+    function mintNote(address tokenAddr, string memory tick, uint256 id, uint256 amount, bytes32 mintTx, address initialOwner)
+        internal
+    {
+        ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({
+            tick: tick,
+            id: id,
+            amount: amount
+        });
+        string memory mintContent =
+            string(abi.encodePacked('data:,{"p":"erc-20","op":"mint","tick":"', tick, '","id":"', id.toString(), '","amt":"', amount.toString(), '"}'));
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(
+            createTokenParams(
+                mintTx,
+                initialOwner,
+                mintContent,
+                CANONICAL_PROTOCOL,
+                "mint",
+                abi.encode(mintOp)
+            )
+        );
+    }
+
+    function testMintToOwnerAndNullOwnerViaManager() public {
+        address tokenAddr = deployToken("TNULL", 10000, 1000, bytes32(uint256(0x9999)), alice);
+        ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr);
+
+        // Mint to bob
+        mintNote(tokenAddr, "TNULL", 1, 1000, bytes32(uint256(0xAAAA)), bob);
+        assertEq(token.balanceOf(bob), 1000 * 1e18);
+        assertEq(token.ownerOf(1), bob);
+        assertEq(token.totalSupply(), 1000 * 1e18);
+
+        // Mint to null owner (initialOwner = 0) should end with 0x0 owning NFT and ERC20
+        mintNote(tokenAddr, "TNULL", 2, 1000, bytes32(uint256(0xBBBB)), address(0));
+        assertEq(token.balanceOf(address(0)), 1000 * 1e18);
+        assertEq(token.ownerOf(2), address(0));
+        assertEq(token.totalSupply(), 2000 * 1e18);
+    }
+
+    function testForceTransferToZeroKeepsSupply() public {
+        address tokenAddr = deployToken("FORCE", 10000, 1000, bytes32(uint256(0x4242)), alice);
+        ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr);
+
+        // Mint to bob
+        mintNote(tokenAddr, "FORCE", 1, 1000, bytes32(uint256(0xCAFE)), bob);
+        uint256 supplyBefore = token.totalSupply();
+
+        // Manager forceTransfer to zero
+        vm.prank(address(fixedDenominationManager));
+        token.forceTransfer(bob, address(0), 1);
+
+        assertEq(token.totalSupply(), supplyBefore);
+        assertEq(token.balanceOf(address(0)), 1000 * 1e18);
+        assertEq(token.ownerOf(1), address(0));
+    }
+
+    function testCapEnforcedOnMint() public {
+        // cap: maxSupply 1000, mintAmount 1000 => only 1 note allowed
+        address tokenAddr = deployToken("CAPX", 1000, 1000, bytes32(uint256(0xDEAD)), alice);
+        ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr);
+
+        // First mint succeeds
+        mintNote(tokenAddr, "CAPX", 1, 1000, bytes32(uint256(0x1111)), bob);
+        assertEq(token.totalSupply(), 1000 * 1e18);
+
+        // Second mint should revert on cap (call mint directly via manager role)
+        vm.prank(address(fixedDenominationManager));
+        vm.expectRevert();
+        token.mint(bob, 2);
+        assertEq(token.totalSupply(), 1000 * 1e18);
+    }
+}
diff --git a/contracts/test/ERC721Enumerable.t.sol b/contracts/test/ERC721Enumerable.t.sol
new file mode 100644
index 0000000..b9a1370
--- /dev/null
+++ b/contracts/test/ERC721Enumerable.t.sol
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.24;
+
+import "forge-std/Test.sol";
+import "../src/Ethscriptions.sol";
+import "./TestSetup.sol";
+
+contract ERC721EnumerableTest is TestSetup {
+    address alice = makeAddr("alice");
+    address bob = makeAddr("bob");
+
+    function _createEthscription(
+        address creator,
+        address owner,
+        bytes32 txHash,
+        string memory content
+    ) internal returns (uint256) {
+        vm.prank(creator);
+        return ethscriptions.createEthscription(
+            Ethscriptions.CreateEthscriptionParams({
+                ethscriptionId: txHash,
+                contentUriSha: keccak256(bytes(content)),
+                initialOwner: owner,
+                content: bytes(content),
+                mimetype: "text/plain",
+                esip6: false,
+                protocolParams: Ethscriptions.ProtocolParams({
+                    protocolName: "",
+                    operation: "",
+                    data: ""
+                })
+            })
+        );
+    }
+
+    function testTotalSupplyStartsWithGenesis() public {
+        // Genesis creates some initial ethscriptions
+        uint256 initialSupply = ethscriptions.totalSupply();
+        assertTrue(initialSupply > 0, "Should have genesis ethscriptions");
+    }
+
+    function testTotalSupplyIncrementsOnMint() public {
+        uint256 initialSupply = ethscriptions.totalSupply();
+
+        // Create ethscription for alice
+        _createEthscription(alice, alice, keccak256("tx1"), "hello1");
+        assertEq(ethscriptions.totalSupply(), initialSupply + 1);
+
+        // Create another for bob
+        _createEthscription(bob, bob, keccak256("tx2"), "hello2");
+        assertEq(ethscriptions.totalSupply(), initialSupply + 2);
+    }
+
+    function testTokenByIndex() public {
+        // Create multiple ethscriptions
+        _createEthscription(alice, alice, keccak256("tx1"), "hello1");
+        _createEthscription(bob, bob, keccak256("tx2"), "hello2");
+
+        // Check tokenByIndex
+        assertEq(ethscriptions.tokenByIndex(0), 0); // First token has ID 0
+        assertEq(ethscriptions.tokenByIndex(1), 1); // Second token has ID 1
+    }
+
+    function testTokenOfOwnerByIndex() public {
+        uint256 aliceInitialBalance = ethscriptions.balanceOf(alice);
+        uint256 bobInitialBalance = ethscriptions.balanceOf(bob);
+
+        // Create multiple ethscriptions
+        uint256 token1 = _createEthscription(alice, alice, keccak256("tx1"), "hello1");
+        uint256 token2 = _createEthscription(alice, alice, keccak256("tx2"), "hello2");
+        uint256 token3 = _createEthscription(bob, bob, keccak256("tx3"), "hello3");
+
+        // Alice owns 2 more tokens
+        assertEq(ethscriptions.balanceOf(alice), aliceInitialBalance + 2);
+        assertEq(ethscriptions.tokenOfOwnerByIndex(alice, aliceInitialBalance), token1);
+        assertEq(ethscriptions.tokenOfOwnerByIndex(alice, aliceInitialBalance + 1), token2);
+
+        // Bob owns 1 more token
+        assertEq(ethscriptions.balanceOf(bob), bobInitialBalance + 1);
+        assertEq(ethscriptions.tokenOfOwnerByIndex(bob, bobInitialBalance), token3);
+    }
+
+    function testTransferUpdatesEnumeration() public {
+        uint256 aliceInitialBalance = ethscriptions.balanceOf(alice);
+        uint256 bobInitialBalance = ethscriptions.balanceOf(bob);
+
+        // Create ethscription owned by alice
+        uint256 tokenId = _createEthscription(alice, alice, keccak256("tx1"), "hello");
+
+        // Transfer from alice to bob
+        vm.prank(alice);
+        ethscriptions.transferFrom(alice, bob, tokenId);
+
+        // Check alice's balance decreased
+        assertEq(ethscriptions.balanceOf(alice), aliceInitialBalance);
+
+        // Check bob's balance increased
+        assertEq(ethscriptions.balanceOf(bob), bobInitialBalance + 1);
+        assertEq(ethscriptions.tokenOfOwnerByIndex(bob, bobInitialBalance), tokenId);
+    }
+
+    function testSupportsIERC721Enumerable() public {
+        // Check that it supports IERC721Enumerable interface
+        bytes4 ierc721EnumerableInterfaceId = 0x780e9d63;
+        assertTrue(ethscriptions.supportsInterface(ierc721EnumerableInterfaceId));
+    }
+
+    function testNullAddressOwnership() public {
+        uint256 nullInitialBalance = ethscriptions.balanceOf(address(0));
+        uint256 initialSupply = ethscriptions.totalSupply();
+
+        // Create ethscription owned by alice
+        uint256 tokenId = _createEthscription(alice, alice, keccak256("tx1"), "hello");
+
+        // Transfer to address(0) - this should work in our implementation
+        vm.prank(alice);
+        ethscriptions.transferFrom(alice, address(0), tokenId);
+
+        // Check that address(0) owns the token
+        assertEq(ethscriptions.ownerOf(tokenId), address(0));
+        assertEq(ethscriptions.balanceOf(address(0)), nullInitialBalance + 1);
+
+        // Token should still be enumerable
+        assertEq(ethscriptions.totalSupply(), initialSupply + 1);
+        assertEq(ethscriptions.tokenOfOwnerByIndex(address(0), nullInitialBalance), tokenId);
+    }
+
+    function testRevertOnOutOfBoundsIndex() public {
+        uint256 currentSupply = ethscriptions.totalSupply();
+
+        // Test tokenByIndex out of bounds
+        vm.expectRevert(abi.encodeWithSelector(ERC721EthscriptionsUpgradeable.ERC721OutOfBoundsIndex.selector, address(0), currentSupply));
+        ethscriptions.tokenByIndex(currentSupply);
+
+        // Test tokenOfOwnerByIndex out of bounds for alice
+        uint256 aliceBalance = ethscriptions.balanceOf(alice);
+        if (aliceBalance > 0) {
+            vm.expectRevert(abi.encodeWithSelector(ERC721EthscriptionsUpgradeable.ERC721OutOfBoundsIndex.selector, alice, aliceBalance));
+            ethscriptions.tokenOfOwnerByIndex(alice, aliceBalance);
+        } else {
+            // Create one token for alice if she has none
+            _createEthscription(alice, alice, keccak256("tx1"), "hello");
+            vm.expectRevert(abi.encodeWithSelector(ERC721EthscriptionsUpgradeable.ERC721OutOfBoundsIndex.selector, alice, 1));
+            ethscriptions.tokenOfOwnerByIndex(alice, 1);
+        }
+    }
+}
diff --git a/contracts/test/ERC721EthscriptionsMixins.t.sol b/contracts/test/ERC721EthscriptionsMixins.t.sol
new file mode 100644
index 0000000..3f245a4
--- /dev/null
+++ b/contracts/test/ERC721EthscriptionsMixins.t.sol
@@ -0,0 +1,144 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.24;
+
+import "forge-std/Test.sol";
+import "../src/ERC721EthscriptionsSequentialEnumerableUpgradeable.sol";
+import "../src/ERC721EthscriptionsEnumerableUpgradeable.sol";
+
+contract SequentialEnumerableHarness is ERC721EthscriptionsSequentialEnumerableUpgradeable {
+    function initialize() external initializer {
+        __ERC721_init("SequentialHarness", "SEQH");
+    }
+
+    function mint(address to, uint256 tokenId) external {
+        _mint(to, tokenId);
+    }
+
+    function burn(uint256 tokenId) external {
+        _setTokenExists(tokenId, false);
+    }
+
+    function tokenURI(uint256) public pure override returns (string memory) {
+        return "";
+    }
+}
+
+contract EnumerableHarness is ERC721EthscriptionsEnumerableUpgradeable {
+    function initialize() external initializer {
+        __ERC721_init("EnumerableHarness", "ENUMH");
+    }
+
+    function mint(address to, uint256 tokenId) external {
+        _mint(to, tokenId);
+    }
+
+    function burn(uint256 tokenId) external {
+        _setTokenExists(tokenId, false);
+    }
+
+    function forceTransfer(address to, uint256 tokenId) external {
+        _update(to, tokenId, address(0));
+    }
+
+    function tokenURI(uint256) public pure override returns (string memory) {
+        return "";
+    }
+}
+
+contract ERC721EthscriptionsMixinsTest is Test {
+    SequentialEnumerableHarness internal sequential;
+    EnumerableHarness internal enumerable;
+
+    address internal alice = address(0xA11CE);
+    address internal bob = address(0xB0B);
+
+    function setUp() public {
+        sequential = new SequentialEnumerableHarness();
+        sequential.initialize();
+
+        enumerable = new EnumerableHarness();
+        enumerable.initialize();
+    }
+
+    function testSequentialMintEnforcesOrdering() public {
+        sequential.mint(alice, 0);
+        sequential.mint(alice, 1);
+
+        assertEq(sequential.totalSupply(), 2);
+        assertEq(sequential.tokenByIndex(0), 0);
+        assertEq(sequential.tokenByIndex(1), 1);
+        assertEq(sequential.tokenOfOwnerByIndex(alice, 0), 0);
+        assertEq(sequential.tokenOfOwnerByIndex(alice, 1), 1);
+    }
+
+    function testSequentialMintRejectsSkippedIds() public {
+        vm.expectRevert(
+            abi.encodeWithSelector(
+                ERC721EthscriptionsSequentialEnumerableUpgradeable
+                    .ERC721SequentialEnumerableInvalidTokenId
+                    .selector,
+                0,
+                1
+            )
+        );
+        sequential.mint(alice, 1);
+    }
+
+    function testSequentialBurnForbidden() public {
+        sequential.mint(alice, 0);
+
+        vm.expectRevert(
+            abi.encodeWithSelector(
+                ERC721EthscriptionsSequentialEnumerableUpgradeable
+                    .ERC721SequentialEnumerableTokenRemoval
+                    .selector,
+                0
+            )
+        );
+        sequential.burn(0);
+    }
+
+    function testEnumerableTracksSparseIdsAcrossBurns() public {
+        enumerable.mint(alice, 10);
+        enumerable.mint(alice, 20);
+        enumerable.mint(alice, 30);
+
+        assertEq(enumerable.totalSupply(), 3);
+        assertEq(enumerable.tokenByIndex(0), 10);
+        assertEq(enumerable.tokenByIndex(1), 20);
+        assertEq(enumerable.tokenByIndex(2), 30);
+
+        enumerable.burn(20);
+        assertEq(enumerable.totalSupply(), 2);
+        assertEq(enumerable.tokenByIndex(0), 10);
+        assertEq(enumerable.tokenByIndex(1), 30);
+        vm.expectRevert(
+            abi.encodeWithSelector(
+                ERC721EthscriptionsUpgradeable.ERC721OutOfBoundsIndex.selector,
+                address(0),
+                2
+            )
+        );
+        enumerable.tokenByIndex(2);
+
+        enumerable.mint(alice, 40);
+        assertEq(enumerable.totalSupply(), 3);
+        assertEq(enumerable.tokenByIndex(2), 40);
+    }
+
+    function testEnumerableUpdatesOwnerEnumerationOnTransfer() public {
+        enumerable.mint(alice, 1);
+        enumerable.mint(alice, 2);
+
+        assertEq(enumerable.balanceOf(alice), 2);
+        assertEq(enumerable.tokenOfOwnerByIndex(alice, 0), 1);
+        assertEq(enumerable.tokenOfOwnerByIndex(alice, 1), 2);
+
+        enumerable.forceTransfer(bob, 1);
+
+        assertEq(enumerable.balanceOf(alice), 1);
+        assertEq(enumerable.balanceOf(bob), 1);
+        assertEq(enumerable.tokenOfOwnerByIndex(alice, 0), 2);
+        assertEq(enumerable.tokenOfOwnerByIndex(bob, 0), 1);
+    }
+}
diff --git a/contracts/test/EndToEndCompression.t.sol b/contracts/test/EndToEndCompression.t.sol
new file mode 100644
index 0000000..89a369c
--- /dev/null
+++ b/contracts/test/EndToEndCompression.t.sol
@@ -0,0 +1,118 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+import {LibZip} from "solady/utils/LibZip.sol";
+import {LibString} from "solady/utils/LibString.sol";
+import "forge-std/console.sol";
+
+contract EndToEndCompressionTest is TestSetup {
+    using LibZip for bytes;
+    using LibString for *;
+    
+    // function testRubyCompressionEndToEnd() public {
+    //     // Load example ethscription content
+    //     string memory json = vm.readFile("test/example_ethscription.json");
+    //     // string memory originalContent = vm.parseJsonString(json, ".result.content_uri");
+    //     string memory originalContent = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAoMBg+QK2EoAAAAASUVORK5CYII=";
+    //     // Call Ruby script to compress the content
+    //     string[] memory inputs = new string[](3);
+    //     inputs[0] = "ruby";
+    //     inputs[1] = "test/compress_content.rb";
+    //     inputs[2] = originalContent;
+        
+    //     bytes memory result = vm.ffi(inputs);
+        
+    //     // Parse the JSON response from Ruby
+    //     string memory jsonResult = string(result);
+    //     bytes memory compressedContent = vm.parseJsonBytes(jsonResult, ".compressed");
+    //     bool isCompressed = vm.parseJsonBool(jsonResult, ".is_compressed");
+    //     uint256 originalSize = vm.parseJsonUint(jsonResult, ".original_size");
+    //     uint256 compressedSize = vm.parseJsonUint(jsonResult, ".compressed_size");
+        
+    //     console.log("Original size:", bytes(originalContent).length);
+    //     console.log("Original rune count:", originalContent.runeCount());
+    //     console.log("Compressed size:", compressedContent.length);
+    //     console.log("Is compressed:", isCompressed);
+        
+    //     if (isCompressed) {
+    //         console.log("Compression ratio:", (compressedSize * 100) / originalSize, "%");
+            
+    //         // Verify the compressed content can be decompressed
+    //         bytes memory decompressed = LibZip.flzDecompress(compressedContent);
+    //         assertEq(decompressed.toHexString(), bytes(originalContent).toHexString(), "Decompressed content should match original");
+    //     }
+        
+    //     // // Create ethscription with the result from Ruby
+    //     // bytes32 txHash = bytes32(uint256(0x52554259)); // "RUBY" in hex
+    //     // address owner = address(0xCAFE);
+        
+    //     // vm.prank(owner);
+    //     // uint256 tokenId = ethscriptions.createEthscription(Ethscriptions.CreateEthscriptionParams({
+    //     //     transactionHash: txHash,
+    //     //     initialOwner: owner,
+    //     //     contentUri: isCompressed ? compressedContent : bytes(originalContent),
+    //     //     mimetype: "image/png",
+    //     //     esip6: false,
+    //     //     isCompressed: isCompressed,
+    //     //     protocolParams: Ethscriptions.ProtocolParams({
+    //     //         protocolName: "",
+    //     //         operation: "",
+    //     //         data: ""
+    //     //     })
+    //     // }));
+        
+    //     // // Verify the ethscription was created
+    //     // assertEq(tokenId, uint256(txHash));
+    //     // assertEq(ethscriptions.ownerOf(tokenId), owner);
+        
+    //     // // Get tokenURI - should automatically decompress if needed
+    //     // string memory tokenURI = ethscriptions.tokenURI(tokenId);
+    //     // assertEq(tokenURI, originalContent, "Retrieved content should match original");
+        
+    //     // console.log("Successfully created ethscription with Ruby compression decision!");
+    // }
+    
+    // function testMultipleContentsWithRuby() public {
+    //     // Test various content types through Ruby
+    //     string[3] memory testContents;
+    //     testContents[0] = "data:text/plain,Hello World!"; // Small text - shouldn't compress
+        
+    //     // Build a repetitive JSON string that should compress well
+    //     bytes memory jsonData = abi.encodePacked('{"data":"');
+    //     for (uint i = 0; i < 100; i++) {
+    //         jsonData = abi.encodePacked(jsonData, 'AAAAAAAAAA');
+    //     }
+    //     jsonData = abi.encodePacked(jsonData, '"}');
+    //     testContents[1] = string(abi.encodePacked("data:application/json,", jsonData));
+        
+    //     testContents[2] = "data:image/svg+xml,"; // SVG - should compress
+        
+    //     for (uint i = 0; i < testContents.length; i++) {
+    //         console.log("Testing content", i);
+            
+    //         string[] memory inputs = new string[](3);
+    //         inputs[0] = "ruby";
+    //         inputs[1] = "test/compress_content.rb";
+    //         inputs[2] = testContents[i];
+            
+    //         bytes memory result = vm.ffi(inputs);
+    //         string memory jsonResult = string(result);
+            
+    //         bool isCompressed = vm.parseJsonBool(jsonResult, ".is_compressed");
+    //         uint256 originalSize = vm.parseJsonUint(jsonResult, ".original_size");
+    //         uint256 compressedSize = vm.parseJsonUint(jsonResult, ".compressed_size");
+            
+    //         console.log("  Original size:", originalSize);
+    //         console.log("  Result size:", compressedSize);
+    //         console.log("  Compressed:", isCompressed);
+            
+    //         if (isCompressed) {
+    //             uint256 ratio = (compressedSize * 100) / originalSize;
+    //             console.log("  Compression ratio:", ratio, "%");
+    //             // Should be at least 10% smaller if compressed
+    //             assertTrue(ratio <= 90, "Compression should achieve at least 10% reduction");
+    //         }
+    //     }
+    // }
+}
\ No newline at end of file
diff --git a/contracts/test/EthscriptionsBurn.t.sol b/contracts/test/EthscriptionsBurn.t.sol
new file mode 100644
index 0000000..d6c6dd3
--- /dev/null
+++ b/contracts/test/EthscriptionsBurn.t.sol
@@ -0,0 +1,166 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+
+contract EthscriptionsBurnTest is TestSetup {
+    address alice = makeAddr("alice");
+    address bob = makeAddr("bob");
+    bytes32 testTxHash = keccak256("test_tx");
+    
+    function setUp() public override {
+        super.setUp();
+        
+        // Create a test ethscription owned by alice
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            testTxHash,
+            alice,
+            "data:text/plain,Hello World",
+            false
+        );
+        
+        vm.prank(alice);
+        ethscriptions.createEthscription(params);
+    }
+    
+    function testBurnViaTransferToAddressZero() public {
+        uint256 tokenId = ethscriptions.getTokenId(testTxHash);
+
+        // Verify alice owns the ethscription
+        assertEq(ethscriptions.ownerOf(tokenId), alice);
+        
+        // Alice transfers the ethscription to address(0) (null ownership, not burn)
+        vm.prank(alice);
+        ethscriptions.transferFrom(alice, address(0), tokenId);
+
+        // Verify the token is now owned by address(0)
+        assertEq(ethscriptions.ownerOf(tokenId), address(0));
+        
+        // Verify the ethscription data still exists
+        Ethscriptions.Ethscription memory etsc = ethscriptions.getEthscription(testTxHash);
+        assertEq(etsc.creator, alice);
+        assertEq(etsc.previousOwner, alice); // Previous owner should be set to alice
+    }
+    
+    function testBurnViaTransferEthscription() public {
+        uint256 tokenId = ethscriptions.getTokenId(testTxHash);
+
+        // Verify alice owns the ethscription
+        assertEq(ethscriptions.ownerOf(tokenId), alice);
+        
+        // Alice transfers using transferEthscription function to address(0)
+        vm.prank(alice);
+        ethscriptions.transferEthscription(address(0), testTxHash);
+
+        // Verify the token is now owned by address(0)
+        assertEq(ethscriptions.ownerOf(tokenId), address(0));
+        
+        // Verify previousOwner was updated
+        Ethscriptions.Ethscription memory etsc = ethscriptions.getEthscription(testTxHash);
+        assertEq(etsc.previousOwner, alice);
+    }
+    
+    function testBurnWithPreviousOwnerValidation() public {
+        uint256 tokenId = ethscriptions.getTokenId(testTxHash);
+
+        // First transfer from alice to bob
+        vm.prank(alice);
+        ethscriptions.transferFrom(alice, bob, tokenId);
+        
+        // Verify bob owns it and alice is previous owner
+        assertEq(ethscriptions.ownerOf(tokenId), bob);
+        Ethscriptions.Ethscription memory etsc = ethscriptions.getEthscription(testTxHash);
+        assertEq(etsc.previousOwner, alice);
+        
+        // Bob transfers to address(0) with previous owner validation
+        vm.prank(bob);
+        ethscriptions.transferEthscriptionForPreviousOwner(address(0), testTxHash, alice);
+
+        // Verify the token is now owned by address(0)
+        assertEq(ethscriptions.ownerOf(tokenId), address(0));
+        
+        // Verify previousOwner was updated to bob
+        etsc = ethscriptions.getEthscription(testTxHash);
+        assertEq(etsc.previousOwner, bob);
+    }
+    
+    function testCannotTransferBurnedToken() public {
+        uint256 tokenId = ethscriptions.getTokenId(testTxHash);
+
+        // Alice burns the token
+        vm.prank(alice);
+        ethscriptions.transferFrom(alice, address(0), tokenId);
+        
+        // Try to transfer a burned token (should fail)
+        vm.prank(alice);
+        vm.expectRevert();
+        ethscriptions.transferFrom(address(0), bob, tokenId);
+    }
+    
+    function testBurnUpdatesBalances() public {
+        uint256 tokenId = ethscriptions.getTokenId(testTxHash);
+
+        // Check initial balance
+        assertEq(ethscriptions.balanceOf(alice), 1);
+        
+        // Burn the token
+        vm.prank(alice);
+        ethscriptions.transferFrom(alice, address(0), tokenId);
+        
+        // Check balance after burn
+        assertEq(ethscriptions.balanceOf(alice), 0);
+        // Note: We can't check balanceOf(address(0)) as OpenZeppelin prevents that
+        // But the token should be burned (not owned by anyone)
+    }
+    
+    function testOnlyOwnerCanBurn() public {
+        uint256 tokenId = ethscriptions.getTokenId(testTxHash);
+
+        // Bob tries to burn alice's token (should fail)
+        vm.prank(bob);
+        vm.expectRevert();
+        ethscriptions.transferFrom(alice, address(0), tokenId);
+        
+        // Token should still be owned by alice
+        assertEq(ethscriptions.ownerOf(tokenId), alice);
+    }
+    
+    function testApprovedCanBurn() public {
+        // Approvals are not supported in our implementation
+        uint256 tokenId = ethscriptions.getTokenId(testTxHash);
+
+        // Alice tries to approve bob (should fail)
+        vm.prank(alice);
+        vm.expectRevert("Approvals not supported");
+        ethscriptions.approve(bob, tokenId);
+
+        // Verify alice still owns the token
+        assertEq(ethscriptions.ownerOf(tokenId), alice);
+    }
+    
+    function testBurnCallsERC20FixedDenominationManagerHandleTransfer() public {
+        // Create a simple non-token ethscription first to test basic burn
+        bytes32 simpleTxHash = keccak256("simple_tx");
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            simpleTxHash,
+            alice,
+            "data:text/plain,Simple text",
+            false
+        );
+        
+        vm.prank(alice);
+        ethscriptions.createEthscription(params);
+        
+        uint256 simpleTokenId = ethscriptions.getTokenId(simpleTxHash);
+
+        // Transfer the ethscription to address(0) (null ownership)
+        vm.prank(alice);
+        ethscriptions.transferFrom(alice, address(0), simpleTokenId);
+
+        // Verify it's owned by address(0)
+        assertEq(ethscriptions.ownerOf(simpleTokenId), address(0));
+
+        // The transfer should have called ERC20FixedDenominationManager.handleTokenTransfer with to=address(0)
+        // This ensures ERC20FixedDenominationManager is notified of transfers to null address
+    }
+}
\ No newline at end of file
diff --git a/contracts/test/EthscriptionsCompression.t.sol b/contracts/test/EthscriptionsCompression.t.sol
new file mode 100644
index 0000000..b5e6ab4
--- /dev/null
+++ b/contracts/test/EthscriptionsCompression.t.sol
@@ -0,0 +1,133 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+import {LibZip} from "solady/utils/LibZip.sol";
+import "forge-std/console.sol";
+
+contract EthscriptionsCompressionTest is TestSetup {
+    using LibZip for bytes;
+    
+    function testEthscriptionCreation() public {
+        // Load the actual example ethscription content
+        string memory json = vm.readFile("test/example_ethscription.json");
+        string memory contentUri = vm.parseJsonString(json, ".result.content_uri");
+
+        console.log("Content URI size:", bytes(contentUri).length);
+
+        // Create ethscription
+        bytes32 txHash = bytes32(uint256(0xC0113E55));
+        address owner = address(0x1337);
+
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            owner,
+            contentUri,
+            false
+        );
+
+        vm.prank(owner);
+        uint256 tokenId = ethscriptions.createEthscription(params);
+        
+        // Verify the ethscription was created
+        assertEq(tokenId, ethscriptions.getTokenId(txHash));
+        assertEq(ethscriptions.ownerOf(tokenId), owner);
+        
+        // Get tokenURI - now returns JSON metadata
+        string memory tokenURI = ethscriptions.tokenURI(tokenId);
+
+        // Verify tokenURI returns valid JSON (starts with data:application/json)
+        assertTrue(
+            bytes(tokenURI).length > 0,
+            "Token URI should not be empty"
+        );
+
+        // Use _getContentDataURI to verify decompressed content
+        // Note: Since _getContentDataURI is internal, we test via the JSON containing the content
+        // The JSON should contain our original content in the image field
+        
+        console.log("Successfully created and retrieved compressed ethscription!");
+    }
+    
+    function testUncompressedEthscriptionCreation() public {
+        // Test regular uncompressed creation still works
+        string memory contentUri = "data:,Hello World!";
+        bytes32 txHash = bytes32(uint256(0x00C0113E));
+        address owner = address(0xBEEF);
+
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            owner,
+            contentUri,
+            false
+        );
+
+        vm.prank(owner);
+        uint256 tokenId = ethscriptions.createEthscription(params);
+        
+        // Verify - tokenURI now returns JSON metadata
+        string memory tokenURI = ethscriptions.tokenURI(tokenId);
+
+        // Verify tokenURI returns valid JSON
+        assertTrue(
+            bytes(tokenURI).length > 0,
+            "Token URI should not be empty"
+        );
+
+        // The JSON should contain our original content in the image field
+        // Since we can't easily parse JSON in Solidity, we just verify it exists
+    }
+    
+    // function testCompressionGasSavings() public {
+    //     // Load example content
+    //     string memory json = vm.readFile("test/example_ethscription.json");
+    //     bytes memory originalContent = bytes(vm.parseJsonString(json, ".result.content_uri"));
+    //     bytes memory compressedContent = LibZip.flzCompress(originalContent);
+        
+    //     address owner = address(0x6A5);
+        
+    //     // Measure gas for uncompressed creation
+    //     vm.prank(owner);
+    //     uint256 gasStart = gasleft();
+    //     ethscriptions.createEthscription(Ethscriptions.CreateEthscriptionParams({
+    //         transactionHash: bytes32(uint256(0x1111)),
+    //         initialOwner: owner,
+    //         contentUri: originalContent,
+    //         mimetype: "image/png",
+    //         esip6: false,
+    //         isCompressed: false,
+    //         protocolParams: Ethscriptions.ProtocolParams({
+    //             protocolName: "", operation: "", data: ""
+    //         })
+    //     }));
+    //     uint256 uncompressedGas = gasStart - gasleft();
+        
+    //     // Measure gas for compressed creation
+    //     vm.prank(owner);
+    //     gasStart = gasleft();
+    //     ethscriptions.createEthscription(Ethscriptions.CreateEthscriptionParams({
+    //         transactionHash: bytes32(uint256(0x2222)),
+    //         initialOwner: owner,
+    //         contentUri: compressedContent,
+    //         mimetype: "image/png",
+    //         esip6: false,
+    //         isCompressed: true,
+    //         protocolParams: Ethscriptions.ProtocolParams({
+    //             protocolName: "", operation: "", data: ""
+    //         })
+    //     }));
+    //     uint256 compressedGas = gasStart - gasleft();
+        
+    //     console.log("Uncompressed creation gas:", uncompressedGas);
+    //     console.log("Compressed creation gas:", compressedGas);
+        
+    //     if (compressedGas < uncompressedGas) {
+    //         uint256 gasSaved = uncompressedGas - compressedGas;
+    //         console.log("Gas saved:", gasSaved);
+    //         console.log("Savings percentage:", (gasSaved * 100) / uncompressedGas, "%");
+    //     } else {
+    //         uint256 extraGas = compressedGas - uncompressedGas;
+    //         console.log("Extra gas for compression:", extraGas);
+    //     }
+    // }
+}
\ No newline at end of file
diff --git a/contracts/test/EthscriptionsFailureHandling.t.sol b/contracts/test/EthscriptionsFailureHandling.t.sol
new file mode 100644
index 0000000..16ff186
--- /dev/null
+++ b/contracts/test/EthscriptionsFailureHandling.t.sol
@@ -0,0 +1,229 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+import "../src/ERC20FixedDenominationManager.sol";
+import "../src/EthscriptionsProver.sol";
+import "forge-std/console.sol";
+
+// Mock contracts that can be configured to fail
+contract FailingERC20FixedDenominationManager is ERC20FixedDenominationManager {
+    bool public shouldFail;
+    string public failMessage = "ERC20FixedDenominationManager intentionally failed";
+
+    function setShouldFail(bool _shouldFail) external {
+        shouldFail = _shouldFail;
+    }
+
+    function setFailMessage(string memory _message) external {
+        failMessage = _message;
+    }
+
+    function op_deploy(bytes32 txHash, bytes calldata data) external override onlyEthscriptions {
+        if (shouldFail) {
+            revert(failMessage);
+        }
+        // Otherwise do nothing (simplified for testing)
+    }
+
+    function op_mint(bytes32 txHash, bytes calldata data) external override onlyEthscriptions {
+        if (shouldFail) {
+            revert(failMessage);
+        }
+        // Otherwise do nothing (simplified for testing)
+    }
+
+    function onTransfer(
+        bytes32 transactionHash,
+        address from,
+        address to
+    ) external override onlyEthscriptions {
+        if (shouldFail) {
+            revert(failMessage);
+        }
+        // Otherwise do nothing
+    }
+}
+
+contract FailingProver is EthscriptionsProver {
+    bool public shouldFail;
+    string public failMessage = "Prover intentionally failed";
+
+    function setShouldFail(bool _shouldFail) external {
+        shouldFail = _shouldFail;
+    }
+
+    function setFailMessage(string memory _message) external {
+        failMessage = _message;
+    }
+
+    function queueEthscription(bytes32 txHash) external override {
+        // For testing, always succeed
+        // In the new design, queueing doesn't fail and doesn't emit events
+    }
+}
+
+contract EthscriptionsFailureHandlingTest is TestSetup {
+    FailingERC20FixedDenominationManager failingERC20FixedDenominationManager;
+    FailingProver failingProver;
+
+    event ProtocolHandlerFailed(
+        bytes32 indexed transactionHash,
+        string indexed protocol,
+        bytes revertData
+    );
+
+    function setUp() public override {
+        super.setUp();
+
+        // Deploy failing mocks
+        failingERC20FixedDenominationManager = new FailingERC20FixedDenominationManager();
+        failingProver = new FailingProver();
+
+        // Replace the token manager and prover with our mocks
+        // We need to etch them at the predeploy addresses
+        vm.etch(Predeploys.ERC20_FIXED_DENOMINATION_MANAGER, address(failingERC20FixedDenominationManager).code);
+        vm.etch(Predeploys.ETHSCRIPTIONS_PROVER, address(failingProver).code);
+
+        // Update our references
+        fixedDenominationManager = ERC20FixedDenominationManager(Predeploys.ERC20_FIXED_DENOMINATION_MANAGER);
+        prover = EthscriptionsProver(Predeploys.ETHSCRIPTIONS_PROVER);
+    }
+
+    function testCreateEthscriptionWithERC20FixedDenominationManagerFailure() public {
+        // Configure ERC20FixedDenominationManager to fail
+        FailingERC20FixedDenominationManager(Predeploys.ERC20_FIXED_DENOMINATION_MANAGER).setShouldFail(true);
+        FailingERC20FixedDenominationManager(Predeploys.ERC20_FIXED_DENOMINATION_MANAGER).setFailMessage("Token operation rejected");
+
+        bytes32 txHash = keccak256("test_tx_1");
+        string memory dataUri = "data:,Hello World with failing token manager";
+
+        Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: txHash,
+            contentUriSha: sha256(bytes(dataUri)),
+            initialOwner: address(this),
+            content: bytes("Hello World with failing token manager"),
+            mimetype: "text/plain",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "test",
+                operation: "deploy",
+                data: abi.encode("TEST", uint256(1000000), uint256(100))
+            })
+        });
+
+        // Don't expect the ProtocolHandlerFailed event since this mock doesn't emit it properly
+
+        // Create ethscription - should succeed despite ERC20FixedDenominationManager failure
+        uint256 tokenId = ethscriptions.createEthscription(params);
+
+        // Verify the ethscription was created successfully
+        assertEq(ethscriptions.ownerOf(tokenId), address(this));
+        assertEq(ethscriptions.totalSupply(), 12); // 11 genesis + 1 new
+    }
+
+    function testCreateEthscriptionWithProverFailure() public {
+        // Note: With the new batched proving design, the prover doesn't fail immediately
+        // during creation. Instead, ethscriptions are queued for batch proving.
+        // This test now verifies that creation succeeds and the ethscription is queued.
+
+        bytes32 txHash = keccak256("test_tx_2");
+        string memory dataUri = "data:,Hello World with batched prover";
+
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            address(this),
+            dataUri,
+            false
+        );
+
+        // Create ethscription - should succeed and queue for proving silently (no event)
+        uint256 tokenId = ethscriptions.createEthscription(params);
+
+        // Verify the ethscription was created successfully
+        assertEq(ethscriptions.ownerOf(tokenId), address(this));
+        assertEq(ethscriptions.totalSupply(), 12);
+    }
+
+    function testTransferWithERC20FixedDenominationManagerFailure() public {
+        // First create an ethscription
+        bytes32 txHash = keccak256("test_tx_3");
+        string memory dataUri = "data:,Test transfer";
+
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            address(this),
+            dataUri,
+            false
+        );
+
+        uint256 tokenId = ethscriptions.createEthscription(params);
+
+        // Now configure both ERC20FixedDenominationManager and Prover to fail
+        FailingERC20FixedDenominationManager(Predeploys.ERC20_FIXED_DENOMINATION_MANAGER).setShouldFail(true);
+        FailingERC20FixedDenominationManager(Predeploys.ERC20_FIXED_DENOMINATION_MANAGER).setFailMessage("Transfer handling failed");
+        FailingProver(Predeploys.ETHSCRIPTIONS_PROVER).setShouldFail(true);
+
+        // Transfer should succeed despite failures
+        address recipient = address(0x1234);
+        ethscriptions.transferFrom(address(this), recipient, tokenId);
+
+        // Verify transfer succeeded even though external calls failed
+        assertEq(ethscriptions.ownerOf(tokenId), recipient);
+    }
+
+    function testBothFailuresOnCreate() public {
+        // Configure both to fail
+        FailingERC20FixedDenominationManager(Predeploys.ERC20_FIXED_DENOMINATION_MANAGER).setShouldFail(true);
+        FailingProver(Predeploys.ETHSCRIPTIONS_PROVER).setShouldFail(true);
+
+        bytes32 txHash = keccak256("test_tx_4");
+        string memory dataUri = "data:,{\"p\":\"test\",\"op\":\"deploy\",\"tick\":\"FAIL\",\"max\":\"1000\",\"lim\":\"10\"}";
+
+        Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({
+            ethscriptionId: txHash,
+            contentUriSha: sha256(bytes(dataUri)),
+            initialOwner: address(this),
+            content: bytes("{\"p\":\"test\",\"op\":\"deploy\",\"tick\":\"FAIL\",\"max\":\"1000\",\"lim\":\"10\"}"),
+            mimetype: "application/json",
+            esip6: false,
+            protocolParams: Ethscriptions.ProtocolParams({
+                protocolName: "test",
+                operation: "deploy",
+                data: abi.encode("FAIL", uint256(1000), uint256(10))
+            })
+        });
+
+        // Create should succeed despite both failures
+        uint256 tokenId = ethscriptions.createEthscription(params);
+
+        // Verify creation succeeded even though external calls failed
+        assertEq(ethscriptions.ownerOf(tokenId), address(this));
+        assertEq(ethscriptions.totalSupply(), 12);
+    }
+
+    function testSuccessfulOperationNoFailureEvents() public {
+        // Configure both to succeed
+        FailingERC20FixedDenominationManager(Predeploys.ERC20_FIXED_DENOMINATION_MANAGER).setShouldFail(false);
+        FailingProver(Predeploys.ETHSCRIPTIONS_PROVER).setShouldFail(false);
+
+        bytes32 txHash = keccak256("test_tx_5");
+        string memory dataUri = "data:,Success test";
+
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            address(this),
+            dataUri,
+            false
+        );
+
+        // Should NOT emit any failure events
+        // We test this by not expecting them - if they are emitted, test will fail
+
+        uint256 tokenId = ethscriptions.createEthscription(params);
+
+        // Verify success
+        assertEq(ethscriptions.ownerOf(tokenId), address(this));
+        assertEq(ethscriptions.totalSupply(), 12);
+    }
+}
diff --git a/contracts/test/EthscriptionsJson.t.sol b/contracts/test/EthscriptionsJson.t.sol
new file mode 100644
index 0000000..82d0029
--- /dev/null
+++ b/contracts/test/EthscriptionsJson.t.sol
@@ -0,0 +1,525 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+import "./EthscriptionsWithTestFunctions.sol";
+import "forge-std/StdJson.sol";
+
+contract EthscriptionsJsonTest is TestSetup {
+    using stdJson for string;
+
+    EthscriptionsWithTestFunctions internal eth;
+
+    function setUp() public override {
+        super.setUp();
+
+        // Deploy the test version of Ethscriptions with additional test functions
+        // and replace the regular Ethscriptions at the predeploy address
+        EthscriptionsWithTestFunctions testEthscriptions = new EthscriptionsWithTestFunctions();
+        vm.etch(Predeploys.ETHSCRIPTIONS, address(testEthscriptions).code);
+        eth = EthscriptionsWithTestFunctions(Predeploys.ETHSCRIPTIONS);
+    }
+
+    function test_CreateAndTokenURIGas_FromJson() public {
+        vm.pauseGasMetering();
+        (
+            bytes32 txHash,
+            address creator,
+            address initialOwner,
+            ,
+            string memory contentUri,
+            , // l1BlockNumber - no longer used
+            , // l1BlockTimestamp - no longer used
+            , // l1BlockHash - no longer used
+            , // transactionIndex - no longer used
+            string memory mimetype,
+            string memory mediaType,
+            string memory mimeSubtype
+        ) = _read();
+        
+        // Note: l1BlockNumber, l1BlockTimestamp, l1BlockHash, transactionIndex are read but no longer used
+
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            initialOwner,
+            contentUri,
+            false
+        );
+
+        vm.startPrank(creator);
+        uint256 g0 = gasleft();
+        vm.resumeGasMetering();
+        uint256 tokenId = eth.createEthscription(params);
+        vm.pauseGasMetering();
+        uint256 createGas = g0 - gasleft();
+        vm.stopPrank();
+
+        emit log_named_uint("createEthscription gas", createGas);
+
+        // State checks (not metered)
+        assertEq(eth.ownerOf(tokenId), initialOwner, "owner mismatch");
+
+        // tokenURI gas
+        g0 = gasleft();
+        vm.resumeGasMetering();
+        string memory got = eth.tokenURI(tokenId);
+        vm.pauseGasMetering();
+        uint256 uriGas = g0 - gasleft();
+        emit log_named_uint("tokenURI gas", uriGas);
+
+        // Validate JSON metadata format
+        assertTrue(startsWith(got, "data:application/json;base64,"), "Should return base64-encoded JSON");
+
+        // Decode and validate JSON contains expected fields
+        bytes memory base64Part = bytes(substring(got, 29, bytes(got).length));
+        bytes memory decodedJson = Base64.decode(string(base64Part));
+        string memory json = string(decodedJson);
+
+        // Check JSON contains the actual content in the image field
+        // Images are now wrapped in SVG for pixel-perfect rendering
+        assertTrue(contains(json, '"image":"data:image/svg+xml;base64,'), "JSON should contain SVG-wrapped image");
+        assertTrue(contains(json, '"name":"Ethscription #11"'), "Should have correct name");
+        assertTrue(contains(json, '"attributes":['), "Should have attributes array");
+    }
+
+    function _read()
+        internal
+        view
+        returns (
+            bytes32 txHash,
+            address creator,
+            address initialOwner,
+            address currentOwner,
+            string memory contentUri,
+            uint256, // l1BlockNumber - no longer used
+            uint256, // l1BlockTimestamp - no longer used
+            bytes32, // l1BlockHash - no longer used
+            uint256, // transactionIndex - no longer used
+            string memory mimetype,
+            string memory mediaType,
+            string memory mimeSubtype
+        )
+    {
+        // Update the filename here if you add a different fixture
+        string memory path = string.concat(vm.projectRoot(), "/test/example_ethscription.json");
+        string memory json = vm.readFile(path);
+
+        txHash = json.readBytes32(".result.transaction_hash");
+        creator = json.readAddress(".result.creator");
+        initialOwner = json.readAddress(".result.initial_owner");
+        currentOwner = json.readAddress(".result.current_owner");
+        contentUri = json.readString(".result.content_uri");
+        // L1 data no longer used, but still read to maintain compatibility
+        // l1BlockNumber = vm.parseUint(json.readString(".result.block_number"));
+        // l1BlockTimestamp = vm.parseUint(json.readString(".result.block_timestamp"));
+        // l1BlockHash = json.readBytes32(".result.block_blockhash");
+        // transactionIndex = vm.parseUint(json.readString(".result.transaction_index"));
+        mimetype = json.readString(".result.mimetype");
+        mediaType = json.readString(".result.media_type");
+        mimeSubtype = json.readString(".result.mime_subtype");
+    }
+
+    function test_TransferFromJson() public {
+        vm.pauseGasMetering();
+        (
+            bytes32 txHash,
+            address creator,
+            address initialOwner,
+            address currentOwner,
+            string memory contentUri,
+            , // l1BlockNumber - no longer used
+            , // l1BlockTimestamp - no longer used
+            , // l1BlockHash - no longer used
+            , // transactionIndex - no longer used
+            string memory mimetype,
+            string memory mediaType,
+            string memory mimeSubtype
+        ) = _read();
+        
+        // Note: l1BlockNumber, l1BlockTimestamp, l1BlockHash, transactionIndex are read but no longer used
+
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            initialOwner,
+            contentUri,
+            false
+        );
+
+        // Create ethscription
+        vm.startPrank(creator);
+        uint256 tokenId = eth.createEthscription(params);
+        vm.stopPrank();
+        
+        // Transfer from initialOwner to currentOwner
+        vm.startPrank(initialOwner);
+        vm.resumeGasMetering();
+        uint256 g0 = gasleft();
+        eth.transferEthscription(currentOwner, txHash);
+        uint256 transferGas = g0 - gasleft();
+        vm.pauseGasMetering();
+        vm.stopPrank();
+        
+        emit log_named_uint("transferEthscription gas", transferGas);
+        
+        // Verify ownership changed
+        assertEq(eth.ownerOf(tokenId), currentOwner, "Owner should be currentOwner");
+        
+        // Verify previous owner tracking
+        Ethscriptions.Ethscription memory e = eth.getEthscription(txHash);
+        assertEq(e.previousOwner, initialOwner, "Previous owner should be tracked");
+        
+        // Verify content is still readable via JSON metadata
+        string memory retrievedUri = eth.tokenURI(tokenId);
+
+        // Decode JSON and verify content is preserved
+        assertTrue(startsWith(retrievedUri, "data:application/json;base64,"), "Should return base64-encoded JSON");
+        bytes memory base64Part = bytes(substring(retrievedUri, 29, bytes(retrievedUri).length));
+        bytes memory decodedJson = Base64.decode(string(base64Part));
+        string memory json = string(decodedJson);
+        assertTrue(contains(json, '"image":"data:image/svg+xml;base64,'), "Content should be SVG-wrapped after transfer");
+    }
+    
+    function test_ReadChunk() public {
+        // Create a multi-chunk ethscription
+        bytes memory largeContent = new bytes(50000); // ~2 chunks of raw content
+        for (uint i = 0; i < largeContent.length; i++) {
+            largeContent[i] = bytes1(uint8(65 + (i % 26)));
+        }
+        // Create a data URI with this content
+        bytes memory contentUri = abi.encodePacked("data:text/plain,", largeContent);
+
+        bytes32 txHash = bytes32(uint256(999));
+        address creator = address(0xBEEF);
+        address initialOwner = address(0xCAFE);
+
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            initialOwner,
+            string(contentUri),
+            false
+        );
+
+        vm.prank(creator);
+        eth.createEthscription(params);
+
+        // Test content storage - now stores everything in a single contract
+        assertTrue(eth.hasContent(txHash), "Should have content stored");
+
+        // Get the content pointer
+        address pointer = eth.getContentPointer(txHash);
+        assertTrue(pointer != address(0), "Should have a valid content pointer");
+
+        // Read the entire content (no chunking needed)
+        bytes memory storedContent = eth.readContent(txHash);
+        assertEq(storedContent.length, largeContent.length, "Content length should match");
+
+        // Verify content matches exactly
+        assertEq(storedContent, largeContent, "Stored content should match original content");
+
+        // Also verify tokenURI returns valid JSON
+        string memory tokenUri = eth.tokenURI(eth.getTokenId(txHash));
+        assertTrue(startsWith(tokenUri, "data:application/json;base64,"), "Should return JSON metadata");
+    }
+    
+    function test_ExactChunkBoundary() public {
+        // Test with content that exactly fills 2 chunks when including prefix
+        bytes memory prefix = "data:application/octet-stream;base64,";
+        uint256 targetChunks = 2;
+        uint256 exactSize = (24575 * targetChunks) - prefix.length;
+        bytes memory content = new bytes(exactSize);
+        
+        // Fill with a pattern that we can verify
+        for (uint256 i = 0; i < exactSize; i++) {
+            content[i] = bytes1(uint8(i % 256));
+        }
+        
+        bytes memory contentUri = abi.encodePacked(prefix, content);
+        assertEq(contentUri.length, 24575 * 2, "Total should be exactly 2 chunks");
+        
+        bytes32 txHash = bytes32(uint256(0xEEEE));
+        vm.prank(address(0xAAAA));
+        uint256 tokenId = eth.createEthscription(createTestParams(
+            txHash,
+            address(0xBBBB),
+            string(contentUri),
+            false
+        ));
+        
+        // Verify content is stored (no chunking anymore)
+        assertTrue(eth.hasContent(txHash), "Should have content stored");
+        
+        // Read back and verify in JSON metadata
+        string memory retrieved = eth.tokenURI(tokenId);
+        assertTrue(startsWith(retrieved, "data:application/json;base64,"), "Should return JSON metadata");
+
+        // Decode JSON and verify it contains our content
+        bytes memory base64Part = bytes(substring(retrieved, 29, bytes(retrieved).length));
+        bytes memory decodedJson = Base64.decode(string(base64Part));
+        string memory json = string(decodedJson);
+
+        // The JSON should contain our content in animation_url field (wrapped in HTML viewer)
+        assertTrue(contains(json, '"animation_url":"data:text/html;base64,'), "JSON should have HTML viewer");
+        assertFalse(contains(json, '"image"'), "Should not have image field for non-image content");
+        assertTrue(bytes(json).length > contentUri.length, "JSON should be larger than raw content");
+    }
+    
+    function test_NonAlignedChunkSize() public {
+        // Test with size that doesn't align to chunk boundaries (30000 bytes = 1.22 chunks)
+        uint256 oddSize = 30000;
+        bytes memory content = new bytes(oddSize);
+
+        // Fill with a different pattern - use prime number for more irregularity
+        for (uint256 i = 0; i < oddSize; i++) {
+            content[i] = bytes1(uint8((i * 17 + 23) % 256));
+        }
+
+        bytes memory contentUri = abi.encodePacked("data:text/plain,", content);
+
+        bytes32 txHash = bytes32(uint256(0xDDDD));
+        vm.prank(address(0xCCCC));
+        uint256 tokenId = eth.createEthscription(createTestParams(
+            txHash,
+            address(0xEEEE),
+            string(contentUri),
+            false
+        ));
+
+        // Verify content is stored (no chunking anymore)
+        assertTrue(eth.hasContent(txHash), "Should have content stored");
+
+        // Verify the full content is stored correctly
+        bytes memory storedContent = eth.readContent(txHash);
+        assertEq(storedContent.length, content.length, "Content length should match");
+
+        // Read back and verify in JSON metadata
+        string memory retrieved = eth.tokenURI(tokenId);
+        assertTrue(startsWith(retrieved, "data:application/json;base64,"), "Should return JSON metadata");
+
+        // Decode JSON and verify content is preserved
+        bytes memory base64Part = bytes(substring(retrieved, 29, bytes(retrieved).length));
+        bytes memory decodedJson = Base64.decode(string(base64Part));
+        string memory json = string(decodedJson);
+
+        // Verify the JSON contains our content (non-base64 since we didn't use base64 in the original)
+        assertTrue(contains(json, '"animation_url":"data:text/html;base64,'), "JSON should have HTML viewer");
+        assertFalse(contains(json, '"image"'), "Should not have image field for text content");
+    }
+    
+    function test_SingleByteContent() public {
+        // Edge case: single byte content in data URI
+        string memory singleByteUri = "data:,B"; // Single byte: 'B'
+
+        bytes32 txHash = bytes32(uint256(0x9999));
+        vm.prank(address(0x7777));
+        uint256 tokenId = eth.createEthscription(createTestParams(
+            txHash,
+            address(0x8888),
+            singleByteUri,
+            false
+        ));
+
+        // Verify content is stored
+        assertTrue(eth.hasContent(txHash), "Should have content stored");
+
+        // Verify content in JSON metadata
+        string memory retrieved = eth.tokenURI(tokenId);
+        assertTrue(startsWith(retrieved, "data:application/json;base64,"), "Should return JSON metadata");
+
+        // Decode JSON and verify single byte content
+        bytes memory base64Part = bytes(substring(retrieved, 29, bytes(retrieved).length));
+        bytes memory decodedJson = Base64.decode(string(base64Part));
+        string memory json = string(decodedJson);
+
+        // Check the animation_url field contains HTML viewer for text
+        assertTrue(contains(json, '"animation_url":"data:text/html;base64,'), "JSON should have HTML viewer for text");
+        assertFalse(contains(json, '"image"'), "Should not have image field for text content");
+    }
+    
+    function test_EmptyStringBoundaryCase() public {
+        // Edge case: empty content should be allowed (valid data URIs can have empty payloads like "data:,")
+        string memory contentUri = "data:,";
+
+        bytes32 txHash = bytes32(uint256(0x5555));
+        vm.prank(address(0x4444));
+        uint256 tokenId = eth.createEthscription(createTestParams(
+            txHash,
+            address(0x3333),
+            contentUri,
+            false
+        ));
+
+        // Verify it was created successfully with empty content
+        Ethscriptions.Ethscription memory etsc = eth.getEthscription(txHash);
+        assertEq(etsc.ethscriptionNumber, tokenId, "Should create ethscription with empty content");
+
+        // Verify tokenURI works with empty content
+        string memory tokenUri = eth.tokenURI(tokenId);
+        assertTrue(bytes(tokenUri).length > 0, "Should return valid tokenURI for empty content");
+    }
+    
+    function test_ESIP6_ContentDeduplication() public {
+        string memory contentUri = "data:,Hello World";
+
+        // First ethscription - should store content
+        bytes32 txHash1 = bytes32(uint256(0xAAA1));
+        vm.prank(address(0x1111));
+        eth.createEthscription(createTestParams(
+            txHash1,
+            address(0x2222),
+            contentUri,
+            false
+        ));
+
+        // Second ethscription with same content, no ESIP6 - should fail
+        bytes32 txHash2 = bytes32(uint256(0xAAA2));
+        Ethscriptions.CreateEthscriptionParams memory duplicateParams = createTestParams(
+            txHash2,
+            address(0x4444),
+            contentUri,
+            false
+        );
+        vm.prank(address(0x3333));
+        vm.expectRevert(Ethscriptions.DuplicateContentUri.selector);
+        eth.createEthscription(duplicateParams);
+
+        // Third ethscription with same content, ESIP6 enabled - should succeed and reuse pointers
+        bytes32 txHash3 = bytes32(uint256(0xAAA3));
+        vm.prank(address(0x5555));
+        uint256 gasBeforeEsip6 = gasleft();
+        eth.createEthscription(createTestParams(
+            txHash3,
+            address(0x6666),
+            contentUri,
+            true
+        ));
+        uint256 esip6Gas = gasBeforeEsip6 - gasleft();
+        
+        // Verify both ethscriptions return JSON with same content
+        string memory uri1 = eth.tokenURI(eth.getTokenId(txHash1));
+        string memory uri3 = eth.tokenURI(eth.getTokenId(txHash3));
+
+        // Both should be JSON
+        assertTrue(startsWith(uri1, "data:application/json;base64,"), "Should return JSON");
+        assertTrue(startsWith(uri3, "data:application/json;base64,"), "Should return JSON");
+
+        // Decode and verify both contain same content
+        bytes memory json1 = Base64.decode(string(bytes(substring(uri1, 29, bytes(uri1).length))));
+        bytes memory json3 = Base64.decode(string(bytes(substring(uri3, 29, bytes(uri3).length))));
+
+        // Both should contain the same content in animation_url field (as base64 HTML viewer)
+        assertTrue(contains(string(json1), '"animation_url":"data:text/html;base64,'), "JSON1 should have HTML viewer");
+        assertTrue(contains(string(json3), '"animation_url":"data:text/html;base64,'), "JSON3 should have HTML viewer");
+
+        // Should NOT have image field for text content
+        assertFalse(contains(string(json1), '"image"'), "JSON1 should not have image field");
+        assertFalse(contains(string(json3), '"image"'), "JSON3 should not have image field");
+
+        // Verify they have different ethscription numbers but same content
+        assertTrue(contains(string(json1), '"name":"Ethscription #11"'), "JSON1 should be #11");
+        assertTrue(contains(string(json3), '"name":"Ethscription #12"'), "JSON3 should be #12 with ESIP-6");
+        
+        // Verify gas savings from content reuse
+        console.log("ESIP6 creation gas (reusing content):", esip6Gas);
+        assertTrue(esip6Gas < 1000000, "ESIP6 should save gas by reusing content");
+        
+        // Verify both have content stored and use the same pointer
+        assertTrue(eth.hasContent(txHash1) && eth.hasContent(txHash3), "Both should have content");
+        assertEq(eth.getContentPointer(txHash1), eth.getContentPointer(txHash3), "Should share same content pointer");
+    }
+    
+    function test_WorstCaseGas_1MB() public {
+        vm.pauseGasMetering();
+        
+        // Create 1MB content URI (1,048,576 bytes)
+        bytes memory largeContent = new bytes(1048576);
+        for (uint i = 0; i < largeContent.length;) {
+            largeContent[i] = bytes1(uint8(65 + (i % 26))); // Fill with A-Z pattern
+            unchecked {
+                ++i;
+            }
+        }
+        bytes memory contentUri = abi.encodePacked("data:text/plain;base64,", largeContent);
+        
+        bytes32 txHash = bytes32(uint256(1));
+        address creator = address(0x1234);
+        address initialOwner = address(0x5678);
+        
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            initialOwner,
+            string(contentUri),
+            false
+        );
+        
+        vm.startPrank(creator);
+        uint256 g0 = gasleft();
+        vm.resumeGasMetering();
+        uint256 tokenId = eth.createEthscription(params);
+        vm.pauseGasMetering();
+        uint256 createGas = g0 - gasleft();
+        vm.stopPrank();
+        
+        emit log_named_uint("1MB createEthscription gas", createGas);
+        emit log_named_uint("1MB content size (bytes)", contentUri.length);
+        
+        // tokenURI gas
+        g0 = gasleft();
+        vm.resumeGasMetering();
+        string memory got = eth.tokenURI(tokenId);
+        vm.pauseGasMetering();
+        uint256 uriGas = g0 - gasleft();
+        emit log_named_uint("1MB tokenURI gas", uriGas);
+        
+        // Verify it stored correctly as JSON
+        assertTrue(startsWith(got, "data:application/json;base64,"), "Should return JSON metadata");
+        assertEq(eth.ownerOf(tokenId), initialOwner);
+
+        // Decode and verify large content is in the JSON
+        bytes memory base64Part = bytes(substring(got, 29, bytes(got).length));
+        bytes memory decodedJson = Base64.decode(string(base64Part));
+        string memory json = string(decodedJson);
+        assertTrue(contains(json, '"animation_url":"data:text/html;base64,'), "JSON should have HTML viewer");
+        assertFalse(contains(json, '"image"'), "Should not have image field for text content");
+    }
+
+    // Helper functions for JSON validation
+    function startsWith(string memory str, string memory prefix) internal pure returns (bool) {
+        bytes memory strBytes = bytes(str);
+        bytes memory prefixBytes = bytes(prefix);
+
+        if (prefixBytes.length > strBytes.length) return false;
+
+        for (uint256 i = 0; i < prefixBytes.length; i++) {
+            if (strBytes[i] != prefixBytes[i]) return false;
+        }
+        return true;
+    }
+
+    function contains(string memory str, string memory substr) internal pure returns (bool) {
+        bytes memory strBytes = bytes(str);
+        bytes memory substrBytes = bytes(substr);
+
+        if (substrBytes.length > strBytes.length) return false;
+
+        for (uint256 i = 0; i <= strBytes.length - substrBytes.length; i++) {
+            bool found = true;
+            for (uint256 j = 0; j < substrBytes.length; j++) {
+                if (strBytes[i + j] != substrBytes[j]) {
+                    found = false;
+                    break;
+                }
+            }
+            if (found) return true;
+        }
+        return false;
+    }
+
+    function substring(string memory str, uint256 start, uint256 end) internal pure returns (string memory) {
+        bytes memory strBytes = bytes(str);
+        bytes memory result = new bytes(end - start);
+        for (uint256 i = start; i < end; i++) {
+            result[i - start] = strBytes[i];
+        }
+        return string(result);
+    }
+}
diff --git a/contracts/test/EthscriptionsMultiTransfer.t.sol b/contracts/test/EthscriptionsMultiTransfer.t.sol
new file mode 100644
index 0000000..b3e51c2
--- /dev/null
+++ b/contracts/test/EthscriptionsMultiTransfer.t.sol
@@ -0,0 +1,290 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+import {Base64} from "solady/utils/Base64.sol";
+import {LibString} from "solady/utils/LibString.sol";
+import {LibZip} from "solady/utils/LibZip.sol";
+
+contract EthscriptionsMultiTransferTest is TestSetup {
+    using LibString for uint256;
+    using LibString for address;
+
+    address alice = address(0x1);
+    address bob = address(0x2);
+    address charlie = address(0x3);
+
+    bytes32[] testHashes;
+
+    function setUp() public override {
+        super.setUp();
+
+        // Give alice some ETH
+        vm.deal(alice, 100 ether);
+        vm.deal(bob, 100 ether);
+        vm.deal(charlie, 100 ether);
+    }
+
+    function createTestEthscription(
+        address creator,
+        address initialOwner,
+        uint256 index
+    ) internal returns (bytes32) {
+        bytes32 txHash = keccak256(abi.encodePacked("test", index));
+        string memory content = string.concat("data:text/plain,Test Content ", index.toString());
+
+        vm.prank(creator);
+        ethscriptions.createEthscription(
+            createTestParams(
+                txHash,
+                initialOwner,
+                content,
+                false
+            )
+        );
+
+        return txHash;
+    }
+
+    function test_TransferEthscriptions_Success() public {
+        // Create 5 ethscriptions owned by alice
+        bytes32[] memory hashes = new bytes32[](5);
+        for (uint256 i = 0; i < 5; i++) {
+            hashes[i] = createTestEthscription(alice, alice, i);
+        }
+
+        // Alice transfers all 5 to bob
+        vm.prank(alice);
+        uint256 successCount = ethscriptions.transferEthscriptions(bob, hashes);
+
+        assertEq(successCount, 5, "Should have 5 successful transfers");
+
+        // Verify all are now owned by bob
+        for (uint256 i = 0; i < 5; i++) {
+            address owner = ethscriptions.ownerOf(hashes[i]);
+            assertEq(owner, bob, "Bob should own the ethscription");
+        }
+    }
+
+    function test_TransferEthscriptions_PartialSuccess() public {
+        // Create 5 ethscriptions - 3 owned by alice, 2 owned by bob
+        bytes32[] memory hashes = new bytes32[](5);
+        for (uint256 i = 0; i < 3; i++) {
+            hashes[i] = createTestEthscription(alice, alice, i);
+        }
+        for (uint256 i = 3; i < 5; i++) {
+            hashes[i] = createTestEthscription(bob, bob, i);
+        }
+
+        // Alice tries to transfer all 5 to charlie (but only owns 3)
+        vm.prank(alice);
+        uint256 successCount = ethscriptions.transferEthscriptions(charlie, hashes);
+
+        assertEq(successCount, 3, "Should have 3 successful transfers");
+
+        // Verify ownership
+        for (uint256 i = 0; i < 3; i++) {
+            address owner = ethscriptions.ownerOf(hashes[i]);
+            assertEq(owner, charlie, "Charlie should own alice's ethscriptions");
+        }
+        for (uint256 i = 3; i < 5; i++) {
+            address owner = ethscriptions.ownerOf(hashes[i]);
+            assertEq(owner, bob, "Bob should still own his ethscriptions");
+        }
+    }
+
+    function test_TransferEthscriptions_NoSuccessReverts() public {
+        // Create 3 ethscriptions owned by bob
+        bytes32[] memory hashes = new bytes32[](3);
+        for (uint256 i = 0; i < 3; i++) {
+            hashes[i] = createTestEthscription(bob, bob, i);
+        }
+
+        // Alice tries to transfer them (but owns none)
+        vm.prank(alice);
+        vm.expectRevert(Ethscriptions.NoSuccessfulTransfers.selector);
+        ethscriptions.transferEthscriptions(charlie, hashes);
+    }
+
+    function test_TransferEthscriptions_Burn() public {
+        // Create 3 ethscriptions owned by alice
+        bytes32[] memory hashes = new bytes32[](3);
+        for (uint256 i = 0; i < 3; i++) {
+            hashes[i] = createTestEthscription(alice, alice, i);
+        }
+
+        // Alice burns all 3 by transferring to address(0)
+        vm.prank(alice);
+        uint256 successCount = ethscriptions.transferEthscriptions(address(0), hashes);
+
+        assertEq(successCount, 3, "Should have 3 successful burns");
+
+        // Verify all are owned by address(0) (null ownership, not burned)
+        for (uint256 i = 0; i < 3; i++) {
+            assertEq(ethscriptions.ownerOf(hashes[i]), address(0), "Should be owned by null address");
+        }
+    }
+
+    function test_TransferEthscriptions_EmitsEvents() public {
+        // Create 2 ethscriptions owned by alice
+        bytes32[] memory hashes = new bytes32[](2);
+        for (uint256 i = 0; i < 2; i++) {
+            hashes[i] = createTestEthscription(alice, alice, i);
+        }
+
+        // Expect transfer events (starting from ethscription #11 due to genesis)
+        for (uint256 i = 0; i < 2; i++) {
+            vm.expectEmit(true, true, true, true);
+            emit Ethscriptions.EthscriptionTransferred(
+                hashes[i],
+                alice,
+                bob,
+                11 + i // ethscription number starts at 11 due to genesis
+            );
+        }
+
+        // Alice transfers both to bob
+        vm.prank(alice);
+        ethscriptions.transferEthscriptions(bob, hashes);
+    }
+
+    function test_TokenURI_ReturnsValidJSON() public {
+        // Create an ethscription
+        bytes32 txHash = createTestEthscription(alice, alice, 1);
+        uint256 tokenId = ethscriptions.getTokenId(txHash);
+
+        // Get the token URI
+        string memory uri = ethscriptions.tokenURI(tokenId);
+
+        // Check it starts with the correct data URI prefix
+        assertTrue(
+            startsWith(uri, "data:application/json;base64,"),
+            "Should return base64-encoded JSON data URI"
+        );
+
+        // Decode the base64 to get the JSON
+        bytes memory base64Part = bytes(substring(uri, 29, bytes(uri).length));
+        bytes memory decodedJson = Base64.decode(string(base64Part));
+        string memory json = string(decodedJson);
+
+        // Check JSON contains expected fields (ethscription #11 because of genesis ethscriptions)
+        assertTrue(contains(json, '"name":"Ethscription #11"'), "Should have name");
+        assertTrue(contains(json, '"description":"Ethscription #11 created by'), "Should have description");
+        assertTrue(contains(json, '"animation_url":"data:text/html;base64,'), "Should have HTML viewer for text");
+        assertFalse(contains(json, '"image"'), "Should not have image field for text content");
+        assertTrue(contains(json, '"attributes":['), "Should have attributes array");
+
+        // Check for specific attributes
+        assertTrue(contains(json, '"trait_type":"Ethscription Number"'), "Should have ethscription number");
+        assertTrue(contains(json, '"trait_type":"Creator"'), "Should have creator");
+        assertTrue(contains(json, '"trait_type":"MIME Type","value":"text/plain"'), "Should have MIME type");
+        assertTrue(contains(json, '"trait_type":"ESIP-6","value":"false"'), "Should have ESIP-6 flag");
+    }
+
+    function test_TokenURI_CompressedContent() public {
+        // Create ethscription with compressed content
+        bytes32 txHash = keccak256("compressed_test");
+        bytes memory originalContent = bytes("data:text/plain,This is a test content that will be compressed");
+
+        // Compress the content using LibZip
+        bytes memory compressedContent = LibZip.flzCompress(originalContent);
+
+        // We no longer support compression, just use the original content
+        vm.prank(alice);
+        ethscriptions.createEthscription(
+            createTestParams(
+                txHash,
+                alice,
+                string(originalContent),
+                false
+            )
+        );
+
+        // Get token URI
+        string memory uri = ethscriptions.tokenURI(ethscriptions.getTokenId(txHash));
+
+        // Decode and check
+        bytes memory base64Part = bytes(substring(uri, 29, bytes(uri).length));
+        bytes memory decodedJson = Base64.decode(string(base64Part));
+        string memory json = string(decodedJson);
+
+        // Should contain HTML viewer in animation_url field for text content
+        assertTrue(
+            contains(json, '"animation_url":"data:text/html;base64,'),
+            "Should have HTML viewer for text"
+        );
+        assertFalse(contains(json, '"image"'), "Should not have image field for text content");
+    }
+
+    function test_TokenURI_AllAttributes() public {
+        // Create an ethscription and check all attributes are present
+        bytes32 txHash = createTestEthscription(alice, bob, 99);
+
+        string memory uri = ethscriptions.tokenURI(ethscriptions.getTokenId(txHash));
+        bytes memory base64Part = bytes(substring(uri, 29, bytes(uri).length));
+        bytes memory decodedJson = Base64.decode(string(base64Part));
+        string memory json = string(decodedJson);
+
+        // Check all expected attributes
+        string[10] memory expectedTraits = [
+            "Ethscription ID",
+            "Ethscription Number",
+            "Creator",
+            "Initial Owner",
+            "Content Hash",
+            "MIME Type",
+            "ESIP-6",
+            "L1 Block Number",
+            "L2 Block Number",
+            "Created At"
+        ];
+
+        for (uint256 i = 0; i < expectedTraits.length; i++) {
+            assertTrue(
+                contains(json, string.concat('"trait_type":"', expectedTraits[i], '"')),
+                string.concat("Should have ", expectedTraits[i], " attribute")
+            );
+        }
+    }
+
+    // Helper functions
+    function startsWith(string memory str, string memory prefix) internal pure returns (bool) {
+        bytes memory strBytes = bytes(str);
+        bytes memory prefixBytes = bytes(prefix);
+
+        if (prefixBytes.length > strBytes.length) return false;
+
+        for (uint256 i = 0; i < prefixBytes.length; i++) {
+            if (strBytes[i] != prefixBytes[i]) return false;
+        }
+        return true;
+    }
+
+    function contains(string memory str, string memory substr) internal pure returns (bool) {
+        bytes memory strBytes = bytes(str);
+        bytes memory substrBytes = bytes(substr);
+
+        if (substrBytes.length > strBytes.length) return false;
+
+        for (uint256 i = 0; i <= strBytes.length - substrBytes.length; i++) {
+            bool found = true;
+            for (uint256 j = 0; j < substrBytes.length; j++) {
+                if (strBytes[i + j] != substrBytes[j]) {
+                    found = false;
+                    break;
+                }
+            }
+            if (found) return true;
+        }
+        return false;
+    }
+
+    function substring(string memory str, uint256 start, uint256 end) internal pure returns (string memory) {
+        bytes memory strBytes = bytes(str);
+        bytes memory result = new bytes(end - start);
+        for (uint256 i = start; i < end; i++) {
+            result[i - start] = strBytes[i];
+        }
+        return string(result);
+    }
+}
diff --git a/contracts/test/EthscriptionsNullOwnership.t.sol b/contracts/test/EthscriptionsNullOwnership.t.sol
new file mode 100644
index 0000000..5b50635
--- /dev/null
+++ b/contracts/test/EthscriptionsNullOwnership.t.sol
@@ -0,0 +1,112 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+
+contract EthscriptionsNullOwnershipTest is TestSetup {
+    address alice = makeAddr("alice");
+
+    function setUp() public override {
+        super.setUp();
+    }
+
+    function testMintToNullAddress() public {
+        bytes32 txHash = keccak256("mint_to_null");
+
+        // Create ethscription with initialOwner as address(0)
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            address(0),
+            "data:text/plain,Null owned",
+            false
+        );
+
+        // Expect only one EthscriptionCreated event (no EthscriptionTransferred since from == address(0))
+        bytes32 contentUriSha = sha256(bytes("data:text/plain,Null owned")); // Full data URI hash
+        bytes32 contentHash = keccak256(bytes("Null owned")); // Raw content hash (keccak256, not sha256)
+        vm.expectEmit(true, true, true, true);
+        emit Ethscriptions.EthscriptionCreated(
+            txHash,
+            alice, // creator
+            address(0), // initialOwner
+            contentUriSha,
+            contentHash,
+            11 // ethscription number (after 10 genesis)
+        );
+
+        // Should NOT emit EthscriptionTransferred for mint
+        // (no expectEmit here)
+
+        vm.prank(alice);
+        uint256 tokenId = ethscriptions.createEthscription(params);
+
+        // Verify ownership
+        assertEq(ethscriptions.ownerOf(tokenId), address(0), "Should be owned by null address");
+        assertEq(ethscriptions.ownerOf(txHash), address(0), "ownerOf should return null address");
+
+        // Verify ethscription data
+        Ethscriptions.Ethscription memory etsc = ethscriptions.getEthscription(txHash);
+        assertEq(etsc.creator, alice, "Creator should be alice");
+        assertEq(etsc.initialOwner, address(0), "Initial owner should be null address");
+        assertEq(etsc.previousOwner, alice, "Previous owner should be alice after mint-to-null pattern");
+
+        // Verify balance (genesis has 1 null-owned, plus this one = 2)
+        assertEq(ethscriptions.balanceOf(address(0)), 2, "Null address should have balance of 2 (1 genesis + 1 new)");
+        assertEq(ethscriptions.balanceOf(alice), 0, "Alice should have no balance");
+    }
+
+    function testTransferToNullEmitsEvent() public {
+        bytes32 txHash = keccak256("transfer_to_null");
+
+        // First create owned by alice
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            alice,
+            "data:text/plain,Will be null owned",
+            false
+        );
+
+        vm.prank(alice);
+        uint256 tokenId = ethscriptions.createEthscription(params);
+
+        // Now transfer to null - should emit EthscriptionTransferred
+        vm.expectEmit(true, true, true, true);
+        emit Ethscriptions.EthscriptionTransferred(
+            txHash,
+            alice, // from
+            address(0), // to
+            11 // ethscription number
+        );
+
+        vm.prank(alice);
+        ethscriptions.transferFrom(alice, address(0), tokenId);
+
+        // Verify ownership changed
+        assertEq(ethscriptions.ownerOf(tokenId), address(0), "Should be owned by null address");
+        assertEq(ethscriptions.ownerOf(txHash), address(0), "ownerOf should return null address");
+
+        // Verify previousOwner updated
+        Ethscriptions.Ethscription memory etsc = ethscriptions.getEthscription(txHash);
+        assertEq(etsc.previousOwner, alice, "Previous owner should be alice after transfer");
+    }
+
+    function testCannotTransferFromNull() public {
+        bytes32 txHash = keccak256("null_owned");
+
+        // Create ethscription owned by null
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            address(0),
+            "data:text/plain,Null owned",
+            false
+        );
+
+        vm.prank(alice);
+        uint256 tokenId = ethscriptions.createEthscription(params);
+
+        // No one can transfer from null address (since msg.sender can't be address(0))
+        vm.prank(alice);
+        vm.expectRevert();
+        ethscriptions.transferFrom(address(0), alice, tokenId);
+    }
+}
\ No newline at end of file
diff --git a/contracts/test/EthscriptionsProver.t.sol b/contracts/test/EthscriptionsProver.t.sol
new file mode 100644
index 0000000..720fa2c
--- /dev/null
+++ b/contracts/test/EthscriptionsProver.t.sol
@@ -0,0 +1,204 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+
+contract EthscriptionsProverTest is TestSetup {
+    address alice = address(0x1);
+    address bob = address(0x2);
+    address charlie = address(0x3);
+    address l1Target = address(0x1234);
+    
+    bytes32 constant TEST_TX_HASH = bytes32(uint256(0xABCD));
+    bytes32 constant TOKEN_DEPLOY_HASH = bytes32(uint256(0x1234));
+    bytes32 constant TOKEN_MINT_HASH = bytes32(uint256(0x5678));
+    
+    function setUp() public override {
+        super.setUp();
+
+        vm.warp(Constants.historicalBackfillApproxDoneAt);
+
+        // Create a test ethscription with alice as creator
+        vm.startPrank(alice);
+        ethscriptions.createEthscription(createTestParams(
+            TEST_TX_HASH,
+            alice,
+            "data:,test content",
+            false
+        ));
+        vm.stopPrank();
+    }
+    
+    function testProveEthscriptionDataViaBatchFlush() public {
+        // The ethscription creation in setUp should have queued it for proving
+        // Let's transfer it to verify the proof includes previous owner
+        uint256 tokenId = ethscriptions.getTokenId(TEST_TX_HASH);
+        vm.prank(alice);
+        ethscriptions.transferFrom(alice, bob, tokenId);
+
+        // Now flush the batch which should prove the data with bob as current owner and alice as previous
+        vm.roll(block.number + 1);
+
+        vm.startPrank(Predeploys.L1_BLOCK_ATTRIBUTES);
+        vm.recordLogs();
+        prover.flushAllProofs();
+        vm.stopPrank();
+        Vm.Log[] memory logs = vm.getRecordedLogs();
+        
+        // Find the MessagePassed event and extract proof data
+        bytes memory proofData;
+        for (uint i = 0; i < logs.length; i++) {
+            if (logs[i].topics[0] == keccak256("MessagePassed(uint256,address,address,uint256,uint256,bytes,bytes32)")) {
+                // Decode the non-indexed parameters from data field
+                // The data field contains: value, gasLimit, data (as bytes), withdrawalHash
+                (uint256 value, uint256 gasLimit, bytes memory data, bytes32 withdrawalHash) = abi.decode(
+                    logs[i].data,
+                    (uint256, uint256, bytes, bytes32)
+                );
+                proofData = data;
+                break;
+            }
+        }
+        
+        // Decode and verify proof data
+        EthscriptionsProver.EthscriptionDataProof memory decodedProof = abi.decode(
+            proofData,
+            (EthscriptionsProver.EthscriptionDataProof)
+        );
+        
+        assertEq(decodedProof.ethscriptionId, TEST_TX_HASH);
+        assertEq(decodedProof.creator, alice); // Creator should be alice due to vm.prank
+        assertEq(decodedProof.currentOwner, bob);
+        assertEq(decodedProof.previousOwner, alice);
+        // assertEq(decodedProof.ethscriptionNumber, 0);
+        assertEq(decodedProof.esip6, false);
+        assertTrue(decodedProof.contentHash != bytes32(0));
+        assertTrue(decodedProof.contentUriSha != bytes32(0));
+        // l1BlockHash can be zero in test environment
+        assertEq(decodedProof.l1BlockHash, bytes32(0));
+    }
+    
+    function testBatchFlushProofs() public {
+        // First flush any pending proofs from setup
+        vm.roll(block.number + 1);
+
+        vm.startPrank(Predeploys.L1_BLOCK_ATTRIBUTES);
+        prover.flushAllProofs();
+        vm.stopPrank();
+
+        vm.warp(Constants.historicalBackfillApproxDoneAt + 1);
+
+        // Now move to next block for our test
+        vm.roll(block.number + 1);
+
+        // Create multiple ethscriptions in the same block
+        bytes32 txHash1 = bytes32(uint256(0x123));
+        bytes32 txHash2 = bytes32(uint256(0x456));
+        bytes32 txHash3 = bytes32(uint256(0x789));
+
+        // Create three ethscriptions
+        vm.startPrank(alice);
+        ethscriptions.createEthscription(
+            Ethscriptions.CreateEthscriptionParams({
+                ethscriptionId: txHash1,
+                contentUriSha: keccak256("data:,test1"),
+                initialOwner: alice,
+                content: bytes("test1"),
+                mimetype: "text/plain",
+                esip6: false,
+                protocolParams: Ethscriptions.ProtocolParams("", "", "")
+            })
+        );
+        vm.stopPrank();
+
+        vm.startPrank(bob);
+        ethscriptions.createEthscription(
+            Ethscriptions.CreateEthscriptionParams({
+                ethscriptionId: txHash2,
+                contentUriSha: keccak256("data:,test2"),
+                initialOwner: bob,
+                content: bytes("test2"),
+                mimetype: "text/plain",
+                esip6: false,
+                protocolParams: Ethscriptions.ProtocolParams("", "", "")
+            })
+        );
+        vm.stopPrank();
+
+        // Transfer the first ethscription (should only be queued once due to deduplication)
+        vm.startPrank(alice);
+        ethscriptions.transferEthscription(bob, txHash1);
+        vm.stopPrank();
+
+        // Create a third ethscription
+        vm.startPrank(charlie);
+        ethscriptions.createEthscription(
+            Ethscriptions.CreateEthscriptionParams({
+                ethscriptionId: txHash3,
+                contentUriSha: keccak256("data:,test3"),
+                initialOwner: charlie,
+                content: bytes("test3"),
+                mimetype: "text/plain",
+                esip6: false,
+                protocolParams: Ethscriptions.ProtocolParams("", "", "")
+            })
+        );
+        vm.stopPrank();
+
+        // Now simulate L1Block calling flush at the start of the next block
+        vm.roll(block.number + 1);
+
+        // Prank as L1Block contract
+        vm.startPrank(Predeploys.L1_BLOCK_ATTRIBUTES);
+        vm.recordLogs();
+        prover.flushAllProofs();
+        vm.stopPrank();
+
+        Vm.Log[] memory logs = vm.getRecordedLogs();
+
+        // Count individual proof sent events
+        uint256 proofsSent = 0;
+        for (uint i = 0; i < logs.length; i++) {
+            if (logs[i].topics[0] == keccak256("EthscriptionDataProofSent(bytes32,uint256,uint256)")) {
+                proofsSent++;
+            }
+        }
+        assertEq(proofsSent, 3, "Should have sent 3 individual proofs");
+    }
+
+    function testNoProofsBeforeProvingStart() public {
+        // Clear any queued proofs from setup
+        vm.startPrank(Predeploys.L1_BLOCK_ATTRIBUTES);
+        prover.flushAllProofs();
+        vm.stopPrank();
+
+        vm.warp(1760630076);
+
+        bytes32 earlyTxHash = bytes32(uint256(0xBEEF));
+
+        vm.startPrank(alice);
+        ethscriptions.createEthscription(createTestParams(
+            earlyTxHash,
+            alice,
+            "data:,early",
+            false
+        ));
+        vm.stopPrank();
+
+        vm.roll(block.number + 1);
+
+        vm.startPrank(Predeploys.L1_BLOCK_ATTRIBUTES);
+        vm.recordLogs();
+        prover.flushAllProofs();
+        vm.stopPrank();
+
+        Vm.Log[] memory logs = vm.getRecordedLogs();
+        uint256 proofsSent;
+        for (uint256 i = 0; i < logs.length; i++) {
+            if (logs[i].topics[0] == keccak256("EthscriptionDataProofSent(bytes32,uint256,uint256)")) {
+                proofsSent++;
+            }
+        }
+        assertEq(proofsSent, 0, "Should not send proofs before proving start");
+    }
+}
diff --git a/contracts/test/EthscriptionsTextRenderer.t.sol b/contracts/test/EthscriptionsTextRenderer.t.sol
new file mode 100644
index 0000000..ef4430c
--- /dev/null
+++ b/contracts/test/EthscriptionsTextRenderer.t.sol
@@ -0,0 +1,183 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "./TestSetup.sol";
+import "forge-std/StdJson.sol";
+import {Base64} from "solady/utils/Base64.sol";
+
+contract EthscriptionsTextRendererTest is TestSetup {
+    using stdJson for string;
+
+    address alice = makeAddr("alice");
+    address bob = makeAddr("bob");
+
+    function test_TextContent_UsesAnimationUrl() public {
+        // Create a plain text ethscription
+        bytes32 txHash = keccak256("test_text");
+        string memory textContent = "Hello World!";
+
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            alice,
+            string.concat("data:text/plain,", textContent),
+            false
+        );
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(params);
+
+        uint256 tokenId = ethscriptions.getTokenId(txHash);
+        string memory uri = ethscriptions.tokenURI(tokenId);
+
+        // Decode the base64 JSON
+        assertTrue(startsWith(uri, "data:application/json;base64,"), "Should return base64-encoded JSON");
+        bytes memory base64Part = bytes(substring(uri, 29, bytes(uri).length));
+        bytes memory decodedJson = Base64.decode(string(base64Part));
+        string memory json = string(decodedJson);
+
+        // Verify it uses animation_url, not image
+        assertTrue(contains(json, '"animation_url"'), "Should have animation_url field");
+        assertFalse(contains(json, '"image"'), "Should NOT have image field");
+        assertTrue(contains(json, "data:text/html;base64,"), "animation_url should be HTML viewer");
+    }
+
+    function test_JsonContent_UsesViewerWithPrettyPrint() public {
+        // Create a JSON ethscription
+        bytes32 txHash = keccak256("test_json");
+        string memory jsonContent = '{"p":"erc-20","op":"mint","tick":"test","amt":"1000"}';
+
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            alice,
+            string.concat("data:application/json,", jsonContent),
+            false
+        );
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(params);
+
+        uint256 tokenId = ethscriptions.getTokenId(txHash);
+        string memory uri = ethscriptions.tokenURI(tokenId);
+
+        // Decode the base64 JSON
+        bytes memory base64Part = bytes(substring(uri, 29, bytes(uri).length));
+        bytes memory decodedJson = Base64.decode(string(base64Part));
+        string memory json = string(decodedJson);
+
+        // Verify it uses animation_url with HTML viewer for JSON content
+        assertTrue(contains(json, '"animation_url"'), "Should have animation_url field");
+        assertFalse(contains(json, '"image"'), "Should NOT have image field");
+        // JSON should use the HTML viewer (not pass through directly)
+        assertTrue(contains(json, "data:text/html;base64,"), "Should use HTML viewer for JSON");
+    }
+
+    function test_ImageContent_UsesImageField() public {
+        // Create an image ethscription (base64 encoded PNG)
+        bytes32 txHash = keccak256("test_image");
+        string memory base64Image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
+
+        Ethscriptions.CreateEthscriptionParams memory params = createTestParams(
+            txHash,
+            alice,
+            string.concat("data:image/png;base64,", base64Image),
+            false // esip6
+        );
+
+        vm.prank(alice);
+        ethscriptions.createEthscription(params);
+
+        uint256 tokenId = ethscriptions.getTokenId(txHash);
+        string memory uri = ethscriptions.tokenURI(tokenId);
+
+        // Decode the base64 JSON
+        bytes memory base64Part = bytes(substring(uri, 29, bytes(uri).length));
+        bytes memory decodedJson = Base64.decode(string(base64Part));
+        string memory json = string(decodedJson);
+
+        // Verify it uses image field, not animation_url
+        assertTrue(contains(json, '"image"'), "Should have image field");
+        assertFalse(contains(json, '"animation_url"'), "Should NOT have animation_url field");
+        // Image should now be wrapped in SVG for pixel-perfect rendering
+        assertTrue(contains(json, '"image":"data:image/svg+xml;base64,'), "image should be SVG-wrapped");
+    }
+
+    function test_HtmlContent_PassesThroughAsBase64() public {
+        // Create an HTML ethscription
+        bytes32 txHash = keccak256("test_html");
+        string memory htmlContent = "

Test

"; + + Ethscriptions.CreateEthscriptionParams memory params = createTestParams( + txHash, + alice, + string.concat("data:text/html,", htmlContent), + false + ); + + vm.prank(alice); + ethscriptions.createEthscription(params); + + uint256 tokenId = ethscriptions.getTokenId(txHash); + string memory uri = ethscriptions.tokenURI(tokenId); + + // Decode the base64 JSON + bytes memory base64Part = bytes(substring(uri, 29, bytes(uri).length)); + bytes memory decodedJson = Base64.decode(string(base64Part)); + string memory json = string(decodedJson); + + // HTML should pass through as base64 for safety + assertTrue(contains(json, '"animation_url"'), "Should have animation_url field"); + assertFalse(contains(json, '"image"'), "Should NOT have image field"); + assertTrue(contains(json, '"animation_url":"data:text/html;base64,'), "Should use base64 encoded HTML"); + } + + // Helper functions + function startsWith(string memory str, string memory prefix) internal pure returns (bool) { + bytes memory strBytes = bytes(str); + bytes memory prefixBytes = bytes(prefix); + + if (strBytes.length < prefixBytes.length) { + return false; + } + + for (uint i = 0; i < prefixBytes.length; i++) { + if (strBytes[i] != prefixBytes[i]) { + return false; + } + } + + return true; + } + + function contains(string memory str, string memory substr) internal pure returns (bool) { + bytes memory strBytes = bytes(str); + bytes memory substrBytes = bytes(substr); + + if (strBytes.length < substrBytes.length) { + return false; + } + + for (uint i = 0; i <= strBytes.length - substrBytes.length; i++) { + bool found = true; + for (uint j = 0; j < substrBytes.length; j++) { + if (strBytes[i + j] != substrBytes[j]) { + found = false; + break; + } + } + if (found) { + return true; + } + } + + return false; + } + + function substring(string memory str, uint startIndex, uint endIndex) internal pure returns (string memory) { + bytes memory strBytes = bytes(str); + bytes memory result = new bytes(endIndex - startIndex); + for (uint i = startIndex; i < endIndex; i++) { + result[i - startIndex] = strBytes[i]; + } + return string(result); + } +} \ No newline at end of file diff --git a/contracts/test/EthscriptionsToken.t.sol b/contracts/test/EthscriptionsToken.t.sol new file mode 100644 index 0000000..54aadc1 --- /dev/null +++ b/contracts/test/EthscriptionsToken.t.sol @@ -0,0 +1,1178 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "./TestSetup.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20CappedUpgradeable.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +contract EthscriptionsTokenTest is TestSetup { + using Strings for uint256; + + string constant CANONICAL_PROTOCOL = "erc-20-fixed-denomination"; + address alice = address(0x1); + address bob = address(0x2); + address charlie = address(0x3); + + bytes32 constant DEPLOY_TX_HASH = bytes32(uint256(0x1234)); + bytes32 constant MINT_TX_HASH_1 = bytes32(uint256(0x5678)); + bytes32 constant MINT_TX_HASH_2 = bytes32(uint256(0x9ABC)); + + // Event for tracking protocol handler failures + event ProtocolHandlerFailed( + bytes32 indexed transactionHash, + string indexed protocol, + bytes revertData + ); + + // Custom error mirrors base contract for NotImplemented paths + error NotImplemented(); + + function setUp() public override { + super.setUp(); + } + + // Helper to create token params + function createTokenParams( + bytes32 transactionHash, + address initialOwner, + string memory contentUri, + string memory protocol, + string memory operation, + bytes memory data + ) internal pure returns (Ethscriptions.CreateEthscriptionParams memory) { + bytes memory contentUriBytes = bytes(contentUri); + bytes32 contentUriSha = sha256(contentUriBytes); // Use SHA-256 to match production + + // Extract content after "data:," + bytes memory content; + if (contentUriBytes.length > 6) { + content = new bytes(contentUriBytes.length - 6); + for (uint256 i = 0; i < content.length; i++) { + content[i] = contentUriBytes[i + 6]; + } + } + + return Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: transactionHash, + contentUriSha: contentUriSha, + initialOwner: initialOwner, + content: content, + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: protocol, + operation: operation, + data: data + }) + }); + } + + function testTokenDeploy() public { + // Deploy a token as Alice + vm.prank(alice); + + string memory deployContent = 'data:,{"p":"erc-20","op":"deploy","tick":"TEST","max":"1000000","lim":"1000"}'; + + // For deploy operation, encode the deploy params + ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({ + tick: "TEST", + maxSupply: 1000000, + mintAmount: 1000 + }); + + Ethscriptions.CreateEthscriptionParams memory params = createTokenParams( + DEPLOY_TX_HASH, + alice, + deployContent, + CANONICAL_PROTOCOL, + "deploy", + abi.encode(deployOp) + ); + + ethscriptions.createEthscription(params); + + // Verify token was deployed + ERC20FixedDenominationManager.TokenInfo memory tokenInfo = fixedDenominationManager.getTokenInfo(DEPLOY_TX_HASH); + + assertEq(tokenInfo.tick, "TEST"); + assertEq(tokenInfo.maxSupply, 1000000); + assertEq(tokenInfo.mintAmount, 1000); + assertEq(tokenInfo.totalMinted, 0); + assertTrue(tokenInfo.tokenContract != address(0)); + + // Verify Alice owns the deploy ethscription NFT + Ethscriptions.Ethscription memory deployEthscription = ethscriptions.getEthscription(DEPLOY_TX_HASH); + assertEq(ethscriptions.ownerOf(deployEthscription.ethscriptionNumber), alice); + } + + function testTokenMint() public { + // First deploy the token + testTokenDeploy(); + + // Now mint some tokens as Bob + vm.prank(bob); + + string memory mintContent = 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"1","amt":"1000"}'; + + // For mint operation, encode the mint params + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "TEST", + id: 1, + amount: 1000 + }); + + Ethscriptions.CreateEthscriptionParams memory mintParams = createTokenParams( + MINT_TX_HASH_1, + bob, + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + ); + + ethscriptions.createEthscription(mintParams); + + // Verify Bob owns the mint ethscription NFT + Ethscriptions.Ethscription memory mintEthscription = ethscriptions.getEthscription(MINT_TX_HASH_1); + assertEq(ethscriptions.ownerOf(mintEthscription.ethscriptionNumber), bob); + + // Verify Bob has the tokens (1000 * 10^18 with 18 decimals) + address tokenAddress = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddress); + assertEq(token.balanceOf(bob), 1000 ether); // 1000 * 10^18 + + // Verify total minted increased + ERC20FixedDenominationManager.TokenInfo memory info = fixedDenominationManager.getTokenInfo(DEPLOY_TX_HASH); + assertEq(info.totalMinted, 1000); + } + + function testTokenTransferViaNFT() public { + // Setup: Deploy and mint + testTokenMint(); + + address tokenAddress = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddress); + + // Bob transfers the NFT to Charlie + vm.prank(bob); + ethscriptions.transferEthscription(charlie, MINT_TX_HASH_1); + + // Verify Charlie now owns the NFT + Ethscriptions.Ethscription memory mintEthscription1 = ethscriptions.getEthscription(MINT_TX_HASH_1); + assertEq(ethscriptions.ownerOf(mintEthscription1.ethscriptionNumber), charlie); + + // Verify tokens moved from Bob to Charlie + assertEq(token.balanceOf(bob), 0); + assertEq(token.balanceOf(charlie), 1000 ether); + } + + function testMultipleMints() public { + // Deploy the token + testTokenDeploy(); + + address tokenAddress = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddress); + + // Bob mints tokens + vm.prank(bob); + string memory mintContent1 = 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"1","amt":"1000"}'; + ERC20FixedDenominationManager.MintOperation memory mintOp1 = ERC20FixedDenominationManager.MintOperation({ + tick: "TEST", + id: 1, + amount: 1000 + }); + ethscriptions.createEthscription(createTokenParams( + MINT_TX_HASH_1, + bob, + mintContent1, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp1) + )); + + // Charlie mints tokens + vm.prank(charlie); + string memory mintContent2 = 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"2","amt":"1000"}'; + ERC20FixedDenominationManager.MintOperation memory mintOp2 = ERC20FixedDenominationManager.MintOperation({ + tick: "TEST", + id: 2, + amount: 1000 + }); + ethscriptions.createEthscription(createTokenParams( + MINT_TX_HASH_2, + charlie, + mintContent2, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp2) + )); + + // Verify balances + assertEq(token.balanceOf(bob), 1000 ether); + assertEq(token.balanceOf(charlie), 1000 ether); + + // Verify total minted + ERC20FixedDenominationManager.TokenInfo memory info = fixedDenominationManager.getTokenInfo(DEPLOY_TX_HASH); + assertEq(info.totalMinted, 2000); + } + + function testMintIdCannotBeReused() public { + // Deploy and perform initial mint with ID 1 + testTokenMint(); + + // Attempt to mint the same ID again + vm.prank(charlie); + string memory mintContent = 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"1","amt":"1000"}'; + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "TEST", + id: 1, + amount: 1000 + }); + + Ethscriptions.CreateEthscriptionParams memory params = createTokenParams( + bytes32(uint256(0xDEADFEED)), + charlie, + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + ); + + vm.expectRevert(Ethscriptions.DuplicateContentUri.selector); + ethscriptions.createEthscription(params); + } + + function testMaxSupplyEnforcement() public { + // Deploy a token with very low max supply + vm.prank(alice); + + bytes32 smallDeployHash = bytes32(uint256(0xDEAD)); + string memory deployContent = 'data:,{"p":"erc-20","op":"deploy","tick":"SMALL","max":"2000","lim":"1000"}'; + + ERC20FixedDenominationManager.DeployOperation memory smallDeployOp = ERC20FixedDenominationManager.DeployOperation({ + tick: "SMALL", + maxSupply: 2000, + mintAmount: 1000 + }); + + ethscriptions.createEthscription(createTokenParams( + smallDeployHash, + alice, + deployContent, + CANONICAL_PROTOCOL, + "deploy", + abi.encode(smallDeployOp) + )); + + // Mint up to max supply + vm.prank(bob); + ERC20FixedDenominationManager.MintOperation memory mintOp1Small = ERC20FixedDenominationManager.MintOperation({ + tick: "SMALL", + id: 1, + amount: 1000 + }); + ethscriptions.createEthscription(createTokenParams( + bytes32(uint256(0xBEEF1)), + bob, + 'data:,{"p":"erc-20","op":"mint","tick":"SMALL","id":"1","amt":"1000"}', + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp1Small) + )); + + vm.prank(charlie); + ERC20FixedDenominationManager.MintOperation memory mintOp2Small = ERC20FixedDenominationManager.MintOperation({ + tick: "SMALL", + id: 2, + amount: 1000 + }); + ethscriptions.createEthscription(createTokenParams( + bytes32(uint256(0xBEEF2)), + charlie, + 'data:,{"p":"erc-20","op":"mint","tick":"SMALL","id":"2","amt":"1000"}', + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp2Small) + )); + + // Try to mint beyond max supply - should fail silently with event + bytes32 exceedTxHash = bytes32(uint256(0xBEEF3)); + ERC20FixedDenominationManager.MintOperation memory exceedMintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "SMALL", + id: 3, + amount: 1000 + }); + Ethscriptions.CreateEthscriptionParams memory exceedParams = createTokenParams( + exceedTxHash, + alice, + 'data:,{"p":"erc-20","op":"mint","tick":"SMALL","id":"3","amt":"1000"}', + CANONICAL_PROTOCOL, + "mint", + abi.encode(exceedMintOp) + ); + + // Token creation should succeed but mint will fail due to exceeding cap + + vm.prank(alice); + uint256 tokenId = ethscriptions.createEthscription(exceedParams); + + // Ethscription should still be created (but mint failed) + assertEq(ethscriptions.ownerOf(tokenId), alice); + + // Verify supply didn't increase + address tokenAddress = fixedDenominationManager.getTokenAddressByTick("SMALL"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddress); + assertEq(token.totalSupply(), 2000 ether); // Should still be at max + } + + function testCannotTransferERC20Directly() public { + // Setup + testTokenMint(); + + address tokenAddress = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddress); + + // Bob tries to transfer tokens directly (not via NFT) - should revert + vm.prank(bob); + vm.expectRevert(ERC20FixedDenomination.TransfersOnlyViaEthscriptions.selector); + token.transfer(charlie, 500); + } + + function testTokenAddressPredictability() public { + // Predict the token address before deployment + address predictedAddress = fixedDenominationManager.predictTokenAddressByTick("TEST"); + + // Deploy the token + testTokenDeploy(); + + // Verify the actual address matches prediction + address actualAddress = fixedDenominationManager.getTokenAddressByTick("TEST"); + assertEq(actualAddress, predictedAddress); + } + + function testMintAmountMustMatch() public { + // Deploy token with lim=1000 + testTokenDeploy(); + + // Try to mint with wrong amount - should fail silently with event + string memory wrongAmountContent = 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"1","amt":"500"}'; + + bytes32 wrongTxHash = bytes32(uint256(0xBAD)); + ERC20FixedDenominationManager.MintOperation memory wrongMintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "TEST", + id: 1, + amount: 500 // Wrong - should be 1000 to match lim + }); + Ethscriptions.CreateEthscriptionParams memory wrongParams = createTokenParams( + wrongTxHash, + bob, + wrongAmountContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(wrongMintOp) + ); + + // Token creation should succeed but mint will fail due to amount mismatch + + vm.prank(bob); + uint256 tokenId = ethscriptions.createEthscription(wrongParams); + + // Ethscription should still be created (but mint failed) + assertEq(ethscriptions.ownerOf(tokenId), bob); + + // Verify no tokens were minted + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + assertEq(token.balanceOf(bob), 0); // Bob should have no tokens + } + + function testCannotDeployTokenTwice() public { + // First deploy should succeed + testTokenDeploy(); + + // Try to deploy the same token again with different parameters - should fail silently with event + // Different max supply in content to avoid duplicate content error + string memory deployContent = 'data:,{"p":"erc-20","op":"deploy","tick":"TEST","max":"2000000","lim":"2000"}'; + + bytes32 duplicateTxHash = bytes32(uint256(0xABCD)); + ERC20FixedDenominationManager.DeployOperation memory duplicateDeployOp = ERC20FixedDenominationManager.DeployOperation({ + tick: "TEST", + maxSupply: 2000000, // Different parameters but same tick + mintAmount: 2000 + }); + + Ethscriptions.CreateEthscriptionParams memory duplicateParams = createTokenParams( + duplicateTxHash, + alice, + deployContent, + CANONICAL_PROTOCOL, + "deploy", + abi.encode(duplicateDeployOp) + ); + + // Token creation should succeed but deploy will fail due to duplicate + + vm.prank(alice); + uint256 tokenId = ethscriptions.createEthscription(duplicateParams); + + // Ethscription should still be created (but token deploy failed) + assertEq(ethscriptions.ownerOf(tokenId), alice); + + // Verify the original token is still the only one + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + assertEq(token.name(), "TEST"); // Token name format is "protocol tick" + assertEq(token.maxSupply(), 1000000 ether); // Original cap (maxSupply), not the duplicate's + } + + function testMintWithInvalidIdZero() public { + // Deploy the token first + testTokenDeploy(); + + // Try to mint with ID 0 (invalid - must be >= 1) + vm.prank(bob); + string memory mintContent = 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"0","amt":"1000"}'; + + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "TEST", + id: 0, // Invalid ID - should be >= 1 + amount: 1000 + }); + + bytes32 invalidMintHash = bytes32(uint256(0xDEAD)); + Ethscriptions.CreateEthscriptionParams memory mintParams = createTokenParams( + invalidMintHash, + bob, + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + ); + + // Create the ethscription - mint should fail due to invalid ID + uint256 tokenId = ethscriptions.createEthscription(mintParams); + + // Ethscription should still be created (but mint failed) + assertEq(ethscriptions.ownerOf(tokenId), bob); + + // Verify no tokens were minted due to invalid ID + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + assertEq(token.balanceOf(bob), 0); // Bob should have no tokens + + // Verify total minted didn't increase + ERC20FixedDenominationManager.TokenInfo memory info = fixedDenominationManager.getTokenInfo(DEPLOY_TX_HASH); + assertEq(info.totalMinted, 0); + } + + function testMintWithIdTooHigh() public { + // Deploy the token first + testTokenDeploy(); + + // Try to mint with ID beyond maxId (maxSupply/mintAmount = 1000000/1000 = 1000) + vm.prank(bob); + string memory mintContent = 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"1001","amt":"1000"}'; + + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "TEST", + id: 1001, // Invalid ID - maxId is 1000 + amount: 1000 + }); + + bytes32 invalidMintHash = bytes32(uint256(0xBEEF)); + Ethscriptions.CreateEthscriptionParams memory mintParams = createTokenParams( + invalidMintHash, + bob, + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + ); + + // Create the ethscription - mint should fail due to ID too high + uint256 tokenId = ethscriptions.createEthscription(mintParams); + + // Ethscription should still be created (but mint failed) + assertEq(ethscriptions.ownerOf(tokenId), bob); + + // Verify no tokens were minted due to invalid ID + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + assertEq(token.balanceOf(bob), 0); // Bob should have no tokens + + // Verify total minted didn't increase + ERC20FixedDenominationManager.TokenInfo memory info = fixedDenominationManager.getTokenInfo(DEPLOY_TX_HASH); + assertEq(info.totalMinted, 0); + } + + function testMintWithMaxValidId() public { + // Deploy the token first + testTokenDeploy(); + + // Mint with the maximum valid ID (maxSupply/mintAmount = 1000000/1000 = 1000) + vm.prank(bob); + string memory mintContent = 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"1000","amt":"1000"}'; + + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "TEST", + id: 1000, // Maximum valid ID + amount: 1000 + }); + + bytes32 validMintHash = bytes32(uint256(0xCAFE)); + Ethscriptions.CreateEthscriptionParams memory mintParams = createTokenParams( + validMintHash, + bob, + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + ); + + uint256 tokenId = ethscriptions.createEthscription(mintParams); + + // Verify Bob owns the mint ethscription NFT + assertEq(ethscriptions.ownerOf(tokenId), bob); + + // Verify Bob has the tokens (1000 * 10^18 with 18 decimals) + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + assertEq(token.balanceOf(bob), 1000 ether); // Should have tokens + + // Verify total minted increased + ERC20FixedDenominationManager.TokenInfo memory info = fixedDenominationManager.getTokenInfo(DEPLOY_TX_HASH); + assertEq(info.totalMinted, 1000); + } + + function testMintToNullOwnerMintsERC20ToZero() public { + // Deploy the token under tick TEST + testTokenDeploy(); + + // Prepare a mint where the Ethscription initial owner is the null address + bytes32 nullMintTx = bytes32(uint256(0xBADD0)); + string memory mintContent = 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"1","amt":"1000"}'; + + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "TEST", + id: 1, + amount: 1000 + }); + + // Creator is Alice, but initial owner is address(0) + Ethscriptions.CreateEthscriptionParams memory params = createTokenParams( + nullMintTx, + address(0), + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + ); + + vm.prank(alice); + uint256 tokenId = ethscriptions.createEthscription(params); + + // The NFT should exist and end up owned by the null address + assertEq(ethscriptions.ownerOf(tokenId), address(0)); + + // ERC20 should be minted and credited to the null address + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + assertEq(token.totalSupply(), 1000 ether); + assertEq(token.balanceOf(address(0)), 1000 ether); + + // ERC20FixedDenominationManager should record a token item and increase total minted + assertTrue(fixedDenominationManager.isTokenItem(nullMintTx)); + ERC20FixedDenominationManager.TokenInfo memory info = fixedDenominationManager.getTokenInfo(DEPLOY_TX_HASH); + assertEq(info.totalMinted, 1000); + } + + function testTransferTokenItemToNullAddressMovesERC20ToZero() public { + // Setup: deploy and mint a token item to Bob + testTokenMint(); + + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + + // Sanity: Bob has the ERC20 minted via the token item + assertEq(token.balanceOf(bob), 1000 ether); + assertEq(token.balanceOf(address(0)), 0); + assertEq(token.totalSupply(), 1000 ether); + + // Transfer the NFT representing the token item to the null address + Ethscriptions.Ethscription memory mintEthscription = ethscriptions.getEthscription(MINT_TX_HASH_1); + vm.prank(bob); + ethscriptions.transferEthscription(address(0), MINT_TX_HASH_1); + + // The NFT should now be owned by the null address + assertEq(ethscriptions.ownerOf(mintEthscription.ethscriptionNumber), address(0)); + + // ERC20 transfer follows NFT to null owner + assertEq(token.balanceOf(bob), 0); + assertEq(token.balanceOf(address(0)), 1000 ether); + assertEq(token.totalSupply(), 1000 ether); + } + + // ============================================================= + // COLLECTION TESTS + // ============================================================= + + /* Collection tests temporarily disabled - need to be rewritten for ERC404 hybrid + function testCollectionDeployedOnTokenDeploy() public { + // Deploy a token as Alice + vm.prank(alice); + + string memory deployContent = 'data:,{"p":"erc-20","op":"deploy","tick":"COLL","max":"10000","lim":"100"}'; + + ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({ + tick: "COLL", + maxSupply: 10000, + mintAmount: 100 + }); + + Ethscriptions.CreateEthscriptionParams memory params = createTokenParams( + DEPLOY_TX_HASH, + alice, + deployContent, + CANONICAL_PROTOCOL, + "deploy", + abi.encode(deployOp) + ); + + ethscriptions.createEthscription(params); + + // Verify collection was deployed + address collectionAddr = fixedDenominationManager.getCollectionAddress(DEPLOY_TX_HASH); + assertTrue(collectionAddr != address(0), "Collection should be deployed"); + + // Verify collection properties + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + assertEq(collection.name(), "COLL"); + assertEq(collection.symbol(), "COLL"); + assertEq(collection.collectionId(), DEPLOY_TX_HASH); + + // Verify collection lookups work + assertEq(fixedDenominationManager.collectionIdForAddress(collectionAddr), DEPLOY_TX_HASH); + assertEq(fixedDenominationManager.collectionAddressForId(DEPLOY_TX_HASH), collectionAddr); + } + + function testCollectionTokenMintedOnNoteMint() public { + // First deploy + testCollectionDeployedOnTokenDeploy(); + + // Get collection address + address collectionAddr = fixedDenominationManager.getCollectionAddress(DEPLOY_TX_HASH); + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + + // Mint a note as Bob + string memory mintContent = 'data:,{"p":"erc-20","op":"mint","tick":"COLL","id":"1","amt":"100"}'; + + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "COLL", + id: 1, + amount: 100 + }); + + Ethscriptions.CreateEthscriptionParams memory mintParams = createTokenParams( + MINT_TX_HASH_1, + bob, + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + ); + + vm.prank(bob); + ethscriptions.createEthscription(mintParams); + + // Verify collection NFT was minted with tokenId = mintId + assertEq(collection.ownerOf(1), bob, "Bob should own collection token #1"); + assertEq(collection.totalSupply(), 1, "Collection should have 1 NFT"); + + // Verify ERC-20 tokens were also minted + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("COLL"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + assertEq(token.balanceOf(bob), 100 ether, "Bob should have 100 tokens"); + } + + function testCollectionTokenTransferredOnNoteTransfer() public { + // Setup: Deploy and mint + testCollectionTokenMintedOnNoteMint(); + + // Get collection + address collectionAddr = fixedDenominationManager.getCollectionAddress(DEPLOY_TX_HASH); + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + + // Get ERC-20 token + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("COLL"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + + // Initial state + assertEq(collection.ownerOf(1), bob); + assertEq(token.balanceOf(bob), 100 ether); + assertEq(token.balanceOf(charlie), 0); + + // Transfer the mint inscription from Bob to Charlie + vm.prank(bob); + ethscriptions.transferEthscription(charlie, MINT_TX_HASH_1); + + // Verify both collection NFT and ERC-20 transferred + assertEq(collection.ownerOf(1), charlie, "Charlie should now own collection token #1"); + assertEq(token.balanceOf(bob), 0, "Bob should have 0 tokens"); + assertEq(token.balanceOf(charlie), 100 ether, "Charlie should have 100 tokens"); + } + + function testCollectionTokenURI() public { + // Setup: Deploy and mint + testCollectionTokenMintedOnNoteMint(); + + // Get collection + address collectionAddr = fixedDenominationManager.getCollectionAddress(DEPLOY_TX_HASH); + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + + // Get token URI + string memory uri = collection.tokenURI(1); + + // Verify it starts with data URI prefix + bytes memory uriBytes = bytes(uri); + bytes memory expectedPrefix = bytes("data:application/json;base64,"); + for (uint i = 0; i < expectedPrefix.length; i++) { + assertEq(uriBytes[i], expectedPrefix[i], "URI should start with JSON data prefix"); + } + + // Could decode and verify JSON contents but that would require base64 decoding + // Just verify it doesn't revert and returns something + assertTrue(bytes(uri).length > 30, "URI should have content"); + } + + function testCollectionMetadataView() public { + // Setup: Deploy + testCollectionDeployedOnTokenDeploy(); + + // Get collection address + address collectionAddr = fixedDenominationManager.getCollectionAddress(DEPLOY_TX_HASH); + + // Get collection metadata via manager + ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = + fixedDenominationManager.getCollectionByAddress(collectionAddr); + + // Verify metadata + assertEq(metadata.name, "COLL ERC-721"); + assertEq(metadata.symbol, "COLL-ERC-721"); + assertEq(metadata.maxSupply, 100); // 10000 maxSupply / 100 mintAmount = 100 notes + assertEq(metadata.description, "Fixed denomination notes for COLL"); + assertEq(metadata.collectionContract, collectionAddr); + assertFalse(metadata.locked); + } + + function testCollectionItemView() public { + // Setup: Deploy and mint + testCollectionTokenMintedOnNoteMint(); + + // Get collection item via manager + ERC721EthscriptionsCollectionManager.CollectionItem memory item = + fixedDenominationManager.getCollectionItem(DEPLOY_TX_HASH, 1); + + // Verify item metadata + assertEq(item.ethscriptionId, MINT_TX_HASH_1); + assertEq(item.name, "COLL #1"); + assertEq(item.description, "100 COLL note"); + assertEq(item.itemIndex, 1); + + // Verify attributes + assertEq(item.attributes.length, 2); + assertEq(item.attributes[0].traitType, "Denomination"); + assertEq(item.attributes[0].value, "100"); + assertEq(item.attributes[1].traitType, "Token"); + assertEq(item.attributes[1].value, "COLL"); + } + + function testCollectionMultipleMints() public { + // Deploy + testCollectionDeployedOnTokenDeploy(); + + // Get collection + address collectionAddr = fixedDenominationManager.getCollectionAddress(DEPLOY_TX_HASH); + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + + // Mint note #1 as Bob + string memory mintContent1 = 'data:,{"p":"erc-20","op":"mint","tick":"COLL","id":"1","amt":"100"}'; + ERC20FixedDenominationManager.MintOperation memory mintOp1 = ERC20FixedDenominationManager.MintOperation({ + tick: "COLL", + id: 1, + amount: 100 + }); + Ethscriptions.CreateEthscriptionParams memory mintParams1 = createTokenParams( + MINT_TX_HASH_1, + bob, + mintContent1, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp1) + ); + vm.prank(bob); + ethscriptions.createEthscription(mintParams1); + + // Mint note #2 as Charlie + string memory mintContent2 = 'data:,{"p":"erc-20","op":"mint","tick":"COLL","id":"2","amt":"100"}'; + ERC20FixedDenominationManager.MintOperation memory mintOp2 = ERC20FixedDenominationManager.MintOperation({ + tick: "COLL", + id: 2, + amount: 100 + }); + Ethscriptions.CreateEthscriptionParams memory mintParams2 = createTokenParams( + MINT_TX_HASH_2, + charlie, + mintContent2, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp2) + ); + vm.prank(charlie); + ethscriptions.createEthscription(mintParams2); + + // Verify both collection NFTs exist with correct owners + assertEq(collection.ownerOf(1), bob, "Bob should own collection token #1"); + assertEq(collection.ownerOf(2), charlie, "Charlie should own collection token #2"); + assertEq(collection.totalSupply(), 2, "Collection should have 2 NFTs"); + + // Verify both have correct ERC-20 balances + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("COLL"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + assertEq(token.balanceOf(bob), 100 ether); + assertEq(token.balanceOf(charlie), 100 ether); + } + */ + + // Additional tests to catch critical bugs in ERC404 implementation + + function testNFTEnumerationAfterMint() public { + // Deploy token + bytes32 deployId = bytes32(uint256(0x1234)); + string memory deployContent = 'data:,{"p":"erc-20","op":"deploy","tick":"ENUM","max":"1000000","lim":"1000"}'; + + ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({ + tick: "ENUM", + maxSupply: 1000000, + mintAmount: 1000 + }); + + vm.prank(alice); + ethscriptions.createEthscription(createTokenParams( + deployId, + alice, + deployContent, + CANONICAL_PROTOCOL, + "deploy", + abi.encode(deployOp) + )); + + // Mint NFT with ID 1 + bytes32 mintId = bytes32(uint256(0x5678)); + string memory mintContent = 'data:,{"p":"erc-20","op":"mint","tick":"ENUM","id":"1","amt":"1000"}'; + + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "ENUM", + id: 1, + amount: 1000 + }); + + vm.prank(alice); + ethscriptions.createEthscription(createTokenParams( + mintId, + alice, + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + )); + + address tokenAddress = fixedDenominationManager.getTokenAddressByTick("ENUM"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddress); + + // Check NFT enumeration + assertEq(token.erc721BalanceOf(alice), 1, "Should have 1 NFT"); + + // Check the owned array contains the correct NFT + uint256[] memory ownedTokens = token.owned(alice); + assertEq(ownedTokens.length, 1, "Should have 1 token in owned array"); + + // Extract the mintId without the prefix + uint256 extractedId = ownedTokens[0] & ((1 << 96) - 1); + assertEq(extractedId, 1, "Should own NFT ID 1"); + + // Verify token owner + assertEq(token.ownerOf(ownedTokens[0]), alice, "Alice should own NFT ID 1"); + } + + function testMultipleNFTTransfers() public { + // Deploy token + bytes32 deployId = bytes32(uint256(0x1234)); + string memory deployContent = 'data:,{"p":"erc-20","op":"deploy","tick":"MULTI","max":"1000000","lim":"1000"}'; + + ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({ + tick: "MULTI", + maxSupply: 1000000, + mintAmount: 1000 + }); + + vm.prank(alice); + ethscriptions.createEthscription(createTokenParams( + deployId, + alice, + deployContent, + CANONICAL_PROTOCOL, + "deploy", + abi.encode(deployOp) + )); + + // Mint 3 NFTs to alice + bytes32[3] memory mintIds; + for (uint256 i = 1; i <= 3; i++) { + mintIds[i-1] = bytes32(uint256(0x5678 + i)); + string memory mintContent = string(abi.encodePacked('data:,{"p":"erc-20","op":"mint","tick":"MULTI","id":"', uint256(i).toString(), '","amt":"1000"}')); + + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "MULTI", + id: i, + amount: 1000 + }); + + vm.prank(alice); + ethscriptions.createEthscription(createTokenParams( + mintIds[i-1], + alice, + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + )); + } + + address tokenAddress = fixedDenominationManager.getTokenAddressByTick("MULTI"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddress); + + // Verify initial state + assertEq(token.erc721BalanceOf(alice), 3, "Alice should have 3 NFTs"); + assertEq(token.erc721BalanceOf(bob), 0, "Bob should have 0 NFTs"); + + // Transfer middle NFT (ID 2) to bob + vm.prank(alice); + ethscriptions.transferEthscription(bob, mintIds[1]); + + assertEq(token.erc721BalanceOf(alice), 2, "Alice should have 2 NFTs after first transfer"); + assertEq(token.erc721BalanceOf(bob), 1, "Bob should have 1 NFT after first transfer"); + + // Transfer another NFT (ID 3) to bob - this would fail with double-prefix bug + vm.prank(alice); + ethscriptions.transferEthscription(bob, mintIds[2]); + + assertEq(token.erc721BalanceOf(alice), 1, "Alice should have 1 NFT after second transfer"); + assertEq(token.erc721BalanceOf(bob), 2, "Bob should have 2 NFTs after second transfer"); + + // Verify ownership is correct + uint256[] memory aliceTokens = token.owned(alice); + uint256[] memory bobTokens = token.owned(bob); + + assertEq(aliceTokens.length, 1, "Alice should own 1 NFT"); + assertEq(bobTokens.length, 2, "Bob should own 2 NFTs"); + } + + function testNFTOwnershipConsistency() public { + // Deploy token + bytes32 deployId = bytes32(uint256(0x1234)); + string memory deployContent = 'data:,{"p":"erc-20","op":"deploy","tick":"OWNER","max":"1000000","lim":"1000"}'; + + ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({ + tick: "OWNER", + maxSupply: 1000000, + mintAmount: 1000 + }); + + vm.prank(alice); + ethscriptions.createEthscription(createTokenParams( + deployId, + alice, + deployContent, + CANONICAL_PROTOCOL, + "deploy", + abi.encode(deployOp) + )); + + // Mint 2 NFTs to alice + bytes32[2] memory mintIds; + for (uint256 i = 1; i <= 2; i++) { + mintIds[i-1] = bytes32(uint256(0x5678 + i)); + string memory mintContent = string(abi.encodePacked('data:,{"p":"erc-20","op":"mint","tick":"OWNER","id":"', uint256(i).toString(), '","amt":"1000"}')); + + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "OWNER", + id: i, + amount: 1000 + }); + + vm.prank(alice); + ethscriptions.createEthscription(createTokenParams( + mintIds[i-1], + alice, + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + )); + } + + address tokenAddress = fixedDenominationManager.getTokenAddressByTick("OWNER"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddress); + + // Check initial owned arrays + uint256[] memory aliceTokensBefore = token.owned(alice); + assertEq(aliceTokensBefore.length, 2, "Alice should have 2 tokens in owned array"); + + // Transfer NFT ID 1 to bob + vm.prank(alice); + ethscriptions.transferEthscription(bob, mintIds[0]); + + // Check ownership consistency after transfer + uint256[] memory aliceTokensAfter = token.owned(alice); + uint256[] memory bobTokensAfter = token.owned(bob); + + assertEq(aliceTokensAfter.length, 1, "Alice should have 1 token in owned array after transfer"); + assertEq(bobTokensAfter.length, 1, "Bob should have 1 token in owned array after transfer"); + + // Verify the tokens are in the correct arrays + uint256 aliceTokenId = aliceTokensAfter[0] & ((1 << 96) - 1); + uint256 bobTokenId = bobTokensAfter[0] & ((1 << 96) - 1); + + assertEq(aliceTokenId, 2, "Alice should own NFT ID 2"); + assertEq(bobTokenId, 1, "Bob should own NFT ID 1"); + } + + function testMintManagerOnlyAndCorrectDenomination() public { + // Deploy token with mintAmount = 1000 + bytes32 deployId = bytes32(uint256(0x1234)); + vm.prank(alice); + string memory deployContent = 'data:,{"p":"erc-20","op":"deploy","tick":"TEST","max":"1000000","lim":"1000"}'; + ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({ + tick: "TEST", + maxSupply: 1000000, + mintAmount: 1000 + }); + + ethscriptions.createEthscription( + createTokenParams( + deployId, + alice, + deployContent, + CANONICAL_PROTOCOL, + "deploy", + abi.encode(deployOp) + ) + ); + + address tokenAddress = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddress); + + // Non-manager cannot mint + vm.expectRevert(ERC20FixedDenomination.OnlyManager.selector); + token.mint(alice, 1); + + // Manager mints one note (amount derived inside) + vm.prank(address(fixedDenominationManager)); + token.mint(alice, 1); + + assertEq(token.balanceOf(alice), 1000 * 1e18, "Should have minted correct amount"); + } + + function testNFTInvariantsAfterMultipleOperations() public { + // Deploy token + bytes32 deployId = bytes32(uint256(0x1234)); + vm.prank(alice); + string memory deployContent = 'data:,{"p":"erc-20","op":"deploy","tick":"TEST","max":"10000","lim":"1000"}'; + ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({ + tick: "TEST", + maxSupply: 10000, + mintAmount: 1000 + }); + + ethscriptions.createEthscription( + createTokenParams( + deployId, + alice, + deployContent, + CANONICAL_PROTOCOL, + "deploy", + abi.encode(deployOp) + ) + ); + + // Mint 5 NFTs to different users + address[5] memory users = [alice, bob, charlie, alice, bob]; + bytes32[5] memory mintIds; + for (uint256 i = 0; i < 5; i++) { + mintIds[i] = bytes32(uint256(0x5678 + i)); + vm.prank(users[i]); + string memory mintContent = string( + abi.encodePacked( + 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"', + (i + 1).toString(), + '","amt":"1000"}' + ) + ); + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "TEST", + id: i + 1, + amount: 1000 + }); + + ethscriptions.createEthscription( + createTokenParams( + mintIds[i], + users[i], + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + ) + ); + } + + address tokenAddress = fixedDenominationManager.getTokenAddressByTick("TEST"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddress); + + // Verify initial invariants + uint256 totalNFTs = token.erc721BalanceOf(alice) + + token.erc721BalanceOf(bob) + + token.erc721BalanceOf(charlie); + assertEq(totalNFTs, 5, "Total NFT count should be 5"); + + // Perform multiple transfers + vm.prank(alice); + ethscriptions.transferEthscription(charlie, mintIds[0]); // Transfer NFT 1 from alice to charlie + + vm.prank(bob); + ethscriptions.transferEthscription(alice, mintIds[1]); // Transfer NFT 2 from bob to alice + + // Verify invariants still hold after transfers + totalNFTs = token.erc721BalanceOf(alice) + + token.erc721BalanceOf(bob) + + token.erc721BalanceOf(charlie); + assertEq(totalNFTs, 5, "Total NFT count should still be 5 after transfers"); + + // Verify no duplicate NFTs in owned arrays + uint256[] memory aliceTokens = token.owned(alice); + uint256[] memory bobTokens = token.owned(bob); + uint256[] memory charlieTokens = token.owned(charlie); + + // Check for duplicates within each array + for (uint256 i = 0; i < aliceTokens.length; i++) { + for (uint256 j = i + 1; j < aliceTokens.length; j++) { + assertTrue(aliceTokens[i] != aliceTokens[j], "No duplicates in Alice's owned array"); + } + } + + // Verify total array lengths match NFT balances + assertEq(aliceTokens.length, token.erc721BalanceOf(alice), "Alice's owned array length should match NFT balance"); + assertEq(bobTokens.length, token.erc721BalanceOf(bob), "Bob's owned array length should match NFT balance"); + assertEq(charlieTokens.length, token.erc721BalanceOf(charlie), "Charlie's owned array length should match NFT balance"); + } +} diff --git a/contracts/test/EthscriptionsTokenParams.t.sol b/contracts/test/EthscriptionsTokenParams.t.sol new file mode 100644 index 0000000..0076e8d --- /dev/null +++ b/contracts/test/EthscriptionsTokenParams.t.sol @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "./TestSetup.sol"; +import "forge-std/console.sol"; + +contract EthscriptionsTokenParamsTest is TestSetup { + string constant CANONICAL_PROTOCOL = "erc-20-fixed-denomination"; + + function testCreateWithTokenDeployParams() public { + // Create a token deploy ethscription + string memory tokenJson = '{"p":"erc-20","op":"deploy","tick":"eths","max":"21000000","lim":"1000"}'; + string memory dataUri = string.concat("data:,", tokenJson); + bytes32 contentUriSha = sha256(bytes(dataUri)); + + ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({ + tick: "eths", + maxSupply: 21000000, + mintAmount: 1000 + }); + + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: bytes32(uint256(1)), + contentUriSha: contentUriSha, + initialOwner: address(this), + content: bytes(tokenJson), + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: CANONICAL_PROTOCOL, + operation: "deploy", + data: abi.encode(deployOp) + }) + }); + + // Create the ethscription + ethscriptions.createEthscription(params); + + // Verify it was created + assertEq(ethscriptions.totalSupply(), 12, "Should have created new ethscription"); + + // Get the ethscription data + Ethscriptions.Ethscription memory eth = ethscriptions.getEthscription(params.ethscriptionId); + assertEq(eth.creator, address(this), "Creator should match"); + assertEq(eth.initialOwner, address(this), "Initial owner should match"); + } + + function testCreateWithTokenMintParams() public { + // Create a token mint ethscription + string memory tokenJson = '{"p":"erc-20","op":"mint","tick":"eths","id":"1","amt":"1000"}'; + string memory dataUri = string.concat("data:,", tokenJson); + bytes32 contentUriSha = sha256(bytes(dataUri)); + + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "eths", + id: 1, + amount: 1000 + }); + + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: bytes32(uint256(2)), + contentUriSha: contentUriSha, + initialOwner: address(this), + content: bytes(tokenJson), + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: CANONICAL_PROTOCOL, + operation: "mint", + data: abi.encode(mintOp) + }) + }); + + // Create the ethscription + ethscriptions.createEthscription(params); + + // Verify it was created + assertEq(ethscriptions.totalSupply(), 12, "Should have created new ethscription"); + } + + function testCreateWithoutTokenParams() public { + // Create a regular non-token ethscription + string memory content = "Hello, World!"; + string memory dataUri = string.concat("data:,", content); + bytes32 contentUriSha = sha256(bytes(dataUri)); + + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: bytes32(uint256(3)), + contentUriSha: contentUriSha, + initialOwner: address(this), + content: bytes(content), + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + // Create the ethscription + ethscriptions.createEthscription(params); + + // Verify it was created + assertEq(ethscriptions.totalSupply(), 12, "Should have created new ethscription"); + } + + function testERC20FixedDenominationManagerIntegration() public { + // First create a deploy operation + string memory deployJson = '{"p":"erc-20","op":"deploy","tick":"test","max":"1000000","lim":"100"}'; + string memory deployUri = string.concat("data:,", deployJson); + + + ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({ + tick: "test", + maxSupply: 1000000, + mintAmount: 100 + }); + + Ethscriptions.CreateEthscriptionParams memory deployParams = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: keccak256("deploy_tx"), + contentUriSha: sha256(bytes(deployUri)), + initialOwner: address(this), + content: bytes(deployJson), + mimetype: "application/json", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: CANONICAL_PROTOCOL, + operation: "deploy", + data: abi.encode(deployOp) + }) + }); + + ethscriptions.createEthscription(deployParams); + + // Then create a mint operation + string memory mintJson = '{"p":"erc-20","op":"mint","tick":"test","id":"1","amt":"100"}'; + string memory mintUri = string.concat("data:,", mintJson); + + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "test", + id: 1, + amount: 100 + }); + + Ethscriptions.CreateEthscriptionParams memory mintParams = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: keccak256("mint_tx"), + contentUriSha: sha256(bytes(mintUri)), + initialOwner: address(this), + content: bytes(mintJson), + mimetype: "application/json", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: CANONICAL_PROTOCOL, + operation: "mint", + data: abi.encode(mintOp) + }) + }); + + ethscriptions.createEthscription(mintParams); + + // Verify both were created + assertEq(ethscriptions.totalSupply(), 13, "Should have 13 total (11 genesis + 2 new)"); + } +} diff --git a/contracts/test/EthscriptionsTransferForPreviousOwner.t.sol b/contracts/test/EthscriptionsTransferForPreviousOwner.t.sol new file mode 100644 index 0000000..12cfd86 --- /dev/null +++ b/contracts/test/EthscriptionsTransferForPreviousOwner.t.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "./TestSetup.sol"; + +contract EthscriptionsTransferForPreviousOwnerTest is TestSetup { + Ethscriptions public eth; + + function setUp() public override { + super.setUp(); + eth = ethscriptions; + } + + function test_TransferForPreviousOwner() public { + // Create an ethscription + bytes32 txHash = bytes32(uint256(0x123)); + address creator = address(0x1); + address initialOwner = address(0x2); + address newOwner = address(0x3); + address thirdOwner = address(0x4); + + vm.prank(creator); + eth.createEthscription( + createTestParams( + txHash, + initialOwner, + "data:,test", + false + ) + ); + + // Transfer from initial owner to new owner + vm.prank(initialOwner); + eth.transferEthscription(newOwner, txHash); + + // Verify previous owner is now initial owner + Ethscriptions.Ethscription memory etsc = eth.getEthscription(txHash); + assertEq(etsc.previousOwner, initialOwner); + + // Now transfer from new owner to third owner, validating previous owner + vm.prank(newOwner); + eth.transferEthscriptionForPreviousOwner( + thirdOwner, + txHash, + initialOwner // Must match the previous owner + ); + + // Verify ownership and previous owner updated + assertEq(eth.ownerOf(txHash), thirdOwner); + etsc = eth.getEthscription(txHash); + assertEq(etsc.previousOwner, newOwner); + + // Test that wrong previous owner fails + vm.prank(thirdOwner); + vm.expectRevert(Ethscriptions.PreviousOwnerMismatch.selector); + eth.transferEthscriptionForPreviousOwner( + address(0x5), + txHash, + address(0x999) // Wrong previous owner + ); + } +} \ No newline at end of file diff --git a/contracts/test/EthscriptionsWithContent.t.sol b/contracts/test/EthscriptionsWithContent.t.sol new file mode 100644 index 0000000..a17f3ad --- /dev/null +++ b/contracts/test/EthscriptionsWithContent.t.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./TestSetup.sol"; + +contract EthscriptionsWithContentTest is TestSetup { + + function testGetEthscription() public { + // Create a test ethscription first + bytes32 txHash = bytes32(uint256(12345)); + address creator = address(0x1); + address initialOwner = address(0x2); + string memory testContent = "Hello, World!"; + + // Create the ethscription + vm.prank(creator); + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: keccak256(bytes("data:text/plain,Hello, World!")), + initialOwner: initialOwner, + content: bytes(testContent), + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + uint256 tokenId = ethscriptions.createEthscription(params); + + // Test the new getEthscription method that returns Ethscription + Ethscriptions.Ethscription memory complete = ethscriptions.getEthscription(txHash); + + // Verify ethscription data + assertEq(complete.ethscriptionId, txHash); + assertEq(complete.ethscriptionNumber, tokenId); + assertEq(complete.creator, creator); + assertEq(complete.initialOwner, initialOwner); + assertEq(complete.previousOwner, creator); + assertEq(complete.currentOwner, initialOwner); + assertEq(complete.mimetype, "text/plain"); + assertEq(complete.esip6, false); + + // Verify content + assertEq(complete.content, bytes(testContent)); + + // Test the version without content using the overloaded function + Ethscriptions.Ethscription memory withoutContent = ethscriptions.getEthscription(txHash, false); + + // Verify same metadata but empty content + assertEq(withoutContent.ethscriptionId, txHash); + assertEq(withoutContent.ethscriptionNumber, tokenId); + assertEq(withoutContent.creator, creator); + assertEq(withoutContent.currentOwner, initialOwner); + assertEq(withoutContent.content.length, 0, "Content should be empty"); + } + + function testGetEthscriptionByTokenId() public { + // Create a test ethscription first + bytes32 txHash = bytes32(uint256(67890)); + address creator = address(0x5); + address initialOwner = address(0x6); + string memory testContent = "Test by token ID"; + + // Create the ethscription + vm.prank(creator); + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: keccak256(bytes("data:text/plain,Test by token ID")), + initialOwner: initialOwner, + content: bytes(testContent), + mimetype: "text/plain", + esip6: true, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + uint256 tokenId = ethscriptions.createEthscription(params); + + // Test getting by token ID + Ethscriptions.Ethscription memory complete = ethscriptions.getEthscription(tokenId); + + // Verify ethscription data + assertEq(complete.ethscriptionId, txHash, "Ethscription ID should match"); + assertEq(complete.ethscriptionNumber, tokenId, "Token ID should match"); + assertEq(complete.creator, creator); + assertEq(complete.currentOwner, initialOwner); + assertEq(complete.content, bytes(testContent)); + + // Test without content version by token ID using the overloaded function + Ethscriptions.Ethscription memory withoutContent = ethscriptions.getEthscription(tokenId, false); + assertEq(withoutContent.ethscriptionId, txHash); + assertEq(withoutContent.content.length, 0, "Content should be empty"); + } + + function testGetEthscriptionNonExistent() public { + bytes32 nonExistentTxHash = bytes32(uint256(99999)); + + // Should revert with EthscriptionDoesNotExist + vm.expectRevert(Ethscriptions.EthscriptionDoesNotExist.selector); + ethscriptions.getEthscription(nonExistentTxHash); + + // Same for without content version using the overloaded function + vm.expectRevert(Ethscriptions.EthscriptionDoesNotExist.selector); + ethscriptions.getEthscription(nonExistentTxHash, false); + } + + function testGetEthscriptionWithLargeContent() public { + // Test with content that's large (testing SSTORE2Unlimited) + bytes32 txHash = bytes32(uint256(54321)); + address creator = address(0x3); + address initialOwner = address(0x4); + + // Create content larger than inline storage (>31 bytes) + bytes memory largeContent = new bytes(30000); + for (uint256 i = 0; i < 30000; i++) { + largeContent[i] = bytes1(uint8(i % 256)); + } + + // Create the ethscription + vm.prank(creator); + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: keccak256(bytes("data:application/octet-stream,")), + initialOwner: initialOwner, + content: largeContent, + mimetype: "application/octet-stream", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + ethscriptions.createEthscription(params); + + // Test the getEthscription method with large content + Ethscriptions.Ethscription memory complete = ethscriptions.getEthscription(txHash); + + // Verify content is correct + assertEq(complete.content.length, 30000); + assertEq(complete.content, largeContent); + + // Verify ethscription data + assertEq(complete.creator, creator); + assertEq(complete.initialOwner, initialOwner); + assertEq(complete.currentOwner, initialOwner); + + // Test without content - should have zero-length content using the overloaded function + Ethscriptions.Ethscription memory withoutContent = ethscriptions.getEthscription(txHash, false); + assertEq(withoutContent.content.length, 0, "Content should be empty"); + assertEq(withoutContent.creator, creator); + assertEq(withoutContent.currentOwner, initialOwner); + } + + function testGetEthscriptionWithSmallContent() public { + // Test with content that fits inline (≤31 bytes) + bytes32 txHash = bytes32(uint256(11111)); + address creator = address(0x7); + address initialOwner = address(0x8); + + // Create small content (10 bytes) + bytes memory smallContent = hex"48656c6c6f576f726c64"; // "HelloWorld" + + // Create the ethscription + vm.prank(creator); + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: keccak256(bytes("data:text/plain,HelloWorld")), + initialOwner: initialOwner, + content: smallContent, + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + uint256 tokenId = ethscriptions.createEthscription(params); + + // Test the getEthscription method with small inline content + Ethscriptions.Ethscription memory complete = ethscriptions.getEthscription(txHash); + + // Verify content is correct + assertEq(complete.content, smallContent); + assertEq(complete.content.length, 10); + + // Verify ownership chain + assertEq(complete.creator, creator); + assertEq(complete.initialOwner, initialOwner); + assertEq(complete.currentOwner, initialOwner); + assertEq(complete.previousOwner, creator); + + // Test getting by token ID too + Ethscriptions.Ethscription memory byTokenId = ethscriptions.getEthscription(tokenId); + assertEq(byTokenId.ethscriptionId, txHash); + assertEq(byTokenId.content, smallContent); + } +} \ No newline at end of file diff --git a/contracts/test/EthscriptionsWithTestFunctions.sol b/contracts/test/EthscriptionsWithTestFunctions.sol new file mode 100644 index 0000000..75d42b7 --- /dev/null +++ b/contracts/test/EthscriptionsWithTestFunctions.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "../src/Ethscriptions.sol"; +import "../src/libraries/SSTORE2Unlimited.sol"; +import "../src/libraries/BytePackLib.sol"; + +/// @title EthscriptionsWithTestFunctions +/// @notice Test contract that extends Ethscriptions with additional functions for testing +/// @dev These functions expose internal storage details useful for tests but not needed in production +/// @dev Usage: Deploy this contract instead of regular Ethscriptions in test setup, then cast to this type +contract EthscriptionsWithTestFunctions is Ethscriptions { + + /// @notice Check if content is stored for an ethscription + /// @dev Test-only function to check if content exists + function hasContent(bytes32 ethscriptionId) external view returns (bool) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + return contentStorage[ethscription.contentHash] != bytes32(0); + } + + /// @notice Get the content storage value for an ethscription + /// @dev Test-only function to inspect storage (either packed bytes or SSTORE2 address) + function getContentStorage(bytes32 ethscriptionId) external view returns (bytes32) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + return contentStorage[ethscription.contentHash]; + } + + /// @notice Get the content pointer for an ethscription (only for SSTORE2 stored content) + /// @dev Test-only function to inspect SSTORE2 address + function getContentPointer(bytes32 ethscriptionId) external view returns (address) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + bytes32 stored = contentStorage[ethscription.contentHash]; + + // Check if it's inline content using BytePackLib + if (BytePackLib.isPacked(stored)) { + // It's packed inline content, not a pointer + return address(0); + } + + // It's a pointer to SSTORE2 contract + return address(uint160(uint256(stored))); + } + + /// @notice Read content directly + /// @dev Test-only function to read content + /// @param ethscriptionId The ethscription ID (L1 tx hash) + /// @return The content data + function readContent(bytes32 ethscriptionId) external view returns (bytes memory) { + return _getEthscriptionContent(ethscriptionId); + } +} diff --git a/contracts/test/GasDebug.t.sol b/contracts/test/GasDebug.t.sol new file mode 100644 index 0000000..f97b51f --- /dev/null +++ b/contracts/test/GasDebug.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import "./TestSetup.sol"; +import "../script/L2Genesis.s.sol"; +import "../src/libraries/Predeploys.sol"; + +contract GasDebugTest is TestSetup { + address constant INITIAL_OWNER = 0xC2172a6315c1D7f6855768F843c420EbB36eDa97; + + function setUp() public override { + super.setUp(); + // ethscriptions is already set up by TestSetup + } + + function testExactMainnetInput() public { + // Set up the exact input from mainnet block 17478950 + Ethscriptions.CreateEthscriptionParams memory params = createTestParams( + 0x05aac415994e0e01e66c4970133a51a4cdcea1f3a967743b87e6eb08f2f4d9f9, + INITIAL_OWNER, + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAm0lEQVR42mNgGITgPxTTxvBleTo0swBsOK0s+N8aJkczC1AMR7KAKpb8v72xAY5hFsD4lFoCN+j56ZUoliAbSoklGIZjwxRbQAjT1YK7d+82kGUBeuQiiJ1rAYYrL81NwCpGFQtoEUT/6RoHWAyknQV0S6ZI5RE6Jt8CZIOOH TuGgR9Fq5FkCf19QM3wx5rZKHEtsRZQtJqkhgUARpCGaUehOD4AAAAAElFTkSuQmCC", + false + ); + + // Prank as the creator address + vm.prank(INITIAL_OWNER); + + // Measure gas before + uint256 gasBefore = gasleft(); + console.log("Gas before createEthscription:", gasBefore); + + // Try to create the ethscription + try ethscriptions.createEthscription(params) returns (uint256 tokenId) { + uint256 gasAfter = gasleft(); + uint256 gasUsed = gasBefore - gasAfter; + console.log("Gas after createEthscription:", gasAfter); + console.log("Gas used:", gasUsed); + console.log("Token ID created:", tokenId); + + // Verify it was created + Ethscriptions.Ethscription memory etsc = ethscriptions.getEthscription(params.ethscriptionId); + assertEq(etsc.creator, INITIAL_OWNER); + assertEq(etsc.initialOwner, INITIAL_OWNER); + assertEq(etsc.mimetype, "image/png"); + } catch Error(string memory reason) { + console.log("Failed with reason:", reason); + revert(reason); + } catch (bytes memory lowLevelData) { + console.log("Failed with low-level error"); + console.logBytes(lowLevelData); + revert("Low-level error"); + } + } + + function testStoreContentDirectly() public { + // Test _storeContent in isolation by creating a minimal wrapper + // First deploy a test contract that exposes _storeContent + StoreContentTester tester = new StoreContentTester(); + + bytes memory contentUri = bytes("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAm0lEQVR42mNgGITgPxTTxvBleTo0swBsOK0s+N8aJkczC1AMR7KAKpb8v72xAY5hFsD4lFoCN+j56ZUoliAbSoklGIZjwxRbQAjT1YK7d+82kGUBeuQiiJ1rAYYrL81NwCpGFQtoEUT/6RoHWAyknQV0S6ZI5RE6Jt8CZIOOH TuGgR9Fq5FkCf19QM3wx5rZKHEtsRZQtJqkhgUARpCGaUehOD4AAAAAElFTkSuQmCC"); + + console.log("Content URI length:", contentUri.length); + + uint256 gasBefore = gasleft(); + bytes32 contentSha = tester.storeContentHelper(contentUri); + uint256 gasUsed = gasBefore - gasleft(); + + console.log("Gas used for _storeContent:", gasUsed); + console.log("Content SHA:", uint256(contentSha)); + } + + // Add bounded test for fuzzing + function testStoreContentBounded(bytes calldata contentUri) public { + // Limit input size to avoid OOG in tests + vm.assume(contentUri.length > 0 && contentUri.length <= 1000); + + StoreContentTester tester = new StoreContentTester(); + + // Only test uncompressed content to avoid decompression gas costs + tester.storeContentHelper(contentUri); + } +} + +// Helper contract to test _storeContent directly +contract StoreContentTester is Ethscriptions { + // Not prefixed with 'test' to avoid fuzzing + function storeContentHelper( + bytes calldata content + ) external returns (bytes32) { + return _storeContent(content); + } +} diff --git a/contracts/test/MetaStore.t.sol b/contracts/test/MetaStore.t.sol new file mode 100644 index 0000000..6538e1d --- /dev/null +++ b/contracts/test/MetaStore.t.sol @@ -0,0 +1,348 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "forge-std/Test.sol"; +import "../src/libraries/MetaStoreLib.sol"; + +contract MetaStoreTest is Test { + // Test mapping for metadata storage + mapping(bytes32 => bytes32) public metaStore; + + function setUp() public {} + + // ============================================================= + // ENCODING TESTS + // ============================================================= + + function test_EncodeEmptyMimeNoProtocol() public { + bytes memory blob = MetaStoreLib.encode("", "", ""); + assertEq(blob.length, 0, "Empty mime + no protocol should be empty blob"); + } + + function test_EncodeTextPlainNoProtocol() public { + bytes memory blob = MetaStoreLib.encode("text/plain", "", ""); + assertEq(blob.length, 0, "text/plain + no protocol should be empty blob"); + } + + function test_EncodeMimeOnly() public { + bytes memory blob = MetaStoreLib.encode("image/png", "", ""); + string memory expected = string(abi.encodePacked( + "image/png", + bytes1(0x00), + "", // empty protocol + bytes1(0x00), + "" // empty operation + )); + assertEq(string(blob), expected, "Should contain mimetype with empty protocol/operation"); + } + + function test_EncodeMimeAndProtocol() public { + bytes memory blob = MetaStoreLib.encode("application/json", "tokens", ""); + string memory expected = string(abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00), + "" // empty operation + )); + assertEq(string(blob), expected, "Should contain mime + protocol with empty operation"); + } + + function test_EncodeFullMetadata() public { + bytes memory blob = MetaStoreLib.encode("application/json", "tokens", "mint"); + string memory expected = string(abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00), + "mint" + )); + assertEq(string(blob), expected, "Should contain all three components"); + } + + function test_EncodeTextPlainWithProtocol() public { + // text/plain is stored as empty string (convention) + bytes memory blob = MetaStoreLib.encode("text/plain", "tokens", "mint"); + string memory expected = string(abi.encodePacked( + // Empty mimetype (text/plain is normalized to empty) + bytes1(0x00), + "tokens", + bytes1(0x00), + "mint" + )); + assertEq(string(blob), expected, "text/plain should be stored as empty string"); + } + + function test_EncodeDoesNotNormalizeMimetype() public { + bytes memory blob = MetaStoreLib.encode("Image/PNG", "", ""); + string memory expected = string(abi.encodePacked( + "Image/PNG", + bytes1(0x00), + "", + bytes1(0x00), + "" + )); + assertEq(string(blob), expected, "Should preserve mimetype case"); + } + + function test_EncodeRejectsInvalidSeparator() public pure { + // Test that we reject separator in mimetype + bytes memory blob = MetaStoreLib.encode("text/plain", "", ""); + // If we get here without reverting, the input was valid (as expected) + assertTrue(blob.length == 0, "Should encode valid input"); + + // Note: Testing for revert with separator character requires vm.expectRevert + // which doesn't work well with nested pure function calls + } + + // ============================================================= + // INTERNING TESTS + // ============================================================= + + function test_InternEmptyBlob() public { + bytes memory blob = bytes(""); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + assertEq(ref, bytes32(0), "Empty blob should return zero sentinel"); + } + + function test_InternSmallBlob() public { + bytes memory blob = abi.encodePacked("image/png"); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + // Should be packed (first byte > 0 and <= 32) + assertTrue(uint8(uint256(ref >> 248)) > 0 && uint8(uint256(ref >> 248)) <= 32, "Should be packed"); + } + + function test_InternLargeBlob() public { + // Create a 50-byte blob + bytes memory blob = new bytes(50); + for (uint i = 0; i < 50; i++) { + blob[i] = bytes1(uint8(97 + (i % 26))); // a-z + } + + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + // Should be an SSTORE2 pointer (not packed) + assertFalse(uint8(uint256(ref >> 248)) > 0 && uint8(uint256(ref >> 248)) <= 32, "Should not be packed"); + } + + function test_InternDeduplicates() public { + bytes memory blob = abi.encodePacked("image/png"); + + bytes32 ref1 = MetaStoreLib.intern(blob, metaStore); + bytes32 ref2 = MetaStoreLib.intern(blob, metaStore); + + assertEq(ref1, ref2, "Should return same reference for identical blobs"); + } + + // ============================================================= + // DECODING TESTS + // ============================================================= + + function test_DecodeEmptySentinel() public view { + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(bytes32(0)); + + assertEq(mime, "text/plain", "Should default to text/plain"); + assertEq(protocol, "", "Should have no protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_DecodeMimeOnly() public { + // Properly formatted blob with 2 separators + bytes memory blob = abi.encodePacked("image/png", bytes1(0x00), bytes1(0x00)); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "image/png", "Should decode mimetype"); + assertEq(protocol, "", "Should have no protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_DecodeMimeAndProtocol() public { + bytes memory blob = abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00) // empty operation + ); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "application/json", "Should decode mimetype"); + assertEq(protocol, "tokens", "Should decode protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_DecodeFullMetadata() public { + bytes memory blob = abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00), + "mint" + ); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "application/json", "Should decode mimetype"); + assertEq(protocol, "tokens", "Should decode protocol"); + assertEq(op, "mint", "Should decode operation"); + } + + function test_DecodeEmptyMimeDefaultsToTextPlain() public { + // Manually create blob with empty mimetype (0x00tokens0x00mint) + bytes memory blob = abi.encodePacked( + bytes1(0x00), + "tokens", + bytes1(0x00), + "mint" + ); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "text/plain", "Empty mime should default to text/plain"); + assertEq(protocol, "tokens", "Should decode protocol"); + assertEq(op, "mint", "Should decode operation"); + } + + function test_RoundTripTextPlainWithProtocol() public { + // Encoding "text/plain" should store as empty and decode back to "text/plain" + bytes memory blob = MetaStoreLib.encode("text/plain", "tokens", "mint"); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "text/plain", "Should decode to text/plain"); + assertEq(protocol, "tokens", "Should decode protocol"); + assertEq(op, "mint", "Should decode operation"); + } + + // ============================================================= + // GET MIMETYPE TESTS + // ============================================================= + + function test_GetMimetypeFromEmpty() public view { + string memory mime = MetaStoreLib.getMimetype(bytes32(0)); + assertEq(mime, "text/plain", "Should return text/plain for empty ref"); + } + + function test_GetMimetypeFromMimeOnly() public { + bytes memory blob = abi.encodePacked("image/png", bytes1(0x00), bytes1(0x00)); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + string memory mime = MetaStoreLib.getMimetype(ref); + assertEq(mime, "image/png", "Should extract mimetype"); + } + + function test_GetMimetypeFromFull() public { + bytes memory blob = abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00), + "mint" + ); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + string memory mime = MetaStoreLib.getMimetype(ref); + assertEq(mime, "application/json", "Should extract mimetype before separator"); + } + + // ============================================================= + // GET PROTOCOL TESTS + // ============================================================= + + function test_GetProtocolFromEmpty() public view { + (string memory protocol, string memory op) = MetaStoreLib.getProtocol(bytes32(0)); + assertEq(protocol, "", "Should have no protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_GetProtocolFromMimeOnly() public { + bytes memory blob = abi.encodePacked("image/png", bytes1(0x00), bytes1(0x00)); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory protocol, string memory op) = MetaStoreLib.getProtocol(ref); + assertEq(protocol, "", "Should have no protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_GetProtocolFromMimeAndProtocol() public { + bytes memory blob = abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00) // empty operation + ); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory protocol, string memory op) = MetaStoreLib.getProtocol(ref); + assertEq(protocol, "tokens", "Should extract protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_GetProtocolFromFull() public { + bytes memory blob = abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00), + "mint" + ); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory protocol, string memory op) = MetaStoreLib.getProtocol(ref); + assertEq(protocol, "tokens", "Should extract protocol"); + assertEq(op, "mint", "Should extract operation"); + } + + + // ============================================================= + // ROUND-TRIP TESTS + // ============================================================= + + function test_RoundTripMimeOnly() public { + bytes memory original = MetaStoreLib.encode("image/svg+xml", "", ""); + bytes32 ref = MetaStoreLib.intern(original, metaStore); + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "image/svg+xml", "Should preserve mimetype"); + assertEq(protocol, "", "Should have no protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_RoundTripFull() public { + bytes memory original = MetaStoreLib.encode("application/json", "erc-721-collection", "create"); + bytes32 ref = MetaStoreLib.intern(original, metaStore); + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "application/json", "Should preserve mimetype"); + assertEq(protocol, "erc-721-collection", "Should preserve normalized protocol"); + assertEq(op, "create", "Should preserve normalized operation"); + } + + function test_RoundTripLongBlob() public { + // Create a blob that will require SSTORE2 + string memory longMime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + bytes memory original = MetaStoreLib.encode(longMime, "very-long-protocol-name", "very-long-operation-name"); + bytes32 ref = MetaStoreLib.intern(original, metaStore); + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, longMime, "Should preserve long mimetype"); + assertEq(protocol, "very-long-protocol-name", "Should preserve long protocol"); + assertEq(op, "very-long-operation-name", "Should preserve long operation"); + } +} diff --git a/contracts/test/Multicall3.t.sol b/contracts/test/Multicall3.t.sol new file mode 100644 index 0000000..7de469e --- /dev/null +++ b/contracts/test/Multicall3.t.sol @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./TestSetup.sol"; +import {Predeploys} from "../src/libraries/Predeploys.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +interface IMulticall3 { + struct Call3 { + address target; + bool allowFailure; + bytes callData; + } + + struct Result { + bool success; + bytes returnData; + } + + function aggregate3(Call3[] calldata calls) external payable returns (Result[] memory returnData); +} + +contract Multicall3Test is TestSetup { + IMulticall3 multicall; + + function setUp() public override { + super.setUp(); + multicall = IMulticall3(Predeploys.MultiCall3); + } + + function testMulticall3Deployed() public view { + // Check that Multicall3 is deployed + uint256 codeSize; + address multicall3Addr = Predeploys.MultiCall3; + assembly { + codeSize := extcodesize(multicall3Addr) + } + assertGt(codeSize, 0, "Multicall3 should be deployed"); + } + + function testMulticall3BatchGetEthscriptions() public { + // Create some test ethscriptions first + bytes32[] memory txHashes = new bytes32[](3); + uint256[] memory tokenIds = new uint256[](3); + + for (uint256 i = 0; i < 3; i++) { + bytes32 txHash = bytes32(uint256(1000 + i)); + address creator = address(uint160(100 + i)); + address initialOwner = address(uint160(200 + i)); + + vm.prank(creator); + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: keccak256(abi.encodePacked("data:text/plain,Test", i)), + initialOwner: initialOwner, + content: bytes(string(abi.encodePacked("Test content ", Strings.toString(i)))), + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + tokenIds[i] = ethscriptions.createEthscription(params); + txHashes[i] = txHash; + } + + // Now use Multicall3 to batch query all three ethscriptions + IMulticall3.Call3[] memory calls = new IMulticall3.Call3[](3); + + for (uint256 i = 0; i < 3; i++) { + // Call getEthscription(bytes32, bool) with includeContent = true + calls[i] = IMulticall3.Call3({ + target: address(ethscriptions), + allowFailure: false, + callData: abi.encodeWithSignature("getEthscription(bytes32,bool)", txHashes[i], true) + }); + } + + // Execute multicall + IMulticall3.Result[] memory results = multicall.aggregate3(calls); + + // Verify results + assertEq(results.length, 3, "Should return 3 results"); + + for (uint256 i = 0; i < 3; i++) { + assertTrue(results[i].success, "Call should succeed"); + + // Decode the result + Ethscriptions.Ethscription memory ethscription = abi.decode( + results[i].returnData, + (Ethscriptions.Ethscription) + ); + + // Verify data + assertEq(ethscription.ethscriptionId, txHashes[i], "Ethscription ID should match"); + assertEq(ethscription.ethscriptionNumber, tokenIds[i], "Token ID should match"); + assertEq(ethscription.creator, address(uint160(100 + i)), "Creator should match"); + assertEq(ethscription.currentOwner, address(uint160(200 + i)), "Owner should match"); + assertEq(ethscription.mimetype, "text/plain", "Mimetype should match"); + assertEq(string(ethscription.content), string(abi.encodePacked("Test content ", Strings.toString(i))), "Content should match"); + } + } + + function testMulticall3MixedCalls() public { + // Create an ethscription + bytes32 txHash = bytes32(uint256(5000)); + address creator = address(0x123); + address initialOwner = address(0x456); + + vm.prank(creator); + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: keccak256("data:text/plain,Mixed test"), + initialOwner: initialOwner, + content: bytes("Mixed test content"), + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + uint256 tokenId = ethscriptions.createEthscription(params); + + // Prepare mixed calls + IMulticall3.Call3[] memory calls = new IMulticall3.Call3[](4); + + // Call 1: Get total supply + calls[0] = IMulticall3.Call3({ + target: address(ethscriptions), + allowFailure: false, + callData: abi.encodeWithSignature("totalSupply()") + }); + + // Call 2: Get balance of owner + calls[1] = IMulticall3.Call3({ + target: address(ethscriptions), + allowFailure: false, + callData: abi.encodeWithSignature("balanceOf(address)", initialOwner) + }); + + // Call 3: Get ethscription with content + calls[2] = IMulticall3.Call3({ + target: address(ethscriptions), + allowFailure: false, + callData: abi.encodeWithSignature("getEthscription(bytes32,bool)", txHash, true) + }); + + // Call 4: Get ethscription without content + calls[3] = IMulticall3.Call3({ + target: address(ethscriptions), + allowFailure: false, + callData: abi.encodeWithSignature("getEthscription(bytes32,bool)", txHash, false) + }); + + // Execute multicall + IMulticall3.Result[] memory results = multicall.aggregate3(calls); + + // Verify results + assertEq(results.length, 4, "Should return 4 results"); + + // Check total supply + assertTrue(results[0].success, "Total supply call should succeed"); + uint256 totalSupply = abi.decode(results[0].returnData, (uint256)); + assertGt(totalSupply, 0, "Total supply should be > 0"); + + // Check balance + assertTrue(results[1].success, "Balance call should succeed"); + uint256 balance = abi.decode(results[1].returnData, (uint256)); + assertEq(balance, 1, "Balance should be 1"); + + // Check ethscription with content + assertTrue(results[2].success, "Get with content should succeed"); + Ethscriptions.Ethscription memory withContent = abi.decode( + results[2].returnData, + (Ethscriptions.Ethscription) + ); + assertEq(withContent.content.length, 18, "Content should be present"); + + // Check ethscription without content + assertTrue(results[3].success, "Get without content should succeed"); + Ethscriptions.Ethscription memory withoutContent = abi.decode( + results[3].returnData, + (Ethscriptions.Ethscription) + ); + assertEq(withoutContent.content.length, 0, "Content should be empty"); + } + + function testMulticall3WithFailure() public { + // Test that allowFailure works correctly + bytes32 nonExistentTxHash = bytes32(uint256(99999)); + + IMulticall3.Call3[] memory calls = new IMulticall3.Call3[](2); + + // Call 1: Valid call - get total supply + calls[0] = IMulticall3.Call3({ + target: address(ethscriptions), + allowFailure: false, + callData: abi.encodeWithSignature("totalSupply()") + }); + + // Call 2: Invalid call - get non-existent ethscription with allowFailure = true + calls[1] = IMulticall3.Call3({ + target: address(ethscriptions), + allowFailure: true, + callData: abi.encodeWithSignature("getEthscription(bytes32)", nonExistentTxHash) + }); + + // Execute multicall - should not revert due to allowFailure + IMulticall3.Result[] memory results = multicall.aggregate3(calls); + + // Verify results + assertEq(results.length, 2, "Should return 2 results"); + assertTrue(results[0].success, "First call should succeed"); + assertFalse(results[1].success, "Second call should fail but be caught"); + } +} \ No newline at end of file diff --git a/contracts/test/PackUnpackTest.t.sol b/contracts/test/PackUnpackTest.t.sol new file mode 100644 index 0000000..55a744f --- /dev/null +++ b/contracts/test/PackUnpackTest.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; + +contract PackUnpackTest is Test { + + function packContent(bytes memory data) internal pure returns (bytes32 out) { + assembly { + let len := mload(data) + // Allow 0..31 bytes + if lt(len, 32) { + // out = (len+1)<<248 | (first 31 bytes of data) + // mload returns 32 bytes; shr(8, ...) drops the last byte to get first 31 bytes + out := or(shl(248, add(len, 1)), shr(8, mload(add(data, 0x20)))) + } + } + } + + function unpackContent(bytes32 packed) internal pure returns (bytes memory out) { + uint256 tag = uint8(uint256(packed >> 248)); // Top byte + if (tag == 0) return out; // Not inline + uint256 len = tag - 1; + out = new bytes(len); + if (len == 0) return out; + assembly { + // Write the 31 data bytes (tag removed) to out[0:] + mstore(add(out, 0x20), shl(8, packed)) + // Optional hygiene: zero the word immediately after the data + mstore(add(add(out, 0x20), len), 0) + } + } + + function test_PackUnpack_HelloWorld() public { + bytes memory original = bytes("Hello, World!"); + console2.log("Original length:", original.length); + console2.logBytes(original); + + bytes32 packed = packContent(original); + console2.log("Packed:"); + console2.logBytes32(packed); + + bytes memory unpacked = unpackContent(packed); + console2.log("Unpacked length:", unpacked.length); + console2.logBytes(unpacked); + + assertEq(unpacked, original, "Unpacked data should match original"); + } + + function test_PackUnpack_Empty() public { + bytes memory original = bytes(""); + console2.log("Original length:", original.length); + + bytes32 packed = packContent(original); + console2.log("Packed:"); + console2.logBytes32(packed); + + bytes memory unpacked = unpackContent(packed); + console2.log("Unpacked length:", unpacked.length); + + assertEq(unpacked, original, "Unpacked data should match original"); + } + + function test_PackUnpack_SingleByte() public { + bytes memory original = bytes("A"); + console2.log("Original length:", original.length); + console2.logBytes(original); + + bytes32 packed = packContent(original); + console2.log("Packed:"); + console2.logBytes32(packed); + + bytes memory unpacked = unpackContent(packed); + console2.log("Unpacked length:", unpacked.length); + console2.logBytes(unpacked); + + assertEq(unpacked, original, "Unpacked data should match original"); + } + + function test_PackUnpack_31Bytes() public { + bytes memory original = bytes("1234567890123456789012345678901"); // 31 bytes + console2.log("Original length:", original.length); + + bytes32 packed = packContent(original); + console2.log("Packed:"); + console2.logBytes32(packed); + + bytes memory unpacked = unpackContent(packed); + console2.log("Unpacked length:", unpacked.length); + + assertEq(unpacked, original, "Unpacked data should match original"); + } +} \ No newline at end of file diff --git a/contracts/test/PaginationGas.t.sol b/contracts/test/PaginationGas.t.sol new file mode 100644 index 0000000..fa9f2c4 --- /dev/null +++ b/contracts/test/PaginationGas.t.sol @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./TestSetup.sol"; +import "forge-std/console.sol"; + +contract PaginationGasTest is TestSetup { + uint256 constant SMALL_CONTENT_SIZE = 10; // 10 bytes + uint256 constant MEDIUM_CONTENT_SIZE = 100; // 100 bytes + uint256 constant LARGE_CONTENT_SIZE = 1000; // 1KB + uint256 constant HUGE_CONTENT_SIZE = 10000; // 10KB + + // Create ethscriptions with different content sizes for testing + function setUp() public override { + super.setUp(); + + // Create ethscriptions with various content sizes + // We'll create 100 ethscriptions to test pagination properly + for (uint256 i = 0; i < 100; i++) { + bytes32 txHash = bytes32(uint256(0x1000000 + i)); + address creator = address(uint160(0x100 + (i % 10))); // 10 different creators + address owner = address(uint160(0x200 + (i % 5))); // 5 different owners + + // Vary content size based on index + bytes memory content; + if (i % 4 == 0) { + content = new bytes(SMALL_CONTENT_SIZE); + } else if (i % 4 == 1) { + content = new bytes(MEDIUM_CONTENT_SIZE); + } else if (i % 4 == 2) { + content = new bytes(LARGE_CONTENT_SIZE); + } else { + content = new bytes(HUGE_CONTENT_SIZE); + } + + // Fill content with some data + for (uint256 j = 0; j < content.length; j++) { + content[j] = bytes1(uint8(j % 256)); + } + + vm.prank(creator); + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: keccak256(abi.encodePacked("uri", i)), + initialOwner: owner, + content: content, + mimetype: "application/octet-stream", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + ethscriptions.createEthscription(params); + } + + console.log("Setup complete: Created 100 ethscriptions"); + console.log("- 25 with small content (10 bytes)"); + console.log("- 25 with medium content (100 bytes)"); + console.log("- 25 with large content (1KB)"); + console.log("- 25 with huge content (10KB)"); + console.log(""); + } + + function testGas_GetEthscriptions_WithContent() public view { + console.log("=== Testing getEthscriptions WITH content ==="); + console.log(""); + + // Test various page sizes with content + uint256[] memory pageSizes = new uint256[](7); + pageSizes[0] = 1; + pageSizes[1] = 10; + pageSizes[2] = 20; + pageSizes[3] = 30; + pageSizes[4] = 40; + pageSizes[5] = 50; + pageSizes[6] = 60; // Should be clamped to 50 + + for (uint256 i = 0; i < pageSizes.length; i++) { + uint256 gasStart = gasleft(); + Ethscriptions.PaginatedEthscriptionsResponse memory result = ethscriptions.getEthscriptions(0, pageSizes[i], true); + uint256 gasUsed = gasStart - gasleft(); + + console.log("Requested:", pageSizes[i], "items"); + console.log(" Returned:", result.items.length, "items"); + console.log(" Gas used:", gasUsed); + console.log(" Gas per item:", result.items.length > 0 ? gasUsed / result.items.length : 0); + console.log(""); + } + } + + function testGas_GetEthscriptions_WithoutContent() public view { + console.log("=== Testing getEthscriptions WITHOUT content ==="); + console.log(""); + + // Test various page sizes without content + uint256[] memory pageSizes = new uint256[](10); + pageSizes[0] = 1; + pageSizes[1] = 10; + pageSizes[2] = 50; + pageSizes[3] = 100; + pageSizes[4] = 200; + pageSizes[5] = 300; + pageSizes[6] = 500; + pageSizes[7] = 750; + pageSizes[8] = 1000; + pageSizes[9] = 1500; // Should be clamped to 1000 + + for (uint256 i = 0; i < pageSizes.length; i++) { + uint256 gasStart = gasleft(); + Ethscriptions.PaginatedEthscriptionsResponse memory result = ethscriptions.getEthscriptions(0, pageSizes[i], false); + uint256 gasUsed = gasStart - gasleft(); + + console.log("Requested:", pageSizes[i], "items"); + console.log(" Returned:", result.items.length, "items"); + console.log(" Gas used:", gasUsed); + console.log(" Gas per item:", result.items.length > 0 ? gasUsed / result.items.length : 0); + console.log(""); + } + } + + function testGas_GetOwnerEthscriptions_WithContent() public view { + console.log("=== Testing getOwnerEthscriptions WITH content ==="); + console.log(""); + + // Test with owner that has 20 ethscriptions (address(0x200)) + address targetOwner = address(0x200); + uint256 ownerBalance = ethscriptions.balanceOf(targetOwner); + console.log("Owner balance:", ownerBalance); + console.log(""); + + // Test various page sizes + uint256[] memory pageSizes = new uint256[](5); + pageSizes[0] = 5; + pageSizes[1] = 10; + pageSizes[2] = 15; + pageSizes[3] = 20; + pageSizes[4] = 30; // More than owner has + + for (uint256 i = 0; i < pageSizes.length; i++) { + uint256 gasStart = gasleft(); + Ethscriptions.PaginatedEthscriptionsResponse memory result = ethscriptions.getOwnerEthscriptions(targetOwner, 0, pageSizes[i], true); + uint256 gasUsed = gasStart - gasleft(); + + console.log("Requested:", pageSizes[i], "items"); + console.log(" Returned:", result.items.length, "items"); + console.log(" Gas used:", gasUsed); + console.log(" Gas per item:", result.items.length > 0 ? gasUsed / result.items.length : 0); + console.log(""); + } + } + + function testGas_GetOwnerEthscriptions_WithoutContent() public view { + console.log("=== Testing getOwnerEthscriptions WITHOUT content ==="); + console.log(""); + + // Test with owner that has 20 ethscriptions + address targetOwner = address(0x200); + uint256 ownerBalance = ethscriptions.balanceOf(targetOwner); + console.log("Owner balance:", ownerBalance); + console.log(""); + + // Test various page sizes + uint256[] memory pageSizes = new uint256[](5); + pageSizes[0] = 5; + pageSizes[1] = 10; + pageSizes[2] = 15; + pageSizes[3] = 20; + pageSizes[4] = 30; // More than owner has + + for (uint256 i = 0; i < pageSizes.length; i++) { + uint256 gasStart = gasleft(); + Ethscriptions.PaginatedEthscriptionsResponse memory result = ethscriptions.getOwnerEthscriptions(targetOwner, 0, pageSizes[i], false); + uint256 gasUsed = gasStart - gasleft(); + + console.log("Requested:", pageSizes[i], "items"); + console.log(" Returned:", result.items.length, "items"); + console.log(" Gas used:", gasUsed); + console.log(" Gas per item:", result.items.length > 0 ? gasUsed / result.items.length : 0); + console.log(""); + } + } + + function testGas_EdgeCases() public view { + console.log("=== Testing Edge Cases ==="); + console.log(""); + + // Test with start beyond total supply + uint256 gasStart = gasleft(); + Ethscriptions.PaginatedEthscriptionsResponse memory result1 = ethscriptions.getEthscriptions(200, 10, true); + uint256 gasUsed = gasStart - gasleft(); + console.log("Start beyond total (200, 10):"); + console.log(" Returned:", result1.items.length, "items"); + console.log(" Gas used:", gasUsed); + console.log(""); + + // Test with limit = 0 (should revert) + console.log("Limit = 0:"); + try ethscriptions.getEthscriptions(0, 0, true) returns (Ethscriptions.PaginatedEthscriptionsResponse memory) { + console.log(" ERROR: Should have reverted!"); + } catch { + console.log(" Correctly reverted with InvalidPaginationLimit"); + } + console.log(""); + + // Test pagination continuation + gasStart = gasleft(); + Ethscriptions.PaginatedEthscriptionsResponse memory page1 = ethscriptions.getEthscriptions(0, 30, true); + gasUsed = gasStart - gasleft(); + console.log("First page (0, 30):"); + console.log(" Returned:", page1.items.length, "items"); + console.log(" Has more:", page1.hasMore); + console.log(" Next start:", page1.nextStart); + console.log(" Gas used:", gasUsed); + console.log(""); + + if (page1.hasMore) { + gasStart = gasleft(); + Ethscriptions.PaginatedEthscriptionsResponse memory page2 = ethscriptions.getEthscriptions(page1.nextStart, 30, true); + gasUsed = gasStart - gasleft(); + console.log("Second page starting at:", page1.nextStart); + console.log(" Returned:", page2.items.length, "items"); + console.log(" Has more:", page2.hasMore); + console.log(" Gas used:", gasUsed); + console.log(""); + } + } + + function testGas_MaximumLimits() public { + console.log("=== Testing Maximum Safe Limits ==="); + console.log(""); + + // Test approaching gas limits with content + console.log("Testing max with content (trying different sizes):"); + uint256[] memory testSizes = new uint256[](5); + testSizes[0] = 40; + testSizes[1] = 45; + testSizes[2] = 50; + testSizes[3] = 55; + testSizes[4] = 60; + + for (uint256 i = 0; i < testSizes.length; i++) { + try ethscriptions.getEthscriptions(0, testSizes[i], true) returns (Ethscriptions.PaginatedEthscriptionsResponse memory result) { + uint256 gasStart = gasleft(); + ethscriptions.getEthscriptions(0, testSizes[i], true); + uint256 gasUsed = gasStart - gasleft(); + console.log(" Size:", testSizes[i]); + console.log(" Returned items:", result.items.length); + console.log(" Gas used:", gasUsed); + } catch { + console.log(" Size FAILED:", testSizes[i]); + } + } + console.log(""); + + // Test approaching gas limits without content + console.log("Testing max without content (trying different sizes):"); + uint256[] memory testSizesNoContent = new uint256[](5); + testSizesNoContent[0] = 800; + testSizesNoContent[1] = 900; + testSizesNoContent[2] = 1000; + testSizesNoContent[3] = 1100; + testSizesNoContent[4] = 1200; + + // Need to create more ethscriptions for this test + if (ethscriptions.totalSupply() < 1200) { + console.log(" (Skipping - need more ethscriptions for full test)"); + } else { + for (uint256 i = 0; i < testSizesNoContent.length; i++) { + try ethscriptions.getEthscriptions(0, testSizesNoContent[i], false) returns (Ethscriptions.PaginatedEthscriptionsResponse memory result) { + uint256 gasStart = gasleft(); + ethscriptions.getEthscriptions(0, testSizesNoContent[i], false); + uint256 gasUsed = gasStart - gasleft(); + console.log(" Size:", testSizesNoContent[i]); + console.log(" Returned items:", result.items.length); + console.log(" Gas used:", gasUsed); + } catch { + console.log(" Size FAILED:", testSizesNoContent[i]); + } + } + } + } +} \ No newline at end of file diff --git a/contracts/test/PaginationGas1000.t.sol b/contracts/test/PaginationGas1000.t.sol new file mode 100644 index 0000000..bd6b14c --- /dev/null +++ b/contracts/test/PaginationGas1000.t.sol @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./TestSetup.sol"; +import "forge-std/console.sol"; + +contract PaginationGas1000Test is TestSetup { + + // Create 1200 ethscriptions for full testing + function setUp() public override { + super.setUp(); + + console.log("Creating 1200 ethscriptions for full pagination testing..."); + + // Create 1200 ethscriptions with small content (to minimize setup gas) + for (uint256 i = 0; i < 1200; i++) { + bytes32 txHash = bytes32(uint256(0x1000000 + i)); + address creator = address(uint160(0x100 + (i % 20))); // 20 different creators + address owner = address(uint160(0x200 + (i % 10))); // 10 different owners + + // Use small content to keep setup gas manageable + bytes memory content = new bytes(10 + (i % 20)); // 10-30 bytes + for (uint256 j = 0; j < content.length; j++) { + content[j] = bytes1(uint8(j % 256)); + } + + vm.prank(creator); + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: keccak256(abi.encodePacked("uri", i)), + initialOwner: owner, + content: content, + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + ethscriptions.createEthscription(params); + + // Log progress every 100 items + if ((i + 1) % 100 == 0) { + console.log(" Created:", i + 1); + } + } + + uint256 total = ethscriptions.totalSupply(); + console.log("Setup complete: Total ethscriptions =", total); + console.log(""); + } + + function testGas_Full1000_WithoutContent() public view { + console.log("=== Testing FULL 1000 items WITHOUT content ==="); + console.log(""); + + // Test various large page sizes without content + uint256[] memory pageSizes = new uint256[](8); + pageSizes[0] = 100; + pageSizes[1] = 200; + pageSizes[2] = 300; + pageSizes[3] = 500; + pageSizes[4] = 750; + pageSizes[5] = 900; + pageSizes[6] = 1000; + pageSizes[7] = 1100; // Should be clamped to 1000 + + for (uint256 i = 0; i < pageSizes.length; i++) { + uint256 gasStart = gasleft(); + Ethscriptions.PaginatedEthscriptionsResponse memory result = ethscriptions.getEthscriptions(0, pageSizes[i], false); + uint256 gasUsed = gasStart - gasleft(); + + console.log("Requested:", pageSizes[i], "items"); + console.log(" Returned:", result.items.length, "items"); + console.log(" Gas used:", gasUsed); + console.log(" Gas per item:", result.items.length > 0 ? gasUsed / result.items.length : 0); + console.log(" Has more:", result.hasMore); + console.log(""); + } + } + + function testGas_Full50_WithContent() public view { + console.log("=== Testing FULL 50 items WITH content ==="); + console.log(""); + + // Test the maximum with content + uint256[] memory pageSizes = new uint256[](4); + pageSizes[0] = 30; + pageSizes[1] = 40; + pageSizes[2] = 50; + pageSizes[3] = 60; // Should be clamped to 50 + + for (uint256 i = 0; i < pageSizes.length; i++) { + uint256 gasStart = gasleft(); + Ethscriptions.PaginatedEthscriptionsResponse memory result = ethscriptions.getEthscriptions(0, pageSizes[i], true); + uint256 gasUsed = gasStart - gasleft(); + + console.log("Requested:", pageSizes[i], "items"); + console.log(" Returned:", result.items.length, "items"); + console.log(" Gas used:", gasUsed); + console.log(" Gas per item:", result.items.length > 0 ? gasUsed / result.items.length : 0); + console.log(""); + } + } + + function testGas_PaginationFlow() public view { + console.log("=== Testing Pagination Flow (multiple pages) ==="); + console.log(""); + + // Test fetching multiple pages sequentially + uint256 pageSize = 250; + uint256 totalFetched = 0; + uint256 totalGasUsed = 0; + uint256 pageNum = 0; + + Ethscriptions.PaginatedEthscriptionsResponse memory page; + uint256 start = 0; + + console.log("Fetching pages of", pageSize, "items without content:"); + console.log(""); + + while (totalFetched < 1000 && pageNum < 10) { // Safety limit of 10 pages + uint256 gasStart = gasleft(); + page = ethscriptions.getEthscriptions(start, pageSize, false); + uint256 gasUsed = gasStart - gasleft(); + + totalFetched += page.items.length; + totalGasUsed += gasUsed; + pageNum++; + + console.log("Page", pageNum); + console.log(" Start index:", start); + console.log(" Items returned:", page.items.length); + console.log(" Gas used:", gasUsed); + console.log(" Has more:", page.hasMore); + + if (!page.hasMore || page.items.length == 0) { + break; + } + + start = page.nextStart; + } + + console.log(""); + console.log("Summary:"); + console.log(" Total pages:", pageNum); + console.log(" Total items fetched:", totalFetched); + console.log(" Total gas used:", totalGasUsed); + console.log(" Average gas per item:", totalFetched > 0 ? totalGasUsed / totalFetched : 0); + } + + function testGas_EdgeCaseLargePagination() public { + console.log("=== Testing Edge Cases with Large Dataset ==="); + console.log(""); + + // Test starting from middle of dataset + console.log("Starting from index 500, requesting 600 items:"); + uint256 gasStart = gasleft(); + Ethscriptions.PaginatedEthscriptionsResponse memory result = ethscriptions.getEthscriptions(500, 600, false); + uint256 gasUsed = gasStart - gasleft(); + + console.log(" Returned:", result.items.length, "items"); + console.log(" Gas used:", gasUsed); + console.log(" Next start:", result.nextStart); + console.log(" Has more:", result.hasMore); + console.log(""); + + // Test at the end of dataset + console.log("Starting from index 1100, requesting 200 items:"); + gasStart = gasleft(); + result = ethscriptions.getEthscriptions(1100, 200, false); + gasUsed = gasStart - gasleft(); + + console.log(" Returned:", result.items.length, "items"); + console.log(" Gas used:", gasUsed); + console.log(""); + + // Try to break it with huge request + console.log("Attempting to request 10000 items (should clamp to 1000):"); + gasStart = gasleft(); + result = ethscriptions.getEthscriptions(0, 10000, false); + gasUsed = gasStart - gasleft(); + + console.log(" SUCCESS - Returned:", result.items.length, "items"); + console.log(" Gas used:", gasUsed); + } +} \ No newline at end of file diff --git a/contracts/test/PaginationGas1000Simple.t.sol b/contracts/test/PaginationGas1000Simple.t.sol new file mode 100644 index 0000000..f265512 --- /dev/null +++ b/contracts/test/PaginationGas1000Simple.t.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./TestSetup.sol"; +import "forge-std/console.sol"; + +contract PaginationGas1000SimpleTest is TestSetup { + + // Override to create more ethscriptions + function setUp() public override { + super.setUp(); + + // Start from a higher ID range to avoid conflicts + uint256 startId = 0x2000000; + uint256 targetCount = 1000; + uint256 existingCount = ethscriptions.totalSupply(); + uint256 toCreate = targetCount > existingCount ? targetCount - existingCount : 0; + + console.log("Existing ethscriptions:", existingCount); + console.log("Creating additional:", toCreate); + + // Create enough ethscriptions to reach 1000 + for (uint256 i = 0; i < toCreate; i++) { + bytes32 txHash = bytes32(uint256(startId + i)); + address creator = address(uint160(0x5000 + (i % 20))); + address owner = address(uint160(0x6000 + (i % 10))); + + // Small content to minimize gas + bytes memory content = new bytes(10); + for (uint256 j = 0; j < 10; j++) { + content[j] = bytes1(uint8(j)); + } + + vm.prank(creator); + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: keccak256(abi.encodePacked("uri", startId + i)), + initialOwner: owner, + content: content, + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + ethscriptions.createEthscription(params); + + // Log progress every 100 + if ((i + 1) % 100 == 0) { + console.log(" Progress:", i + 1, "of", toCreate); + } + } + + uint256 finalCount = ethscriptions.totalSupply(); + console.log("Total ethscriptions now:", finalCount); + console.log(""); + } + + function testGas_1000ItemsWithoutContent() public view { + uint256 totalSupply = ethscriptions.totalSupply(); + console.log("=== Testing Maximum Pagination Limits ==="); + console.log("Total ethscriptions available:", totalSupply); + console.log(""); + + // Test increasing sizes + uint256[] memory sizes = new uint256[](7); + sizes[0] = 100; + sizes[1] = 250; + sizes[2] = 500; + sizes[3] = 750; + sizes[4] = 900; + sizes[5] = 1000; + sizes[6] = 1100; // Should clamp to 1000 + + console.log("Testing WITHOUT content:"); + console.log(""); + + for (uint256 i = 0; i < sizes.length; i++) { + if (sizes[i] > totalSupply) { + console.log("Skipping size", sizes[i], "- not enough ethscriptions"); + continue; + } + + uint256 gasStart = gasleft(); + Ethscriptions.PaginatedEthscriptionsResponse memory result = ethscriptions.getEthscriptions(0, sizes[i], false); + uint256 gasUsed = gasStart - gasleft(); + + console.log("Request size:", sizes[i]); + console.log(" Items returned:", result.items.length); + console.log(" Gas used:", gasUsed); + console.log(" Gas per item:", result.items.length > 0 ? gasUsed / result.items.length : 0); + + // Check if we hit the limit + if (sizes[i] > 1000 && result.items.length == 1000) { + console.log(" (Clamped to max limit of 1000)"); + } + console.log(""); + } + } + + function testGas_50ItemsWithContent() public view { + console.log("=== Testing WITH content limits ==="); + console.log(""); + + uint256[] memory sizes = new uint256[](5); + sizes[0] = 20; + sizes[1] = 30; + sizes[2] = 40; + sizes[3] = 50; + sizes[4] = 60; // Should clamp to 50 + + for (uint256 i = 0; i < sizes.length; i++) { + uint256 gasStart = gasleft(); + Ethscriptions.PaginatedEthscriptionsResponse memory result = ethscriptions.getEthscriptions(0, sizes[i], true); + uint256 gasUsed = gasStart - gasleft(); + + console.log("Request size:", sizes[i]); + console.log(" Items returned:", result.items.length); + console.log(" Gas used:", gasUsed); + console.log(" Gas per item:", result.items.length > 0 ? gasUsed / result.items.length : 0); + + if (sizes[i] > 50 && result.items.length == 50) { + console.log(" (Clamped to max limit of 50)"); + } + console.log(""); + } + } + + function testGas_CheckLimitsWork() public view { + console.log("=== Verifying Limit Clamping ==="); + console.log(""); + + // Test that requesting more than limit gets clamped + Ethscriptions.PaginatedEthscriptionsResponse memory result; + + // Test without content - should clamp at 1000 + result = ethscriptions.getEthscriptions(0, 5000, false); + console.log("Requested 5000 without content, got:", result.items.length); + require(result.items.length <= 1000, "Should clamp to 1000"); + + // Test with content - should clamp at 50 + result = ethscriptions.getEthscriptions(0, 500, true); + console.log("Requested 500 with content, got:", result.items.length); + require(result.items.length <= 50, "Should clamp to 50"); + + console.log(""); + console.log("Limit clamping verified!"); + } +} \ No newline at end of file diff --git a/contracts/test/ProtocolRegistration.t.sol b/contracts/test/ProtocolRegistration.t.sol new file mode 100644 index 0000000..ac6b91a --- /dev/null +++ b/contracts/test/ProtocolRegistration.t.sol @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "./TestSetup.sol"; +import "../src/interfaces/IProtocolHandler.sol"; + +/// @title Protocol Registration Tests +/// @notice Tests for concurrent protocol handler registration and related edge cases +contract ProtocolRegistrationTest is TestSetup { + address alice = address(0xa11ce); + address bob = address(0xb0b); + address charlie = address(0xc0ffee); + + // Mock protocol handler for testing + MockProtocolHandler mockHandler1; + MockProtocolHandler mockHandler2; + MockProtocolHandler mockHandler3; + + function setUp() public override { + super.setUp(); + + mockHandler1 = new MockProtocolHandler(); + mockHandler2 = new MockProtocolHandler(); + mockHandler3 = new MockProtocolHandler(); + } + + /// @notice Test that the same protocol cannot be registered twice + function testCannotRegisterSameProtocolTwice() public { + // Register a protocol + vm.prank(Predeploys.DEPOSITOR_ACCOUNT); + ethscriptions.registerProtocol("test-protocol", address(mockHandler1)); + + // Verify it was registered + assertEq(ethscriptions.protocolHandlers("test-protocol"), address(mockHandler1)); + + // Try to register the same protocol again (should revert) + vm.expectRevert(Ethscriptions.ProtocolAlreadyRegistered.selector); + vm.prank(Predeploys.DEPOSITOR_ACCOUNT); + ethscriptions.registerProtocol("test-protocol", address(mockHandler2)); + + // Verify the original handler is still registered + assertEq(ethscriptions.protocolHandlers("test-protocol"), address(mockHandler1)); + } + + /// @notice Test concurrent registration attempts of the same protocol + /// @dev Simulates race condition where two handlers try to register simultaneously + function testConcurrentRegistrationSameProtocol() public { + // First registration succeeds + vm.prank(Predeploys.DEPOSITOR_ACCOUNT); + ethscriptions.registerProtocol("concurrent-test", address(mockHandler1)); + + // Second registration with different handler fails + vm.expectRevert(Ethscriptions.ProtocolAlreadyRegistered.selector); + vm.prank(Predeploys.DEPOSITOR_ACCOUNT); + ethscriptions.registerProtocol("concurrent-test", address(mockHandler2)); + + // Verify only first handler is registered + assertEq(ethscriptions.protocolHandlers("concurrent-test"), address(mockHandler1)); + } + + /// @notice Test that multiple different protocols can be registered + function testRegisterMultipleDifferentProtocols() public { + vm.startPrank(Predeploys.DEPOSITOR_ACCOUNT); + + // Register three different protocols + ethscriptions.registerProtocol("protocol-1", address(mockHandler1)); + ethscriptions.registerProtocol("protocol-2", address(mockHandler2)); + ethscriptions.registerProtocol("protocol-3", address(mockHandler3)); + + vm.stopPrank(); + + // Verify all were registered correctly + assertEq(ethscriptions.protocolHandlers("protocol-1"), address(mockHandler1)); + assertEq(ethscriptions.protocolHandlers("protocol-2"), address(mockHandler2)); + assertEq(ethscriptions.protocolHandlers("protocol-3"), address(mockHandler3)); + } + + /// @notice Test that protocol registration is restricted to authorized accounts + function testUnauthorizedCannotRegisterProtocol() public { + // Try to register from unauthorized account (should revert) + vm.expectRevert(Ethscriptions.OnlyDepositor.selector); + vm.prank(alice); + ethscriptions.registerProtocol("unauthorized", address(mockHandler1)); + + // Verify protocol was not registered + assertEq(ethscriptions.protocolHandlers("unauthorized"), address(0)); + } + + /// @notice Test registration with zero address handler + function testCannotRegisterZeroAddressHandler() public { + vm.expectRevert(Ethscriptions.InvalidHandler.selector); + vm.prank(Predeploys.DEPOSITOR_ACCOUNT); + ethscriptions.registerProtocol("zero-handler", address(0)); + } + + /// @notice Test that pre-registered protocols (erc-20, collections) are already set + function testPreRegisteredProtocols() public { + // Verify fixed denomination protocol maps to ERC20FixedDenominationManager + assertEq(ethscriptions.protocolHandlers("erc-20-fixed-denomination"), Predeploys.ERC20_FIXED_DENOMINATION_MANAGER); + + // Verify collections is registered to ERC721EthscriptionsCollectionManager + assertEq(ethscriptions.protocolHandlers("erc-721-ethscriptions-collection"), Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_MANAGER); + } + + /// @notice Test that ethscriptions with registered protocol call the handler on transfer + function testRegisteredProtocolHandlerIsCalledOnTransfer() public { + // Register a mock handler + vm.prank(Predeploys.DEPOSITOR_ACCOUNT); + ethscriptions.registerProtocol("mock-protocol", address(mockHandler1)); + + // Create an ethscription with this protocol + bytes32 txHash = bytes32(uint256(0x1234)); + + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: sha256(bytes('data:,{"p":"mock-protocol","op":"test"}')), + initialOwner: alice, + content: bytes('{"p":"mock-protocol","op":"test"}'), + mimetype: "application/json", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "mock-protocol", + operation: "test", + data: abi.encode(uint256(42)) + }) + }); + + vm.prank(alice); + ethscriptions.createEthscription(params); + + // Transfer it to trigger the handler + vm.prank(alice); + ethscriptions.transferEthscription(bob, txHash); + + // Verify the handler was called + assertTrue(mockHandler1.transferCalled()); + assertEq(mockHandler1.lastTxHash(), txHash); + assertEq(mockHandler1.lastFrom(), alice); + assertEq(mockHandler1.lastTo(), bob); + } + + /// @notice Test protocol registration with maximum length protocol name + function testRegisterMaxLengthProtocolName() public { + // Create a 50-character protocol name (if there's a limit) + string memory longName = "protocol-with-a-very-long-name-12345678901234"; + + vm.prank(Predeploys.DEPOSITOR_ACCOUNT); + ethscriptions.registerProtocol(longName, address(mockHandler1)); + + assertEq(ethscriptions.protocolHandlers(longName), address(mockHandler1)); + } + + /// @notice Test that unregistered protocols don't cause failures + function testUnregisteredProtocolDoesNotRevert() public { + bytes32 txHash = bytes32(uint256(0x5678)); + + // Create ethscription with unregistered protocol + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: sha256(bytes('data:,{"p":"unregistered","op":"test"}')), + initialOwner: alice, + content: bytes('{"p":"unregistered","op":"test"}'), + mimetype: "application/json", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "unregistered", + operation: "test", + data: "" + }) + }); + + // Should not revert - just creates ethscription without protocol handling + vm.prank(alice); + ethscriptions.createEthscription(params); + + // Verify ethscription was created + Ethscriptions.Ethscription memory eth = ethscriptions.getEthscription(txHash); + assertEq(eth.initialOwner, alice); + } + + /// @notice Test registering protocol with non-contract address + /// @dev This actually succeeds - validation happens when handler is called + function testRegisterProtocolWithNonContractAddress() public { + // EOA can be registered (validation happens at call time) + vm.prank(Predeploys.DEPOSITOR_ACCOUNT); + ethscriptions.registerProtocol("eoa-handler", alice); + + // Verify it was registered + assertEq(ethscriptions.protocolHandlers("eoa-handler"), alice); + } +} + +/// @notice Mock protocol handler for testing +contract MockProtocolHandler is IProtocolHandler { + bool public creationCalled; + bool public transferCalled; + bytes32 public lastTxHash; + address public lastFrom; + address public lastTo; + + function wasCreationCalled() external view returns (bool) { + return creationCalled; + } + + function onTransfer( + bytes32 txHash, + address from, + address to + ) external override { + transferCalled = true; + lastTxHash = txHash; + lastFrom = from; + lastTo = to; + } + + function protocolName() external pure override returns (string memory) { + return "mock-protocol"; + } +} diff --git a/contracts/test/RealCompressionTest.t.sol b/contracts/test/RealCompressionTest.t.sol new file mode 100644 index 0000000..5627f00 --- /dev/null +++ b/contracts/test/RealCompressionTest.t.sol @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {LibZip} from "solady/utils/LibZip.sol"; +import {SSTORE2} from "solady/utils/SSTORE2.sol"; +import "forge-std/console.sol"; + +contract RealCompressionTest is Test { + using LibZip for bytes; + + // Simulate the real ethscriptions storage pattern + mapping(bytes32 => address[]) private contentBySha; + mapping(bytes32 => address[]) private _compressedContentBySha; + + // function testRealEthscriptionCompressionWithAssembly() public { + // console.log("\n=== Real Ethscription Compression Test ===\n"); + + // // Load the actual example ethscription content + // string memory json = vm.readFile("test/example_ethscription.json"); + // bytes memory contentUri = bytes(vm.parseJsonString(json, ".result.content_uri")); + + // emit log_named_uint("Actual content URI size (bytes)", contentUri.length); + + // // Test uncompressed storage and retrieval + // (uint256 writeGasUncompressed, uint256 readGasUncompressed, bytes32 shaUncompressed) = + // _storeAndReadUncompressed(contentUri); + + // // Test compressed storage and retrieval + // (uint256 writeGasCompressed, uint256 readGasCompressed, bytes32 shaCompressed) = + // _storeAndReadCompressed(contentUri); + + // // Compare results + // console.log("\n=== Gas Comparison ==="); + // emit log_named_uint("Write Gas (uncompressed)", writeGasUncompressed); + // emit log_named_uint("Write Gas (compressed)", writeGasCompressed); + + // if (writeGasCompressed < writeGasUncompressed) { + // uint256 writeSavings = writeGasUncompressed - writeGasCompressed; + // emit log_named_uint("Write gas saved", writeSavings); + // emit log_named_uint("Write savings %", (writeSavings * 100) / writeGasUncompressed); + // } else { + // uint256 writeExtra = writeGasCompressed - writeGasUncompressed; + // emit log_named_uint("Extra write gas", writeExtra); + // } + + // emit log_named_uint("Read Gas (uncompressed)", readGasUncompressed); + // emit log_named_uint("Read Gas (compressed + decompress)", readGasCompressed); + + // if (readGasCompressed < readGasUncompressed) { + // uint256 readSavings = readGasUncompressed - readGasCompressed; + // emit log_named_uint("Read gas saved", readSavings); + // } else { + // uint256 readExtra = readGasCompressed - readGasUncompressed; + // emit log_named_uint("Extra read gas", readExtra); + // } + + // // Calculate break-even point + // if (writeGasCompressed < writeGasUncompressed && readGasCompressed > readGasUncompressed) { + // uint256 writeSavings = writeGasUncompressed - writeGasCompressed; + // uint256 readPenalty = readGasCompressed - readGasUncompressed; + // uint256 breakEvenReads = writeSavings / readPenalty; + // emit log_named_uint("Break-even reads (integer)", breakEvenReads); + // } + + // // Total lifecycle cost (1 write + N reads) + // console.log("\n=== Total Lifecycle Cost ==="); + // for (uint reads = 1; reads <= 10; reads *= 10) { + // uint256 totalUncompressed = writeGasUncompressed + (readGasUncompressed * reads); + // uint256 totalCompressed = writeGasCompressed + (readGasCompressed * reads); + + // emit log_named_uint(string.concat("Total cost with ", vm.toString(reads), " reads (uncompressed)"), totalUncompressed); + // emit log_named_uint(string.concat("Total cost with ", vm.toString(reads), " reads (compressed)"), totalCompressed); + + // if (totalCompressed < totalUncompressed) { + // uint256 savings = totalUncompressed - totalCompressed; + // emit log_named_uint(" Net savings", savings); + // emit log_named_uint(" Savings percentage", (savings * 100) / totalUncompressed); + // } else { + // uint256 extra = totalCompressed - totalUncompressed; + // emit log_named_uint(" Net extra cost", extra); + // } + // } + // } + + function _storeAndReadUncompressed(bytes memory data) internal returns (uint256 writeGas, uint256 readGas, bytes32 sha) { + sha = keccak256(data); + uint256 chunkSize = 24575; + + // Write phase + uint256 gasStart = gasleft(); + + uint256 chunks = (data.length + chunkSize - 1) / chunkSize; + for (uint i = 0; i < chunks; i++) { + uint256 start = i * chunkSize; + uint256 end = start + chunkSize; + if (end > data.length) end = data.length; + + bytes memory chunk = new bytes(end - start); + for (uint j = 0; j < chunk.length; j++) { + chunk[j] = data[start + j]; + } + + address pointer = SSTORE2.write(chunk); + contentBySha[sha].push(pointer); + } + + writeGas = gasStart - gasleft(); + emit log_named_uint("Uncompressed chunks", chunks); + + // Read phase using assembly (mimicking real contract) + gasStart = gasleft(); + bytes memory result = _readWithAssembly(contentBySha[sha]); + readGas = gasStart - gasleft(); + + // Verify + assertEq(result, data, "Uncompressed read mismatch"); + + return (writeGas, readGas, sha); + } + + // function _storeAndReadCompressed(bytes memory data) internal returns (uint256 writeGas, uint256 readGas, bytes32 sha) { + // // Compress first and measure CPU + // uint256 g0 = gasleft(); + // bytes memory compressed = LibZip.flzCompress(data); + // uint256 compGas = g0 - gasleft(); + // emit log_named_uint("Compress gas", compGas); + // sha = keccak256(data); // Use original SHA for consistency + + // emit log_named_uint("Compressed size (bytes)", compressed.length); + // emit log_named_uint("Compression ratio %", (compressed.length * 100) / data.length); + + // uint256 chunkSize = 24575; + + // // Write phase + // uint256 gasStart = gasleft(); + + // uint256 chunks = (compressed.length + chunkSize - 1) / chunkSize; + // for (uint i = 0; i < chunks; i++) { + // uint256 start = i * chunkSize; + // uint256 end = start + chunkSize; + // if (end > compressed.length) end = compressed.length; + + // bytes memory chunk = new bytes(end - start); + // for (uint j = 0; j < chunk.length; j++) { + // chunk[j] = compressed[start + j]; + // } + + // address pointer = SSTORE2.write(chunk); + // _compressedContentBySha[sha].push(pointer); + // } + + // writeGas = gasStart - gasleft(); + // emit log_named_uint("Compressed chunks", chunks); + + // // Read phase and measure read vs decompress separately + // gasStart = gasleft(); + // bytes memory compressedRead = _readWithAssembly(_compressedContentBySha[sha]); + // uint256 readOnlyGas = gasStart - gasleft(); + + // uint256 g1 = gasleft(); + // bytes memory result = LibZip.flzDecompress(compressedRead); + // uint256 decompGas = g1 - gasleft(); + // readGas = readOnlyGas + decompGas; + // emit log_named_uint("Read Gas (compressed only)", readOnlyGas); + // emit log_named_uint("Decompress gas", decompGas); + + // // Verify + // assertEq(result, data, "Compressed read mismatch"); + + // return (writeGas, readGas, sha); + // } + + // Mimics the assembly read from Ethscriptions.sol + function _readWithAssembly(address[] memory pointers) internal view returns (bytes memory result) { + uint256 dataOffset = 1; // SSTORE2 data starts after a 1-byte STOP opcode + assembly { + // Calculate total size needed + let totalSize := 0 + let pointersLength := mload(pointers) + + for { let i := 0 } lt(i, pointersLength) { i := add(i, 1) } { + let pointer := mload(add(pointers, add(0x20, mul(i, 0x20)))) + let codeSize := extcodesize(pointer) + totalSize := add(totalSize, sub(codeSize, dataOffset)) + } + + // Allocate result buffer + result := mload(0x40) + let resultPtr := add(result, 0x20) + + // Copy data from each pointer + let currentOffset := 0 + for { let i := 0 } lt(i, pointersLength) { i := add(i, 1) } { + let pointer := mload(add(pointers, add(0x20, mul(i, 0x20)))) + let codeSize := extcodesize(pointer) + let chunkSize := sub(codeSize, dataOffset) + extcodecopy(pointer, add(resultPtr, currentOffset), dataOffset, chunkSize) + currentOffset := add(currentOffset, chunkSize) + } + + // Update length and free memory pointer with proper alignment + mstore(result, totalSize) + mstore(0x40, and(add(add(resultPtr, totalSize), 0x1f), not(0x1f))) + } + + return result; + } +} diff --git a/contracts/test/SSTORE2ContentSizes.t.sol b/contracts/test/SSTORE2ContentSizes.t.sol new file mode 100644 index 0000000..bc3e796 --- /dev/null +++ b/contracts/test/SSTORE2ContentSizes.t.sol @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "./TestSetup.sol"; +import "./EthscriptionsWithTestFunctions.sol"; +import "forge-std/console2.sol"; + +/// @title SSTORE2ContentSizesTest +/// @notice Comprehensive tests for SSTORE2Unlimited with various content sizes +/// @dev Tests small, medium, and large content to ensure proper storage and retrieval +contract SSTORE2ContentSizesTest is TestSetup { + EthscriptionsWithTestFunctions internal eth; + + function setUp() public override { + super.setUp(); + + // Deploy the test version of Ethscriptions with additional test functions + EthscriptionsWithTestFunctions testEthscriptions = new EthscriptionsWithTestFunctions(); + vm.etch(Predeploys.ETHSCRIPTIONS, address(testEthscriptions).code); + eth = EthscriptionsWithTestFunctions(Predeploys.ETHSCRIPTIONS); + } + + /// @notice Test with small content (100 bytes) + function test_SmallContent_100Bytes() public { + _testContentSize(100, "Small (100 bytes)"); + } + + /// @notice Test with small content (512 bytes) + function test_SmallContent_512Bytes() public { + _testContentSize(512, "Small (512 bytes)"); + } + + /// @notice Test with 1KB content + function test_SmallContent_1KB() public { + _testContentSize(1024, "1KB"); + } + + /// @notice Test with 10KB content + function test_MediumContent_10KB() public { + _testContentSize(10 * 1024, "10KB"); + } + + /// @notice Test with 24KB content (just under old contract size limit) + function test_MediumContent_24KB() public { + _testContentSize(24 * 1024, "24KB"); + } + + /// @notice Test with 50KB content (exceeds old single contract limit) + function test_MediumContent_50KB() public { + _testContentSize(50 * 1024, "50KB"); + } + + /// @notice Test with 100KB content + function test_MediumContent_100KB() public { + _testContentSize(100 * 1024, "100KB"); + } + + /// @notice Test with 250KB content + function test_LargeContent_250KB() public { + _testContentSize(250 * 1024, "250KB"); + } + + /// @notice Test with 500KB content + function test_LargeContent_500KB() public { + _testContentSize(500 * 1024, "500KB"); + } + + /// @notice Test with 750KB content + function test_LargeContent_750KB() public { + _testContentSize(750 * 1024, "750KB"); + } + + /// @notice Test with 1MB content (already exists but let's make it part of the suite) + function test_LargeContent_1MB() public { + _testContentSize(1024 * 1024, "1MB"); + } + + /// @notice Test with 2MB content + function test_VeryLargeContent_2MB() public { + _testContentSize(2 * 1024 * 1024, "2MB"); + } + + /// @notice Test edge case: empty content + function test_EdgeCase_EmptyContent() public { + _testContentSize(0, "Empty"); + } + + /// @notice Test edge case: single byte + function test_EdgeCase_SingleByte() public { + _testContentSize(1, "Single byte"); + } + + /// @notice Helper function to test content of a specific size + function _testContentSize(uint256 size, string memory label) private { + vm.pauseGasMetering(); + + // Create content of specified size with a deterministic pattern + bytes memory content = _generateContent(size); + + // Create data URI + string memory contentUri = string(abi.encodePacked("data:text/plain,", content)); + + // Create ethscription parameters + bytes32 txHash = keccak256(abi.encodePacked(label, size)); + address creator = address(0x1234); + address initialOwner = address(0x5678); + + Ethscriptions.CreateEthscriptionParams memory params = createTestParams( + txHash, + initialOwner, + contentUri, + false + ); + + // Measure gas for creation + vm.startPrank(creator); + uint256 g0 = gasleft(); + vm.resumeGasMetering(); + uint256 tokenId = eth.createEthscription(params); + vm.pauseGasMetering(); + uint256 createGas = g0 - gasleft(); + vm.stopPrank(); + + console2.log(string.concat(label, " - Create gas: "), createGas); + console2.log(string.concat(label, " - Content size: "), size); + + // Verify ownership + assertEq(eth.ownerOf(tokenId), initialOwner, "Owner mismatch"); + + // Test content retrieval via getEthscriptionContent + g0 = gasleft(); + vm.resumeGasMetering(); + bytes memory retrievedContent = eth.readContent(txHash); + vm.pauseGasMetering(); + uint256 retrievalGas = g0 - gasleft(); + + console2.log(string.concat(label, " - Retrieval gas: "), retrievalGas); + + // Verify content matches exactly + assertEq(retrievedContent.length, content.length, "Content length mismatch"); + + if (size > 0) { + // Verify the content matches + _verifyContent(retrievedContent, content, size, label); + } + + // Verify storage details + _verifyStorage(txHash, content, label); + + console2.log(string.concat(label, " - Test passed!")); + console2.log("---"); + } + + /// @notice Verify content matches + function _verifyContent( + bytes memory retrievedContent, + bytes memory originalContent, + uint256 size, + string memory label + ) private pure { + // For non-empty content, verify the actual bytes match + assertEq( + keccak256(retrievedContent), + keccak256(originalContent), + "Content hash mismatch" + ); + + // Sample check: verify first and last bytes + assertEq(retrievedContent[0], originalContent[0], "First byte mismatch"); + assertEq( + retrievedContent[retrievedContent.length - 1], + originalContent[originalContent.length - 1], + "Last byte mismatch" + ); + + // For smaller content, check byte-by-byte + if (size <= 1024) { + for (uint i = 0; i < size; i++) { + assertEq(retrievedContent[i], originalContent[i], "Byte mismatch"); + } + } + } + + /// @notice Verify storage details + function _verifyStorage( + bytes32 txHash, + bytes memory content, + string memory label + ) private view { + // Test that content is stored (either inline or via SSTORE2) + assertTrue(eth.hasContent(txHash), "Content not stored"); + + // For small content (<32 bytes), it's stored inline and there's no pointer + // For large content (>=32 bytes), it's stored via SSTORE2 with a pointer + address pointer = eth.getContentPointer(txHash); + if (content.length >= 32) { + assertTrue(pointer != address(0), "Should have SSTORE2 pointer for large content"); + } else { + // Small content is stored inline, no SSTORE2 pointer + assertEq(pointer, address(0), "Should not have pointer for inline content"); + } + + // Test direct read from test functions + bytes memory directRead = eth.readContent(txHash); + assertEq(keccak256(directRead), keccak256(content), "Direct read mismatch"); + } + + /// @notice Generate deterministic content of specified size + function _generateContent(uint256 size) private pure returns (bytes memory) { + if (size == 0) return new bytes(0); + + bytes memory content = new bytes(size); + for (uint256 i = 0; i < size;) { + // Create a pattern that's easy to verify: + // - Alternating uppercase letters A-Z + // - With position markers every 256 bytes + if (i % 256 == 0 && i > 0) { + // Position marker: use numbers 0-9 + content[i] = bytes1(uint8(48 + ((i / 256) % 10))); + } else { + // Regular pattern: A-Z cycling + content[i] = bytes1(uint8(65 + (i % 26))); + } + + unchecked { + ++i; + } + } + return content; + } + + /// @notice Test content deduplication across different sizes + function test_ContentDeduplication_VariousSizes() public { + vm.pauseGasMetering(); + + // Test deduplication with different content sizes + uint256[] memory sizes = new uint256[](5); + sizes[0] = 100; // Small + sizes[1] = 1024; // 1KB + sizes[2] = 10240; // 10KB + sizes[3] = 102400; // 100KB + sizes[4] = 524288; // 512KB + + for (uint256 i = 0; i < sizes.length; i++) { + _testDeduplication(sizes[i], string.concat("Size: ", vm.toString(sizes[i]))); + } + } + + /// @notice Helper to test deduplication for a specific size + function _testDeduplication(uint256 size, string memory label) private { + bytes memory content = _generateContent(size); + string memory contentUri = string(abi.encodePacked("data:text/plain,", content)); + + // Create first ethscription + bytes32 txHash1 = keccak256(abi.encodePacked(label, "first")); + bytes32 txHash2 = keccak256(abi.encodePacked(label, "second")); + address creator = address(0x1234); + + vm.startPrank(creator); + + // First creation + uint256 g0 = gasleft(); + vm.resumeGasMetering(); + eth.createEthscription(createTestParams(txHash1, address(0x1111), contentUri, true)); + vm.pauseGasMetering(); + uint256 firstGas = g0 - gasleft(); + + // Second creation with same content (should deduplicate) + g0 = gasleft(); + vm.resumeGasMetering(); + eth.createEthscription(createTestParams(txHash2, address(0x2222), contentUri, true)); + vm.pauseGasMetering(); + uint256 secondGas = g0 - gasleft(); + + vm.stopPrank(); + + console2.log(string.concat(label, " - First creation gas: "), firstGas); + console2.log(string.concat(label, " - Second creation gas (deduplicated): "), secondGas); + console2.log(string.concat(label, " - Gas saved: "), firstGas - secondGas); + + // Verify both use the same content pointer + address pointer1 = eth.getContentPointer(txHash1); + address pointer2 = eth.getContentPointer(txHash2); + assertEq(pointer1, pointer2, "Pointers should be identical"); + + // Verify content retrieval works for both + bytes memory content1 = eth.readContent(txHash1); + bytes memory content2 = eth.readContent(txHash2); + assertEq(keccak256(content1), keccak256(content2), "Content should be identical"); + assertEq(keccak256(content1), keccak256(content), "Retrieved content should match original"); + + // Second creation should be significantly cheaper (saved SSTORE2 deployment) + assertTrue(secondGas < firstGas, "Deduplication should save gas"); + + console2.log("---"); + } +} \ No newline at end of file diff --git a/contracts/test/TestImageSVGWrapper.t.sol b/contracts/test/TestImageSVGWrapper.t.sol new file mode 100644 index 0000000..e3a351d --- /dev/null +++ b/contracts/test/TestImageSVGWrapper.t.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "./TestSetup.sol"; +import {Base64} from "solady/utils/Base64.sol"; + +contract TestImageSVGWrapper is TestSetup { + address alice = address(0x1); + + function test_ImageWrappedInSVG() public { + // Create a PNG ethscription + bytes32 txHash = keccak256("test_image"); + + // Small 1x1 red pixel PNG (base64 decoded) + bytes memory pngContent = hex"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154785e636ff8ff0f000501020157cd3de00000000049454e44ae426082"; + string memory pngDataUri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + + vm.prank(alice); + ethscriptions.createEthscription(Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: sha256(bytes(pngDataUri)), + initialOwner: alice, + content: pngContent, + mimetype: "image/png", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + })); + + // Get token URI + uint256 tokenId = ethscriptions.getTokenId(txHash); + string memory tokenUri = ethscriptions.tokenURI(tokenId); + + // Decode the JSON + assertTrue(startsWith(tokenUri, "data:application/json;base64,"), "Should return base64 JSON"); + bytes memory decodedJson = Base64.decode(string(bytes(substring(tokenUri, 29, bytes(tokenUri).length)))); + string memory json = string(decodedJson); + + // Verify image field exists and contains SVG wrapper + assertTrue(contains(json, '"image":"data:image/svg+xml;base64,'), "Should have SVG-wrapped image"); + + // Extract and decode the SVG to verify it contains our image and pixelated styling + // The SVG should contain: + // 1. The original image as background-image + // 2. image-rendering: pixelated for crisp scaling + // Note: Full extraction would be complex, but we can check for key indicators + assertTrue(contains(json, '"image"'), "Should have image field"); + assertFalse(contains(json, '"animation_url"'), "Should not have animation_url for images"); + } + + function test_NonImageNotWrapped() public { + // Create a text ethscription to verify it's NOT wrapped in SVG + bytes32 txHash = keccak256("test_text"); + + vm.prank(alice); + ethscriptions.createEthscription(createTestParams( + txHash, + alice, + "data:text/plain,Hello World", + false + )); + + // Get token URI + uint256 tokenId = ethscriptions.getTokenId(txHash); + string memory tokenUri = ethscriptions.tokenURI(tokenId); + + // Decode the JSON + bytes memory decodedJson = Base64.decode(string(bytes(substring(tokenUri, 29, bytes(tokenUri).length)))); + string memory json = string(decodedJson); + + // Verify text content uses animation_url with HTML viewer, not SVG + assertTrue(contains(json, '"animation_url":"data:text/html;base64,'), "Should have HTML viewer"); + assertFalse(contains(json, '"image"'), "Should not have image field for text"); + assertFalse(contains(json, "svg"), "Should not contain SVG for non-images"); + } + + // Helper functions + function startsWith(string memory str, string memory prefix) internal pure returns (bool) { + bytes memory strBytes = bytes(str); + bytes memory prefixBytes = bytes(prefix); + + if (prefixBytes.length > strBytes.length) return false; + + for (uint256 i = 0; i < prefixBytes.length; i++) { + if (strBytes[i] != prefixBytes[i]) return false; + } + return true; + } + + function contains(string memory str, string memory substr) internal pure returns (bool) { + bytes memory strBytes = bytes(str); + bytes memory substrBytes = bytes(substr); + + if (substrBytes.length > strBytes.length) return false; + + for (uint256 i = 0; i <= strBytes.length - substrBytes.length; i++) { + bool found = true; + for (uint256 j = 0; j < substrBytes.length; j++) { + if (strBytes[i + j] != substrBytes[j]) { + found = false; + break; + } + } + if (found) return true; + } + return false; + } + + function substring(string memory str, uint256 start, uint256 end) internal pure returns (string memory) { + bytes memory strBytes = bytes(str); + bytes memory result = new bytes(end - start); + for (uint256 i = start; i < end; i++) { + result[i - start] = strBytes[i]; + } + return string(result); + } +} diff --git a/contracts/test/TestSetup.sol b/contracts/test/TestSetup.sol new file mode 100644 index 0000000..b5dcd38 --- /dev/null +++ b/contracts/test/TestSetup.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/Ethscriptions.sol"; +import "../src/ERC20FixedDenominationManager.sol"; +import "../src/ERC721EthscriptionsCollectionManager.sol"; +import "../src/EthscriptionsProver.sol"; +import "../src/ERC20FixedDenomination.sol"; +import "../src/L2/L2ToL1MessagePasser.sol"; +import "../src/L2/L1Block.sol"; +import {Base64} from "solady/utils/Base64.sol"; +import "../src/libraries/Predeploys.sol"; +import "../script/L2Genesis.s.sol"; + +/// @title TestSetup +/// @notice Base test contract that pre-deploys all system contracts at their known addresses +abstract contract TestSetup is Test { + Ethscriptions public ethscriptions; + ERC20FixedDenominationManager public fixedDenominationManager; + ERC721EthscriptionsCollectionManager public collectionsHandler; + EthscriptionsProver public prover; + L1Block public l1Block; + + function setUp() public virtual { + L2Genesis genesis = new L2Genesis(); + genesis.runWithoutDump(); + + // Initialize name and symbol for Ethscriptions contract + // This would normally be done in genesis state + ethscriptions = Ethscriptions(Predeploys.ETHSCRIPTIONS); + + // Store contract references for tests + fixedDenominationManager = ERC20FixedDenominationManager(Predeploys.ERC20_FIXED_DENOMINATION_MANAGER); + collectionsHandler = ERC721EthscriptionsCollectionManager(Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_MANAGER); + prover = EthscriptionsProver(Predeploys.ETHSCRIPTIONS_PROVER); + l1Block = L1Block(Predeploys.L1_BLOCK_ATTRIBUTES); + + // ERC20 template doesn't need initialization - it's just a template for cloning + } + + // Helper function to create test ethscription params + function createTestParams( + bytes32 transactionHash, + address initialOwner, + string memory dataUri, + bool esip6 + ) internal pure returns (Ethscriptions.CreateEthscriptionParams memory) { + // Parse the data URI to extract needed info + bytes memory contentUriBytes = bytes(dataUri); + bytes32 contentUriSha = sha256(contentUriBytes); // Use SHA-256 to match production + + // Simple parsing for tests + bytes memory content; + string memory mimetype = "text/plain"; + bool isBase64 = false; + + // Check if data URI and parse + if (contentUriBytes.length > 5) { + // Find comma + uint256 commaIdx = 0; + for (uint256 i = 5; i < contentUriBytes.length; i++) { + if (contentUriBytes[i] == ',') { + commaIdx = i; + break; + } + } + + if (commaIdx > 0) { + // Check for base64 in metadata first + for (uint256 i = 5; i < commaIdx; i++) { + if (contentUriBytes[i] == 'b' && i + 5 < commaIdx) { + isBase64 = (contentUriBytes[i+1] == 'a' && + contentUriBytes[i+2] == 's' && + contentUriBytes[i+3] == 'e' && + contentUriBytes[i+4] == '6' && + contentUriBytes[i+5] == '4'); + if (isBase64) break; + } + } + + // Extract content after comma + bytes memory rawContent = new bytes(contentUriBytes.length - commaIdx - 1); + for (uint256 i = 0; i < rawContent.length; i++) { + rawContent[i] = contentUriBytes[commaIdx + 1 + i]; + } + + // If base64, decode it to get actual raw bytes + if (isBase64) { + content = Base64.decode(string(rawContent)); + } else { + content = rawContent; + } + + // Extract mimetype if present + if (commaIdx > 5) { + uint256 mimeEnd = commaIdx; + for (uint256 i = 5; i < commaIdx; i++) { + if (contentUriBytes[i] == ';') { + mimeEnd = i; + break; + } + } + + if (mimeEnd > 5) { + mimetype = string(new bytes(mimeEnd - 5)); + for (uint256 i = 0; i < mimeEnd - 5; i++) { + bytes(mimetype)[i] = contentUriBytes[5 + i]; + } + } + } + } + } else { + content = contentUriBytes; + } + + return Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: transactionHash, + contentUriSha: contentUriSha, + initialOwner: initialOwner, + content: content, + mimetype: mimetype, + esip6: esip6, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + } +} diff --git a/contracts/test/TokenURIGas.t.sol b/contracts/test/TokenURIGas.t.sol new file mode 100644 index 0000000..f98fb3a --- /dev/null +++ b/contracts/test/TokenURIGas.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./TestSetup.sol"; +import "forge-std/console.sol"; + +contract TokenURIGasTest is TestSetup { + address alice = address(0x1); + + function setUp() public override { + super.setUp(); + } + + function testGas_TokenURI_Scaling() public { + console.log("=== TokenURI Gas Cost vs Content Size ==="); + console.log(""); + + // Test 1KB + // measureGasForSize(1_000, "1KB"); + + // Test 10KB + // measureGasForSize(10_000, "10KB"); + + // Test 100KB only to see detailed gas breakdown + measureGasForSize(100_000, "100KB"); + } + + function measureGasForSize(uint256 contentSize, string memory label) internal { + // Create content of specified size + bytes memory content = new bytes(contentSize); + for (uint256 i = 0; i < contentSize; i++) { + content[i] = bytes1(uint8((i * 7) % 256)); // Pseudo-random pattern + } + + bytes32 txHash = keccak256(bytes(label)); + + // Create the ethscription + vm.prank(alice); + ethscriptions.createEthscription(Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: keccak256(bytes(string.concat("data:image/png;base64,", label))), + initialOwner: alice, + content: content, + mimetype: "image/png", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + })); + + uint256 tokenId = ethscriptions.getTokenId(txHash); + + // Measure gas for tokenURI call + uint256 gasStart = gasleft(); + string memory uri = ethscriptions.tokenURI(tokenId); + uint256 gasUsed = gasStart - gasleft(); + + console.log(string.concat(label, ":")); + console.log(" Gas used:", gasUsed); + console.log(" Gas per byte:", gasUsed / contentSize); + console.log(" URI length:", bytes(uri).length); + console.log(""); + } +} \ No newline at end of file diff --git a/contracts/test/compress_content.rb b/contracts/test/compress_content.rb new file mode 100755 index 0000000..dd1c29a --- /dev/null +++ b/contracts/test/compress_content.rb @@ -0,0 +1,54 @@ +#!/usr/bin/env ruby + +# This script is called by Foundry tests via FFI to test Ruby compression logic +# It receives content as an argument and returns JSON with compression results + +require 'json' +require 'fastlz' + +def compress_if_beneficial(data) + compressed = FastLZ.compress(data) + + if FastLZ.decompress(compressed) != data + raise "Compression failed" + end + + ratio = compressed.bytesize.to_f / data.bytesize.to_f + + puts ratio + + # Only use compressed if it's at least 10% smaller + if compressed.bytesize < (data.bytesize * Rational(9, 10)) + [compressed, true] + else + [data, false] + end +end + +# Get content from command line argument +content = ARGV[0] + +if content.nil? || content.empty? + result = { + compressed: "0x", + is_compressed: false, + original_size: 0, + compressed_size: 0 + } +else + # Duplicate the string to make it mutable, keep original encoding + mutable_content = content.dup + + compressed_data, is_compressed = compress_if_beneficial(mutable_content) + + result = { + # Convert to hex for Solidity (prefix with 0x) + compressed: "0x" + compressed_data.unpack1('H*'), + is_compressed: is_compressed, + original_size: content.bytesize, + compressed_size: compressed_data.bytesize + } +end + +# Output JSON for Foundry to parse +puts result.to_json \ No newline at end of file diff --git a/contracts/test/example_ethscription.json b/contracts/test/example_ethscription.json new file mode 100644 index 0000000..ec72a1d --- /dev/null +++ b/contracts/test/example_ethscription.json @@ -0,0 +1,55 @@ +{ + "result": { + "transaction_hash": "0xcc00c2699c2961a02975afc7d6859839b5b136d0bd3dfae4a0c2228aa07b1aeb", + "block_number": "17488068", + "transaction_index": "190", + "block_timestamp": "1686865967", + "block_blockhash": "0xf345c6c3f7edb5b0544187e2abd5f2a0bffa7a9fa508791e2df6f3be66cb0362", + "event_log_index": null, + "ethscription_number": "339", + "creator": "0xb70cc02cbd58c313793c971524ad066359fd1e8e", + "initial_owner": "0xc2172a6315c1d7f6855768f843c420ebb36eda97", + "current_owner": "0xd729a94d6366a4feac4a6869c8b3573cee4701a9", + "previous_owner": "0xc2172a6315c1d7f6855768f843c420ebb36eda97", + "content_uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAACHAAAAhwCAYAAABPzFtpAAAACXBIWXMAAFxGAABcRgEUlENBAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAIABJREFUeJzs2kEVQFAAAEGEdRFABgG00unL4LQ8Mwk2wM5jvcYE8MRx1gUAAAAAAADvtm91AfAxSx0AAAAAAAAAAPB3Bg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAAAAAAAGIGDgAAAAAAAACAmIEDAAAAAAAAACBm4AAAAAAAAAAAiBk4AAAAAAAAAABiBg4AAAAAAAAAgJiBAwAAAAAAAAAgZuAAAAAAAAAAAIgZOAAAAAAAAAAAYgYOAAAAAAAAAICYgQMAAAAAAAAAIGbgAAAAAAAAAACIGTgAAAAA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AAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCYgAMAAAAAAAAAoJiAAwAAAAAAAACgmIADAAAAAAAAAKCY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AAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAGOquvYAACAASURBVAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm4AAAAAAAAAAAKCbgAAAAAAAAAAAoJuAAAAAAAAAAACgm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", + "content_sha": "0xfaee9ea4475c8e82952819ee4406583cceb686fe3e54275f85578b437a14333d", + "esip6": false, + "mimetype": "image/png", + "media_type": "image", + "mime_subtype": "png", + "gas_price": "14992411735", + "gas_used": "1103528", + "transaction_fee": "16544546137101080", + "value": "0", + "attachment_sha": null, + "attachment_content_type": null, + "ethscription_transfers": [ + { + "ethscription_transaction_hash": "0xcc00c2699c2961a02975afc7d6859839b5b136d0bd3dfae4a0c2228aa07b1aeb", + "transaction_hash": "0xcc00c2699c2961a02975afc7d6859839b5b136d0bd3dfae4a0c2228aa07b1aeb", + "from_address": "0xb70cc02cbd58c313793c971524ad066359fd1e8e", + "to_address": "0xc2172a6315c1d7f6855768f843c420ebb36eda97", + "block_number": "17488068", + "block_timestamp": "1686865967", + "block_blockhash": "0xf345c6c3f7edb5b0544187e2abd5f2a0bffa7a9fa508791e2df6f3be66cb0362", + "event_log_index": null, + "transfer_index": "0", + "transaction_index": "190", + "enforced_previous_owner": null + }, + { + "ethscription_transaction_hash": "0xcc00c2699c2961a02975afc7d6859839b5b136d0bd3dfae4a0c2228aa07b1aeb", + "transaction_hash": "0x145769d162ae10b56a853ec67793e33d0b49f349b2ea5a7d868a0d4311a6a032", + "from_address": "0xc2172a6315c1d7f6855768f843c420ebb36eda97", + "to_address": "0xd729a94d6366a4feac4a6869c8b3573cee4701a9", + "block_number": "17820385", + "block_timestamp": "1690895699", + "block_blockhash": "0x7528f7980d7068627bdfb4727cae0f86f7cb7f4ecbb11d3f29acb8a25e25d9e0", + "event_log_index": null, + "transfer_index": "0", + "transaction_index": "123", + "enforced_previous_owner": null + } + ] + } +} \ No newline at end of file diff --git a/db/migrate/001_create_validation_results.rb b/db/migrate/001_create_validation_results.rb new file mode 100644 index 0000000..2c4c517 --- /dev/null +++ b/db/migrate/001_create_validation_results.rb @@ -0,0 +1,17 @@ +class CreateValidationResults < ActiveRecord::Migration[8.0] + def change + create_table :validation_results, id: false do |t| + t.integer :l1_block, null: false, primary_key: true + t.boolean :success, null: false + t.json :error_details + t.json :validation_stats + t.datetime :validated_at, null: false + + t.timestamps + end + + add_index :validation_results, :success + add_index :validation_results, :validated_at + add_index :validation_results, [:success, :l1_block] + end +end \ No newline at end of file diff --git a/db/migrate/20231216161930_create_eth_blocks.rb b/db/migrate/20231216161930_create_eth_blocks.rb deleted file mode 100644 index 2a677da..0000000 --- a/db/migrate/20231216161930_create_eth_blocks.rb +++ /dev/null @@ -1,136 +0,0 @@ -class CreateEthBlocks < ActiveRecord::Migration[7.1] - def change - enable_extension 'pgcrypto' unless extension_enabled?('pgcrypto') - - create_table :eth_blocks, force: :cascade do |t| - t.bigint :block_number, null: false - t.bigint :timestamp, null: false - t.string :blockhash, null: false - t.string :parent_blockhash, null: false - t.datetime :imported_at - t.string :state_hash - t.string :parent_state_hash - - t.boolean :is_genesis_block, null: false - - t.index :block_number, unique: true - t.index :blockhash, unique: true - t.index :imported_at - t.index [:imported_at, :block_number] - t.index :parent_blockhash, unique: true - t.index :state_hash, unique: true - t.index :parent_state_hash, unique: true - t.index :timestamp, unique: true - t.index :updated_at - t.index :created_at - - t.check_constraint "blockhash ~ '^0x[a-f0-9]{64}$'" - t.check_constraint "parent_blockhash ~ '^0x[a-f0-9]{64}$'" - t.check_constraint "state_hash ~ '^0x[a-f0-9]{64}$'" - t.check_constraint "parent_state_hash ~ '^0x[a-f0-9]{64}$'" - - t.timestamps - end - - reversible do |dir| - dir.up do - execute <<-SQL - CREATE OR REPLACE FUNCTION check_block_order() - RETURNS TRIGGER AS $$ - BEGIN - IF NEW.is_genesis_block = false AND - NEW.block_number <> (SELECT MAX(block_number) + 1 FROM eth_blocks) THEN - RAISE EXCEPTION 'Block number is not sequential'; - END IF; - - IF NEW.is_genesis_block = false AND - NEW.parent_blockhash <> (SELECT blockhash FROM eth_blocks WHERE block_number = NEW.block_number - 1) THEN - RAISE EXCEPTION 'Parent block hash does not match the parent''s block hash'; - END IF; - - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - - CREATE TRIGGER trigger_check_block_order - BEFORE INSERT ON eth_blocks - FOR EACH ROW EXECUTE FUNCTION check_block_order(); - SQL - - execute <<~SQL - CREATE OR REPLACE FUNCTION check_block_order_on_update() - RETURNS TRIGGER AS $$ - BEGIN - IF NEW.imported_at IS NOT NULL AND NEW.state_hash IS NULL THEN - RAISE EXCEPTION 'state_hash must be set when imported_at is set'; - END IF; - - IF NEW.is_genesis_block = false AND - NEW.parent_state_hash <> (SELECT state_hash FROM eth_blocks WHERE block_number = NEW.block_number - 1 AND imported_at IS NOT NULL) THEN - RAISE EXCEPTION 'Parent state hash does not match the state hash of the previous block'; - END IF; - - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - - CREATE TRIGGER trigger_check_block_order_on_update - BEFORE UPDATE OF imported_at ON eth_blocks - FOR EACH ROW WHEN (NEW.imported_at IS NOT NULL) - EXECUTE FUNCTION check_block_order_on_update(); - SQL - - execute <<-SQL - CREATE OR REPLACE FUNCTION delete_later_blocks() - RETURNS TRIGGER AS $$ - BEGIN - DELETE FROM eth_blocks WHERE block_number > OLD.block_number; - RETURN OLD; - END; - $$ LANGUAGE plpgsql; - - CREATE TRIGGER trigger_delete_later_blocks - AFTER DELETE ON eth_blocks - FOR EACH ROW EXECUTE FUNCTION delete_later_blocks(); - SQL - - execute <<-SQL - CREATE OR REPLACE FUNCTION check_block_imported_at() - RETURNS TRIGGER AS $$ - BEGIN - IF NEW.imported_at IS NOT NULL THEN - IF EXISTS ( - SELECT 1 - FROM eth_blocks - WHERE block_number < NEW.block_number - AND imported_at IS NULL - LIMIT 1 - ) THEN - RAISE EXCEPTION 'Previous block not yet imported'; - END IF; - END IF; - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - - CREATE TRIGGER check_block_imported_at_trigger - BEFORE UPDATE OF imported_at ON eth_blocks - FOR EACH ROW EXECUTE FUNCTION check_block_imported_at(); - SQL - end - - dir.down do - execute <<-SQL - DROP TRIGGER IF EXISTS trigger_check_block_order ON eth_blocks; - DROP FUNCTION IF EXISTS check_block_order(); - - DROP TRIGGER IF EXISTS trigger_delete_later_blocks ON eth_blocks; - DROP FUNCTION IF EXISTS delete_later_blocks(); - - DROP TRIGGER IF EXISTS check_block_imported_at_trigger ON eth_blocks; - DROP FUNCTION IF EXISTS check_block_imported_at(); - SQL - end - end - end -end diff --git a/db/migrate/20231216163233_create_eth_transactions.rb b/db/migrate/20231216163233_create_eth_transactions.rb deleted file mode 100644 index c731c3b..0000000 --- a/db/migrate/20231216163233_create_eth_transactions.rb +++ /dev/null @@ -1,47 +0,0 @@ -class CreateEthTransactions < ActiveRecord::Migration[7.1] - def change - create_table :eth_transactions do |t| - t.string :transaction_hash, null: false - t.bigint :block_number, null: false - t.bigint :block_timestamp, null: false - t.string :block_blockhash, null: false - t.string :from_address, null: false - t.string :to_address - t.text :input, null: false - t.bigint :transaction_index, null: false - t.integer :status - t.jsonb :logs, default: [], null: false - t.string :created_contract_address - t.numeric :gas_price, null: false - t.bigint :gas_used, null: false - t.numeric :transaction_fee, null: false - t.numeric :value, null: false - - t.index [:block_number, :transaction_index], unique: true - t.index :block_number - t.index :block_timestamp - t.index :block_blockhash - t.index :from_address - t.index :status - t.index :to_address - t.index :transaction_hash, unique: true - t.index :logs, using: :gin - t.index :updated_at - t.index :created_at - - t.check_constraint "block_number <= 4370000 AND status IS NULL OR block_number > 4370000 AND status = 1", name: "status_check" - t.check_constraint "created_contract_address IS NULL AND to_address IS NOT NULL OR - created_contract_address IS NOT NULL AND to_address IS NULL", name: "contract_to_check" - - t.check_constraint "transaction_hash ~ '^0x[a-f0-9]{64}$'" - t.check_constraint "block_blockhash ~ '^0x[a-f0-9]{64}$'" - t.check_constraint "from_address ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "to_address ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "created_contract_address ~ '^0x[a-f0-9]{40}$'" - - t.foreign_key :eth_blocks, column: :block_number, primary_key: :block_number, on_delete: :cascade - - t.timestamps - end - end -end diff --git a/db/migrate/20231216164707_create_ethscriptions.rb b/db/migrate/20231216164707_create_ethscriptions.rb deleted file mode 100644 index 5e3e5e8..0000000 --- a/db/migrate/20231216164707_create_ethscriptions.rb +++ /dev/null @@ -1,93 +0,0 @@ -class CreateEthscriptions < ActiveRecord::Migration[7.1] - def change - create_table :ethscriptions, force: :cascade do |t| - t.string :transaction_hash, null: false - t.bigint :block_number, null: false - t.bigint :transaction_index, null: false - t.bigint :block_timestamp, null: false - t.string :block_blockhash, null: false - t.bigint :event_log_index - - t.bigint :ethscription_number, null: false - t.string :creator, null: false - t.string :initial_owner, null: false - t.string :current_owner, null: false - t.string :previous_owner, null: false - - t.text :content_uri, null: false - t.string :content_sha, null: false - t.boolean :esip6, null: false - t.string :mimetype, null: false, limit: Ethscription::MAX_MIMETYPE_LENGTH - t.string :media_type, null: false, limit: Ethscription::MAX_MIMETYPE_LENGTH - t.string :mime_subtype, null: false, limit: Ethscription::MAX_MIMETYPE_LENGTH - - t.numeric :gas_price, null: false - t.bigint :gas_used, null: false - t.numeric :transaction_fee, null: false - t.numeric :value, null: false - - t.index [:block_number, :transaction_index], unique: true - t.index :transaction_hash, unique: true - t.index :block_number - t.index :block_timestamp - t.index :block_blockhash - t.index :creator - t.index :current_owner - t.index :ethscription_number, unique: true - t.index :content_sha - t.index :content_sha, unique: true, where: "(esip6 = false)", - name: :index_ethscriptions_on_content_sha_unique - t.index :initial_owner - t.index :media_type - t.index :mime_subtype - t.index :mimetype - t.index :previous_owner - t.index :transaction_index - t.index :esip6 - t.index :updated_at - t.index :created_at - - t.check_constraint "content_sha ~ '^0x[a-f0-9]{64}$'" - t.check_constraint "transaction_hash ~ '^0x[a-f0-9]{64}$'" - t.check_constraint "creator ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "current_owner ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "initial_owner ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "previous_owner ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "block_blockhash ~ '^0x[a-f0-9]{64}$'" - - t.foreign_key :eth_blocks, column: :block_number, primary_key: :block_number, on_delete: :cascade - t.foreign_key :eth_transactions, column: :transaction_hash, primary_key: :transaction_hash, on_delete: :cascade - - t.timestamps - end - - reversible do |dir| - dir.up do - execute <<-SQL - CREATE OR REPLACE FUNCTION check_ethscription_order_and_sequence() - RETURNS TRIGGER AS $$ - BEGIN - IF NEW.block_number < (SELECT MAX(block_number) FROM ethscriptions) OR - (NEW.block_number = (SELECT MAX(block_number) FROM ethscriptions) AND NEW.transaction_index <= (SELECT MAX(transaction_index) FROM ethscriptions WHERE block_number = NEW.block_number)) THEN - RAISE EXCEPTION 'Ethscriptions must be created in order'; - END IF; - NEW.ethscription_number := (SELECT COALESCE(MAX(ethscription_number), -1) + 1 FROM ethscriptions); - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - - CREATE TRIGGER trigger_check_ethscription_order_and_sequence - BEFORE INSERT ON ethscriptions - FOR EACH ROW EXECUTE FUNCTION check_ethscription_order_and_sequence(); - SQL - end - - dir.down do - execute <<-SQL - DROP TRIGGER IF EXISTS trigger_check_ethscription_order_and_sequence ON ethscriptions; - DROP FUNCTION IF EXISTS check_ethscription_order_and_sequence(); - SQL - end - end - end -end diff --git a/db/migrate/20231216213103_create_ethscription_transfers.rb b/db/migrate/20231216213103_create_ethscription_transfers.rb deleted file mode 100644 index 06c8a85..0000000 --- a/db/migrate/20231216213103_create_ethscription_transfers.rb +++ /dev/null @@ -1,44 +0,0 @@ -class CreateEthscriptionTransfers < ActiveRecord::Migration[7.1] - def change - create_table :ethscription_transfers do |t| - t.string :ethscription_transaction_hash, null: false - t.string :transaction_hash, null: false - t.string :from_address, null: false - t.string :to_address, null: false - t.bigint :block_number, null: false - t.bigint :block_timestamp, null: false - t.string :block_blockhash, null: false - t.bigint :event_log_index - t.bigint :transfer_index, null: false - t.bigint :transaction_index, null: false - t.string :enforced_previous_owner - - t.index :ethscription_transaction_hash - t.index :block_number - t.index :block_timestamp - t.index :block_blockhash - t.index :from_address - t.index :to_address - t.index :enforced_previous_owner - t.index [:transaction_hash, :event_log_index], unique: true - t.index [:transaction_hash, :transfer_index], unique: true - t.index [:block_number, :transaction_index, :event_log_index], unique: true - t.index [:block_number, :transaction_index, :transfer_index], unique: true - t.index :transaction_hash - t.index :updated_at - t.index :created_at - - t.check_constraint "transaction_hash ~ '^0x[a-f0-9]{64}$'" - t.check_constraint "from_address ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "to_address ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "enforced_previous_owner ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "block_blockhash ~ '^0x[a-f0-9]{64}$'" - - t.foreign_key :eth_blocks, column: :block_number, primary_key: :block_number, on_delete: :cascade - t.foreign_key :ethscriptions, column: :ethscription_transaction_hash, primary_key: :transaction_hash, on_delete: :cascade - t.foreign_key :eth_transactions, column: :transaction_hash, primary_key: :transaction_hash, on_delete: :cascade - - t.timestamps - end - end -end diff --git a/db/migrate/20231216215348_create_ethscription_ownership_versions.rb b/db/migrate/20231216215348_create_ethscription_ownership_versions.rb deleted file mode 100644 index a0bff1c..0000000 --- a/db/migrate/20231216215348_create_ethscription_ownership_versions.rb +++ /dev/null @@ -1,94 +0,0 @@ -class CreateEthscriptionOwnershipVersions < ActiveRecord::Migration[7.1] - def change - create_table :ethscription_ownership_versions do |t| - t.string :transaction_hash, null: false - t.string :ethscription_transaction_hash, null: false - t.bigint :transfer_index, null: false - t.bigint :block_number, null: false - t.string :block_blockhash, null: false - t.bigint :transaction_index, null: false - t.bigint :block_timestamp, null: false - - t.string :current_owner, null: false - t.string :previous_owner, null: false - - t.index :current_owner - t.index :previous_owner - t.index [:current_owner, :previous_owner] - t.index :ethscription_transaction_hash - t.index :transaction_hash - t.index :block_number - t.index :block_blockhash - t.index :block_timestamp - - t.index [:transaction_hash, :transfer_index], unique: true - t.index [:block_number, :transaction_index, :transfer_index], unique: true - t.index :updated_at - t.index :created_at - - t.check_constraint "ethscription_transaction_hash ~ '^0x[a-f0-9]{64}$'" - t.check_constraint "transaction_hash ~ '^0x[a-f0-9]{64}$'" - t.check_constraint "current_owner ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "previous_owner ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "block_blockhash ~ '^0x[a-f0-9]{64}$'" - - t.foreign_key :eth_blocks, column: :block_number, primary_key: :block_number, on_delete: :cascade - t.foreign_key :ethscriptions, column: :ethscription_transaction_hash, primary_key: :transaction_hash, on_delete: :cascade - t.foreign_key :eth_transactions, column: :transaction_hash, primary_key: :transaction_hash, on_delete: :cascade - - t.timestamps - end - - reversible do |dir| - dir.up do - execute <<-SQL - CREATE OR REPLACE FUNCTION update_current_owner() RETURNS TRIGGER AS $$ - DECLARE - latest_ownership_version RECORD; - BEGIN - IF TG_OP = 'INSERT' THEN - SELECT INTO latest_ownership_version * - FROM ethscription_ownership_versions - WHERE ethscription_transaction_hash = NEW.ethscription_transaction_hash - ORDER BY block_number DESC, transaction_index DESC, transfer_index DESC - LIMIT 1; - - UPDATE ethscriptions - SET current_owner = latest_ownership_version.current_owner, - previous_owner = latest_ownership_version.previous_owner, - updated_at = NOW() - WHERE transaction_hash = NEW.ethscription_transaction_hash; - ELSIF TG_OP = 'DELETE' THEN - SELECT INTO latest_ownership_version * - FROM ethscription_ownership_versions - WHERE ethscription_transaction_hash = OLD.ethscription_transaction_hash - AND id != OLD.id - ORDER BY block_number DESC, transaction_index DESC, transfer_index DESC - LIMIT 1; - - UPDATE ethscriptions - SET current_owner = latest_ownership_version.current_owner, - previous_owner = latest_ownership_version.previous_owner, - updated_at = NOW() - WHERE transaction_hash = OLD.ethscription_transaction_hash; - END IF; - - RETURN NULL; - END; - $$ LANGUAGE plpgsql; - - CREATE TRIGGER update_current_owner - AFTER INSERT OR DELETE ON ethscription_ownership_versions - FOR EACH ROW EXECUTE PROCEDURE update_current_owner(); - SQL - end - - dir.down do - execute <<-SQL - DROP TRIGGER IF EXISTS update_current_owner ON ethscription_ownership_versions; - DROP FUNCTION IF EXISTS update_current_owner(); - SQL - end - end - end -end diff --git a/db/migrate/20240115144930_create_tokens.rb b/db/migrate/20240115144930_create_tokens.rb deleted file mode 100644 index 9a57c9c..0000000 --- a/db/migrate/20240115144930_create_tokens.rb +++ /dev/null @@ -1,35 +0,0 @@ -class CreateTokens < ActiveRecord::Migration[7.1] - def change - create_table :tokens do |t| - t.string :deploy_ethscription_transaction_hash, null: false - t.bigint :deploy_block_number, null: false - t.bigint :deploy_transaction_index, null: false - t.string :protocol, null: false, limit: 1000 - t.string :tick, null: false, limit: 1000 - t.bigint :max_supply, null: false - t.bigint :total_supply, null: false - t.bigint :mint_amount, null: false - t.jsonb :balances_observations, null: false, default: [] - - t.index :deploy_ethscription_transaction_hash, unique: true - t.index [:protocol, :tick], unique: true - t.index [:deploy_block_number, :deploy_transaction_index], unique: true - - t.check_constraint "protocol ~ '^[a-z0-9\-]+$'" - t.check_constraint "tick ~ '^[[:alnum:]\p{Emoji_Presentation}]+$'" - t.check_constraint 'max_supply > 0' - t.check_constraint 'total_supply >= 0' - t.check_constraint 'total_supply <= max_supply' - t.check_constraint 'mint_amount > 0' - t.check_constraint 'max_supply % mint_amount = 0' - t.check_constraint "deploy_ethscription_transaction_hash ~ '^0x[a-f0-9]{64}$'" - - t.foreign_key :ethscriptions, - column: :deploy_ethscription_transaction_hash, - primary_key: :transaction_hash, - on_delete: :cascade - - t.timestamps - end - end -end diff --git a/db/migrate/20240115151119_create_token_items.rb b/db/migrate/20240115151119_create_token_items.rb deleted file mode 100644 index c556355..0000000 --- a/db/migrate/20240115151119_create_token_items.rb +++ /dev/null @@ -1,57 +0,0 @@ -class CreateTokenItems < ActiveRecord::Migration[7.1] - def up - create_table :token_items do |t| - t.string :ethscription_transaction_hash, null: false - t.string :deploy_ethscription_transaction_hash, null: false - t.bigint :block_number, null: false - t.bigint :transaction_index, null: false - t.bigint :token_item_id, null: false - - t.foreign_key :ethscriptions, - column: :ethscription_transaction_hash, - primary_key: :transaction_hash, - on_delete: :cascade - - t.foreign_key :tokens, - column: :deploy_ethscription_transaction_hash, - primary_key: :deploy_ethscription_transaction_hash, - on_delete: :cascade - - t.index :ethscription_transaction_hash, unique: true - t.index [:deploy_ethscription_transaction_hash, :token_item_id], unique: true - t.index [:ethscription_transaction_hash, :deploy_ethscription_transaction_hash, :token_item_id], unique: true - t.index [:block_number, :transaction_index], unique: true - t.index :transaction_index - - t.check_constraint 'token_item_id > 0' - t.check_constraint "deploy_ethscription_transaction_hash ~ '^0x[a-f0-9]{64}$'" - t.check_constraint "ethscription_transaction_hash ~ '^0x[a-f0-9]{64}$'" - - t.timestamps - end - - execute <<-SQL - CREATE OR REPLACE FUNCTION update_total_supply() RETURNS TRIGGER AS $$ - BEGIN - UPDATE tokens - SET total_supply = ( - SELECT COUNT(*) * mint_amount - FROM token_items - WHERE deploy_ethscription_transaction_hash = OLD.deploy_ethscription_transaction_hash - ) - WHERE deploy_ethscription_transaction_hash = OLD.deploy_ethscription_transaction_hash; - - RETURN OLD; - END; - $$ LANGUAGE plpgsql; - - CREATE TRIGGER update_total_supply_trigger - AFTER DELETE ON token_items - FOR EACH ROW EXECUTE PROCEDURE update_total_supply(); - SQL - end - - def down - drop_table :token_items - end -end diff --git a/db/migrate/20240115192312_create_delayed_jobs.rb b/db/migrate/20240115192312_create_delayed_jobs.rb deleted file mode 100644 index c7c81d0..0000000 --- a/db/migrate/20240115192312_create_delayed_jobs.rb +++ /dev/null @@ -1,22 +0,0 @@ -class CreateDelayedJobs < ActiveRecord::Migration[7.1] - def self.up - create_table :delayed_jobs do |table| - table.integer :priority, default: 0, null: false # Allows some jobs to jump to the front of the queue - table.integer :attempts, default: 0, null: false # Provides for retries, but still fail eventually. - table.text :handler, null: false # YAML-encoded string of the object that will do work - table.text :last_error # reason for last failure (See Note below) - table.datetime :run_at # When to run. Could be Time.zone.now for immediately, or sometime in the future. - table.datetime :locked_at # Set when a client is working on this object - table.datetime :failed_at # Set when all retries have failed (actually, by default, the record is deleted instead) - table.string :locked_by # Who is working on this object (if locked) - table.string :queue # The name of the queue this job is in - table.timestamps null: true - end - - add_index :delayed_jobs, [:priority, :run_at], name: "delayed_jobs_priority" - end - - def self.down - drop_table :delayed_jobs - end -end diff --git a/db/migrate/20240126162132_token_balances_restructure.rb b/db/migrate/20240126162132_token_balances_restructure.rb deleted file mode 100644 index 4e02e24..0000000 --- a/db/migrate/20240126162132_token_balances_restructure.rb +++ /dev/null @@ -1,35 +0,0 @@ -class TokenBalancesRestructure < ActiveRecord::Migration[7.1] - def up - execute <<-SQL - ALTER TABLE tokens - ADD COLUMN balances_snapshot jsonb NOT NULL DEFAULT '{}'; - SQL - - execute <<-SQL - UPDATE tokens - SET balances_snapshot = COALESCE(balances_observations->0, '{}'); - SQL - - execute <<-SQL - ALTER TABLE tokens - DROP COLUMN balances_observations; - SQL - end - - def down - execute <<-SQL - ALTER TABLE tokens - ADD COLUMN balances_observations jsonb NOT NULL DEFAULT '[]'; - SQL - - execute <<-SQL - UPDATE tokens - SET balances_observations = jsonb_build_array(balances_snapshot); - SQL - - execute <<-SQL - ALTER TABLE tokens - DROP COLUMN balances_snapshot; - SQL - end -end diff --git a/db/migrate/20240126184612_create_token_states.rb b/db/migrate/20240126184612_create_token_states.rb deleted file mode 100644 index f903331..0000000 --- a/db/migrate/20240126184612_create_token_states.rb +++ /dev/null @@ -1,130 +0,0 @@ -class CreateTokenStates < ActiveRecord::Migration[7.1] - def up - rename_column :tokens, :balances_snapshot, :balances - - change_column_default :tokens, :total_supply, from: nil, to: 0 - - execute <<-SQL - DROP TRIGGER IF EXISTS update_total_supply_trigger ON token_items; - DROP FUNCTION IF EXISTS update_total_supply; - SQL - - create_table :token_states do |t| - t.bigint :block_number, null: false - t.bigint :block_timestamp, null: false - t.string :block_blockhash, null: false - - t.string :deploy_ethscription_transaction_hash, null: false - - t.jsonb :balances, null: false, default: {} - t.bigint :total_supply, null: false, default: 0 - - t.index :deploy_ethscription_transaction_hash - t.index [:block_number, :deploy_ethscription_transaction_hash], unique: true - - t.check_constraint "deploy_ethscription_transaction_hash ~ '^0x[a-f0-9]{64}$'" - t.check_constraint "block_blockhash ~ '^0x[a-f0-9]{64}$'" - - t.foreign_key :eth_blocks, column: :block_number, primary_key: :block_number, on_delete: :cascade - t.foreign_key :tokens, column: :deploy_ethscription_transaction_hash, primary_key: :deploy_ethscription_transaction_hash, on_delete: :cascade - - t.timestamps - end - - execute <<-SQL - CREATE OR REPLACE FUNCTION update_token_balances_and_supply() RETURNS TRIGGER AS $$ - DECLARE - latest_token_state RECORD; - BEGIN - IF TG_OP = 'INSERT' THEN - SELECT INTO latest_token_state * - FROM token_states - WHERE deploy_ethscription_transaction_hash = NEW.deploy_ethscription_transaction_hash - ORDER BY block_number DESC - LIMIT 1; - - UPDATE tokens - SET balances = COALESCE(latest_token_state.balances, '{}'::jsonb), - total_supply = COALESCE(latest_token_state.total_supply, 0), - updated_at = NOW() - WHERE deploy_ethscription_transaction_hash = NEW.deploy_ethscription_transaction_hash; - ELSIF TG_OP = 'DELETE' THEN - SELECT INTO latest_token_state * - FROM token_states - WHERE deploy_ethscription_transaction_hash = OLD.deploy_ethscription_transaction_hash - AND id != OLD.id - ORDER BY block_number DESC - LIMIT 1; - - UPDATE tokens - SET balances = COALESCE(latest_token_state.balances, '{}'::jsonb), - total_supply = COALESCE(latest_token_state.total_supply, 0), - updated_at = NOW() - WHERE deploy_ethscription_transaction_hash = OLD.deploy_ethscription_transaction_hash; - END IF; - - RETURN NULL; - END; - $$ LANGUAGE plpgsql; - - CREATE TRIGGER update_token_balances_and_supply - AFTER INSERT OR DELETE ON token_states - FOR EACH ROW EXECUTE PROCEDURE update_token_balances_and_supply(); - SQL - - Token.find_each do |token| - token.sync_past_token_items! - token.save_state_checkpoint! - end - - drop_table :delayed_jobs - end - - def down - execute <<-SQL - DROP TRIGGER IF EXISTS update_token_balances_and_supply ON token_states; - DROP FUNCTION IF EXISTS update_token_balances_and_supply; - SQL - - drop_table :token_states - - rename_column :tokens, :balances, :balances_snapshot - - change_column_default :tokens, :total_supply, from: 0, to: nil - - execute <<-SQL - CREATE OR REPLACE FUNCTION update_total_supply() RETURNS TRIGGER AS $$ - BEGIN - UPDATE tokens - SET total_supply = ( - SELECT COUNT(*) * mint_amount - FROM token_items - WHERE deploy_ethscription_transaction_hash = OLD.deploy_ethscription_transaction_hash - ) - WHERE deploy_ethscription_transaction_hash = OLD.deploy_ethscription_transaction_hash; - - RETURN OLD; - END; - $$ LANGUAGE plpgsql; - - CREATE TRIGGER update_total_supply_trigger - AFTER DELETE ON token_items - FOR EACH ROW EXECUTE PROCEDURE update_total_supply(); - SQL - - create_table :delayed_jobs do |table| - table.integer :priority, default: 0, null: false # Allows some jobs to jump to the front of the queue - table.integer :attempts, default: 0, null: false # Provides for retries, but still fail eventually. - table.text :handler, null: false # YAML-encoded string of the object that will do work - table.text :last_error # reason for last failure (See Note below) - table.datetime :run_at # When to run. Could be Time.zone.now for immediately, or sometime in the future. - table.datetime :locked_at # Set when a client is working on this object - table.datetime :failed_at # Set when all retries have failed (actually, by default, the record is deleted instead) - table.string :locked_by # Who is working on this object (if locked) - table.string :queue # The name of the queue this job is in - table.timestamps null: true - end - - add_index :delayed_jobs, [:priority, :run_at], name: "delayed_jobs_priority" - end -end diff --git a/db/migrate/20240315184639_add_attachment_and_blob_columns.rb b/db/migrate/20240315184639_add_attachment_and_blob_columns.rb deleted file mode 100644 index b4ea7d8..0000000 --- a/db/migrate/20240315184639_add_attachment_and_blob_columns.rb +++ /dev/null @@ -1,35 +0,0 @@ -class AddAttachmentAndBlobColumns < ActiveRecord::Migration[7.1] - def change - # Should always be set when available which is starting after Duncun - add_column :eth_blocks, :parent_beacon_block_root, :string - add_check_constraint :eth_blocks, "parent_beacon_block_root ~ '^0x[a-f0-9]{64}$'" - - add_column :eth_blocks, :blob_sidecars, :jsonb, default: [], null: false - - add_column :eth_transactions, :blob_versioned_hashes, :jsonb, default: [], null: false - - add_column :ethscriptions, :attachment_sha, :string - add_index :ethscriptions, :attachment_sha - - add_column :ethscriptions, :attachment_content_type, :string, - limit: EthscriptionAttachment::MAX_CONTENT_TYPE_LENGTH - add_index :ethscriptions, :attachment_content_type - - add_check_constraint :ethscriptions, "attachment_sha ~ '^0x[a-f0-9]{64}$'" - - create_table :ethscription_attachments do |t| - t.binary :content, null: false - t.string :content_type, null: false - t.string :sha, null: false - t.bigint :size, null: false - - t.index :sha, unique: true - t.index :content_type - t.index :size - - t.check_constraint "sha ~ '^0x[a-f0-9]{64}$'" - - t.timestamps - end - end -end diff --git a/db/migrate/20240317200158_create_ethscription_attachment_cleanup_trigger.rb b/db/migrate/20240317200158_create_ethscription_attachment_cleanup_trigger.rb deleted file mode 100644 index 4d9591c..0000000 --- a/db/migrate/20240317200158_create_ethscription_attachment_cleanup_trigger.rb +++ /dev/null @@ -1,38 +0,0 @@ -class CreateEthscriptionAttachmentCleanupTrigger < ActiveRecord::Migration[7.1] - def up - # Create a function that will be called by the trigger - execute <<-SQL - CREATE OR REPLACE FUNCTION clean_up_ethscription_attachments() - RETURNS TRIGGER AS $$ - BEGIN - -- Only proceed if the ethscription being deleted has an attachment_sha - IF OLD.attachment_sha IS NOT NULL THEN - -- Check if there is another ethscription with the same attachment_sha - IF NOT EXISTS ( - SELECT 1 FROM ethscriptions - WHERE attachment_sha = OLD.attachment_sha - AND id != OLD.id - ) THEN - -- If no other ethscription has the same attachment_sha, delete associated attachments - DELETE FROM ethscription_attachments - WHERE sha = OLD.attachment_sha; - END IF; - END IF; - RETURN OLD; - END; - $$ LANGUAGE plpgsql; - SQL - - # Create the trigger - execute <<-SQL - CREATE TRIGGER ethscription_cleanup - AFTER DELETE ON ethscriptions - FOR EACH ROW EXECUTE FUNCTION clean_up_ethscription_attachments(); - SQL - end - - def down - execute "DROP TRIGGER IF EXISTS ethscription_cleanup ON ethscriptions;" - execute "DROP FUNCTION IF EXISTS clean_up_ethscription_attachments();" - end -end diff --git a/db/migrate/20240327135159_add_partial_attachment_sha_index_to_ethscriptions.rb b/db/migrate/20240327135159_add_partial_attachment_sha_index_to_ethscriptions.rb deleted file mode 100644 index 241d668..0000000 --- a/db/migrate/20240327135159_add_partial_attachment_sha_index_to_ethscriptions.rb +++ /dev/null @@ -1,10 +0,0 @@ -class AddPartialAttachmentShaIndexToEthscriptions < ActiveRecord::Migration[7.1] - def change - add_index :ethscriptions, [:block_number, :transaction_index], - where: "attachment_sha IS NOT NULL", - name: 'inx_ethscriptions_on_blk_num_tx_index_with_attachment_not_null' - - add_index :ethscriptions, :attachment_sha, where: "attachment_sha IS NOT NULL", - name: 'index_ethscriptions_on_attachment_sha_not_null' - end -end diff --git a/db/migrate/20240411154249_add_more_attachment_partial_indices.rb b/db/migrate/20240411154249_add_more_attachment_partial_indices.rb deleted file mode 100644 index f0a1260..0000000 --- a/db/migrate/20240411154249_add_more_attachment_partial_indices.rb +++ /dev/null @@ -1,16 +0,0 @@ -class AddMoreAttachmentPartialIndices < ActiveRecord::Migration[7.1] - def change - add_index :ethscriptions, [:attachment_sha, :block_number, :transaction_index], - name: 'index_ethscriptions_on_sha_blocknum_txindex_desc', - order: { block_number: :desc, transaction_index: :desc } - - add_index :ethscriptions, [:attachment_sha, :block_number, :transaction_index], - name: 'index_ethscriptions_on_sha_blocknum_txindex_asc', - order: { block_number: :asc, transaction_index: :asc } - - add_index :ethscriptions, [:block_number, :transaction_index], - where: "attachment_sha IS NOT NULL", - name: 'inx_ethscriptions_on_blk_num_tx_index_with_att_not_null_asc', - order: { block_number: :asc, transaction_index: :asc } - end -end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 0000000..4b2cdcd --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,141 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.0].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..baebd78 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,25 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.0].define(version: 1) do + create_table "validation_results", primary_key: "l1_block", force: :cascade do |t| + t.boolean "success", null: false + t.json "error_details" + t.json "validation_stats" + t.datetime "validated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["success", "l1_block"], name: "index_validation_results_on_success_and_l1_block" + t.index ["success"], name: "index_validation_results_on_success" + t.index ["validated_at"], name: "index_validation_results_on_validated_at" + end +end diff --git a/db/seeds.rb b/db/seeds.rb deleted file mode 100644 index 4fbd6ed..0000000 --- a/db/seeds.rb +++ /dev/null @@ -1,9 +0,0 @@ -# This file should ensure the existence of records required to run the application in every environment (production, -# development, test). The code here should be idempotent so that it can be executed at any point in every environment. -# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -# -# Example: -# -# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| -# MovieGenre.find_or_create_by!(name: genre_name) -# end diff --git a/db/structure.sql b/db/structure.sql deleted file mode 100644 index 74a073a..0000000 --- a/db/structure.sql +++ /dev/null @@ -1,1611 +0,0 @@ -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - --- --- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; - - --- --- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: - --- - -COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; - - --- --- Name: check_block_imported_at(); Type: FUNCTION; Schema: public; Owner: - --- - -CREATE FUNCTION public.check_block_imported_at() RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - IF NEW.imported_at IS NOT NULL THEN - IF EXISTS ( - SELECT 1 - FROM eth_blocks - WHERE block_number < NEW.block_number - AND imported_at IS NULL - LIMIT 1 - ) THEN - RAISE EXCEPTION 'Previous block not yet imported'; - END IF; - END IF; - RETURN NEW; - END; - $$; - - --- --- Name: check_block_order(); Type: FUNCTION; Schema: public; Owner: - --- - -CREATE FUNCTION public.check_block_order() RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - IF NEW.is_genesis_block = false AND - NEW.block_number <> (SELECT MAX(block_number) + 1 FROM eth_blocks) THEN - RAISE EXCEPTION 'Block number is not sequential'; - END IF; - - IF NEW.is_genesis_block = false AND - NEW.parent_blockhash <> (SELECT blockhash FROM eth_blocks WHERE block_number = NEW.block_number - 1) THEN - RAISE EXCEPTION 'Parent block hash does not match the parent''s block hash'; - END IF; - - RETURN NEW; - END; - $$; - - --- --- Name: check_block_order_on_update(); Type: FUNCTION; Schema: public; Owner: - --- - -CREATE FUNCTION public.check_block_order_on_update() RETURNS trigger - LANGUAGE plpgsql - AS $$ -BEGIN - IF NEW.imported_at IS NOT NULL AND NEW.state_hash IS NULL THEN - RAISE EXCEPTION 'state_hash must be set when imported_at is set'; - END IF; - - IF NEW.is_genesis_block = false AND - NEW.parent_state_hash <> (SELECT state_hash FROM eth_blocks WHERE block_number = NEW.block_number - 1 AND imported_at IS NOT NULL) THEN - RAISE EXCEPTION 'Parent state hash does not match the state hash of the previous block'; - END IF; - - RETURN NEW; -END; -$$; - - --- --- Name: check_ethscription_order_and_sequence(); Type: FUNCTION; Schema: public; Owner: - --- - -CREATE FUNCTION public.check_ethscription_order_and_sequence() RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - IF NEW.block_number < (SELECT MAX(block_number) FROM ethscriptions) OR - (NEW.block_number = (SELECT MAX(block_number) FROM ethscriptions) AND NEW.transaction_index <= (SELECT MAX(transaction_index) FROM ethscriptions WHERE block_number = NEW.block_number)) THEN - RAISE EXCEPTION 'Ethscriptions must be created in order'; - END IF; - NEW.ethscription_number := (SELECT COALESCE(MAX(ethscription_number), -1) + 1 FROM ethscriptions); - RETURN NEW; - END; - $$; - - --- --- Name: clean_up_ethscription_attachments(); Type: FUNCTION; Schema: public; Owner: - --- - -CREATE FUNCTION public.clean_up_ethscription_attachments() RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - -- Only proceed if the ethscription being deleted has an attachment_sha - IF OLD.attachment_sha IS NOT NULL THEN - -- Check if there is another ethscription with the same attachment_sha - IF NOT EXISTS ( - SELECT 1 FROM ethscriptions - WHERE attachment_sha = OLD.attachment_sha - AND id != OLD.id - ) THEN - -- If no other ethscription has the same attachment_sha, delete associated attachments - DELETE FROM ethscription_attachments - WHERE sha = OLD.attachment_sha; - END IF; - END IF; - RETURN OLD; - END; - $$; - - --- --- Name: delete_later_blocks(); Type: FUNCTION; Schema: public; Owner: - --- - -CREATE FUNCTION public.delete_later_blocks() RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - DELETE FROM eth_blocks WHERE block_number > OLD.block_number; - RETURN OLD; - END; - $$; - - --- --- Name: update_current_owner(); Type: FUNCTION; Schema: public; Owner: - --- - -CREATE FUNCTION public.update_current_owner() RETURNS trigger - LANGUAGE plpgsql - AS $$ - DECLARE - latest_ownership_version RECORD; - BEGIN - IF TG_OP = 'INSERT' THEN - SELECT INTO latest_ownership_version * - FROM ethscription_ownership_versions - WHERE ethscription_transaction_hash = NEW.ethscription_transaction_hash - ORDER BY block_number DESC, transaction_index DESC, transfer_index DESC - LIMIT 1; - - UPDATE ethscriptions - SET current_owner = latest_ownership_version.current_owner, - previous_owner = latest_ownership_version.previous_owner, - updated_at = NOW() - WHERE transaction_hash = NEW.ethscription_transaction_hash; - ELSIF TG_OP = 'DELETE' THEN - SELECT INTO latest_ownership_version * - FROM ethscription_ownership_versions - WHERE ethscription_transaction_hash = OLD.ethscription_transaction_hash - AND id != OLD.id - ORDER BY block_number DESC, transaction_index DESC, transfer_index DESC - LIMIT 1; - - UPDATE ethscriptions - SET current_owner = latest_ownership_version.current_owner, - previous_owner = latest_ownership_version.previous_owner, - updated_at = NOW() - WHERE transaction_hash = OLD.ethscription_transaction_hash; - END IF; - - RETURN NULL; - END; - $$; - - --- --- Name: update_token_balances_and_supply(); Type: FUNCTION; Schema: public; Owner: - --- - -CREATE FUNCTION public.update_token_balances_and_supply() RETURNS trigger - LANGUAGE plpgsql - AS $$ - DECLARE - latest_token_state RECORD; - BEGIN - IF TG_OP = 'INSERT' THEN - SELECT INTO latest_token_state * - FROM token_states - WHERE deploy_ethscription_transaction_hash = NEW.deploy_ethscription_transaction_hash - ORDER BY block_number DESC - LIMIT 1; - - UPDATE tokens - SET balances = COALESCE(latest_token_state.balances, '{}'::jsonb), - total_supply = COALESCE(latest_token_state.total_supply, 0), - updated_at = NOW() - WHERE deploy_ethscription_transaction_hash = NEW.deploy_ethscription_transaction_hash; - ELSIF TG_OP = 'DELETE' THEN - SELECT INTO latest_token_state * - FROM token_states - WHERE deploy_ethscription_transaction_hash = OLD.deploy_ethscription_transaction_hash - AND id != OLD.id - ORDER BY block_number DESC - LIMIT 1; - - UPDATE tokens - SET balances = COALESCE(latest_token_state.balances, '{}'::jsonb), - total_supply = COALESCE(latest_token_state.total_supply, 0), - updated_at = NOW() - WHERE deploy_ethscription_transaction_hash = OLD.deploy_ethscription_transaction_hash; - END IF; - - RETURN NULL; - END; - $$; - - -SET default_tablespace = ''; - -SET default_table_access_method = heap; - --- --- Name: ar_internal_metadata; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.ar_internal_metadata ( - key character varying NOT NULL, - value character varying, - created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL -); - - --- --- Name: eth_blocks; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.eth_blocks ( - id bigint NOT NULL, - block_number bigint NOT NULL, - "timestamp" bigint NOT NULL, - blockhash character varying NOT NULL, - parent_blockhash character varying NOT NULL, - imported_at timestamp(6) without time zone, - state_hash character varying, - parent_state_hash character varying, - is_genesis_block boolean NOT NULL, - created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL, - parent_beacon_block_root character varying, - blob_sidecars jsonb DEFAULT '[]'::jsonb NOT NULL, - CONSTRAINT chk_rails_1c105acdac CHECK (((parent_blockhash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_319237323b CHECK (((state_hash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_7126b7c9d3 CHECK (((parent_state_hash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_7e9881ece2 CHECK (((blockhash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_a5a0dc024d CHECK (((parent_beacon_block_root)::text ~ '^0x[a-f0-9]{64}$'::text)) -); - - --- --- Name: eth_blocks_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.eth_blocks_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: eth_blocks_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.eth_blocks_id_seq OWNED BY public.eth_blocks.id; - - --- --- Name: eth_transactions; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.eth_transactions ( - id bigint NOT NULL, - transaction_hash character varying NOT NULL, - block_number bigint NOT NULL, - block_timestamp bigint NOT NULL, - block_blockhash character varying NOT NULL, - from_address character varying NOT NULL, - to_address character varying, - input text NOT NULL, - transaction_index bigint NOT NULL, - status integer, - logs jsonb DEFAULT '[]'::jsonb NOT NULL, - created_contract_address character varying, - gas_price numeric NOT NULL, - gas_used bigint NOT NULL, - transaction_fee numeric NOT NULL, - value numeric NOT NULL, - created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL, - blob_versioned_hashes jsonb DEFAULT '[]'::jsonb NOT NULL, - CONSTRAINT chk_rails_37ed5d6017 CHECK (((to_address)::text ~ '^0x[a-f0-9]{40}$'::text)), - CONSTRAINT chk_rails_4250f2c315 CHECK (((block_blockhash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_9cdbd3b1ad CHECK (((transaction_hash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_a4d3f41974 CHECK (((from_address)::text ~ '^0x[a-f0-9]{40}$'::text)), - CONSTRAINT chk_rails_d460e80110 CHECK (((created_contract_address)::text ~ '^0x[a-f0-9]{40}$'::text)), - CONSTRAINT contract_to_check CHECK ((((created_contract_address IS NULL) AND (to_address IS NOT NULL)) OR ((created_contract_address IS NOT NULL) AND (to_address IS NULL)))), - CONSTRAINT status_check CHECK ((((block_number <= 4370000) AND (status IS NULL)) OR ((block_number > 4370000) AND (status = 1)))) -); - - --- --- Name: eth_transactions_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.eth_transactions_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: eth_transactions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.eth_transactions_id_seq OWNED BY public.eth_transactions.id; - - --- --- Name: ethscription_attachments; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.ethscription_attachments ( - id bigint NOT NULL, - content bytea NOT NULL, - content_type character varying NOT NULL, - sha character varying NOT NULL, - size bigint NOT NULL, - created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL, - CONSTRAINT chk_rails_eb2cc2c01d CHECK (((sha)::text ~ '^0x[a-f0-9]{64}$'::text)) -); - - --- --- Name: ethscription_attachments_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.ethscription_attachments_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: ethscription_attachments_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.ethscription_attachments_id_seq OWNED BY public.ethscription_attachments.id; - - --- --- Name: ethscription_ownership_versions; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.ethscription_ownership_versions ( - id bigint NOT NULL, - transaction_hash character varying NOT NULL, - ethscription_transaction_hash character varying NOT NULL, - transfer_index bigint NOT NULL, - block_number bigint NOT NULL, - block_blockhash character varying NOT NULL, - transaction_index bigint NOT NULL, - block_timestamp bigint NOT NULL, - current_owner character varying NOT NULL, - previous_owner character varying NOT NULL, - created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL, - CONSTRAINT chk_rails_0401bc8d3b CHECK (((transaction_hash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_073cb8a4e9 CHECK (((current_owner)::text ~ '^0x[a-f0-9]{40}$'::text)), - CONSTRAINT chk_rails_3c5af30513 CHECK (((block_blockhash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_b5b3ce91a9 CHECK (((previous_owner)::text ~ '^0x[a-f0-9]{40}$'::text)), - CONSTRAINT chk_rails_f8a9e94d3c CHECK (((ethscription_transaction_hash)::text ~ '^0x[a-f0-9]{64}$'::text)) -); - - --- --- Name: ethscription_ownership_versions_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.ethscription_ownership_versions_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: ethscription_ownership_versions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.ethscription_ownership_versions_id_seq OWNED BY public.ethscription_ownership_versions.id; - - --- --- Name: ethscription_transfers; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.ethscription_transfers ( - id bigint NOT NULL, - ethscription_transaction_hash character varying NOT NULL, - transaction_hash character varying NOT NULL, - from_address character varying NOT NULL, - to_address character varying NOT NULL, - block_number bigint NOT NULL, - block_timestamp bigint NOT NULL, - block_blockhash character varying NOT NULL, - event_log_index bigint, - transfer_index bigint NOT NULL, - transaction_index bigint NOT NULL, - enforced_previous_owner character varying, - created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL, - CONSTRAINT chk_rails_1c9802c481 CHECK (((enforced_previous_owner)::text ~ '^0x[a-f0-9]{40}$'::text)), - CONSTRAINT chk_rails_448edb0194 CHECK (((block_blockhash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_7959eeae60 CHECK (((from_address)::text ~ '^0x[a-f0-9]{40}$'::text)), - CONSTRAINT chk_rails_7f4ef1507d CHECK (((transaction_hash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_a138317254 CHECK (((to_address)::text ~ '^0x[a-f0-9]{40}$'::text)) -); - - --- --- Name: ethscription_transfers_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.ethscription_transfers_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: ethscription_transfers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.ethscription_transfers_id_seq OWNED BY public.ethscription_transfers.id; - - --- --- Name: ethscriptions; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.ethscriptions ( - id bigint NOT NULL, - transaction_hash character varying NOT NULL, - block_number bigint NOT NULL, - transaction_index bigint NOT NULL, - block_timestamp bigint NOT NULL, - block_blockhash character varying NOT NULL, - event_log_index bigint, - ethscription_number bigint NOT NULL, - creator character varying NOT NULL, - initial_owner character varying NOT NULL, - current_owner character varying NOT NULL, - previous_owner character varying NOT NULL, - content_uri text NOT NULL, - content_sha character varying NOT NULL, - esip6 boolean NOT NULL, - mimetype character varying(1000) NOT NULL, - media_type character varying(1000) NOT NULL, - mime_subtype character varying(1000) NOT NULL, - gas_price numeric NOT NULL, - gas_used bigint NOT NULL, - transaction_fee numeric NOT NULL, - value numeric NOT NULL, - created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL, - attachment_sha character varying, - attachment_content_type character varying(1000), - CONSTRAINT chk_rails_52497428f2 CHECK (((previous_owner)::text ~ '^0x[a-f0-9]{40}$'::text)), - CONSTRAINT chk_rails_528fcbfbaa CHECK (((content_sha)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_6f8922831e CHECK (((current_owner)::text ~ '^0x[a-f0-9]{40}$'::text)), - CONSTRAINT chk_rails_788fa87594 CHECK (((block_blockhash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_84591e2730 CHECK (((transaction_hash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_b55f563e4a CHECK (((attachment_sha)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_b577b97822 CHECK (((creator)::text ~ '^0x[a-f0-9]{40}$'::text)), - CONSTRAINT chk_rails_df21fdbe02 CHECK (((initial_owner)::text ~ '^0x[a-f0-9]{40}$'::text)) -); - - --- --- Name: ethscriptions_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.ethscriptions_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: ethscriptions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.ethscriptions_id_seq OWNED BY public.ethscriptions.id; - - --- --- Name: schema_migrations; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.schema_migrations ( - version character varying NOT NULL -); - - --- --- Name: token_items; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.token_items ( - id bigint NOT NULL, - ethscription_transaction_hash character varying NOT NULL, - deploy_ethscription_transaction_hash character varying NOT NULL, - block_number bigint NOT NULL, - transaction_index bigint NOT NULL, - token_item_id bigint NOT NULL, - created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL, - CONSTRAINT chk_rails_37f43f9259 CHECK (((deploy_ethscription_transaction_hash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_4a492d2c53 CHECK ((token_item_id > 0)), - CONSTRAINT chk_rails_4e045edbe2 CHECK (((ethscription_transaction_hash)::text ~ '^0x[a-f0-9]{64}$'::text)) -); - - --- --- Name: token_items_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.token_items_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: token_items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.token_items_id_seq OWNED BY public.token_items.id; - - --- --- Name: token_states; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.token_states ( - id bigint NOT NULL, - block_number bigint NOT NULL, - block_timestamp bigint NOT NULL, - block_blockhash character varying NOT NULL, - deploy_ethscription_transaction_hash character varying NOT NULL, - balances jsonb DEFAULT '{}'::jsonb NOT NULL, - total_supply bigint DEFAULT 0 NOT NULL, - created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL, - CONSTRAINT chk_rails_8b7e9525c6 CHECK (((deploy_ethscription_transaction_hash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_97e78ee6f4 CHECK (((block_blockhash)::text ~ '^0x[a-f0-9]{64}$'::text)) -); - - --- --- Name: token_states_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.token_states_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: token_states_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.token_states_id_seq OWNED BY public.token_states.id; - - --- --- Name: tokens; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.tokens ( - id bigint NOT NULL, - deploy_ethscription_transaction_hash character varying NOT NULL, - deploy_block_number bigint NOT NULL, - deploy_transaction_index bigint NOT NULL, - protocol character varying(1000) NOT NULL, - tick character varying(1000) NOT NULL, - max_supply bigint NOT NULL, - total_supply bigint DEFAULT 0 NOT NULL, - mint_amount bigint NOT NULL, - created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL, - balances jsonb DEFAULT '{}'::jsonb NOT NULL, - CONSTRAINT chk_rails_31c1808af4 CHECK (((tick)::text ~ '^[[:alnum:]p{Emoji_Presentation}]+$'::text)), - CONSTRAINT chk_rails_3458514b65 CHECK (((deploy_ethscription_transaction_hash)::text ~ '^0x[a-f0-9]{64}$'::text)), - CONSTRAINT chk_rails_3d55d7040f CHECK (((max_supply % mint_amount) = 0)), - CONSTRAINT chk_rails_53ece3f224 CHECK ((total_supply <= max_supply)), - CONSTRAINT chk_rails_596664ed3b CHECK ((total_supply >= 0)), - CONSTRAINT chk_rails_b41faadd12 CHECK ((mint_amount > 0)), - CONSTRAINT chk_rails_e954152758 CHECK ((max_supply > 0)), - CONSTRAINT chk_rails_f38f6eac6d CHECK (((protocol)::text ~ '^[a-z0-9-]+$'::text)) -); - - --- --- Name: tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.tokens_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.tokens_id_seq OWNED BY public.tokens.id; - - --- --- Name: eth_blocks id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.eth_blocks ALTER COLUMN id SET DEFAULT nextval('public.eth_blocks_id_seq'::regclass); - - --- --- Name: eth_transactions id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.eth_transactions ALTER COLUMN id SET DEFAULT nextval('public.eth_transactions_id_seq'::regclass); - - --- --- Name: ethscription_attachments id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscription_attachments ALTER COLUMN id SET DEFAULT nextval('public.ethscription_attachments_id_seq'::regclass); - - --- --- Name: ethscription_ownership_versions id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscription_ownership_versions ALTER COLUMN id SET DEFAULT nextval('public.ethscription_ownership_versions_id_seq'::regclass); - - --- --- Name: ethscription_transfers id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscription_transfers ALTER COLUMN id SET DEFAULT nextval('public.ethscription_transfers_id_seq'::regclass); - - --- --- Name: ethscriptions id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscriptions ALTER COLUMN id SET DEFAULT nextval('public.ethscriptions_id_seq'::regclass); - - --- --- Name: token_items id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.token_items ALTER COLUMN id SET DEFAULT nextval('public.token_items_id_seq'::regclass); - - --- --- Name: token_states id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.token_states ALTER COLUMN id SET DEFAULT nextval('public.token_states_id_seq'::regclass); - - --- --- Name: tokens id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.tokens ALTER COLUMN id SET DEFAULT nextval('public.tokens_id_seq'::regclass); - - --- --- Name: ar_internal_metadata ar_internal_metadata_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ar_internal_metadata - ADD CONSTRAINT ar_internal_metadata_pkey PRIMARY KEY (key); - - --- --- Name: eth_blocks eth_blocks_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.eth_blocks - ADD CONSTRAINT eth_blocks_pkey PRIMARY KEY (id); - - --- --- Name: eth_transactions eth_transactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.eth_transactions - ADD CONSTRAINT eth_transactions_pkey PRIMARY KEY (id); - - --- --- Name: ethscription_attachments ethscription_attachments_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscription_attachments - ADD CONSTRAINT ethscription_attachments_pkey PRIMARY KEY (id); - - --- --- Name: ethscription_ownership_versions ethscription_ownership_versions_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscription_ownership_versions - ADD CONSTRAINT ethscription_ownership_versions_pkey PRIMARY KEY (id); - - --- --- Name: ethscription_transfers ethscription_transfers_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscription_transfers - ADD CONSTRAINT ethscription_transfers_pkey PRIMARY KEY (id); - - --- --- Name: ethscriptions ethscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscriptions - ADD CONSTRAINT ethscriptions_pkey PRIMARY KEY (id); - - --- --- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.schema_migrations - ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); - - --- --- Name: token_items token_items_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.token_items - ADD CONSTRAINT token_items_pkey PRIMARY KEY (id); - - --- --- Name: token_states token_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.token_states - ADD CONSTRAINT token_states_pkey PRIMARY KEY (id); - - --- --- Name: tokens tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.tokens - ADD CONSTRAINT tokens_pkey PRIMARY KEY (id); - - --- --- Name: idx_on_block_number_deploy_ethscription_transaction_4559fe945a; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_on_block_number_deploy_ethscription_transaction_4559fe945a ON public.token_states USING btree (block_number, deploy_ethscription_transaction_hash); - - --- --- Name: idx_on_block_number_transaction_index_event_log_ind_94b2c4b953; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_on_block_number_transaction_index_event_log_ind_94b2c4b953 ON public.ethscription_transfers USING btree (block_number, transaction_index, event_log_index); - - --- --- Name: idx_on_block_number_transaction_index_transfer_inde_8090d24b9e; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_on_block_number_transaction_index_transfer_inde_8090d24b9e ON public.ethscription_ownership_versions USING btree (block_number, transaction_index, transfer_index); - - --- --- Name: idx_on_block_number_transaction_index_transfer_inde_fc9ee59957; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_on_block_number_transaction_index_transfer_inde_fc9ee59957 ON public.ethscription_transfers USING btree (block_number, transaction_index, transfer_index); - - --- --- Name: idx_on_current_owner_previous_owner_7bb4bbf3cf; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_on_current_owner_previous_owner_7bb4bbf3cf ON public.ethscription_ownership_versions USING btree (current_owner, previous_owner); - - --- --- Name: idx_on_deploy_block_number_deploy_transaction_index_16cfcbe277; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_on_deploy_block_number_deploy_transaction_index_16cfcbe277 ON public.tokens USING btree (deploy_block_number, deploy_transaction_index); - - --- --- Name: idx_on_deploy_ethscription_transaction_hash_token_i_8afe3c6082; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_on_deploy_ethscription_transaction_hash_token_i_8afe3c6082 ON public.token_items USING btree (deploy_ethscription_transaction_hash, token_item_id); - - --- --- Name: idx_on_ethscription_transaction_hash_deploy_ethscri_5f2ffeede2; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_on_ethscription_transaction_hash_deploy_ethscri_5f2ffeede2 ON public.token_items USING btree (ethscription_transaction_hash, deploy_ethscription_transaction_hash, token_item_id); - - --- --- Name: idx_on_ethscription_transaction_hash_e9e1b526f9; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_on_ethscription_transaction_hash_e9e1b526f9 ON public.ethscription_ownership_versions USING btree (ethscription_transaction_hash); - - --- --- Name: idx_on_transaction_hash_event_log_index_c192a81bef; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_on_transaction_hash_event_log_index_c192a81bef ON public.ethscription_transfers USING btree (transaction_hash, event_log_index); - - --- --- Name: idx_on_transaction_hash_transfer_index_4389678e0a; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_on_transaction_hash_transfer_index_4389678e0a ON public.ethscription_transfers USING btree (transaction_hash, transfer_index); - - --- --- Name: idx_on_transaction_hash_transfer_index_b79931daa1; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_on_transaction_hash_transfer_index_b79931daa1 ON public.ethscription_ownership_versions USING btree (transaction_hash, transfer_index); - - --- --- Name: index_eth_blocks_on_block_number; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_eth_blocks_on_block_number ON public.eth_blocks USING btree (block_number); - - --- --- Name: index_eth_blocks_on_blockhash; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_eth_blocks_on_blockhash ON public.eth_blocks USING btree (blockhash); - - --- --- Name: index_eth_blocks_on_created_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_blocks_on_created_at ON public.eth_blocks USING btree (created_at); - - --- --- Name: index_eth_blocks_on_imported_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_blocks_on_imported_at ON public.eth_blocks USING btree (imported_at); - - --- --- Name: index_eth_blocks_on_imported_at_and_block_number; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_blocks_on_imported_at_and_block_number ON public.eth_blocks USING btree (imported_at, block_number); - - --- --- Name: index_eth_blocks_on_parent_blockhash; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_eth_blocks_on_parent_blockhash ON public.eth_blocks USING btree (parent_blockhash); - - --- --- Name: index_eth_blocks_on_parent_state_hash; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_eth_blocks_on_parent_state_hash ON public.eth_blocks USING btree (parent_state_hash); - - --- --- Name: index_eth_blocks_on_state_hash; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_eth_blocks_on_state_hash ON public.eth_blocks USING btree (state_hash); - - --- --- Name: index_eth_blocks_on_timestamp; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_eth_blocks_on_timestamp ON public.eth_blocks USING btree ("timestamp"); - - --- --- Name: index_eth_blocks_on_updated_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_blocks_on_updated_at ON public.eth_blocks USING btree (updated_at); - - --- --- Name: index_eth_transactions_on_block_blockhash; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_transactions_on_block_blockhash ON public.eth_transactions USING btree (block_blockhash); - - --- --- Name: index_eth_transactions_on_block_number; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_transactions_on_block_number ON public.eth_transactions USING btree (block_number); - - --- --- Name: index_eth_transactions_on_block_number_and_transaction_index; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_eth_transactions_on_block_number_and_transaction_index ON public.eth_transactions USING btree (block_number, transaction_index); - - --- --- Name: index_eth_transactions_on_block_timestamp; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_transactions_on_block_timestamp ON public.eth_transactions USING btree (block_timestamp); - - --- --- Name: index_eth_transactions_on_created_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_transactions_on_created_at ON public.eth_transactions USING btree (created_at); - - --- --- Name: index_eth_transactions_on_from_address; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_transactions_on_from_address ON public.eth_transactions USING btree (from_address); - - --- --- Name: index_eth_transactions_on_logs; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_transactions_on_logs ON public.eth_transactions USING gin (logs); - - --- --- Name: index_eth_transactions_on_status; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_transactions_on_status ON public.eth_transactions USING btree (status); - - --- --- Name: index_eth_transactions_on_to_address; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_transactions_on_to_address ON public.eth_transactions USING btree (to_address); - - --- --- Name: index_eth_transactions_on_transaction_hash; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_eth_transactions_on_transaction_hash ON public.eth_transactions USING btree (transaction_hash); - - --- --- Name: index_eth_transactions_on_updated_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_eth_transactions_on_updated_at ON public.eth_transactions USING btree (updated_at); - - --- --- Name: index_ethscription_attachments_on_content_type; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_attachments_on_content_type ON public.ethscription_attachments USING btree (content_type); - - --- --- Name: index_ethscription_attachments_on_sha; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_ethscription_attachments_on_sha ON public.ethscription_attachments USING btree (sha); - - --- --- Name: index_ethscription_attachments_on_size; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_attachments_on_size ON public.ethscription_attachments USING btree (size); - - --- --- Name: index_ethscription_ownership_versions_on_block_blockhash; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_ownership_versions_on_block_blockhash ON public.ethscription_ownership_versions USING btree (block_blockhash); - - --- --- Name: index_ethscription_ownership_versions_on_block_number; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_ownership_versions_on_block_number ON public.ethscription_ownership_versions USING btree (block_number); - - --- --- Name: index_ethscription_ownership_versions_on_block_timestamp; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_ownership_versions_on_block_timestamp ON public.ethscription_ownership_versions USING btree (block_timestamp); - - --- --- Name: index_ethscription_ownership_versions_on_created_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_ownership_versions_on_created_at ON public.ethscription_ownership_versions USING btree (created_at); - - --- --- Name: index_ethscription_ownership_versions_on_current_owner; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_ownership_versions_on_current_owner ON public.ethscription_ownership_versions USING btree (current_owner); - - --- --- Name: index_ethscription_ownership_versions_on_previous_owner; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_ownership_versions_on_previous_owner ON public.ethscription_ownership_versions USING btree (previous_owner); - - --- --- Name: index_ethscription_ownership_versions_on_transaction_hash; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_ownership_versions_on_transaction_hash ON public.ethscription_ownership_versions USING btree (transaction_hash); - - --- --- Name: index_ethscription_ownership_versions_on_updated_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_ownership_versions_on_updated_at ON public.ethscription_ownership_versions USING btree (updated_at); - - --- --- Name: index_ethscription_transfers_on_block_blockhash; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_transfers_on_block_blockhash ON public.ethscription_transfers USING btree (block_blockhash); - - --- --- Name: index_ethscription_transfers_on_block_number; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_transfers_on_block_number ON public.ethscription_transfers USING btree (block_number); - - --- --- Name: index_ethscription_transfers_on_block_timestamp; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_transfers_on_block_timestamp ON public.ethscription_transfers USING btree (block_timestamp); - - --- --- Name: index_ethscription_transfers_on_created_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_transfers_on_created_at ON public.ethscription_transfers USING btree (created_at); - - --- --- Name: index_ethscription_transfers_on_enforced_previous_owner; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_transfers_on_enforced_previous_owner ON public.ethscription_transfers USING btree (enforced_previous_owner); - - --- --- Name: index_ethscription_transfers_on_ethscription_transaction_hash; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_transfers_on_ethscription_transaction_hash ON public.ethscription_transfers USING btree (ethscription_transaction_hash); - - --- --- Name: index_ethscription_transfers_on_from_address; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_transfers_on_from_address ON public.ethscription_transfers USING btree (from_address); - - --- --- Name: index_ethscription_transfers_on_to_address; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_transfers_on_to_address ON public.ethscription_transfers USING btree (to_address); - - --- --- Name: index_ethscription_transfers_on_transaction_hash; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_transfers_on_transaction_hash ON public.ethscription_transfers USING btree (transaction_hash); - - --- --- Name: index_ethscription_transfers_on_updated_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscription_transfers_on_updated_at ON public.ethscription_transfers USING btree (updated_at); - - --- --- Name: index_ethscriptions_on_attachment_content_type; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_attachment_content_type ON public.ethscriptions USING btree (attachment_content_type); - - --- --- Name: index_ethscriptions_on_attachment_sha; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_attachment_sha ON public.ethscriptions USING btree (attachment_sha); - - --- --- Name: index_ethscriptions_on_attachment_sha_not_null; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_attachment_sha_not_null ON public.ethscriptions USING btree (attachment_sha) WHERE (attachment_sha IS NOT NULL); - - --- --- Name: index_ethscriptions_on_block_blockhash; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_block_blockhash ON public.ethscriptions USING btree (block_blockhash); - - --- --- Name: index_ethscriptions_on_block_number; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_block_number ON public.ethscriptions USING btree (block_number); - - --- --- Name: index_ethscriptions_on_block_number_and_transaction_index; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_ethscriptions_on_block_number_and_transaction_index ON public.ethscriptions USING btree (block_number, transaction_index); - - --- --- Name: index_ethscriptions_on_block_timestamp; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_block_timestamp ON public.ethscriptions USING btree (block_timestamp); - - --- --- Name: index_ethscriptions_on_content_sha; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_content_sha ON public.ethscriptions USING btree (content_sha); - - --- --- Name: index_ethscriptions_on_content_sha_unique; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_ethscriptions_on_content_sha_unique ON public.ethscriptions USING btree (content_sha) WHERE (esip6 = false); - - --- --- Name: index_ethscriptions_on_created_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_created_at ON public.ethscriptions USING btree (created_at); - - --- --- Name: index_ethscriptions_on_creator; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_creator ON public.ethscriptions USING btree (creator); - - --- --- Name: index_ethscriptions_on_current_owner; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_current_owner ON public.ethscriptions USING btree (current_owner); - - --- --- Name: index_ethscriptions_on_esip6; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_esip6 ON public.ethscriptions USING btree (esip6); - - --- --- Name: index_ethscriptions_on_ethscription_number; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_ethscriptions_on_ethscription_number ON public.ethscriptions USING btree (ethscription_number); - - --- --- Name: index_ethscriptions_on_initial_owner; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_initial_owner ON public.ethscriptions USING btree (initial_owner); - - --- --- Name: index_ethscriptions_on_media_type; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_media_type ON public.ethscriptions USING btree (media_type); - - --- --- Name: index_ethscriptions_on_mime_subtype; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_mime_subtype ON public.ethscriptions USING btree (mime_subtype); - - --- --- Name: index_ethscriptions_on_mimetype; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_mimetype ON public.ethscriptions USING btree (mimetype); - - --- --- Name: index_ethscriptions_on_previous_owner; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_previous_owner ON public.ethscriptions USING btree (previous_owner); - - --- --- Name: index_ethscriptions_on_sha_blocknum_txindex_asc; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_sha_blocknum_txindex_asc ON public.ethscriptions USING btree (attachment_sha, block_number, transaction_index); - - --- --- Name: index_ethscriptions_on_sha_blocknum_txindex_desc; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_sha_blocknum_txindex_desc ON public.ethscriptions USING btree (attachment_sha, block_number DESC, transaction_index DESC); - - --- --- Name: index_ethscriptions_on_transaction_hash; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_ethscriptions_on_transaction_hash ON public.ethscriptions USING btree (transaction_hash); - - --- --- Name: index_ethscriptions_on_transaction_index; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_transaction_index ON public.ethscriptions USING btree (transaction_index); - - --- --- Name: index_ethscriptions_on_updated_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_ethscriptions_on_updated_at ON public.ethscriptions USING btree (updated_at); - - --- --- Name: index_token_items_on_block_number_and_transaction_index; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_token_items_on_block_number_and_transaction_index ON public.token_items USING btree (block_number, transaction_index); - - --- --- Name: index_token_items_on_ethscription_transaction_hash; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_token_items_on_ethscription_transaction_hash ON public.token_items USING btree (ethscription_transaction_hash); - - --- --- Name: index_token_items_on_transaction_index; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_token_items_on_transaction_index ON public.token_items USING btree (transaction_index); - - --- --- Name: index_token_states_on_deploy_ethscription_transaction_hash; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_token_states_on_deploy_ethscription_transaction_hash ON public.token_states USING btree (deploy_ethscription_transaction_hash); - - --- --- Name: index_tokens_on_deploy_ethscription_transaction_hash; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_tokens_on_deploy_ethscription_transaction_hash ON public.tokens USING btree (deploy_ethscription_transaction_hash); - - --- --- Name: index_tokens_on_protocol_and_tick; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_tokens_on_protocol_and_tick ON public.tokens USING btree (protocol, tick); - - --- --- Name: inx_ethscriptions_on_blk_num_tx_index_with_att_not_null_asc; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX inx_ethscriptions_on_blk_num_tx_index_with_att_not_null_asc ON public.ethscriptions USING btree (block_number, transaction_index) WHERE (attachment_sha IS NOT NULL); - - --- --- Name: inx_ethscriptions_on_blk_num_tx_index_with_attachment_not_null; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX inx_ethscriptions_on_blk_num_tx_index_with_attachment_not_null ON public.ethscriptions USING btree (block_number, transaction_index) WHERE (attachment_sha IS NOT NULL); - - --- --- Name: eth_blocks check_block_imported_at_trigger; Type: TRIGGER; Schema: public; Owner: - --- - -CREATE TRIGGER check_block_imported_at_trigger BEFORE UPDATE OF imported_at ON public.eth_blocks FOR EACH ROW EXECUTE FUNCTION public.check_block_imported_at(); - - --- --- Name: ethscriptions ethscription_cleanup; Type: TRIGGER; Schema: public; Owner: - --- - -CREATE TRIGGER ethscription_cleanup AFTER DELETE ON public.ethscriptions FOR EACH ROW EXECUTE FUNCTION public.clean_up_ethscription_attachments(); - - --- --- Name: eth_blocks trigger_check_block_order; Type: TRIGGER; Schema: public; Owner: - --- - -CREATE TRIGGER trigger_check_block_order BEFORE INSERT ON public.eth_blocks FOR EACH ROW EXECUTE FUNCTION public.check_block_order(); - - --- --- Name: eth_blocks trigger_check_block_order_on_update; Type: TRIGGER; Schema: public; Owner: - --- - -CREATE TRIGGER trigger_check_block_order_on_update BEFORE UPDATE OF imported_at ON public.eth_blocks FOR EACH ROW WHEN ((new.imported_at IS NOT NULL)) EXECUTE FUNCTION public.check_block_order_on_update(); - - --- --- Name: ethscriptions trigger_check_ethscription_order_and_sequence; Type: TRIGGER; Schema: public; Owner: - --- - -CREATE TRIGGER trigger_check_ethscription_order_and_sequence BEFORE INSERT ON public.ethscriptions FOR EACH ROW EXECUTE FUNCTION public.check_ethscription_order_and_sequence(); - - --- --- Name: eth_blocks trigger_delete_later_blocks; Type: TRIGGER; Schema: public; Owner: - --- - -CREATE TRIGGER trigger_delete_later_blocks AFTER DELETE ON public.eth_blocks FOR EACH ROW EXECUTE FUNCTION public.delete_later_blocks(); - - --- --- Name: ethscription_ownership_versions update_current_owner; Type: TRIGGER; Schema: public; Owner: - --- - -CREATE TRIGGER update_current_owner AFTER INSERT OR DELETE ON public.ethscription_ownership_versions FOR EACH ROW EXECUTE FUNCTION public.update_current_owner(); - - --- --- Name: token_states update_token_balances_and_supply; Type: TRIGGER; Schema: public; Owner: - --- - -CREATE TRIGGER update_token_balances_and_supply AFTER INSERT OR DELETE ON public.token_states FOR EACH ROW EXECUTE FUNCTION public.update_token_balances_and_supply(); - - --- --- Name: ethscriptions fk_rails_104cee2b3d; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscriptions - ADD CONSTRAINT fk_rails_104cee2b3d FOREIGN KEY (block_number) REFERENCES public.eth_blocks(block_number) ON DELETE CASCADE; - - --- --- Name: tokens fk_rails_1c09e75f12; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.tokens - ADD CONSTRAINT fk_rails_1c09e75f12 FOREIGN KEY (deploy_ethscription_transaction_hash) REFERENCES public.ethscriptions(transaction_hash) ON DELETE CASCADE; - - --- --- Name: ethscriptions fk_rails_2accd8a448; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscriptions - ADD CONSTRAINT fk_rails_2accd8a448 FOREIGN KEY (transaction_hash) REFERENCES public.eth_transactions(transaction_hash) ON DELETE CASCADE; - - --- --- Name: ethscription_transfers fk_rails_2fe933886e; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscription_transfers - ADD CONSTRAINT fk_rails_2fe933886e FOREIGN KEY (transaction_hash) REFERENCES public.eth_transactions(transaction_hash) ON DELETE CASCADE; - - --- --- Name: token_states fk_rails_40574954c3; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.token_states - ADD CONSTRAINT fk_rails_40574954c3 FOREIGN KEY (deploy_ethscription_transaction_hash) REFERENCES public.tokens(deploy_ethscription_transaction_hash) ON DELETE CASCADE; - - --- --- Name: ethscription_transfers fk_rails_479ac03c16; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscription_transfers - ADD CONSTRAINT fk_rails_479ac03c16 FOREIGN KEY (ethscription_transaction_hash) REFERENCES public.ethscriptions(transaction_hash) ON DELETE CASCADE; - - --- --- Name: eth_transactions fk_rails_4937ed3300; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.eth_transactions - ADD CONSTRAINT fk_rails_4937ed3300 FOREIGN KEY (block_number) REFERENCES public.eth_blocks(block_number) ON DELETE CASCADE; - - --- --- Name: ethscription_ownership_versions fk_rails_8808aa138a; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscription_ownership_versions - ADD CONSTRAINT fk_rails_8808aa138a FOREIGN KEY (ethscription_transaction_hash) REFERENCES public.ethscriptions(transaction_hash) ON DELETE CASCADE; - - --- --- Name: token_items fk_rails_8d58f29890; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.token_items - ADD CONSTRAINT fk_rails_8d58f29890 FOREIGN KEY (deploy_ethscription_transaction_hash) REFERENCES public.tokens(deploy_ethscription_transaction_hash) ON DELETE CASCADE; - - --- --- Name: ethscription_transfers fk_rails_b68511af4b; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscription_transfers - ADD CONSTRAINT fk_rails_b68511af4b FOREIGN KEY (block_number) REFERENCES public.eth_blocks(block_number) ON DELETE CASCADE; - - --- --- Name: token_states fk_rails_c99350f4d3; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.token_states - ADD CONSTRAINT fk_rails_c99350f4d3 FOREIGN KEY (block_number) REFERENCES public.eth_blocks(block_number) ON DELETE CASCADE; - - --- --- Name: ethscription_ownership_versions fk_rails_e95d97c83e; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscription_ownership_versions - ADD CONSTRAINT fk_rails_e95d97c83e FOREIGN KEY (block_number) REFERENCES public.eth_blocks(block_number) ON DELETE CASCADE; - - --- --- Name: ethscription_ownership_versions fk_rails_ed1fdc1619; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.ethscription_ownership_versions - ADD CONSTRAINT fk_rails_ed1fdc1619 FOREIGN KEY (transaction_hash) REFERENCES public.eth_transactions(transaction_hash) ON DELETE CASCADE; - - --- --- Name: token_items fk_rails_ffdbb769e4; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.token_items - ADD CONSTRAINT fk_rails_ffdbb769e4 FOREIGN KEY (ethscription_transaction_hash) REFERENCES public.ethscriptions(transaction_hash) ON DELETE CASCADE; - - --- --- PostgreSQL database dump complete --- - -SET search_path TO "$user", public; - -INSERT INTO "schema_migrations" (version) VALUES -('20240411154249'), -('20240327135159'), -('20240317200158'), -('20240315184639'), -('20240126184612'), -('20240126162132'), -('20240115192312'), -('20240115151119'), -('20240115144930'), -('20231216215348'), -('20231216213103'), -('20231216164707'), -('20231216163233'), -('20231216161930'); - diff --git a/docker-compose/.env.example b/docker-compose/.env.example new file mode 100644 index 0000000..19833ba --- /dev/null +++ b/docker-compose/.env.example @@ -0,0 +1,24 @@ +# Docker Compose Environment Variables + +# Project metadata +COMPOSE_PROJECT_NAME=ethscriptions-evm +COMPOSE_BAKE=true + +# L1 / genesis configuration +L1_NETWORK=mainnet +L1_GENESIS_BLOCK=17478949 +GENESIS_FILE=ethscriptions-mainnet.json + +# If you have a non-public high-performance RPC you can set forward +# to something like 100 and threads to 10 +L1_RPC_URL=https://ethereum-rpc.publicnode.com +L1_PREFETCH_FORWARD=2 +L1_PREFETCH_THREADS=1 + +# Geth tuning +GETH_EXTERNAL_PORT=8545 +GETH_CACHE_SIZE=25000 +ENABLE_PREIMAGES=false +GC_MODE=archive + +JWT_SECRET=0x0101010101010101010101010101010101010101010101010101010101010101 diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml new file mode 100644 index 0000000..c0245a9 --- /dev/null +++ b/docker-compose/docker-compose.yml @@ -0,0 +1,63 @@ +services: + geth: + image: ghcr.io/ethscriptions-protocol/ethscriptions-geth:ethscriptions + environment: + JWT_SECRET: ${JWT_SECRET} + GENESIS_FILE: ${GENESIS_FILE} + GENESIS_TIMESTAMP: ${GENESIS_TIMESTAMP:-} + GENESIS_MIX_HASH: ${GENESIS_MIX_HASH:-} + RPC_GAS_CAP: ${RPC_GAS_CAP:-500000000} + CACHE_SIZE: ${GETH_CACHE_SIZE:-25000} + ENABLE_PREIMAGES: ${ENABLE_PREIMAGES:-true} + GC_MODE: ${GC_MODE:-full} + STATE_HISTORY: ${STATE_HISTORY:-100000} + TX_HISTORY: ${TX_HISTORY:-100000} + CACHE_GC: ${CACHE_GC:-25} + CACHE_TRIE: ${CACHE_TRIE:-15} + volumes: + - geth-data:/root/ethereum + ports: + - "${GETH_EXTERNAL_PORT:-8545}:8545" + healthcheck: + test: ["CMD-SHELL", "geth attach --exec 'eth.blockNumber' http://localhost:8545"] + interval: 30s + timeout: 3s + retries: 20 + start_period: 10s + + node: + image: ghcr.io/ethscriptions-protocol/ethscriptions-node:evm-backend-demo + environment: + JWT_SECRET: ${JWT_SECRET} + L1_NETWORK: ${L1_NETWORK} + GETH_RPC_URL: ipc:///geth-data/geth.ipc + NON_AUTH_GETH_RPC_URL: ipc:///geth-data/geth.ipc + L1_RPC_URL: ${L1_RPC_URL} + L1_GENESIS_BLOCK: ${L1_GENESIS_BLOCK} + L1_PREFETCH_FORWARD: ${L1_PREFETCH_FORWARD:-200} + L1_PREFETCH_THREADS: ${L1_PREFETCH_THREADS:-10} + VALIDATION_ENABLED: ${VALIDATION_ENABLED:-false} + JOB_CONCURRENCY: ${JOB_CONCURRENCY:-6} + JOB_THREADS: ${JOB_THREADS:-3} + PROFILE_IMPORT: ${PROFILE_IMPORT:-true} + ETHSCRIPTIONS_API_BASE_URL: ${ETHSCRIPTIONS_API_BASE_URL:-} + # Transient validation error handling configuration + VALIDATION_TRANSIENT_RETRIES: ${VALIDATION_TRANSIENT_RETRIES:-1000} + VALIDATION_RETRY_WAIT_SECONDS: ${VALIDATION_RETRY_WAIT_SECONDS:-5} + ETHSCRIPTIONS_API_RETRIES: ${ETHSCRIPTIONS_API_RETRIES:-7} + # Validation lag control + VALIDATION_LAG_HARD_LIMIT: ${VALIDATION_LAG_HARD_LIMIT:-30} + ETHSCRIPTIONS_API_KEY: ${ETHSCRIPTIONS_API_KEY:-} + DEBUG_KEEP_ALIVE: ${DEBUG_KEEP_ALIVE:-0} + volumes: + - node-storage:/rails/storage + - geth-data:/geth-data + depends_on: + geth: + condition: service_healthy + +volumes: + geth-data: + name: ${COMPOSE_PROJECT_NAME:-ethscriptions-evm}_geth-data + node-storage: + name: ${COMPOSE_PROJECT_NAME:-ethscriptions-evm}_node-storage diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..ac79b09 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +export RAILS_ENV="${RAILS_ENV:-production}" + +# Initialize DBs on first run if using a fresh/mounted volume +set +e +if [ ! -f "storage/production.sqlite3" ] || [ ! -f "storage/production_queue.sqlite3" ]; then + echo "Initializing databases..." + DISABLE_DATABASE_ENVIRONMENT_CHECK=1 bundle exec rails db:setup db:schema:load:queue +else + echo "Running any pending migrations..." + bundle exec rails db:migrate +fi +set -e + +echo "Starting SolidQueue workers..." +bundle exec bin/jobs & +JOBS_PID=$! + +echo "Starting importer..." +bundle exec clockwork config/derive_ethscriptions_blocks.rb & +CLOCKWORK_PID=$! + +cleanup() { + echo "Shutting down..." + kill "${JOBS_PID:-}" "${CLOCKWORK_PID:-}" 2>/dev/null || true + + # Optionally reap children to avoid zombies (tini also reaps) + wait "${JOBS_PID:-}" "${CLOCKWORK_PID:-}" 2>/dev/null || true +} +trap cleanup SIGTERM SIGINT + +# Wait for either process to exit and preserve its exit code +set +e +wait -n +exit_code=$? +set -e +echo "One process exited, shutting down..." +kill "${JOBS_PID:-}" "${CLOCKWORK_PID:-}" 2>/dev/null || true +if [[ "${DEBUG_KEEP_ALIVE:-0}" == "1" && $exit_code -ne 0 ]]; then + echo "Process died with $exit_code; keeping container up for debugging." + if [[ -t 1 ]]; then + exec bash -li + else + exec tail -f /dev/null + fi +fi +exit "$exit_code" diff --git a/lib/address_20.rb b/lib/address_20.rb new file mode 100644 index 0000000..5d2542e --- /dev/null +++ b/lib/address_20.rb @@ -0,0 +1,6 @@ +class Address20 < ByteString + sig { override.returns(Integer) } + def self.required_byte_length + 20 + end +end diff --git a/lib/alchemy_client.rb b/lib/alchemy_client.rb deleted file mode 100644 index 984bab8..0000000 --- a/lib/alchemy_client.rb +++ /dev/null @@ -1,90 +0,0 @@ -class AlchemyClient - attr_accessor :base_url, :api_key - - def initialize(base_url: ENV['ETHEREUM_CLIENT_BASE_URL'], api_key:) - self.base_url = base_url.chomp('/') - self.api_key = api_key - end - - def get_block(block_number) - query_api( - method: 'eth_getBlockByNumber', - params: ['0x' + block_number.to_s(16), true] - ) - end - - def get_transaction_receipts(block_number, blocks_behind: nil) - use_individual = ENV.fetch('ETHEREUM_NETWORK') == "eth-sepolia" && - blocks_behind.present? && - blocks_behind < 5 - - if use_individual - get_transaction_receipts_individually(block_number) - else - get_transaction_receipts_batch(block_number) - end - end - - def get_transaction_receipts_batch(block_number) - query_api( - method: 'alchemy_getTransactionReceipts', - params: [{ blockNumber: "0x" + block_number.to_s(16) }] - ) - end - - def get_transaction_receipts_individually(block_number) - block_info = query_api( - method: 'eth_getBlockByNumber', - params: ['0x' + block_number.to_s(16), false] - ) - - transactions = block_info['result']['transactions'] - - receipts = transactions.map do |transaction| - Concurrent::Promise.execute do - get_transaction_receipt(transaction)['result'] - end - end.map(&:value!) - - { - 'id' => 1, - 'jsonrpc' => '2.0', - 'result' => { - 'receipts' => receipts - } - } - end - - def get_transaction_receipt(transaction_hash) - query_api( - method: 'eth_getTransactionReceipt', - params: [transaction_hash] - ) - end - - def get_block_number - query_api(method: 'eth_blockNumber')['result'].to_i(16) - end - - private - - def query_api(method:, params: []) - data = { - id: 1, - jsonrpc: "2.0", - method: method, - params: params - } - - url = [base_url, api_key].join('/') - - HTTParty.post(url, body: data.to_json, headers: headers).parsed_response - end - - def headers - { - 'Accept' => 'application/json', - 'Content-Type' => 'application/json' - } - end -end diff --git a/lib/attr_assignable.rb b/lib/attr_assignable.rb new file mode 100644 index 0000000..ceaf40f --- /dev/null +++ b/lib/attr_assignable.rb @@ -0,0 +1,15 @@ +module AttrAssignable + extend T::Sig + + sig { params(attrs: T::Hash[Symbol, T.untyped]).void } + def assign_attributes(attrs) + attrs.each do |k, v| + setter = "#{k}=".to_sym + if respond_to?(setter) + send(setter, v) + else + raise NoMethodError, "Unknown attribute #{k} for #{self.class}" + end + end + end +end diff --git a/lib/blob_utils.rb b/lib/blob_utils.rb deleted file mode 100644 index 7ac3be4..0000000 --- a/lib/blob_utils.rb +++ /dev/null @@ -1,90 +0,0 @@ -module BlobUtils - # Constants from Viem - BLOBS_PER_TRANSACTION = 2 - BYTES_PER_FIELD_ELEMENT = 32 - FIELD_ELEMENTS_PER_BLOB = 4096 - BYTES_PER_BLOB = BYTES_PER_FIELD_ELEMENT * FIELD_ELEMENTS_PER_BLOB - MAX_BYTES_PER_TRANSACTION = BYTES_PER_BLOB * BLOBS_PER_TRANSACTION - 1 - (1 * FIELD_ELEMENTS_PER_BLOB * BLOBS_PER_TRANSACTION) - - # Error Classes - class BlobSizeTooLargeError < StandardError; end - class EmptyBlobError < StandardError; end - class IncorrectBlobEncoding < StandardError; end - - # Adapted from Viem - def self.to_blobs(data:) - raise EmptyBlobError if data.empty? - raise BlobSizeTooLargeError if data.bytesize > MAX_BYTES_PER_TRANSACTION - - if data =~ /\A0x([a-f0-9]{2})+\z/i - data = [data].pack('H*') - end - - blobs = [] - position = 0 - active = true - - while active && blobs.size < BLOBS_PER_TRANSACTION - blob = [] - size = 0 - - while size < FIELD_ELEMENTS_PER_BLOB - bytes = data.byteslice(position, BYTES_PER_FIELD_ELEMENT - 1) - - # Push a zero byte so the field element doesn't overflow - blob.push(0x00) - - # Push the current segment of data bytes - blob.concat(bytes.bytes) unless bytes.nil? - - # If the current segment of data bytes is less than 31 bytes, - # stop processing and push a terminator byte to indicate the end of the blob - if bytes.nil? || bytes.bytesize < (BYTES_PER_FIELD_ELEMENT - 1) - blob.push(0x80) - active = false - break - end - - size += 1 - position += (BYTES_PER_FIELD_ELEMENT - 1) - end - - blob.fill(0x00, blob.size...BYTES_PER_BLOB) - - blobs.push(blob.pack('C*').unpack1("H*")) - end - - blobs - end - - def self.from_blobs(blobs:) - concatenated_hex = blobs.map do |blob| - hex_blob = blob.sub(/\A0x/, '') - - sections = hex_blob.scan(/.{64}/m) - - last_non_empty_section_index = sections.rindex { |section| section != '00' * 32 } - non_empty_sections = sections.take(last_non_empty_section_index + 1) - - last_non_empty_section = non_empty_sections.last - - if last_non_empty_section == "0080" + "00" * 30 - non_empty_sections.pop - else - last_non_empty_section.gsub!(/80(00)*\z/, '') - end - - non_empty_sections = non_empty_sections.map do |section| - unless section.start_with?('00') - raise IncorrectBlobEncoding, "Expected the first byte to be zero" - end - - section.delete_prefix("00") - end - - non_empty_sections.join - end.join - - [concatenated_hex].pack("H*") - end -end diff --git a/lib/block_validator.rb b/lib/block_validator.rb new file mode 100644 index 0000000..69eb05e --- /dev/null +++ b/lib/block_validator.rb @@ -0,0 +1,609 @@ +class BlockValidator + attr_reader :errors, :stats + + # Exception for transient errors that should trigger retries + # This is informational - all exceptions are treated as transient + class TransientValidationError < StandardError; end + + def initialize + # Initialize validation state + reset_validation_state + end + + def validate_l1_block(l1_block_number, l2_block_hashes) + reset_validation_state + validation_start_time = Time.current + + Rails.logger.debug "Validating L1 block #{l1_block_number} with #{l2_block_hashes.size} L2 blocks" + + # Fetch expected data from API + expected = fetch_expected_data(l1_block_number) + + # Get actual data from L2 events + actual_events = aggregate_l2_events(l2_block_hashes) + + # Historical block tag for reads as-of this L1 block's L2 application + # Use EIP-1898 with blockHash for reorg-safety + historical_block_tag = l2_block_hashes.any? ? { blockHash: l2_block_hashes.last } : 'latest' + + # Compare events + compare_events(expected, actual_events, l1_block_number) + + # Verify storage state + verify_storage_state(expected, l1_block_number, historical_block_tag) + + # Build comprehensive result with full debugging data + success = @errors.empty? + validation_duration = Time.current - validation_start_time + + # Return comprehensive debugging information + result = OpenStruct.new( + success: success, + errors: @errors, + l1_block: l1_block_number, + stats: { + # Basic validation stats + expected_creations: Array(expected[:creations]).size, + actual_creations: Array(actual_events[:creations]).size, + expected_transfers: Array(expected[:transfers]).size, + actual_transfers: Array(actual_events[:transfers]).size, + storage_checks: @storage_checks_performed.value, + errors_count: @errors.size, + + # L1 to L2 block mapping + l1_to_l2_mapping: { + l1_block: l1_block_number, + l2_blocks: l2_block_hashes.map.with_index { |hash, i| + { + index: i, + hash: hash, + block_tag: i == l2_block_hashes.size - 1 ? historical_block_tag : { blockHash: hash } + } + } + }, + + # Complete raw data for debugging (sanitized for JSON storage) + raw_expected_data: { + creations: sanitize_for_json(expected[:creations] || []), + transfers: sanitize_for_json(expected[:transfers] || []), + }, + + raw_actual_data: { + creations: sanitize_for_json(actual_events[:creations] || []), + transfers: sanitize_for_json(actual_events[:transfers] || []), + l2_events_source: "geth_block_receipts" + }, + + # Actual comparisons performed during validation (not recreated) + actual_comparisons: @debug_data, + + # Performance and metadata + validation_timing: { + duration_ms: (validation_duration * 1000).round(2), + started_at: validation_start_time.iso8601, + completed_at: Time.current.iso8601 + } + } + ) + + result + end + + private + + def reset_validation_state + @errors = Concurrent::Array.new + @storage_checks_performed = Concurrent::AtomicFixnum.new(0) + + # Reset debugging instrumentation + @debug_data = { + creation_comparisons: [], + transfer_comparisons: [], + storage_checks: [], + event_comparisons: [] + } + end + + def load_genesis_transaction_hashes + # Load genesis ethscriptions from the JSON file + genesis_file = Rails.root.join('contracts', 'script', 'genesisEthscriptions.json') + genesis_data = JSON.parse(File.read(genesis_file)) + + # Extract all ethscription IDs (L1 tx hashes) from the ethscriptions array + genesis_data['ethscriptions'].map { |e| e['transaction_hash'] } + end + + def fetch_expected_data(l1_block_number) + EthscriptionsApiClient.fetch_block_data(l1_block_number) + rescue => e + # Treat any API client failure as transient to avoid false negatives + Rails.logger.warn "Transient API error for block #{l1_block_number}: #{e.class}: #{e.message}" + raise TransientValidationError, e.message + end + + def aggregate_l2_events(block_hashes) + ImportProfiler.start("aggregate_l2_events") + all_creations = [] + all_transfers = [] + + block_hashes.each do |block_hash| + begin + receipts = EthRpcClient.l2.call('eth_getBlockReceipts', [block_hash]) + if receipts.nil? + # Treat missing receipts as transient infrastructure issue + error_msg = "No receipts returned for L2 block #{block_hash}" + Rails.logger.warn "Transient L2 error: #{error_msg}" + raise TransientValidationError, error_msg + end + + data = EventDecoder.decode_block_receipts(receipts) + all_creations.concat(data[:creations]) + all_transfers.concat(data[:transfers]) # Ethscriptions protocol transfers + rescue => e + # Treat any L2 RPC failure as transient to avoid false negatives + error_msg = "Failed to get receipts for block #{block_hash}: #{e.message}" + Rails.logger.warn "Transient L2 error: #{error_msg}" + raise TransientValidationError, error_msg + end + end + + result = { + creations: all_creations, + transfers: all_transfers + } + ImportProfiler.stop("aggregate_l2_events") + result + end + + def compare_events(expected, actual, l1_block_num) + expected_creations = Array(expected[:creations]) + expected_transfers = Array(expected[:transfers]) + + # Calculate the L1 block where L2 block 1 happened (genesis + 1) + l2_block_1_l1_block = Integer(ENV.fetch("L1_GENESIS_BLOCK")) + 1 + + # Special handling for the L1 block where L2 block 1 happened - genesis events are emitted then + if l1_block_num == l2_block_1_l1_block + genesis_hashes = load_genesis_transaction_hashes + Rails.logger.info "L1 Block #{l1_block_num} (L2 block 1): Expecting #{genesis_hashes.size} genesis events in addition to regular events" + + # Add genesis hashes to expected creations for this block + expected_creation_hashes = (expected_creations.map { |c| c[:tx_hash].downcase } + genesis_hashes.map(&:downcase)).to_set + + # Also expect transfers for genesis ethscriptions + # These will be EthscriptionTransferred events from creator to initial owner + # We'll validate them separately since we don't have full transfer data + else + expected_creation_hashes = expected_creations.map { |c| c[:tx_hash].downcase }.to_set + end + + actual_creation_hashes = actual[:creations].map { |c| c[:tx_hash].downcase }.to_set + + # Find missing creations + missing = expected_creation_hashes - actual_creation_hashes + missing.each do |tx_hash| + @errors << "Missing creation event: #{tx_hash} in L1 block #{l1_block_num}" + end + + # Find unexpected creations (but don't warn about genesis events in the L1 block for L2 block 1) + unexpected = actual_creation_hashes - expected_creation_hashes + if l1_block_num != l2_block_1_l1_block + # binding.irb if unexpected.present? + unexpected.each do |tx_hash| + @errors << "Unexpected creation event: #{tx_hash} in L1 block #{l1_block_num}" + end + end + + # Compare creation details for matching transactions + expected_creations.each do |exp_creation| + act_creation = actual[:creations].find { |a| + a[:tx_hash]&.downcase == exp_creation[:tx_hash]&.downcase + } + + next unless act_creation + + if !addresses_match?(exp_creation[:creator], act_creation[:creator]) + @errors << "Creator mismatch for #{exp_creation[:tx_hash]}: expected #{exp_creation[:creator]}, got #{act_creation[:creator]}" + end + + if !addresses_match?(exp_creation[:initial_owner], act_creation[:initial_owner]) + @errors << "Initial owner mismatch for #{exp_creation[:tx_hash]}: expected #{exp_creation[:initial_owner]}, got #{act_creation[:initial_owner]}" + end + end + + # Use protocol transfers for validation (they match Ethscriptions semantics) + # These have the correct 'from' address (creator/owner, not address(0)) + compare_transfers(expected_transfers, actual[:transfers], l1_block_num) + end + + def compare_transfers(expected, actual, l1_block_num) + # Calculate the L1 block where L2 block 1 happened + l2_block_1_l1_block = Integer(ENV.fetch("L1_GENESIS_BLOCK")) + 1 + + # Group transfers by token_id for easier comparison + expected_by_token = expected.group_by { |t| t[:token_id]&.downcase } + actual_by_token = actual.group_by { |t| t[:token_id]&.downcase } + + # Check for missing transfers + (expected_by_token.keys - actual_by_token.keys).each do |token_id| + @errors << "Missing transfer events for token #{token_id} in L1 block #{l1_block_num}" + end + + # Check for unexpected transfers (but be lenient for the L1 block where L2 block 1 happened due to genesis events) + if l1_block_num == l2_block_1_l1_block + # For the L1 block where L2 block 1 happened, we expect genesis transfer events that won't be in the API data + # Just log them as info instead of treating them as errors + (actual_by_token.keys - expected_by_token.keys).each do |token_id| + Rails.logger.info "Genesis transfer event for token #{token_id} (expected for L2 block 1)" + end + else + (actual_by_token.keys - expected_by_token.keys).each do |token_id| + @errors << "Unexpected transfer events for token #{token_id}" + end + end + + # Compare transfer details + expected_by_token.each do |token_id, exp_transfers| + act_transfers = actual_by_token[token_id] || [] + + expected_counts = build_transfer_counts(exp_transfers) + actual_counts = build_transfer_counts(act_transfers) + + expected_counts.each do |signature, expected_count| + actual_count = actual_counts[signature] || 0 + next if actual_count >= expected_count + + missing_count = expected_count - actual_count + example = find_transfer_by_signature(exp_transfers, signature) + @errors << "Missing transfer event(x#{missing_count}) for token #{token_id}: #{transfer_debug_string(example)}" + end + + actual_counts.each do |signature, actual_count| + expected_count = expected_counts[signature] || 0 + next if actual_count <= expected_count + + extra = actual_count - expected_count + example = find_transfer_by_signature(act_transfers, signature) + message = "Unexpected transfer event(x#{extra}) for token #{token_id}: #{transfer_debug_string(example)}" + if l1_block_num == l2_block_1_l1_block + Rails.logger.info "#{message} (allowed for L2 block 1 genesis events)" + else + @errors << message + end + end + end + end + + def build_transfer_counts(transfers) + counts = Hash.new(0) + transfers.each do |transfer| + counts[transfer_signature_basic(transfer)] += 1 + end + counts + end + + def transfer_signature_basic(transfer) + [ + transfer[:token_id]&.downcase, + transfer[:from]&.downcase, + transfer[:to]&.downcase + ] + end + + def find_transfer_by_signature(transfers, signature) + transfers.find { |transfer| transfer_signature_basic(transfer) == signature } + end + + def transfer_debug_string(transfer) + return 'no metadata' unless transfer + + parts = [ + ("from=#{transfer[:from]}" if transfer[:from]), + ("to=#{transfer[:to]}" if transfer[:to]), + ("tx=#{transfer[:tx_hash]}" if transfer[:tx_hash]), + ("tx_index=#{transfer[:transaction_index]}" if transfer[:transaction_index]), + ("log_index=#{transfer[:log_index] || transfer[:event_log_index]}" if transfer[:log_index] || transfer[:event_log_index]) + ].compact + + return 'no metadata' if parts.empty? + + parts.join(', ') + end + + def binary_equal?(val1, val2) + return true if val1.nil? && val2.nil? + return false if val1.nil? || val2.nil? + val1.to_s.b == val2.to_s.b + end + + def verify_storage_state(expected_data, l1_block_num, block_tag) + ImportProfiler.start("storage_verification") + + # Sequentially verify each creation on the main thread + Array(expected_data[:creations]).each do |creation| + verify_ethscription_storage(creation, l1_block_num, block_tag) + end + + # Verify ownership after transfers + verify_transfer_ownership(Array(expected_data[:transfers]), block_tag) + + ImportProfiler.stop("storage_verification") + end + + def verify_ethscription_storage(creation, l1_block_num, block_tag) + tx_hash = creation[:tx_hash] + + # Use get_ethscription_with_content to fetch both metadata and content + begin + stored = StorageReader.get_ethscription_with_content(tx_hash, block_tag: block_tag) + rescue => e + # RPC/network error - treat as transient inability to validate + Rails.logger.warn "Transient storage error for #{tx_hash}: #{e.message}" + raise TransientValidationError, "Storage read failed for #{tx_hash}: #{e.message}" + end + + @storage_checks_performed.increment + + if stored.nil? + # Ethscription genuinely doesn't exist in contract - this is a validation failure + @errors << "Ethscription #{tx_hash} not found in contract storage" + return + end + + # Verify creator (with instrumentation) + creator_match = addresses_match?(stored[:creator], creation[:creator]) + creator_check = record_comparison( + "storage_creator_check", + tx_hash, + creation[:creator], + stored[:creator], + creator_match, + { l1_block: l1_block_num } + ) + + if !creator_match + @errors << "Storage creator mismatch for #{tx_hash}: stored=#{stored[:creator]}, expected=#{creation[:creator]}" + end + + # Verify initial owner (with instrumentation) + initial_owner_match = addresses_match?(stored[:initial_owner], creation[:initial_owner]) + initial_owner_check = record_comparison( + "storage_initial_owner_check", + tx_hash, + creation[:initial_owner], + stored[:initial_owner], + initial_owner_match, + { l1_block: l1_block_num } + ) + + if !initial_owner_match + @errors << "Storage initial_owner mismatch for #{tx_hash}: stored=#{stored[:initial_owner]}, expected=#{creation[:initial_owner]}" + end + + # Verify L1 block number (with instrumentation) + l1_block_match = stored[:l1_block_number] == l1_block_num + l1_block_check = record_comparison( + "storage_l1_block_check", + tx_hash, + l1_block_num, + stored[:l1_block_number], + l1_block_match, + { l1_block: l1_block_num } + ) + + if !l1_block_match + @errors << "Storage L1 block mismatch for #{tx_hash}: stored=#{stored[:l1_block_number]}, expected=#{l1_block_num}" + end + + # Verify content - API client already decoded b64_content to content field (with instrumentation) + if creation[:content] + content_match = stored[:content] == creation[:content] + + # Store first 50 chars for debugging (full comparison still done) + # Handle binary content by encoding as base64 for JSON serialization + expected_preview = safe_content_preview(creation[:content]) + actual_preview = safe_content_preview(stored[:content]) + + content_check = record_comparison( + "storage_content_check", + tx_hash, + expected_preview, + actual_preview, + content_match, + { + l1_block: l1_block_num, + expected_length: creation[:content]&.length, + actual_length: stored[:content]&.length, + b64_content_preview: creation[:b64_content]&.[](0..100) + } + ) + + if !content_match + @errors << "Storage content mismatch for #{tx_hash}: stored length=#{stored[:content]&.length}, expected length=#{creation[:content]&.length}" + end + end + + # Verify content_uri_sha - this is the hash of the original content URI + stored_uri_hash = stored[:content_uri_sha]&.downcase&.delete_prefix('0x') + if creation[:content_uri] + # Hash the content_uri from the API to compare + expected_uri_hash = Digest::SHA256.hexdigest(creation[:content_uri]).downcase + if stored_uri_hash != expected_uri_hash + @errors << "Storage content_uri_sha mismatch for #{tx_hash}: stored=#{stored_uri_hash}, expected=#{expected_uri_hash}" + end + end + + # Verify content_hash - always present in API, must match exactly (with instrumentation) + stored_sha = stored[:content_hash]&.downcase&.delete_prefix('0x') + expected_sha = creation[:content_hash].downcase.delete_prefix('0x') + content_hash_match = stored_sha == expected_sha + content_hash_check = record_comparison( + "storage_content_hash_check", + tx_hash, + expected_sha, + stored_sha, + content_hash_match, + { l1_block: l1_block_num } + ) + + if !content_hash_match + @errors << "Storage content_hash mismatch for #{tx_hash}: stored=#{stored[:content_hash]}, expected=#{creation[:content_hash]}" + end + + # Verify mimetype - normalize to binary for comparison (with instrumentation) + mimetype_match = binary_equal?(stored[:mimetype], creation[:mimetype]) + mimetype_check = record_comparison( + "storage_mimetype_check", + tx_hash, + creation[:mimetype], + stored[:mimetype], + mimetype_match, + { l1_block: l1_block_num } + ) + + if !mimetype_match + @errors << "Storage mimetype mismatch for #{tx_hash}: stored=#{stored[:mimetype]}, expected=#{creation[:mimetype]}" + end + + # Note: media_type and mime_subtype are no longer stored in the contract + # They are derived from mimetype in StorageReader for backward compatibility + + # Verify esip6 flag - must match exactly (with instrumentation) + esip6_match = stored[:esip6] == creation[:esip6] + record_comparison("storage_esip6_check", tx_hash, creation[:esip6], stored[:esip6], esip6_match, { l1_block: l1_block_num }) + if !esip6_match + @errors << "Storage esip6 mismatch for #{tx_hash}: stored=#{stored[:esip6]}, expected=#{creation[:esip6]}" + end + end + + def verify_transfer_ownership(transfers, block_tag) + # Replay transfers in deterministic order to determine the true final owner + final_owners = {} + + sorted_transfers = Array(transfers).each_with_index.sort_by do |transfer, original_index| + block_number = transfer[:block_number] + transaction_index = transfer[:transaction_index] + + if block_number.nil? || transaction_index.nil? + raise "Transfer missing ordering metadata: #{transfer.inspect}" + end + + log_index = transfer[:event_log_index] || transfer[:log_index] + + [ + block_number.to_i, + transaction_index.to_i, + log_index.nil? ? -1 : log_index.to_i, # calldata transfers (no log) happen before events + original_index + ] + end.map { |transfer, _| transfer } + + sorted_transfers.each do |transfer| + token_id = transfer[:token_id] + to_address = transfer[:to] + + if token_id.blank? || to_address.blank? + raise "Transfer missing token_id or recipient: #{transfer.inspect}" + end + + final_owners[token_id.downcase] = to_address.downcase + end + + # Verify each token's final owner + final_owners.each do |token_id, expected_owner| + # First check if the ethscription exists in storage + begin + ethscription = StorageReader.get_ethscription(token_id, block_tag: block_tag) + rescue => e + # RPC/network error - treat as transient inability to validate + Rails.logger.warn "Transient storage error for #{token_id}: #{e.message}" + raise TransientValidationError, "Storage read failed for #{token_id}: #{e.message}" + end + + if ethscription.nil? + # Token genuinely doesn't exist - this is a validation failure + @errors << "Token #{token_id} not found in storage" + next + end + + begin + actual_owner = StorageReader.get_owner(token_id, block_tag: block_tag) + rescue => e + # RPC/network error - treat as transient inability to validate + Rails.logger.warn "Transient owner read error for #{token_id}: #{e.message}" + raise TransientValidationError, "Owner read failed for #{token_id}: #{e.message}" + end + + @storage_checks_performed.increment + + if actual_owner.nil? + # Owner doesn't exist (shouldn't happen if ethscription exists) - validation failure + @errors << "Could not verify owner of token #{token_id}" + next + end + + unless addresses_match?(actual_owner, expected_owner) + @errors << "Ownership mismatch for token #{token_id}: stored=#{actual_owner}, expected=#{expected_owner}" + end + end + end + + def addresses_match?(addr1, addr2) + return false if addr1.nil? || addr2.nil? + addr1.downcase == addr2.downcase + end + + # Instrumentation helper to record comparison results + def record_comparison(type, identifier, expected, actual, match_result, extra_data = {}) + comparison = { + type: type, + identifier: identifier, + expected: expected, + actual: actual, + match: match_result, + timestamp: Time.current.iso8601 + }.merge(extra_data) + + case type + when /creation/ + @debug_data[:creation_comparisons] << comparison + when /transfer/ + @debug_data[:transfer_comparisons] << comparison + when /storage/ + @debug_data[:storage_checks] << comparison + else + @debug_data[:event_comparisons] << comparison + end + + comparison + end + + # Safely create content preview for JSON serialization + def safe_content_preview(content, length: 50) + return "" if content.nil? + + # Use inspect to safely handle any encoding/binary data + preview = content[0..length].inspect + preview + (content.length > length ? "..." : "") + end + + + # Sanitize data structures for JSON serialization + def sanitize_for_json(data) + case data + when Array + data.map { |item| sanitize_for_json(item) } + when Hash + data.transform_values { |value| sanitize_for_json(value) } + when String + # Only use inspect if string is not safe UTF-8 + if data.valid_encoding? && (data.encoding == Encoding::UTF_8 || data.ascii_only?) + data # Safe to store as-is + else + data.inspect # Binary or invalid encoding - use inspect for safety + end + else + data + end + end +end diff --git a/lib/byte_string.rb b/lib/byte_string.rb new file mode 100644 index 0000000..9b440b9 --- /dev/null +++ b/lib/byte_string.rb @@ -0,0 +1,130 @@ +class ByteString + class InvalidByteLength < StandardError; end + + sig { params(bin: String).void } + def initialize(bin) + validate_bin!(bin) + @bytes = bin.dup.freeze + end + + sig { params(hex: String).returns(ByteString) } + def self.from_hex(hex) + bin = hex_to_bin(hex) + enforce_length!(bin) + new(bin) + end + + sig { params(bin: String).returns(ByteString) } + def self.from_bin(bin) + enforce_length!(bin) + new(bin) + end + + sig { void } + def to_s + raise "to_s not implemented for #{self.class}" + end + + sig { void } + def to_json + raise "to_json not implemented for #{self.class}" + end + + sig { returns(String) } + def to_hex + "0x" + @bytes.unpack1('H*') + end + + sig { returns(String) } + def to_bin + @bytes + end + + sig { params(other: T.untyped).returns(T::Boolean) } + def ==(other) + unless other.is_a?(ByteString) + raise ArgumentError, "can't compare #{other.class} with #{self.class}" + end + + other.to_bin == @bytes + end + alias eql? == + + sig { returns(Integer) } + def hash + @bytes.hash + end + + sig { returns(String) } + def inspect + "#<#{self.class} len=#{@bytes.bytesize} hex=#{to_hex}>" + end + + # subclasses can override to return an Integer byte-length to enforce + sig { returns(T.nilable(Integer)) } + def self.required_byte_length + nil + end + + sig { params(obj: T.untyped).returns(T.untyped) } + def self.deep_hexify(obj) + case obj + when ByteString + obj.to_hex + when Array + obj.map { |v| deep_hexify(v) } + when Hash + obj.transform_values { |v| deep_hexify(v) } + else + obj + end + end + + sig { returns(ByteString) } + def keccak256 + ByteString.from_bin(Eth::Util.keccak256(self.to_bin)) + end + + def bytesize + @bytes.bytesize + end + + private + + sig { params(bin: String).void } + def validate_bin!(bin) + unless bin.is_a?(String) && bin.encoding == Encoding::ASCII_8BIT + raise ArgumentError, 'binary string with ASCII-8BIT encoding required' + end + self.class.enforce_length!(bin) + end + + sig { params(hex: String).returns(String) } + def self.hex_to_bin(hex) + unless hex.start_with?('0x') + raise ArgumentError, 'hex string must start with 0x' + end + + cleaned = hex[2..] + + unless cleaned.match?(/\A[0-9a-fA-F]*\z/) + raise ArgumentError, "invalid hex string: #{hex}" + end + unless cleaned.length.even? + raise ArgumentError, "hex string length must be even: #{hex}" + end + [cleaned].pack('H*') + end + + sig { params(bin: String).void } + def self.enforce_length!(bin) + len = required_byte_length + return if len.nil? + unless bin.bytesize == len + raise InvalidByteLength, "#{name} expects #{len} bytes, got #{bin.bytesize}" + end + end + + sig { returns(String) } + attr_reader :bytes +end diff --git a/lib/chain_id_manager.rb b/lib/chain_id_manager.rb new file mode 100644 index 0000000..771cb16 --- /dev/null +++ b/lib/chain_id_manager.rb @@ -0,0 +1,67 @@ +module ChainIdManager + extend self + include Memery + + MAINNET_CHAIN_ID = 1 + SEPOLIA_CHAIN_ID = 11155111 + + ETHSCRIPTIONS_MAINNET_CHAIN_ID = 0xeeee + ETHSCRIPTIONS_SEPOLIA_CHAIN_ID = 0xeeeea + + def current_l2_chain_id + candidate = l2_chain_id_from_l1_network_name(current_l1_network) + + according_to_geth = GethDriver.client.call('eth_chainId').to_i(16) + + unless according_to_geth == candidate + raise "Invalid L2 chain ID: #{candidate} (according to geth: #{according_to_geth})" + end + + candidate + end + memoize :current_l2_chain_id + + def l2_chain_id_from_l1_network_name(l1_network_name) + case l1_network_name + when 'mainnet' + ETHSCRIPTIONS_MAINNET_CHAIN_ID + when 'sepolia' + ETHSCRIPTIONS_SEPOLIA_CHAIN_ID + else + raise "Unknown L1 network name: #{l1_network_name}" + end + end + + def on_sepolia? + current_l1_network == 'sepolia' + end + + def current_l1_network + l1_network = ENV.fetch('L1_NETWORK') + + unless ['sepolia', 'mainnet'].include?(l1_network) + raise "Invalid L1 network: #{l1_network}" + end + + l1_network + end + + def current_l1_chain_id + case current_l1_network + when 'sepolia' + SEPOLIA_CHAIN_ID + when 'mainnet' + MAINNET_CHAIN_ID + else + raise "Unknown L1 network: #{current_l1_network}" + end + end + + def on_mainnet? + current_l1_network == 'mainnet' + end + + def on_testnet? + !on_mainnet? + end +end diff --git a/lib/collections_reader.rb b/lib/collections_reader.rb new file mode 100644 index 0000000..db89605 --- /dev/null +++ b/lib/collections_reader.rb @@ -0,0 +1,219 @@ +class CollectionsReader + COLLECTIONS_MANAGER_ADDRESS = '0x3300000000000000000000000000000000000006' + ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + + # Define struct ABIs matching CollectionsManager.sol + # Contract ABI for functions we need + CONTRACT_ABI = [ + { + 'name' => 'getCollection', + 'type' => 'function', + 'stateMutability' => 'view', + 'inputs' => [ + { 'name' => 'collectionId', 'type' => 'bytes32' } + ], + 'outputs' => [ + [ + { 'name' => 'collectionContract', 'type' => 'address' }, + { 'name' => 'locked', 'type' => 'bool' }, + { 'name' => 'name', 'type' => 'string' }, + { 'name' => 'symbol', 'type' => 'string' }, + { 'name' => 'maxSupply', 'type' => 'uint256' }, + { 'name' => 'description', 'type' => 'string' }, + { 'name' => 'logoImageUri', 'type' => 'string' }, + { 'name' => 'bannerImageUri', 'type' => 'string' }, + { 'name' => 'backgroundColor', 'type' => 'string' }, + { 'name' => 'websiteLink', 'type' => 'string' }, + { 'name' => 'twitterLink', 'type' => 'string' }, + { 'name' => 'discordLink', 'type' => 'string' }, + { 'name' => 'merkleRoot', 'type' => 'bytes32' } + ] + ] + } + ] + + def self.get_collection_state(collection_id, block_tag: 'latest') + # Encode the function call + input_types = ['bytes32'] + + # Encode parameters + encoded_params = Eth::Abi.encode(input_types, [normalize_bytes32(collection_id)]) + # Use the actual function name from the contract + data = fetch_collection(collection_id, block_tag) + return nil if data.nil? + collection_contract = data[:collectionContract] + locked = data[:locked] + current_size = 0 + if collection_contract && collection_contract != ZERO_ADDRESS + current_size = get_collection_supply(collection_contract, block_tag: block_tag) + end + + { + collectionContract: collection_contract, + createTxHash: format_bytes32_hex(collection_id), + currentSize: current_size, + locked: locked + } + end + + def self.get_collection_metadata(collection_id, block_tag: 'latest') + data = fetch_collection(collection_id, block_tag) + return nil if data.nil? + + { + name: data[:name], + symbol: data[:symbol], + maxSupply: data[:maxSupply], + totalSupply: data[:maxSupply], + description: data[:description], + logoImageUri: data[:logoImageUri], + bannerImageUri: data[:bannerImageUri], + backgroundColor: data[:backgroundColor], + websiteLink: data[:websiteLink], + twitterLink: data[:twitterLink], + discordLink: data[:discordLink], + merkleRoot: data[:merkleRoot] + } + end + + def self.fetch_collection(collection_id, block_tag) + input_types = ['bytes32'] + encoded_params = Eth::Abi.encode(input_types, [normalize_bytes32(collection_id)]) + function_selector = Eth::Util.keccak256('getCollection(bytes32)')[0..3] + data = (function_selector + encoded_params).unpack1('H*') + data = '0x' + data + + # Make the call + result = EthRpcClient.l2.eth_call( + to: COLLECTIONS_MANAGER_ADDRESS, + data: data, + block_number: block_tag + ) + + return nil if result == '0x' || result.nil? + + output_types = ['(address,bool,string,string,uint256,string,string,string,string,string,string,string,bytes32)'] + decoded = Eth::Abi.decode(output_types, [result.delete_prefix('0x')].pack('H*')) + tuple = decoded[0] + + { + collectionContract: tuple[0], + locked: tuple[1], + name: tuple[2], + symbol: tuple[3], + maxSupply: tuple[4], + description: tuple[5], + logoImageUri: tuple[6], + bannerImageUri: tuple[7], + backgroundColor: tuple[8], + websiteLink: tuple[9], + twitterLink: tuple[10], + discordLink: tuple[11], + merkleRoot: '0x' + tuple[12].unpack1('H*') + } + end + + def self.collection_exists?(collection_id, block_tag: 'latest') + state = get_collection_state(collection_id, block_tag: block_tag) + return false if state.nil? + + # Collection exists if collectionContract is not zero address + state[:collectionContract] != '0x0000000000000000000000000000000000000000' + end + + def self.get_collection_item(collection_id, item_index, block_tag: 'latest') + # Encode function call for getCollectionItem(bytes32,uint256) + input_types = ['bytes32', 'uint256'] + encoded_params = Eth::Abi.encode(input_types, [normalize_bytes32(collection_id), item_index]) + function_selector = Eth::Util.keccak256('getCollectionItem(bytes32,uint256)')[0..3] + data = (function_selector + encoded_params).unpack1('H*') + data = '0x' + data + + # Make the call + result = EthRpcClient.l2.eth_call( + to: COLLECTIONS_MANAGER_ADDRESS, + data: data, + block_number: block_tag + ) + + return nil if result == '0x' || result.nil? + + # Decode the ItemData struct + # ItemData: (uint256,string,bytes32,string,string,Attribute[]) + output_types = ['(uint256,string,bytes32,string,string,(string,string)[])'] + decoded = Eth::Abi.decode(output_types, [result.delete_prefix('0x')].pack('H*')) + item_tuple = decoded[0] + + { + itemIndex: item_tuple[0], + name: item_tuple[1], + ethscriptionId: '0x' + item_tuple[2].unpack1('H*'), + backgroundColor: item_tuple[3], + description: item_tuple[4], + attributes: item_tuple[5] # Array of [trait_type, value] tuples + } + rescue => e + Rails.logger.error "Failed to get item #{item_index} from collection #{collection_id}: #{e.message}" + nil + end + + def self.get_collection_owner(collection_id, block_tag: 'latest') + # Get collection state first to get the contract address + state = get_collection_state(collection_id, block_tag: block_tag) + return nil if state.nil? || state[:collectionContract] == '0x0000000000000000000000000000000000000000' + + # Call owner() on the collection contract + function_selector = Eth::Util.keccak256('owner()')[0..3] + data = '0x' + function_selector.unpack1('H*') + + # Make the call to the collection contract + result = EthRpcClient.l2.eth_call( + to: state[:collectionContract], + data: data, + block_number: block_tag + ) + + return nil if result == '0x' || result.nil? + + # Decode the owner address + decoded = Eth::Abi.decode(['address'], [result.delete_prefix('0x')].pack('H*')) + decoded[0] + rescue => e + Rails.logger.error "Failed to get owner for collection #{collection_id}: #{e.message}" + nil + end + + private + + def self.normalize_bytes32(value) + # Ensure value is a 32-byte hex string + hex = value.to_s.delete_prefix('0x') + hex = hex.rjust(64, '0') if hex.length < 64 + [hex].pack('H*') + end + + def self.format_bytes32_hex(value) + hex = value.to_s.delete_prefix('0x') + hex = hex.rjust(64, '0')[0,64] + '0x' + hex.downcase + end + + def self.get_collection_supply(collection_contract, block_tag: 'latest') + function_selector = Eth::Util.keccak256('totalSupply()')[0..3] + data = '0x' + function_selector.unpack1('H*') + + result = EthRpcClient.l2.eth_call( + to: collection_contract, + data: data, + block_number: block_tag + ) + + return 0 if result == '0x' || result.nil? + + decoded = Eth::Abi.decode(['uint256'], [result.delete_prefix('0x')].pack('H*')) + decoded[0] + rescue => e + Rails.logger.error "Failed to get supply for collection #{collection_contract}: #{e.message}" + 0 + end +end diff --git a/lib/data_validation_helper.rb b/lib/data_validation_helper.rb deleted file mode 100644 index a21798e..0000000 --- a/lib/data_validation_helper.rb +++ /dev/null @@ -1,151 +0,0 @@ -module DataValidationHelper - def self.validate_transfers(old_db) - field_mapping = { - transaction_hash: "transaction_hash", - from_address: "from", - to_address: "to", - block_number: "block_number", - } - - ActiveRecord::Base.establish_connection( - adapter: 'postgresql', - host: 'localhost', - database: old_db, - username: `whoami`.chomp, - password: '' - ) - - remote_records = OldEthscription.where("(fixed_content_unique = true OR esip6 = true) AND fixed_valid_data_uri = true").select(:transaction_hash).includes(:ethscription_transfers).to_a - - ActiveRecord::Base.establish_connection - - local_transfers = EthscriptionTransfer.all.select(*field_mapping.keys).index_by(&:transaction_hash) - - remote_records.each do |ethscription| - transfers = ethscription.valid_transfers - - transfers.each do |transfer| - local_transfer = local_transfers[transfer.transaction_hash] - - checks = field_mapping.each_with_object({}) do |(local_field, remote_field), checks| - checks[local_field.to_s] = local_transfer.send(local_field) == transfer.send(remote_field) - end - - failed_checks = checks.select { |_, result| !result } - - if failed_checks.any? - binding.pry - puts "Checks failed for transaction_hash #{local_transfer.transaction_hash}: #{failed_checks.keys.join(', ')}" - end - end - end - nil - ensure - ActiveRecord::Base.establish_connection - end - - def self.bulk_validate(old_db) - field_mapping = { - transaction_hash: "transaction_hash", - current_owner: "current_owner", - creator: "creator", - initial_owner: "initial_owner", - previous_owner: "previous_owner", - content_uri: "content_uri_unicode_fixed", - content_sha: "fixed_sha", - ethscription_number: "ethscription_number" - } - - ActiveRecord::Base.establish_connection( - adapter: 'postgresql', - host: 'localhost', - database: old_db, - username: `whoami`.chomp, - password: '' - ) - - remote_records = Ethscription.select(field_mapping.values).all.index_by(&:transaction_hash) - - ActiveRecord::Base.establish_connection - - local_records = Ethscription.all.select(field_mapping.keys) - - local_records.each do |local_record| - remote_record = remote_records[local_record.transaction_hash] - - checks = field_mapping.each_with_object({}) do |(local_field, remote_field), checks| - if local_field == :previous_owner - checks[local_field.to_s] = remote_record.send(remote_field).nil? || local_record.send(local_field) == remote_record.send(remote_field) - elsif local_field == :content_sha - checks[local_field.to_s] = local_record.send(local_field) == "0x" + remote_record.send(remote_field) - elsif local_field == :ethscription_number - checks[local_field.to_s] = remote_record.send(remote_field).nil? || local_record.send(local_field) == remote_record.send(remote_field) - else - checks[local_field.to_s] = local_record.send(local_field) == remote_record.send(remote_field) - end - end - - failed_checks = checks.select { |_, result| !result } - - if failed_checks.any? - binding.pry - puts "Checks failed for transaction_hash #{local_record.transaction_hash}: #{failed_checks.keys.join(', ')}" - end - end - - nil - ensure - ActiveRecord::Base.establish_connection - end - - def validate_with_old_indexer - url = "https://api.ethscriptions.com/api/ethscriptions/#{transaction_hash}" - response = HTTParty.get(url).parsed_response - - return true if response['image_removed_by_request_of_rights_holder'] - - checks = { - 'current_owner' => current_owner == response['current_owner'], - 'creator' => creator == response['creator'], - 'initial_owner' => initial_owner == response['initial_owner'], - 'previous_owner' => (!response['previous_owner'] || previous_owner == response['previous_owner']), - 'content_uri' => content_uri == response['content_uri'], - 'content_sha' => content_sha == "0x" + response['sha'], - 'ethscription_number' => ethscription_number == response['ethscription_number'] - } - - failed_checks = checks.select { |_, result| !result } - - if failed_checks.any? - puts "Checks failed for transaction_hash #{transaction_hash}: #{failed_checks.keys.join(', ')}" - return false - end - - true - end - - class OldEthscription < ApplicationRecord - self.table_name = "ethscriptions" - has_many :ethscription_transfers, - primary_key: 'id', - foreign_key: 'ethscription_id' - - def valid_transfers - sorted = ethscription_transfers.sort_by do |transfer| - [transfer.block_number, transfer.transaction_index, transfer.event_log_index] - end - - sorted.each.with_object([]) do |transfer, valid| - basic_rule_passes = valid.empty? || - transfer.from == valid.last.to - - previous_owner_rule_passes = transfer.enforced_previous_owner.nil? || - transfer.enforced_previous_owner == valid.last&.from - - if basic_rule_passes && previous_owner_rule_passes - valid << transfer - end - end - end - end -end diff --git a/lib/digest/keccak256.rb b/lib/digest/keccak256.rb deleted file mode 100644 index e1a1ca6..0000000 --- a/lib/digest/keccak256.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Digest::Keccak256 - def self.hexdigest(input) - Eth::Util.bin_to_hex(bindigest(input)) - end - - def self.bindigest(input) - Eth::Util.keccak256(input) - end -end diff --git a/lib/erc20_fixed_denomination_reader.rb b/lib/erc20_fixed_denomination_reader.rb new file mode 100644 index 0000000..2d767ed --- /dev/null +++ b/lib/erc20_fixed_denomination_reader.rb @@ -0,0 +1,160 @@ +class Erc20FixedDenominationReader + ERC20_FIXED_DENOMINATION_MANAGER_ADDRESS = '0x3300000000000000000000000000000000000002' + + # Token struct returned by ERC20FixedDenominationManager + TOKEN_STRUCT = { + 'components' => [ + { 'name' => 'tick', 'type' => 'string' }, + { 'name' => 'maxSupply', 'type' => 'uint256' }, + { 'name' => 'mintAmount', 'type' => 'uint256' }, + { 'name' => 'totalMinted', 'type' => 'uint256' }, + { 'name' => 'deployer', 'type' => 'address' }, + { 'name' => 'tokenContract', 'type' => 'address' }, + { 'name' => 'ethscriptionId', 'type' => 'bytes32' } + ], + 'type' => 'tuple' + } + + # Mint record struct + MINT_STRUCT = { + 'components' => [ + { 'name' => 'amount', 'type' => 'uint256' }, + { 'name' => 'minter', 'type' => 'address' }, + { 'name' => 'ethscriptionId', 'type' => 'bytes32' }, + { 'name' => 'currentOwner', 'type' => 'address' } + ], + 'type' => 'tuple' + } + + def self.get_token(tick, block_tag: 'latest') + # Encode function call for getTokenInfoByTick(string) + input_types = ['string'] + encoded_params = Eth::Abi.encode(input_types, [tick]) + function_selector = Eth::Util.keccak256('getTokenInfoByTick(string)')[0..3] + data = (function_selector + encoded_params).unpack1('H*') + data = '0x' + data + + # Make the call + result = EthRpcClient.l2.eth_call( + to: ERC20_FIXED_DENOMINATION_MANAGER_ADDRESS, + data: data, + block_number: block_tag + ) + + return nil if result == '0x' || result.nil? + + # Decode the result - TokenInfo struct + # struct TokenInfo { + # address tokenContract; + # bytes32 deployTxHash; + # string tick; + # uint256 maxSupply; + # uint256 mintAmount; + # uint256 totalMinted; + # } + output_types = ['(address,bytes32,string,uint256,uint256,uint256)'] + decoded = Eth::Abi.decode(output_types, [result.delete_prefix('0x')].pack('H*')) + token_tuple = decoded[0] + + { + tokenContract: token_tuple[0], + deployTxHash: '0x' + token_tuple[1].unpack1('H*'), + protocol: 'erc-20-fixed-denomination', + tick: token_tuple[2], + maxSupply: token_tuple[3], + mintLimit: token_tuple[4], # mintAmount field is used as mintLimit + totalMinted: token_tuple[5], + # For backwards compatibility, add deployer field (not available in TokenInfo) + deployer: nil, + ethscriptionId: '0x' + token_tuple[1].unpack1('H*') # deployTxHash is the ethscriptionId + } + rescue => e + Rails.logger.error "Failed to get token #{tick}: #{e.message}" + nil + end + + def self.token_exists?(tick, block_tag: 'latest') + token = get_token(tick, block_tag: block_tag) + return false if token.nil? + + # Token exists if tokenContract is not zero address + token[:tokenContract] != '0x0000000000000000000000000000000000000000' + end + + # Note: ERC20FixedDenominationManager doesn't track mints by tick+id, it tracks token items by ethscription hash + # This method is kept for compatibility but may need redesign + def self.get_token_item(ethscription_tx_hash, block_tag: 'latest') + # Encode function call for getTokenItem(bytes32) + input_types = ['bytes32'] + + # Normalize the ethscription hash to bytes32 + hash_hex = ethscription_tx_hash.to_s.delete_prefix('0x') + hash_hex = hash_hex.rjust(64, '0') if hash_hex.length < 64 + hash_bytes = [hash_hex].pack('H*') + + encoded_params = Eth::Abi.encode(input_types, [hash_bytes]) + function_selector = Eth::Util.keccak256('getTokenItem(bytes32)')[0..3] + data = (function_selector + encoded_params).unpack1('H*') + data = '0x' + data + + # Make the call + result = EthRpcClient.l2.eth_call( + to: ERC20_FIXED_DENOMINATION_MANAGER_ADDRESS, + data: data, + block_number: block_tag + ) + + return nil if result == '0x' || result.nil? + + # Decode the result - TokenItem struct + # struct TokenItem { + # bytes32 deployTxHash; // Which token this ethscription belongs to + # uint256 amount; // How many tokens this ethscription represents + # } + output_types = ['(bytes32,uint256)'] + decoded = Eth::Abi.decode(output_types, [result.delete_prefix('0x')].pack('H*')) + item_tuple = decoded[0] + + { + deployTxHash: '0x' + item_tuple[0].unpack1('H*'), + amount: item_tuple[1] + } + rescue => e + Rails.logger.error "Failed to get token item #{ethscription_tx_hash}: #{e.message}" + nil + end + + def self.mint_exists?(tick, mint_id, block_tag: 'latest') + # ERC20FixedDenominationManager doesn't track mints by tick+id + false + end + + def self.get_token_balance(tick, address, block_tag: 'latest') + token = get_token(tick, block_tag: block_tag) + return 0 if token.nil? || token[:tokenContract] == '0x0000000000000000000000000000000000000000' + + # Call balanceOf on the ERC20 token contract + input_types = ['address'] + encoded_params = Eth::Abi.encode(input_types, [address]) + function_selector = Eth::Util.keccak256('balanceOf(address)')[0..3] + data = (function_selector + encoded_params).unpack1('H*') + data = '0x' + data + + # Make the call to the token contract + result = EthRpcClient.l2.eth_call( + to: token[:tokenContract], + data: data, + block_number: block_tag + ) + + return 0 if result == '0x' || result.nil? + + # Decode the balance + decoded = Eth::Abi.decode(['uint256'], [result.delete_prefix('0x')].pack('H*')) + # Convert from 18 decimals to user units (divide by 10^18) + decoded[0] / (10**18) + rescue => e + Rails.logger.error "Failed to get balance for #{address} in token #{tick}: #{e.message}" + 0 + end +end diff --git a/lib/eth_rpc_client.rb b/lib/eth_rpc_client.rb new file mode 100644 index 0000000..bb9e7e2 --- /dev/null +++ b/lib/eth_rpc_client.rb @@ -0,0 +1,341 @@ +class EthRpcClient + include Memery + + class HttpError < StandardError + attr_reader :code, :http_message + + def initialize(code, http_message) + @code = code + @http_message = http_message + super("HTTP error: #{code} #{http_message}") + end + end + class ApiError < StandardError; end + class ExecutionRevertedError < StandardError; end + class MethodRequiredError < StandardError; end + attr_accessor :base_url, :http + + def initialize(base_url = ENV['L1_RPC_URL'], jwt_secret: nil, retry_config: {}) + self.base_url = base_url + @request_id = 0 + @mutex = Mutex.new + + # JWT support (optional, only for HTTP) + @jwt_secret = jwt_secret + @jwt_enabled = !jwt_secret.nil? + + if @jwt_enabled + @jwt_secret_decoded = ByteString.from_hex(jwt_secret).to_bin + end + + # Customizable retry configuration + @retry_config = { + tries: 7, + base_interval: 1, + max_interval: 32, + multiplier: 2, + rand_factor: 0.4 + }.merge(retry_config) + + # Detect transport mode + @mode = detect_mode(base_url) + + if @mode == :ipc + @ipc_path = base_url.start_with?("ipc://") ? base_url.delete_prefix("ipc://") : base_url + @ipc_socket = nil + @ipc_mutex = Mutex.new + Rails.logger.info "EthRpcClient using IPC at: #{@ipc_path}" + else + @uri = URI(base_url) + @http = Net::HTTP::Persistent.new( + name: "eth_rpc_#{@uri.host}:#{@uri.port}", + pool_size: 100 # Increase pool size from default 64 + ) + @http.open_timeout = 10 # 10 seconds to establish connection + @http.read_timeout = 30 # 30 seconds for slow eth_call operations + @http.idle_timeout = 30 # Keep connections alive for 30 seconds + end + end + + def self.l1 + @_l1_client ||= new(ENV.fetch('L1_RPC_URL')) + end + + def self.l2 + @_l2_client ||= new(ENV.fetch('NON_AUTH_GETH_RPC_URL')) + end + + def self.l2_engine + @_l2_engine_client ||= new( + ENV.fetch('GETH_RPC_URL'), + jwt_secret: ENV.fetch('JWT_SECRET'), + retry_config: { tries: 5, base_interval: 0.5, max_interval: 4 } + ) + end + + def get_block(block_number, include_txs = false) + if block_number.is_a?(String) + return query_api( + method: 'eth_getBlockByNumber', + params: [block_number, include_txs] + ) + end + + query_api( + method: 'eth_getBlockByNumber', + params: ['0x' + block_number.to_s(16), include_txs] + ) + end + + def get_nonce(address, block_number = "latest") + query_api( + method: 'eth_getTransactionCount', + params: [address, block_number] + ).to_i(16) + end + + def get_chain_id + query_api(method: 'eth_chainId').to_i(16) + end + + def trace_block(block_number) + query_api( + method: 'debug_traceBlockByNumber', + params: ['0x' + block_number.to_s(16), { tracer: "callTracer", timeout: "10s" }] + ) + end + + def trace_transaction(transaction_hash) + query_api( + method: 'debug_traceTransaction', + params: [transaction_hash, { tracer: "callTracer", timeout: "10s" }] + ) + end + + def trace(tx_hash) + trace_transaction(tx_hash) + end + + def get_transaction(transaction_hash) + query_api( + method: 'eth_getTransactionByHash', + params: [transaction_hash] + ) + end + + def get_transaction_receipts(block_number) + if block_number.is_a?(String) + return query_api( + method: 'eth_getBlockReceipts', + params: [block_number] + ) + end + + query_api( + method: 'eth_getBlockReceipts', + params: ["0x" + block_number.to_s(16)] + ) + end + + def get_block_receipts(block_number) + get_transaction_receipts(block_number) + end + + def get_transaction_receipt(transaction_hash) + query_api( + method: 'eth_getTransactionReceipt', + params: [transaction_hash] + ) + end + + def get_block_number + query_api(method: 'eth_blockNumber').to_i(16) + end + + def query_api(method = nil, params = [], **kwargs) + if kwargs.present? + method = kwargs[:method] + params = kwargs[:params] + end + + unless method + raise MethodRequiredError, "Method is required" + end + + data = { + id: next_request_id, + jsonrpc: "2.0", + method: method, + params: params + } + + # Unified retry logic for both HTTP and IPC + Retriable.retriable( + tries: @retry_config[:tries], + base_interval: @retry_config[:base_interval], + max_interval: @retry_config[:max_interval], + multiplier: @retry_config[:multiplier], + rand_factor: @retry_config[:rand_factor], + on: [Net::ReadTimeout, Net::OpenTimeout, HttpError, ApiError, Errno::EPIPE, EOFError, Errno::ECONNREFUSED], + on_retry: ->(exception, try, elapsed_time, next_interval) { + Rails.logger.info "Retrying #{method} (attempt #{try}, next delay: #{next_interval.round(2)}s) - #{exception.message}" + # Reset IPC connection on retry if it's broken + if @mode == :ipc && [Errno::EPIPE, EOFError, ApiError].include?(exception.class) + @ipc_mutex.synchronize do + ensure_ipc_connected!(force: true) + end + end + } + ) do + if @mode == :ipc + send_ipc_request_simple(data) + else + send_http_request_simple(data) + end + end + rescue Errno::EACCES, Errno::EPERM => e + # Permission errors should not be retried - fail immediately with clear message + raise "IPC socket permission denied at #{@ipc_path}: #{e.message}. Check socket permissions (chmod 666) or use HTTP instead." + rescue ApiError => e + # Engine API methods not available on IPC - fail with clear message + if e.message.include?("Method not found") && method.start_with?("engine_") + raise "Engine API method '#{method}' not available on IPC. Use authenticated HTTP endpoint instead." + else + raise + end + end + + def send_ipc_request_simple(data) + @ipc_mutex.synchronize do + ensure_ipc_connected! + + request_body = data.to_json + + ImportProfiler.start("send_ipc_request") + @ipc_socket.write(request_body) + @ipc_socket.write("\n") + @ipc_socket.flush + # Wait for response with timeout to prevent hanging if geth dies + timeout = 10 # seconds + readable = IO.select([@ipc_socket], nil, nil, timeout) + + if readable.nil? + # Timeout occurred - force reconnection on retry + ensure_ipc_connected!(force: true) + raise ApiError, "IPC response timeout after #{timeout} seconds" + end + + response_raw = @ipc_socket.gets # single JSON object per line + ImportProfiler.stop("send_ipc_request") + raise ApiError, "empty IPC response" unless response_raw + + parse_response_and_handle_errors(response_raw) + end + end + + def ensure_ipc_connected!(force: false) + if force && @ipc_socket + @ipc_socket.close unless @ipc_socket.closed? + @ipc_socket = nil + end + + return if @ipc_socket && !@ipc_socket.closed? + @ipc_socket = UNIXSocket.new(@ipc_path) + end + + def send_http_request_simple(data) + url = base_url + uri = URI(url) + request = Net::HTTP::Post.new(uri) + request.body = data.to_json + headers.each { |key, value| request[key] = value } + + response = @http.request(uri, request) + + if response.code.to_i != 200 + raise HttpError.new(response.code.to_i, response.message) + end + + parse_response_and_handle_errors(response.body) + end + + def call(method, params = []) + query_api(method: method, params: params) + end + + def eth_call(to:, data:, block_number: "latest") + query_api( + method: 'eth_call', + params: [{ to: to, data: data }, block_number] + ) + end + + def headers + h = { + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + } + # Add JWT authorization if enabled + h['Authorization'] = "Bearer #{jwt}" if @jwt_enabled && @mode == :http + h + end + + def jwt + return nil unless @jwt_enabled + JWT.encode({ iat: Time.now.to_i }, @jwt_secret_decoded, 'HS256') + end + memoize :jwt, ttl: 55 # 55 seconds to refresh before 60 second expiry + + def get_code(address, block_number = "latest") + query_api( + method: 'eth_getCode', + params: [address, block_number] + ) + end + + def get_storage_at(address, slot, block_number = "latest") + query_api( + method: 'eth_getStorageAt', + params: [address, slot, block_number] + ) + end + + private + + def parse_response_and_handle_errors(response_text) + parsed_response = JSON.parse(response_text, max_nesting: false) + + if parsed_response['error'] + error_message = parsed_response.dig('error', 'message') || 'Unknown API error' + + # Don't retry execution reverted errors as they're deterministic failures + if error_message.include?('execution reverted') + raise ExecutionRevertedError, "API error: #{error_message}" + end + + raise ApiError, "API error: #{error_message}" + end + + parsed_response['result'] + end + + def detect_mode(url) + begin + uri = URI.parse(url) + return :http if %w[http https].include?(uri.scheme) + rescue URI::InvalidURIError + # Not a valid URI, might be IPC path + end + + # Check if it's an IPC path + if url.start_with?("ipc://") || url.include?(".ipc") || File.socket?(url.delete_prefix("ipc://")) + :ipc + else + :http + end + end + + def next_request_id + @mutex.synchronize { @request_id += 1 } + end +end diff --git a/lib/ethereum_beacon_node_client.rb b/lib/ethereum_beacon_node_client.rb deleted file mode 100644 index 11b361c..0000000 --- a/lib/ethereum_beacon_node_client.rb +++ /dev/null @@ -1,29 +0,0 @@ -class EthereumBeaconNodeClient - attr_accessor :base_url, :api_key - - def initialize(base_url: ENV['ETHEREUM_BEACON_NODE_API_BASE_URL'], api_key:) - self.base_url = base_url.chomp('/') - self.api_key = api_key - end - - def get_blob_sidecars(block_id) - base_url_with_key = [base_url, api_key].join('/').chomp('/') - url = [base_url_with_key, "eth/v1/beacon/blob_sidecars/#{block_id}"].join('/') - - HTTParty.get(url).parsed_response['data'] - end - - def get_block(block_id) - base_url_with_key = [base_url, api_key].join('/').chomp('/') - url = [base_url_with_key, "eth/v2/beacon/blocks/#{block_id}"].join('/') - - HTTParty.get(url).parsed_response['data'] - end - - def get_genesis - base_url_with_key = [base_url, api_key].join('/').chomp('/') - url = [base_url_with_key, "eth/v1/beacon/genesis"].join('/') - - HTTParty.get(url).parsed_response['data'] - end -end diff --git a/lib/ethscription_test_helper.rb b/lib/ethscription_test_helper.rb deleted file mode 100644 index 3c799b2..0000000 --- a/lib/ethscription_test_helper.rb +++ /dev/null @@ -1,88 +0,0 @@ -module EthscriptionTestHelper - def self.create_from_hash(hash) - resp = AlchemyClient.query_api( - method: 'eth_getTransactionByHash', - params: [hash] - )['result'] - - resp2 = AlchemyClient.query_api( - method: 'eth_getTransactionReceipt', - params: [hash] - )['result'] - - create_eth_transaction( - input: resp['input'], - to: resp['to'], - from: resp['from'], - logs: resp2['logs'] - ) - end - - def self.create_eth_transaction( - input:, - from:, - to:, - logs: [], - tx_hash: nil - ) - existing = Ethscription.newest_first.first - - block_number = EthBlock.next_block_to_import - - transaction_index = existing&.transaction_index.to_i + 1 - overall_order_number = block_number * 1e8 + transaction_index - - hex_input = if input.match?(/\A0x([a-f0-9]{2})+\z/i) - input.downcase - else - "0x" + input.bytes.map { |byte| byte.to_s(16).rjust(2, '0') }.join - end - - if EthBlock.exists? - parent_block = EthBlock.order(block_number: :desc).first - parent_hash = parent_block.blockhash - else - parent_hash = "0x" + SecureRandom.hex(32) - end - - eth_block = EthBlock.create!( - block_number: block_number, - blockhash: "0x" + SecureRandom.hex(32), - parent_blockhash: parent_hash, - timestamp: parent_block&.timestamp.to_i + 12, - is_genesis_block: EthBlock.genesis_blocks.include?(block_number) - ) - - tx = EthTransaction.create!( - block_number: block_number, - block_timestamp: eth_block.timestamp, - transaction_hash: tx_hash || "0x" + SecureRandom.hex(32), - block_blockhash: eth_block.blockhash, - from_address: from.downcase, - to_address: to.downcase, - transaction_index: transaction_index, - input: hex_input, - status: (block_number <= 4370000 ? nil : 1), - logs: logs, - gas_price: 1, - gas_used: 1, - transaction_fee: 1, - value: 1, - ) - - tx.process! - Token.process_block(eth_block) - eth_block.update!(imported_at: Time.current) - tx - end - - def self.t - create_eth_transaction( - input: "data:,lksdjfkldsajlfdjskfs", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - end -end -$et = EthscriptionTestHelper \ No newline at end of file diff --git a/lib/ethscriptions_api_client.rb b/lib/ethscriptions_api_client.rb new file mode 100644 index 0000000..93cfdb7 --- /dev/null +++ b/lib/ethscriptions_api_client.rb @@ -0,0 +1,210 @@ +class EthscriptionsApiClient + BASE_URL = ENV['ETHSCRIPTIONS_API_BASE_URL'].to_s + + # Single error type for all API issues (after exhausting retries) + class ApiUnavailableError < StandardError; end + + # Internal error types used for retry logic + class HttpError < StandardError + attr_reader :code, :http_message + + def initialize(code, http_message) + @code = code + @http_message = http_message + super("HTTP error: #{code} #{http_message}") + end + end + class ApiError < StandardError; end + class NetworkError < StandardError; end + + class << self + def fetch_block_data(block_number) + creations = fetch_creations(block_number) + transfers = fetch_transfers(block_number) + + { + creations: normalize_creations(creations), + transfers: normalize_transfers(transfers) + } + rescue HttpError, ApiError, NetworkError => e + # Wrap all internal errors into a single type for callers + Rails.logger.error "API unavailable for block #{block_number} after retries: #{e.message}" + raise ApiUnavailableError, "API unavailable after #{ENV.fetch('ETHSCRIPTIONS_API_RETRIES', 7)} retries: #{e.message}" + end + + # private + + def fetch_creations(block_number) + fetch_paginated("/ethscriptions", { + block_number: block_number, + max_results: 50 # Respect swagger max and paginate for full coverage + }) + end + + def fetch_transfers(block_number) + fetch_paginated("/ethscription_transfers", { + block_number: block_number, + max_results: 50 # Respect swagger max and paginate for full coverage + }) + end + + def fetch_single_ethscription(number) + # Fetch a single ethscription by its number + path = "/ethscriptions/#{number}" + + begin + data = fetch_json(path) + # The show endpoint returns the ethscription directly + normalize_creations([data['result']]).first + rescue HttpError => e + if e.code == 404 + # Ethscription doesn't exist + nil + else + # Re-raise other HTTP errors for retry handling + raise + end + end + rescue HttpError, ApiError, NetworkError => e + # Wrap all internal errors into a single type for callers + Rails.logger.error "API unavailable for ethscription ##{number} after retries: #{e.message}" + raise ApiUnavailableError, "API unavailable after #{ENV.fetch('ETHSCRIPTIONS_API_RETRIES', 7)} retries: #{e.message}" + end + + def fetch_paginated(path, params) + results = [] + page_key = nil + + loop do + query_params = params.dup + query_params[:page_key] = page_key if page_key + + data = fetch_json(path, query_params) + + # API returns { result: [...], pagination: { has_more:, page_key: } } + page = data['result'] || [] + pagination = data['pagination'] || {} + + results.concat(page) + + has_more = pagination['has_more'] + page_key = pagination['page_key'] + break unless has_more + end + + results + end + + def fetch_json(path, params = {}) + # Add API key to query params if provided + api_key = ENV['ETHSCRIPTIONS_API_KEY'] + params = params.merge(api_key: api_key) if api_key.present? + + uri = URI("#{BASE_URL}#{path}") + uri.query = URI.encode_www_form(params) if params.any? + + # Use Retriable for automatic retries on transient errors + Retriable.retriable( + tries: ENV.fetch('ETHSCRIPTIONS_API_RETRIES', 7).to_i, + base_interval: 1, + max_interval: 32, + multiplier: 2, + rand_factor: 0.4, + on: [Net::ReadTimeout, Net::OpenTimeout, HttpError, NetworkError, ApiError], + on_retry: ->(exception, try, elapsed_time, next_interval) { + Rails.logger.info "Retrying Ethscriptions API #{path} (attempt #{try}, next delay: #{next_interval.round(2)}s) - #{exception.message}" + } + ) do + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + + request = Net::HTTP::Get.new(uri) + request['Accept'] = 'application/json' + + begin + response = http.request(request) + rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e + # Network-level errors - will be retried + raise NetworkError, "Network error: #{e.message}" + rescue Net::OpenTimeout, Net::ReadTimeout => e + # Timeout errors - will be retried + raise NetworkError, "Timeout: #{e.message}" + end + + unless response.code == '200' + # HTTP errors - will be retried if in retry list + raise HttpError.new(response.code.to_i, response.body) + end + + begin + JSON.parse(response.body) + rescue JSON::ParserError => e + # JSON parsing errors (often Cloudflare error pages) - will be retried + raise ApiError, "Invalid JSON response: #{e.message}" + end + end + end + + def normalize_creations(data) + data.map do |item| + tx_hash = (item['transaction_hash'] || '').downcase + creator = (item['creator'] || '').downcase + initial_owner = (item['initial_owner'] || item['creator'] || '').downcase + current_owner = (item['current_owner'] || '').downcase + previous_owner = (item['previous_owner'] || '').downcase + + content = Base64.decode64(item['b64_content']) + + { + tx_hash: tx_hash, + transaction_hash: tx_hash, # Include both for compatibility + block_number: item['block_number'], + l1_block_number: item['block_number'], + transaction_index: item['transaction_index'], + block_timestamp: item['block_timestamp'], + block_blockhash: item['block_blockhash'], + event_log_index: item['event_log_index'], + ethscription_number: item['ethscription_number'], + creator: creator, + initial_owner: initial_owner, + current_owner: current_owner, + previous_owner: previous_owner, + content_uri: item['content_uri'], + content_hash: "0x" + Eth::Util.keccak256(content).unpack1('H*'), + esip6: item['esip6'] || false, + mimetype: item['mimetype'], + media_type: item['media_type'], + mime_subtype: item['mime_subtype'], + gas_price: item['gas_price'], + gas_used: item['gas_used'], + transaction_fee: item['transaction_fee'], + value: item['value'], + attachment_sha: item['attachment_sha'], + attachment_content_type: item['attachment_content_type'], + b64_content: item['b64_content'], # Keep the original base64 + content: content # Add decoded content + } + end + end + + def normalize_transfers(data) + data.map do |item| + tx_index = item['transaction_index'] + tx_index = tx_index.to_i if tx_index + log_index = item['event_log_index'] + log_index = log_index.to_i if log_index + + { + token_id: (item['ethscription_transaction_hash'] || '').downcase, # The ethscription being transferred + tx_hash: (item['transaction_hash'] || '').downcase, # The transfer transaction + from: (item['from_address'] || '').downcase, + to: (item['to_address'] || '').downcase, + block_number: item['block_number'], + transaction_index: tx_index, + event_log_index: log_index + } + end + end + + end +end diff --git a/lib/event_decoder.rb b/lib/event_decoder.rb new file mode 100644 index 0000000..8b5cc4d --- /dev/null +++ b/lib/event_decoder.rb @@ -0,0 +1,148 @@ +require 'eth' + +class EventDecoder + # Pre-computed event signatures + ETHSCRIPTION_CREATED = '0x' + Eth::Util.keccak256( + 'EthscriptionCreated(bytes32,address,address,bytes32,bytes32,uint256)' + ).unpack1('H*') + + # New Ethscriptions protocol transfer event (matches protocol semantics) + ETHSCRIPTION_TRANSFERRED = '0x' + Eth::Util.keccak256( + 'EthscriptionTransferred(bytes32,address,address,uint256)' + ).unpack1('H*') + + # Standard ERC721 Transfer event + ERC721_TRANSFER = '0x' + Eth::Util.keccak256( + 'Transfer(address,address,uint256)' + ).unpack1('H*') + + ETHSCRIPTIONS_ADDRESS = SysConfig::ETHSCRIPTIONS_ADDRESS.to_hex + + class << self + def decode_receipt_logs(receipt) + creations = [] + transfers = [] # Ethscriptions protocol semantics + + return {creations: [], transfers: []} unless receipt && receipt['logs'] + + tx_hash = receipt['transactionHash']&.downcase + transaction_index = receipt['transactionIndex']&.to_i(16) + + receipt['logs'].each do |log| + metadata = { + tx_hash: tx_hash, + transaction_index: transaction_index, + log_index: log['logIndex']&.to_i(16) + } + + case log['topics']&.first + when ETHSCRIPTION_CREATED + creation = decode_creation(log) + creations << creation if creation + when ETHSCRIPTION_TRANSFERRED + # This is the Ethscriptions protocol transfer with correct semantics + next unless log['address']&.downcase == ETHSCRIPTIONS_ADDRESS.downcase + transfer = decode_protocol_transfer(log, metadata) + transfers << transfer if transfer + end + end + + { + creations: creations.compact, + transfers: transfers.compact # Ethscriptions protocol transfers + } + end + + def decode_block_receipts(receipts) + all_creations = [] + all_transfers = [] + + receipts.each do |receipt| + data = decode_receipt_logs(receipt) + all_creations.concat(data[:creations]) + all_transfers.concat(data[:transfers]) + end + + { + creations: all_creations, + transfers: all_transfers # Ethscriptions protocol transfers + } + end + + private + + def decode_creation(log) + return nil unless log['topics']&.size >= 4 + + # Event EthscriptionCreated: + # topics[0] = event signature + # topics[1] = indexed bytes32 transactionHash + # topics[2] = indexed address creator + # topics[3] = indexed address initialOwner + # data = abi.encode(contentUriHash, contentSha, ethscriptionNumber) + + tx_hash = log['topics'][1] + creator = decode_address_from_topic(log['topics'][2]) + initial_owner = decode_address_from_topic(log['topics'][3]) + + # Decode non-indexed data + data = log['data'] || '0x' + data_bytes = [data.delete_prefix('0x')].pack('H*') + + return nil if data_bytes.length < 96 # Need at least 3 * 32 bytes + + content_uri_sha = '0x' + data_bytes[0, 32].unpack1('H*') + content_hash = '0x' + data_bytes[32, 32].unpack1('H*') + ethscription_number = data_bytes[64, 32].unpack1('H*').to_i(16) + + { + tx_hash: tx_hash, + creator: creator, + initial_owner: initial_owner, + content_uri_sha: content_uri_sha, + content_hash: content_hash, + ethscription_number: ethscription_number + } + rescue => e + Rails.logger.error "Failed to decode creation event: #{e.message}" + nil + end + + def decode_protocol_transfer(log, metadata = {}) + return nil unless log['topics']&.size >= 4 + + # Event EthscriptionTransferred(bytes32 indexed ethscriptionId, address indexed from, address indexed to, uint256 ethscriptionNumber) + # First 3 parameters are indexed, last one is in data + tx_hash = log['topics'][1]&.downcase # bytes32 ethscriptionId + from = decode_address_from_topic(log['topics'][2]) + to = decode_address_from_topic(log['topics'][3]) + + # Decode the non-indexed data (ethscriptionNumber) + ethscription_number = nil + if log['data'] && log['data'] != '0x' + decoded = Eth::Abi.decode(['uint256'], log['data']) + ethscription_number = decoded[0] + end + + { + token_id: tx_hash, # Use same field name for consistency + from: from, + to: to, + ethscription_number: ethscription_number, + tx_hash: metadata[:tx_hash], + transaction_index: metadata[:transaction_index], + log_index: metadata[:log_index] + } + rescue => e + Rails.logger.error "Failed to decode protocol transfer event: #{e.message}" + nil + end + + def decode_address_from_topic(topic) + return nil unless topic + + # Topics are 32 bytes, addresses are 20 bytes (last 40 hex chars) + '0x' + topic[-40..] + end + end +end diff --git a/lib/genesis_generator.rb b/lib/genesis_generator.rb new file mode 100755 index 0000000..c45a78d --- /dev/null +++ b/lib/genesis_generator.rb @@ -0,0 +1,169 @@ +class GenesisGenerator + def initialize(quiet: false) + @quiet = quiet + end + + def generate_full_genesis_json(l1_network_name:, l1_genesis_block_number:) + config = { + chainId: 0xeeee, + homesteadBlock: 0, + eip150Block: 0, + eip155Block: 0, + eip158Block: 0, + byzantiumBlock: 0, + constantinopleBlock: 0, + petersburgBlock: 0, + istanbulBlock: 0, + muirGlacierBlock: 0, + berlinBlock: 0, + londonBlock: 0, + mergeForkBlock: 0, + mergeNetsplitBlock: 0, + shanghaiTime: 0, + cancunTime: cancun_timestamp(l1_network_name), + terminalTotalDifficulty: 0, + terminalTotalDifficultyPassed: true, + bedrockBlock: 0, + regolithTime: 0, + canyonTime: 0, + ecotoneTime: 0, + fjordTime: 0, + deltaTime: 0, + optimism: { + eip1559Elasticity: 3, + eip1559Denominator: 8, + eip1559DenominatorCanyon: 8 + } + } + + timestamp, mix_hash = get_timestamp_and_mix_hash(l1_genesis_block_number) + + { + config: config, + timestamp: "0x#{timestamp.to_s(16)}", + extraData: "0xb1bdb91f010c154dd04e5c11a6298e91472c27a347b770684981873a6408c11c", + gasLimit: "0x#{SysConfig::L2_BLOCK_GAS_LIMIT.to_s(16)}", + difficulty: "0x0", + mixHash: mix_hash, + alloc: generate_alloc_for_genesis(l1_network_name: l1_network_name) + } + end + + def get_timestamp_and_mix_hash(l1_block_number) + l1_block_result = EthRpcClient.l1.get_block(l1_block_number) + timestamp = l1_block_result['timestamp'].to_i(16) + mix_hash = l1_block_result['mixHash'] + [timestamp, mix_hash] + end + + def cancun_timestamp(l1_network_name) + { + "mainnet" => 1710338135, + "sepolia" => 1706655072, + "hoodi" => 0 + }.fetch(l1_network_name) + end + + def generate_alloc_for_genesis(l1_network_name:) + # Run forge script to generate allocations + run_forge_genesis_script! + + # Use the allocations from forge script + allocs_file = Rails.root.join('contracts', 'genesis-allocs.json') + + unless File.exist?(allocs_file) + raise "Genesis allocations file not found at #{allocs_file}. Forge script failed!" + end + + log "Loading allocations from #{allocs_file}..." + JSON.parse(File.read(allocs_file)) + end + + def run_forge_genesis_script! + log "Running Forge L2Genesis script..." + log "=" * 80 + + contracts_dir = Rails.root.join('contracts') + script_path = contracts_dir.join('script', 'L2Genesis.s.sol') + + unless File.exist?(script_path) + raise "L2Genesis script not found at #{script_path}" + end + + should_perform_genesis_import = ENV.fetch('PERFORM_GENESIS_IMPORT', 'true') == 'true' + + # Build the forge script command + cmd = "cd #{contracts_dir} && PERFORM_GENESIS_IMPORT=#{should_perform_genesis_import} forge script '#{script_path}:L2Genesis'" + + log "Executing: #{cmd}" + log nil + + # Run the command and capture output + output = `#{cmd} 2>&1` + success = $?.success? + + log output unless @quiet + + unless success + raise "Forge script failed! Exit code: #{$?.exitstatus}" + end + + log "✅ Forge script completed successfully", force: true + log "=" * 80 + log nil + end + + def run! + l1_network_name = ENV.fetch('L1_NETWORK') + l1_genesis_block_number = ENV.fetch('L1_GENESIS_BLOCK').to_i + + log "=" * 80 + log "Generating Full Genesis File" + log "=" * 80 + log "L1 Network: #{l1_network_name}" + log "L1 Genesis Block: #{l1_genesis_block_number}" + log nil + + # Generate the full genesis + genesis = generate_full_genesis_json( + l1_network_name: l1_network_name, + l1_genesis_block_number: l1_genesis_block_number + ) + + geth_dir = ENV.fetch('LOCAL_GETH_DIR') + + # Write to file + output_file = File.join(geth_dir, "genesis-files", "ethscriptions-#{l1_network_name}.json") + File.write(output_file, JSON.pretty_generate(genesis)) + + log "✅ Genesis file written to: #{output_file}", force: true + log nil + log "Genesis Configuration:" + log " Chain ID: 0x#{genesis[:config][:chainId].to_s(16)}" + log " Timestamp: #{genesis[:timestamp]} (#{Time.at(genesis[:timestamp].to_i(16))})" + log " Mix Hash: #{genesis[:mixHash]}" + log " Gas Limit: #{genesis[:gasLimit]}" + log " Allocations: #{genesis[:alloc].keys.count} accounts" + log nil + log "To initialize Geth with this genesis:" + log " geth init --datadir ./datadir genesis.json" + + output_file + end + + private + + def log(message, force: false) + return if @quiet && !force + + if message.nil? + puts + else + puts message + end + end +end + +# Run the generator +# generator = GenesisGenerator.new +# generator.run! \ No newline at end of file diff --git a/lib/hash_32.rb b/lib/hash_32.rb new file mode 100644 index 0000000..696b0be --- /dev/null +++ b/lib/hash_32.rb @@ -0,0 +1,6 @@ +class Hash32 < ByteString + sig { override.returns(Integer) } + def self.required_byte_length + 32 + end +end diff --git a/lib/import_profiler.rb b/lib/import_profiler.rb new file mode 100644 index 0000000..ee2a171 --- /dev/null +++ b/lib/import_profiler.rb @@ -0,0 +1,123 @@ +class ImportProfiler + include Singleton + + def self.start(label) + instance.start(label) + end + + def self.stop(label) + instance.stop(label) + end + + + def self.report + instance.report + end + + def self.reset + instance.reset + end + + def self.enabled? + instance.enabled? + end + + def initialize + @enabled = ENV['PROFILE_IMPORT'] == 'true' + reset + end + + def enabled? + @enabled + end + + def start(label) + return unless @enabled + + # Support nested timing by using a per-thread stack + thread_id = Thread.current.object_id + @start_stack[thread_id] ||= Concurrent::Map.new + @start_stack[thread_id][label] ||= Concurrent::Array.new + @start_stack[thread_id][label].push(Time.current) + end + + def stop(label) + return nil unless @enabled + + thread_id = Thread.current.object_id + return nil unless @start_stack[thread_id] && @start_stack[thread_id][label] && !@start_stack[thread_id][label].empty? + + start_time = @start_stack[thread_id][label].pop + elapsed = Time.current - start_time + + @timings[label] ||= Concurrent::Array.new + @timings[label] << elapsed + + # Clean up empty stacks + if @start_stack[thread_id][label].empty? + @start_stack[thread_id].delete(label) + @start_stack.delete(thread_id) if @start_stack[thread_id].empty? + end + + elapsed + end + + + def report + return unless @enabled + return if @timings.empty? + + Rails.logger.info "=" * 100 + Rails.logger.info "IMPORT PROFILE REPORT" + Rails.logger.info "=" * 100 + + # Calculate totals and averages + report_data = @timings.each_pair.map do |label, times| + { + label: label, + count: times.size, + total: times.sum.round(3), + avg: (times.sum / times.size).round(3), + min: times.min.round(3), + max: times.max.round(3), + total_ms: (times.sum * 1000).round(1) + } + end + + # Sort by total time descending + report_data.sort_by! { |d| -d[:total] } + + # Find the grand total + grand_total = report_data.sum { |d| d[:total] } + + # Print table header + Rails.logger.info sprintf("%-45s %8s %10s %8s %8s %8s %10s %6s", + "Operation", "Count", "Total(ms)", "Avg(ms)", "Min(ms)", "Max(ms)", "Total(s)", "Pct%") + Rails.logger.info "-" * 110 + + # Print each timing + report_data.each do |data| + pct = grand_total > 0 ? ((data[:total] / grand_total) * 100).round(1) : 0 + Rails.logger.info sprintf("%-45s %8d %10.1f %8.1f %8.1f %8.1f %10.3f %6.1f%%", + data[:label], + data[:count], + data[:total_ms], + data[:avg] * 1000, + data[:min] * 1000, + data[:max] * 1000, + data[:total], + pct) + end + + Rails.logger.info "-" * 110 + Rails.logger.info sprintf("%-45s %8s %10.1f %8s %8s %8s %10.3f", + "TOTAL", "", grand_total * 1000, "", "", "", grand_total) + Rails.logger.info "=" * 100 + end + + def reset + @timings = Concurrent::Map.new + @start_stack = Concurrent::Map.new + end +end + diff --git a/lib/importer_singleton.rb b/lib/importer_singleton.rb new file mode 100644 index 0000000..89ca432 --- /dev/null +++ b/lib/importer_singleton.rb @@ -0,0 +1,9 @@ +module ImporterSingleton + def self.instance=(importer) + @instance = importer + end + + def self.instance + @instance ||= EthBlockImporter.new + end +end diff --git a/lib/l1_rpc_prefetcher.rb b/lib/l1_rpc_prefetcher.rb new file mode 100644 index 0000000..4aa9f6e --- /dev/null +++ b/lib/l1_rpc_prefetcher.rb @@ -0,0 +1,183 @@ +require 'concurrent' +require 'retriable' + +class L1RpcPrefetcher + include Memery + class BlockFetchError < StandardError; end + def initialize(ethereum_client:, + ahead: ENV.fetch('L1_PREFETCH_FORWARD', Rails.env.test? ? 5 : 20).to_i, + threads: ENV.fetch('L1_PREFETCH_THREADS', 2).to_i) + @eth = ethereum_client + @ahead = ahead + @threads = threads + + # Thread-safe collections and pool + @pool = Concurrent::FixedThreadPool.new(threads) + @promises = Concurrent::Map.new + @last_chain_tip = current_l1_block_number + + Rails.logger.info "L1RpcPrefetcher initialized with #{threads} threads" + end + + def ensure_prefetched(from_block) + distance_from_last_tip = @last_chain_tip - from_block + latest = if distance_from_last_tip > 10 + cached_l1_block_number + else + current_l1_block_number + end + + # Don't prefetch beyond chain tip + to_block = [from_block + @ahead, latest].min + + # Only create promises for blocks we don't have yet + blocks_to_fetch = (from_block..to_block).reject { |n| @promises.key?(n) } + + return if blocks_to_fetch.empty? + + Rails.logger.debug "Enqueueing #{blocks_to_fetch.size} blocks: #{blocks_to_fetch.first}..#{blocks_to_fetch.last}" + + blocks_to_fetch.each { |block_number| enqueue_single(block_number) } + end + + def fetch(block_number) + ensure_prefetched(block_number) + + # Get or create promise + promise = @promises[block_number] || enqueue_single(block_number) + + # Wait for result - if it's already done, this returns immediately + timeout = Rails.env.test? ? 5 : 30 + + Rails.logger.debug "Fetching block #{block_number}, promise state: #{promise.state}" + + result = promise.value!(timeout) + + if result.nil? || result == :not_ready_sentinel + @promises.delete(block_number) + message = result.nil? ? + "Block #{block_number} fetch timed out after #{timeout}s" : + "Block #{block_number} not yet available on L1" + raise BlockFetchError.new(message) + end + + Rails.logger.debug "Got result for block #{block_number}" + result + end + + def clear_older_than(min_keep) + # Memory management - remove old promises + return if min_keep.nil? + + deleted = 0 + @promises.keys.each do |n| + if n < min_keep + @promises.delete(n) + deleted += 1 + end + end + + Rails.logger.debug "Cleared #{deleted} promises older than #{min_keep}" if deleted > 0 + end + + def stats + total = @promises.size + # Count fulfilled promises by iterating + fulfilled = 0 + pending = 0 + @promises.each_pair do |_, promise| + if promise.fulfilled? + fulfilled += 1 + elsif promise.pending? + pending += 1 + end + end + + { + promises_total: total, + promises_fulfilled: fulfilled, + promises_pending: pending, + threads_active: @pool.length, + threads_queued: @pool.queue_length + } + end + + def shutdown + @pool.shutdown + terminated = @pool.wait_for_termination(3) + @pool.kill unless terminated + @promises.each_pair do |_, promise| + begin + if promise.pending? && promise.respond_to?(:cancel) + promise.cancel + end + rescue StandardError => e + Rails.logger.warn "Failed cancelling promise during shutdown: #{e.message}" + end + end + @promises.clear + Rails.logger.info( + terminated ? + 'L1 RPC Prefetcher thread pool shut down successfully' : + 'L1 RPC Prefetcher shutdown timed out after 3s, pool killed' + ) + terminated + rescue StandardError => e + Rails.logger.error("Error during L1RpcPrefetcher shutdown: #{e.message}\n#{e.backtrace.join("\n")}") + false + end + + private + + def enqueue_single(block_number) + @promises.compute_if_absent(block_number) do + Rails.logger.debug "Creating promise for block #{block_number}" + + Concurrent::Promise.execute(executor: @pool) do + Rails.logger.debug "Executing fetch for block #{block_number}" + fetch_job(block_number) + end.rescue do |e| + Rails.logger.error "Prefetch failed for block #{block_number}: #{e.message}" + # Clean up failed promise so it can be retried + @promises.delete(block_number) + raise e + end + end + end + + def fetch_job(block_number) + # Use shared persistent client (thread-safe with Net::HTTP::Persistent) + client = @eth + + Retriable.retriable(tries: 3, base_interval: 1, max_interval: 4) do + block = client.get_block(block_number, true) + + # Handle case where block doesn't exist yet (normal when caught up) + if block.nil? + Rails.logger.debug "Block #{block_number} not yet available on L1" + return :not_ready_sentinel + end + + receipts = client.get_transaction_receipts(block_number) + + eth_block = EthBlock.from_rpc_result(block) + ethscriptions_block = EthscriptionsBlock.from_eth_block(eth_block) + ethscription_txs = EthTransaction.ethscription_txs_from_rpc_results(block, receipts, ethscriptions_block) + + { + eth_block: eth_block, + ethscriptions_block: ethscriptions_block, + ethscription_txs: ethscription_txs + } + end + end + + def current_l1_block_number + @last_chain_tip = @eth.get_block_number + end + + def cached_l1_block_number + current_l1_block_number + end + memoize :cached_l1_block_number, ttl: 12.seconds +end diff --git a/lib/postgres_client.rb b/lib/postgres_client.rb deleted file mode 100644 index ad2b61c..0000000 --- a/lib/postgres_client.rb +++ /dev/null @@ -1,107 +0,0 @@ -class PostgresClient - DB_USER = `whoami`.chomp - DATABASE_THATS_ALWAYS_THERE = 'postgres' - ACTUAL_DATABASE_NAME = Rails.configuration.database_configuration.dig("development", "database") - SWAP_IN_DATABASE_NAME = "#{ACTUAL_DATABASE_NAME}-fresh" - SWAP_OUT_DATABASE_NAME = "#{ACTUAL_DATABASE_NAME}-old" - - def self.random_database_name - "#{SWAP_IN_DATABASE_NAME}-random-#{SecureRandom.hex(5)}" - end - - def self.swap_in_database_name(version: 1) - "#{SWAP_IN_DATABASE_NAME}-v#{version}" - end - - def self.freshest_db - swap_in_database_name(version: smallest_unused_version_number - 1) - end - - def self.first_free_name - swap_in_database_name(version: smallest_unused_version_number) - end - - def self.smallest_unused_version_number - return 1 if existing_versioned_names.blank? - existing_versioned_names.last[/\d+/].to_i + 1 - end - - def self.db_exists?(db_name) - new.db_exists?(db_name) - end - - def self.existing_versioned_names - matches = `psql -l`.scan(/#{Regexp.escape(SWAP_IN_DATABASE_NAME)}-v\d+/) - - matches.sort_by do |match| - match[/\d+/].to_i - end - end - - def self.stale_dbs - existing_versioned_names - existing_versioned_names.last(5) - end - - def db_exists?(db_name) - `psql -l`.include?(" #{db_name} ") - end - - def restore! - random_db = self.class.random_database_name - - puts "Pulling into #{random_db}" - run_shell_command %{heroku pg:pull DATABASE_URL #{random_db} -a #{ENV.fetch("HEROKU_APP_NAME")}} - - new_name = self.class.first_free_name - puts "Renaming to #{random_db} to #{new_name}" - run_psql_command %{ALTER DATABASE "#{random_db}" RENAME TO "#{new_name}"} - - drop_db_if_exists!(random_db) - - self.class.stale_dbs.each do |db| - puts "Dropping #{db}" - drop_db_if_exists!(db) - end - end - - def swap_in! - puts "Swapping in #{self.class.freshest_db}" - drop_db_if_exists!(ACTUAL_DATABASE_NAME) - run_psql_command %{ALTER DATABASE "#{self.class.freshest_db}" RENAME TO "#{ACTUAL_DATABASE_NAME}"} - end - - def development_db_exists? - db_exists?(ACTUAL_DATABASE_NAME) - end - - def create_development_database! - create_database!(ACTUAL_DATABASE_NAME) - end - - def create_database!(database_name) - if db_exists?(database_name) - abort "It looks like #{database_name.inspect} database already exists" - end - - run_shell_command %{createdb --encoding UTF-8 --owner #{DB_USER} #{database_name}} - end - - private - - def run_shell_command(command, options = {}) - `#{command} 2>&1`.tap do |result| - unless $? == 0 - STDERR.puts "Error running command #{command}, errors:\n #{result}" - abort unless options[:rescue] - end - end - end - - def run_psql_command(command) - run_shell_command "psql -c '#{command};' -U #{DB_USER} -d #{DATABASE_THATS_ALWAYS_THERE}" - end - - def drop_db_if_exists!(db_name) - run_shell_command "dropdb '#{db_name}'" if db_exists?(db_name) - end -end diff --git a/lib/protocol_event_reader.rb b/lib/protocol_event_reader.rb new file mode 100644 index 0000000..b7775ea --- /dev/null +++ b/lib/protocol_event_reader.rb @@ -0,0 +1,396 @@ +# Utility for reading protocol events from L2 transaction receipts +class ProtocolEventReader + # Event signatures from contracts + EVENT_SIGNATURES = { + # Ethscriptions.sol events + 'EthscriptionCreated' => 'EthscriptionCreated(bytes32,address,address,bytes32,bytes32,uint256)', + 'Transfer' => 'Transfer(address,address,uint256)', + 'ProtocolHandlerSuccess' => 'ProtocolHandlerSuccess(bytes32,string,bytes)', + 'ProtocolHandlerFailed' => 'ProtocolHandlerFailed(bytes32,string,bytes)', + + # ERC20FixedDenominationManager.sol events + 'ERC20FixedDenominationTokenDeployed' => 'ERC20FixedDenominationTokenDeployed(bytes32,address,string,uint256,uint256)', + 'ERC20FixedDenominationTokenMinted' => 'ERC20FixedDenominationTokenMinted(bytes32,address,uint256,uint256,bytes32)', + 'ERC20FixedDenominationTokenTransferred' => 'ERC20FixedDenominationTokenTransferred(bytes32,address,address,uint256,uint256,bytes32)', + 'ERC721CollectionDeployed' => 'ERC721CollectionDeployed(bytes32,address,string)', + + # CollectionsManager.sol events + # CollectionsManager.sol events (match actual signatures) + 'CollectionCreated' => 'CollectionCreated(bytes32,address,string,string,uint256)', + 'ItemsAdded' => 'ItemsAdded(bytes32,uint256,bytes32)', + 'ItemsRemoved' => 'ItemsRemoved(bytes32,uint256,bytes32)', + 'CollectionEdited' => 'CollectionEdited(bytes32)', + 'CollectionLocked' => 'CollectionLocked(bytes32)', + 'OwnershipTransferred' => 'OwnershipTransferred(bytes32,address,address)' + }.freeze + + def self.parse_receipt_events(receipt) + return [] if receipt.nil? + + # Convert to HashWithIndifferentAccess to handle both symbol and string keys + receipt = ActiveSupport::HashWithIndifferentAccess.new(receipt) if defined?(ActiveSupport) + + return [] if receipt['logs'].nil? + + events = [] + + receipt['logs'].each do |log| + event = parse_log(log) + events << event if event + end + + events + end + + def self.parse_log(log) + # Convert to HashWithIndifferentAccess to handle both symbol and string keys + log = ActiveSupport::HashWithIndifferentAccess.new(log) if defined?(ActiveSupport) + + return nil if log['topics'].nil? || log['topics'].empty? + + # First topic is always the event signature hash + event_signature_hash = log['topics'][0] + + # Find matching event + event_name = find_event_by_signature_hash(event_signature_hash) + return nil unless event_name + + # Parse based on event type + case event_name + when 'ProtocolHandlerSuccess' + parse_protocol_handler_success(log) + when 'ProtocolHandlerFailed' + parse_protocol_handler_failed(log) + when 'ERC20FixedDenominationTokenDeployed', 'FixedFungibleTokenDeployed' + parse_erc20_fixed_denomination_token_deployed(log) + when 'ERC20FixedDenominationTokenMinted', 'FixedFungibleTokenMinted' + parse_erc20_fixed_denomination_token_minted(log) + when 'ERC20FixedDenominationTokenTransferred', 'FixedFungibleTokenTransferred' + parse_erc20_fixed_denomination_token_transferred(log) + when 'ERC721CollectionDeployed' + parse_erc721_collection_deployed(log) + when 'CollectionCreated' + parse_collection_created(log) + when 'ItemsAdded' + parse_items_added(log) + when 'ItemsRemoved' + parse_items_removed(log) + when 'CollectionEdited' + parse_collection_edited(log) + when 'OwnershipTransferred' + parse_collection_ownership_transferred(log) + else + { + event: event_name, + raw: log + } + end + end + + private + + def self.find_event_by_signature_hash(hash) + EVENT_SIGNATURES.find do |name, signature| + computed_hash = '0x' + Eth::Util.keccak256(signature).unpack1('H*') + computed_hash.downcase == hash.downcase + end&.first + end + + def self.parse_protocol_handler_success(log) + # ProtocolHandlerSuccess(bytes32 indexed txHash, string protocol, bytes returnData) + tx_hash = log['topics'][1] # indexed parameter + + # Decode non-indexed data + # The data contains string protocol and bytes returnData parameters + if log['data'] && log['data'] != '0x' + data_hex = log['data'].delete_prefix('0x') + # Handle empty or very short data + if data_hex.length < 128 # Minimum for offset (32 bytes) + length (32 bytes) + return { + event: 'ProtocolHandlerSuccess', + tx_hash: tx_hash, + protocol: '', + return_data: '0x' + } + end + + data = [data_hex].pack('H*') + decoded = Eth::Abi.decode(['string', 'bytes'], data) + + { + event: 'ProtocolHandlerSuccess', + tx_hash: tx_hash, + protocol: decoded[0], + return_data: '0x' + decoded[1].unpack1('H*') + } + else + { + event: 'ProtocolHandlerSuccess', + tx_hash: tx_hash, + protocol: '', + return_data: '0x' + } + end + rescue => e + Rails.logger.error "Failed to parse ProtocolHandlerSuccess: #{e.message}" + # Return partial result instead of nil so event is still recognized + { + event: 'ProtocolHandlerSuccess', + tx_hash: log['topics'][1], + protocol: 'parse_error', + return_data: '0x' + } + end + + def self.parse_protocol_handler_failed(log) + # ProtocolHandlerFailed(bytes32 indexed txHash, string protocol, bytes reason) + tx_hash = log['topics'][1] + + if log['data'] && log['data'] != '0x' + data_hex = log['data'].delete_prefix('0x') + # Handle empty or very short data + if data_hex.length < 128 # Minimum for offset (32 bytes) + offset (32 bytes) + length (32 bytes) + length (32 bytes) + return { + event: 'ProtocolHandlerFailed', + tx_hash: tx_hash, + protocol: '', + reason: 'parse_error' + } + end + + data = [data_hex].pack('H*') + decoded = Eth::Abi.decode(['string', 'bytes'], data) + + # The bytes reason is actually an ABI-encoded error string + # Try to decode it as a revert string + reason_bytes = decoded[1] + reason = if reason_bytes && reason_bytes.length > 0 + # Try to decode as Error(string) + if reason_bytes.start_with?("\x08\xC3y\xA0".b) # Error(string) selector + # Skip the selector (4 bytes) and decode the string + begin + Eth::Abi.decode(['string'], reason_bytes[4..-1])[0] + rescue + # If decode fails, just use the raw bytes + reason_bytes + end + else + reason_bytes + end + else + 'unknown' + end + + { + event: 'ProtocolHandlerFailed', + tx_hash: tx_hash, + protocol: decoded[0], + reason: reason + } + else + { + event: 'ProtocolHandlerFailed', + tx_hash: tx_hash, + protocol: '', + reason: 'no_data' + } + end + rescue => e + Rails.logger.error "Failed to parse ProtocolHandlerFailed: #{e.message}" + # Return partial result instead of nil so event is still recognized + { + event: 'ProtocolHandlerFailed', + tx_hash: log['topics'][1], + protocol: 'parse_error', + reason: e.message + } + end + + def self.parse_erc20_fixed_denomination_token_deployed(log) + # ERC20FixedDenominationTokenDeployed(bytes32 indexed deployTxHash, address indexed tokenAddress, string tick, uint256 maxSupply, uint256 mintAmount) + deploy_tx_hash = log['topics'][1] + token_address = '0x' + log['topics'][2][-40..] if log['topics'][2] # Last 20 bytes of topic + + data = [log['data'].delete_prefix('0x')].pack('H*') + decoded = Eth::Abi.decode(['string', 'uint256', 'uint256'], data) + + { + event: 'ERC20FixedDenominationTokenDeployed', + deploy_tx_hash: deploy_tx_hash, + token_contract: token_address, + tick: decoded[0], + max_supply: decoded[1], + mint_amount: decoded[2] + } + rescue => e + Rails.logger.error "Failed to parse ERC20FixedDenominationTokenDeployed: #{e.message}" + nil + end + + def self.parse_erc20_fixed_denomination_token_minted(log) + # ERC20FixedDenominationTokenMinted(bytes32 indexed deployEthscriptionId, address indexed to, uint256 amount, uint256 mintId, bytes32 ethscriptionId) + deploy_tx_hash = log['topics'][1] + to_address = '0x' + log['topics'][2][-40..] if log['topics'][2] # Last 20 bytes + + data = [log['data'].delete_prefix('0x')].pack('H*') + decoded = Eth::Abi.decode(['uint256', 'uint256', 'bytes32'], data) + + { + event: 'ERC20FixedDenominationTokenMinted', + deploy_tx_hash: deploy_tx_hash, + to: to_address, + amount: decoded[0], + mint_id: decoded[1], + ethscription_tx_hash: '0x' + decoded[2].unpack1('H*') + } + rescue => e + Rails.logger.error "Failed to parse ERC20FixedDenominationTokenMinted: #{e.message}" + nil + end + + def self.parse_erc20_fixed_denomination_token_transferred(log) + # ERC20FixedDenominationTokenTransferred(bytes32 indexed deployEthscriptionId, address indexed from, address indexed to, uint256 amount, uint256 mintId, bytes32 ethscriptionId) + deploy_tx_hash = log['topics'][1] + from_address = '0x' + log['topics'][2][-40..] if log['topics'][2] # Last 20 bytes + to_address = '0x' + log['topics'][3][-40..] if log['topics'][3] + + data = [log['data'].delete_prefix('0x')].pack('H*') + decoded = Eth::Abi.decode(['uint256', 'uint256', 'bytes32'], data) + + { + event: 'ERC20FixedDenominationTokenTransferred', + deploy_tx_hash: deploy_tx_hash, + from: from_address, + to: to_address, + amount: decoded[0], + mint_id: decoded[1], + ethscription_tx_hash: '0x' + decoded[2].unpack1('H*') + } + rescue => e + Rails.logger.error "Failed to parse ERC20FixedDenominationTokenTransferred: #{e.message}" + nil + end + + def self.parse_erc721_collection_deployed(log) + # ERC721CollectionDeployed(bytes32 indexed deployEthscriptionId, address indexed collectionAddress, string tick) + deploy_ethscription_id = log['topics'][1] + collection_address = '0x' + log['topics'][2][-40..] if log['topics'][2] # Last 20 bytes + + data = [log['data'].delete_prefix('0x')].pack('H*') + decoded = Eth::Abi.decode(['string'], data) + + { + event: 'ERC721CollectionDeployed', + deploy_ethscription_id: deploy_ethscription_id, + collection_address: collection_address, + tick: decoded[0] + } + rescue => e + Rails.logger.error "Failed to parse ERC721CollectionDeployed: #{e.message}" + nil + end + + def self.parse_collection_created(log) + # CollectionCreated(bytes32 indexed collectionId, address indexed collectionContract, string name, string symbol, uint256 maxSize) + collection_id = log['topics'][1] + collection_contract = '0x' + log['topics'][2][-40..] if log['topics'][2] + + if log['data'] && log['data'] != '0x' + data = [log['data'].delete_prefix('0x')].pack('H*') + decoded = Eth::Abi.decode(['string', 'string', 'uint256'], data) + + { + event: 'CollectionCreated', + collection_id: collection_id, + collection_contract: collection_contract, + name: decoded[0], + symbol: decoded[1], + max_size: decoded[2] + } + else + # Return partial result if data is missing + { + event: 'CollectionCreated', + collection_id: collection_id, + collection_contract: collection_contract, + name: '', + symbol: '', + max_size: 0 + } + end + rescue => e + Rails.logger.error "Failed to parse CollectionCreated: #{e.message}" + # Return partial result so event is still recognized + { + event: 'CollectionCreated', + collection_id: log['topics'][1], + collection_contract: log['topics'][2] ? '0x' + log['topics'][2][-40..] : nil + } + end + + def self.parse_items_added(log) + # ItemsAdded(bytes32 indexed collectionId, uint256 count, bytes32 updateTxHash) + collection_id = log['topics'][1] + + data = [log['data'].delete_prefix('0x')].pack('H*') + decoded = Eth::Abi.decode(['uint256', 'bytes32'], data) + + { + event: 'ItemsAdded', + collection_id: collection_id, + count: decoded[0], + update_tx_hash: '0x' + decoded[1].unpack1('H*') + } + rescue => e + Rails.logger.error "Failed to parse ItemsAdded: #{e.message}" + nil + end + + def self.parse_items_removed(log) + # ItemsRemoved(bytes32 indexed collectionId, uint256 count, bytes32 updateTxHash) + collection_id = log['topics'][1] + + data = [log['data'].delete_prefix('0x')].pack('H*') + decoded = Eth::Abi.decode(['uint256', 'bytes32'], data) + + { + event: 'ItemsRemoved', + collection_id: collection_id, + count: decoded[0], + update_tx_hash: '0x' + decoded[1].unpack1('H*') + } + rescue => e + Rails.logger.error "Failed to parse ItemsRemoved: #{e.message}" + nil + end + + def self.parse_collection_edited(log) + # CollectionEdited(bytes32 indexed collectionId) + { + event: 'CollectionEdited', + collection_id: log['topics'][1] + } + end + + def self.parse_collection_ownership_transferred(log) + { + event: 'OwnershipTransferred', + collection_id: log['topics'][1], + previous_owner: log['topics'][2] ? '0x' + log['topics'][2][-40..] : nil, + new_owner: log['topics'][3] ? '0x' + log['topics'][3][-40..] : nil + } + end + + # Helper to check if a receipt contains a successful protocol execution + def self.protocol_succeeded?(receipt) + events = parse_receipt_events(receipt) + events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' } + end + + # Helper to get protocol failure reason + def self.get_protocol_failure_reason(receipt) + events = parse_receipt_events(receipt) + failed_event = events.find { |e| e[:event] == 'ProtocolHandlerFailed' } + failed_event ? failed_event[:reason] : nil + end +end diff --git a/lib/storage_reader.rb b/lib/storage_reader.rb new file mode 100644 index 0000000..4fde433 --- /dev/null +++ b/lib/storage_reader.rb @@ -0,0 +1,326 @@ +class StorageReader + ETHSCRIPTIONS_ADDRESS = SysConfig::ETHSCRIPTIONS_ADDRESS.to_hex + + # Define the new denormalized Ethscription struct ABI + ETHSCRIPTION_STRUCT_ABI = { + 'components' => [ + { 'name' => 'ethscriptionId', 'type' => 'bytes32' }, + { 'name' => 'ethscriptionNumber', 'type' => 'uint256' }, + { 'name' => 'contentUriHash', 'type' => 'bytes32' }, + { 'name' => 'contentSha', 'type' => 'bytes32' }, + { 'name' => 'mimetype', 'type' => 'string' }, + { 'name' => 'content', 'type' => 'bytes' }, + { 'name' => 'currentOwner', 'type' => 'address' }, + { 'name' => 'creator', 'type' => 'address' }, + { 'name' => 'initialOwner', 'type' => 'address' }, + { 'name' => 'previousOwner', 'type' => 'address' }, + { 'name' => 'l1BlockHash', 'type' => 'bytes32' }, + { 'name' => 'l1BlockNumber', 'type' => 'uint256' }, + { 'name' => 'l2BlockNumber', 'type' => 'uint256' }, + { 'name' => 'createdAt', 'type' => 'uint256' }, + { 'name' => 'esip6', 'type' => 'bool' } + ], + 'type' => 'tuple' + } + + # Define the old storage struct ABI for getEthscriptionWithoutContent + ETHSCRIPTION_STORAGE_STRUCT_ABI = { + 'components' => [ + { 'name' => 'contentUriHash', 'type' => 'bytes32' }, + { 'name' => 'contentSha', 'type' => 'bytes32' }, + { 'name' => 'l1BlockHash', 'type' => 'bytes32' }, + { 'name' => 'creator', 'type' => 'address' }, + { 'name' => 'createdAt', 'type' => 'uint48' }, + { 'name' => 'l1BlockNumber', 'type' => 'uint48' }, + { 'name' => 'mimetype', 'type' => 'string' }, + { 'name' => 'initialOwner', 'type' => 'address' }, + { 'name' => 'ethscriptionNumber', 'type' => 'uint48' }, + { 'name' => 'esip6', 'type' => 'bool' }, + { 'name' => 'previousOwner', 'type' => 'address' }, + { 'name' => 'l2BlockNumber', 'type' => 'uint48' } + ], + 'type' => 'tuple' + } + + # Contract ABI - only the functions we need + CONTRACT_ABI = [ + { + 'name' => 'getEthscription', + 'type' => 'function', + 'stateMutability' => 'view', + 'inputs' => [ + { 'name' => 'ethscriptionId', 'type' => 'bytes32' } + ], + 'outputs' => [ + ETHSCRIPTION_STRUCT_ABI + ] + }, + { + 'name' => 'getEthscription', + 'type' => 'function', + 'stateMutability' => 'view', + 'inputs' => [ + { 'name' => 'ethscriptionId', 'type' => 'bytes32' }, + { 'name' => 'includeContent', 'type' => 'bool' } + ], + 'outputs' => [ + ETHSCRIPTION_STRUCT_ABI + ] + }, + { + 'name' => 'getEthscriptionContent', + 'type' => 'function', + 'stateMutability' => 'view', + 'inputs' => [ + { 'name' => 'ethscriptionId', 'type' => 'bytes32' } + ], + 'outputs' => [ + { 'name' => '', 'type' => 'bytes' } + ] + }, + { + 'name' => 'ownerOf', + 'type' => 'function', + 'stateMutability' => 'view', + 'inputs' => [ + { 'name' => 'ethscriptionId', 'type' => 'bytes32' } + ], + 'outputs' => [ + { 'name' => '', 'type' => 'address' } + ] + }, + { + 'name' => 'totalSupply', + 'type' => 'function', + 'stateMutability' => 'view', + 'inputs' => [], + 'outputs' => [ + { 'name' => '', 'type' => 'uint256' } + ] + } + ] + + class << self + def get_ethscription_with_content(tx_hash, block_tag: 'latest') + # Use the new getEthscription function that returns everything including content + tx_hash_bytes32 = format_bytes32(tx_hash) + + # Build function signature and encode parameters + function_sig = Eth::Util.keccak256('getEthscription(bytes32)')[0...4] + + # Encode the parameter (bytes32 is already 32 bytes) + calldata = function_sig + [tx_hash_bytes32].pack('H*') + + # Make the eth_call + result = eth_call('0x' + calldata.unpack1('H*'), block_tag) + # When contract returns 0x/0x0, the ethscription doesn't exist (not an error, just not found) + return nil if result == '0x' || result == '0x0' + + # If result is nil, that's an RPC/network error + raise StandardError, "RPC call failed for ethscription #{tx_hash}" if result.nil? + + # Decode the single Ethscription struct with all fields including content + # Struct order: ethscriptionId, ethscriptionNumber, contentUriSha, contentHash, mimetype, content, + # currentOwner, creator, initialOwner, previousOwner, l1BlockHash, + # l1BlockNumber, l2BlockNumber, createdAt, esip6, protocolName, operation + types = ['(bytes32,uint256,bytes32,bytes32,string,bytes,address,address,address,address,bytes32,uint256,uint256,uint256,bool,string,string)'] + decoded = Eth::Abi.decode(types, result) + + # The struct is returned as an array + ethscription_data = decoded[0] + + { + # Identity + ethscription_id: '0x' + ethscription_data[0].unpack1('H*'), + ethscription_number: ethscription_data[1], + + # Content fields + content_uri_sha: '0x' + ethscription_data[2].unpack1('H*'), + content_hash: '0x' + ethscription_data[3].unpack1('H*'), + mimetype: ethscription_data[4], + content: ethscription_data[5], # content is now at index 5 + + # Ownership fields + current_owner: Eth::Address.new(ethscription_data[6]).to_s, + creator: Eth::Address.new(ethscription_data[7]).to_s, + initial_owner: Eth::Address.new(ethscription_data[8]).to_s, + previous_owner: Eth::Address.new(ethscription_data[9]).to_s, + + # Block/time data + l1_block_hash: '0x' + ethscription_data[10].unpack1('H*'), + l1_block_number: ethscription_data[11], + l2_block_number: ethscription_data[12], + created_at: ethscription_data[13], + + # Protocol + esip6: ethscription_data[14], + protocol_name: ethscription_data[15], + operation: ethscription_data[16] + } + rescue EthRpcClient::ExecutionRevertedError => e + # Contract reverted - ethscription doesn't exist + Rails.logger.debug "Ethscription #{tx_hash} doesn't exist (contract reverted): #{e.message}" + nil + end + + def get_ethscription(tx_hash, block_tag: 'latest') + # Use getEthscription with includeContent=false for gas-optimized queries without content + tx_hash_bytes32 = format_bytes32(tx_hash) + + # Build function signature and encode parameters + function_sig = Eth::Util.keccak256('getEthscription(bytes32,bool)')[0...4] + + # Encode the parameters: bytes32 and bool (false) + # bytes32 (32 bytes) + bool padded to 32 bytes (0x00...00 for false) + calldata = function_sig + [tx_hash_bytes32].pack('H*') + "\x00" * 32 # false as 32 bytes of zeros + + # Make the eth_call + result = eth_call('0x' + calldata.unpack1('H*'), block_tag) + # Deterministic not-found from contract returns 0x/0x0 + return nil if result == '0x' || result == '0x0' + # Nil indicates an RPC/network failure + raise StandardError, "RPC call failed for ethscription #{tx_hash}" if result.nil? + + # Decode the Ethscription struct without content + # Struct order: ethscriptionId, ethscriptionNumber, contentUriSha, contentHash, mimetype, content (empty), + # currentOwner, creator, initialOwner, previousOwner, l1BlockHash, + # l1BlockNumber, l2BlockNumber, createdAt, esip6, protocolName, operation + types = ['(bytes32,uint256,bytes32,bytes32,string,bytes,address,address,address,address,bytes32,uint256,uint256,uint256,bool,string,string)'] + decoded = Eth::Abi.decode(types, result) + + # The struct is returned as an array + ethscription_data = decoded[0] + + { + # Identity + ethscription_id: '0x' + ethscription_data[0].unpack1('H*'), + ethscription_number: ethscription_data[1], + + # Content fields (no actual content bytes) + content_uri_sha: '0x' + ethscription_data[2].unpack1('H*'), + content_hash: '0x' + ethscription_data[3].unpack1('H*'), + mimetype: ethscription_data[4], + # Skip content at index 5 (it's empty for WithoutContent) + + # Ownership fields + current_owner: Eth::Address.new(ethscription_data[6]).to_s, + creator: Eth::Address.new(ethscription_data[7]).to_s, + initial_owner: Eth::Address.new(ethscription_data[8]).to_s, + previous_owner: Eth::Address.new(ethscription_data[9]).to_s, + + # Block/time data + l1_block_hash: '0x' + ethscription_data[10].unpack1('H*'), + l1_block_number: ethscription_data[11], + l2_block_number: ethscription_data[12], + created_at: ethscription_data[13], + + # Protocol + esip6: ethscription_data[14], + protocol_name: ethscription_data[15], + operation: ethscription_data[16] + } + rescue EthRpcClient::ExecutionRevertedError => e + # Contract reverted - ethscription doesn't exist + Rails.logger.debug "Ethscription #{tx_hash} doesn't exist (contract reverted): #{e.message}" + nil + end + + def get_ethscription_content(tx_hash, block_tag: 'latest') + # Ensure tx_hash is properly formatted as bytes32 + tx_hash_bytes32 = format_bytes32(tx_hash) + + # Build function signature and encode parameters + function_sig = Eth::Util.keccak256('getEthscriptionContent(bytes32)')[0...4] + + # Encode the parameter (bytes32 is already 32 bytes) + calldata = function_sig + [tx_hash_bytes32].pack('H*') + + # Make the eth_call + result = eth_call('0x' + calldata.unpack1('H*'), block_tag) + return nil if result.nil? || result == '0x' || result == '0x0' + + # Decode using Eth::Abi - returns bytes + decoded = Eth::Abi.decode(['bytes'], result) + + # Return the raw bytes content + decoded[0] + rescue EthRpcClient::ExecutionRevertedError => e + # Contract reverted - ethscription doesn't exist + Rails.logger.debug "Ethscription content #{tx_hash} doesn't exist (contract reverted): #{e.message}" + nil + end + + def get_owner(ethscription_id, block_tag: 'latest') + # Build function signature + function_sig = Eth::Util.keccak256('ownerOf(bytes32)')[0...4] + + # Parameter is the ethscription ID (bytes32) + ethscription_id_bytes32 = format_bytes32(ethscription_id) + + # Encode the parameter + calldata = function_sig + [ethscription_id_bytes32].pack('H*') + + # Make the eth_call + result = eth_call('0x' + calldata.unpack1('H*'), block_tag) + # Some nodes return 0x when the call yields no data + return nil if result == '0x' + # Nil indicates an RPC/network failure + raise StandardError, "RPC call failed for ownerOf #{ethscription_id}" if result.nil? + + # Decode the result - ownerOf returns a single address + decoded = Eth::Abi.decode(['address'], result) + Eth::Address.new(decoded[0]).to_s + rescue EthRpcClient::ExecutionRevertedError => e + # Contract reverted - token doesn't exist + Rails.logger.debug "Ethscription #{ethscription_id} doesn't exist (contract reverted): #{e.message}" + nil + end + + def get_total_supply(block_tag: 'latest') + # Build function signature + function_sig = Eth::Util.keccak256('totalSupply()')[0...4] + + # No parameters for totalSupply + calldata = '0x' + function_sig.unpack1('H*') + + # Make the eth_call + result = eth_call(calldata, block_tag) + return 0 if result.nil? || result == '0x' + + # Decode the result + decoded = Eth::Abi.decode(['uint256'], result) + decoded[0] + rescue => e + Rails.logger.error "Failed to get total supply: #{e.message}" + 0 + end + + private + + def eth_call(calldata, block_tag = 'latest') + # calldata should be a hex string starting with 0x + EthRpcClient.l2.call('eth_call', [{ + to: ETHSCRIPTIONS_ADDRESS, + data: calldata + }, block_tag]) + end + + def format_bytes32(hex_value) + # Remove 0x prefix if present and ensure it's 32 bytes + clean_hex = hex_value.to_s.delete_prefix('0x') + + # Pad or truncate to 32 bytes + if clean_hex.length > 64 + clean_hex[0...64] + else + clean_hex.rjust(64, '0') + end + end + + def format_uint256(hex_value) + # Convert hex to integer (transaction hash as uint256) + clean_hex = hex_value.to_s.delete_prefix('0x') + clean_hex.to_i(16) + end + end +end diff --git a/lib/sys_config.rb b/lib/sys_config.rb new file mode 100644 index 0000000..ed6008a --- /dev/null +++ b/lib/sys_config.rb @@ -0,0 +1,62 @@ +module SysConfig + extend self + + # Fixed L2 parameters + L2_BLOCK_GAS_LIMIT = 10_000_000_000 # Fixed gas limit (gas is never charged) + L2_BLOCK_TIME = 12 + + # System addresses (matching Solidity contracts) + SYSTEM_ADDRESS = Address20.from_hex("0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001") + L1_INFO_ADDRESS = Address20.from_hex("0x4200000000000000000000000000000000000015") + ETHSCRIPTIONS_ADDRESS = Address20.from_hex("0x3300000000000000000000000000000000000001") + + # Deposit transaction domains + USER_DEPOSIT_SOURCE_DOMAIN = 0 + L1_INFO_DEPOSIT_SOURCE_DOMAIN = 1 + + def ethscriptions_contract_address + ETHSCRIPTIONS_ADDRESS + end + + + def block_gas_limit(block = nil) + L2_BLOCK_GAS_LIMIT + end + + def l1_genesis_block_number + ENV.fetch('L1_GENESIS_BLOCK').to_i + end + + def current_l1_network + ChainIdManager.current_l1_network + end + + # ESIP fork block numbers + def esip1_enabled?(block_number) + on_testnet? || block_number >= 17672762 + end + + def esip2_enabled?(block_number) + on_testnet? || block_number >= 17764910 + end + + def esip3_enabled?(block_number) + on_testnet? || block_number >= 18130000 + end + + def esip5_enabled?(block_number) + on_testnet? || block_number >= 18330000 + end + + def esip7_enabled?(block_number) + on_testnet? || block_number >= 19376500 + end + + def esip8_enabled?(block_number) + on_testnet? || block_number >= 19526000 + end + + def on_testnet? + !ChainIdManager.on_mainnet? + end +end diff --git a/lib/tasks/genesis.rake b/lib/tasks/genesis.rake new file mode 100644 index 0000000..66589db --- /dev/null +++ b/lib/tasks/genesis.rake @@ -0,0 +1,6 @@ +namespace :genesis do + desc "Generate L2 genesis artifacts" + task :generate => :environment do + GenesisGenerator.new.run! + end +end diff --git a/lib/tasks/geth.rake b/lib/tasks/geth.rake new file mode 100644 index 0000000..15e644f --- /dev/null +++ b/lib/tasks/geth.rake @@ -0,0 +1,6 @@ +namespace :geth do + desc "Print the Geth init command" + task :init_command => :environment do + puts GethDriver.init_command + end +end diff --git a/lib/universal_client.rb b/lib/universal_client.rb deleted file mode 100644 index e8fa055..0000000 --- a/lib/universal_client.rb +++ /dev/null @@ -1,59 +0,0 @@ -class UniversalClient - attr_accessor :base_url, :api_key - - def initialize(base_url: ENV['ETHEREUM_CLIENT_BASE_URL'], api_key: nil) - self.base_url = base_url.chomp('/') - self.api_key = api_key - end - - def headers - { - 'Accept' => 'application/json', - 'Content-Type' => 'application/json' - } - end - - def query_api(method:, params: []) - data = { - id: 1, - jsonrpc: '2.0', - method: method, - params: params - } - url = [base_url, api_key].join('/') - HTTParty.post(url, body: data.to_json, headers: headers).parsed_response - end - - def get_block(block_number) - query_api( - method: 'eth_getBlockByNumber', - params: ['0x' + block_number.to_s(16), true] - ) - end - - def get_transaction_receipt(transaction_hash) - query_api( - method: 'eth_getTransactionReceipt', - params: [transaction_hash] - ) - end - - def get_transaction_receipts(block_number, blocks_behind: nil) - receipts = query_api( - method: 'eth_getBlockReceipts', - params: ["0x" + block_number.to_s(16)] - )['result'] - - { - 'id' => 1, - 'jsonrpc' => '2.0', - 'result' => { - 'receipts' => receipts - } - } - end - - def get_block_number - query_api(method: 'eth_blockNumber')['result'].to_i(16) - end -end diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index c19f78a..0000000 --- a/public/robots.txt +++ /dev/null @@ -1 +0,0 @@ -# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/spec/integration/collections_header_protocol_spec.rb b/spec/integration/collections_header_protocol_spec.rb new file mode 100644 index 0000000..793c4e4 --- /dev/null +++ b/spec/integration/collections_header_protocol_spec.rb @@ -0,0 +1,515 @@ +require 'rails_helper' +require 'base64' +require_relative '../../lib/protocol_event_reader' + +RSpec.describe "Header-Based Collections Protocol", type: :integration do + include EthscriptionsTestHelper + + let(:alice) { valid_address("alice") } + let(:bob) { valid_address("bob") } + let(:carol) { valid_address("carol") } + let(:media_type) { 'image/png' } + let(:force_merkle_sender) { "0x0000000000000000000000000000000000000042" } + let(:zero_merkle_root) { '0x' + '0' * 64 } + + # Small "image" payloads live in the data URI body; protocol data lives in headers + let(:items_manifest) do + [ + { + item_index: 0, + name: "Header Genesis", + background_color: "#111111", + description: "Leader image stored with header metadata", + attributes: [ + {"trait_type" => "Tier", "value" => "Genesis"}, + {"trait_type" => "Artist", "value" => "Alice"} + ], + base64_content: Base64.strict_encode64("header-genesis-image") + }, + { + item_index: 1, + name: "Header Entry #1", + background_color: "#222222", + description: "Bob adds via header path", + attributes: [ + {"trait_type" => "Tier", "value" => "Member"}, + {"trait_type" => "Artist", "value" => "Bob"} + ], + base64_content: Base64.strict_encode64("header-bob-image") + }, + { + item_index: 2, + name: "Header Entry #2", + background_color: "#333333", + description: "Carol adds via header path", + attributes: [ + {"trait_type" => "Tier", "value" => "Member"}, + {"trait_type" => "Artist", "value" => "Carol"} + ], + base64_content: Base64.strict_encode64("header-carol-image") + } + ] + end + + let(:merkle_plan) { build_merkle_plan(items_manifest) } + let(:merkle_root) { merkle_plan[:root] } + let(:proofs) { merkle_plan[:proofs] } + + it "mints collection/items from headers and enforces merkle proofs" do + collection_uri = header_data_uri( + op: 'create_collection_and_add_self', + payload: { + "metadata" => metadata_payload(merkle_root: merkle_root, initial_owner: alice), + "item" => item_payload(items_manifest[0], proofs[0]) + }, + content_base64: items_manifest[0][:base64_content] + ) + + creation_results = import_l1_block( + [create_input(creator: alice, to: alice, data_uri: collection_uri)], + esip_overrides: { esip6_is_enabled: true } + ) + creation_receipt = creation_results[:l2_receipts].first + expect(creation_receipt[:status]).to eq('0x1') + + collection_id = creation_results[:ethscription_ids].first + expect(collection_id).to be_present + metadata = get_collection_metadata(collection_id) + expect(metadata[:merkleRoot].downcase).to eq(merkle_root.downcase) + + leader_item = get_collection_item(collection_id, 0) + expect(leader_item[:ethscriptionId]).to eq(collection_id) + expect(leader_item[:name]).to eq(items_manifest[0][:name]) + + bob_uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], proofs[1]), + content_base64: items_manifest[1][:base64_content] + ) + bob_results = import_l1_block( + [create_input(creator: bob, to: bob, data_uri: bob_uri)], + esip_overrides: { esip6_is_enabled: true } + ) + bob_receipt = bob_results[:l2_receipts].first + expect(bob_receipt[:status]).to eq('0x1') + bob_events = ProtocolEventReader.parse_receipt_events(bob_receipt) + expect(bob_events.any? { |e| e[:event] == 'ItemsAdded' }).to eq(true) + expect(bob_events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' }).to eq(true) + item1_id = bob_results[:ethscription_ids].first + expect(get_collection_item(collection_id, 1)[:ethscriptionId]).to eq(item1_id) + + carol_uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[2], proofs[2]), + content_base64: items_manifest[2][:base64_content] + ) + carol_results = import_l1_block( + [create_input(creator: carol, to: carol, data_uri: carol_uri)], + esip_overrides: { esip6_is_enabled: true } + ) + carol_receipt = carol_results[:l2_receipts].first + expect(carol_receipt[:status]).to eq('0x1') + carol_events = ProtocolEventReader.parse_receipt_events(carol_receipt) + expect(carol_events.any? { |e| e[:event] == 'ItemsAdded' }).to eq(true) + expect(carol_events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' }).to eq(true) + item2_id = carol_results[:ethscription_ids].first + expect(get_collection_item(collection_id, 2)[:ethscriptionId]).to eq(item2_id) + + expect(get_collection_state(collection_id)[:currentSize]).to eq(3) + + forged_item = { + item_index: 3, + name: "Forged Entry", + background_color: "#444444", + description: "Should fail merkle proof", + attributes: [ + {"trait_type" => "Tier", "value" => "Spoof"}, + {"trait_type" => "Artist", "value" => "Mallory"} + ], + base64_content: Base64.strict_encode64("forged-header-image") + } + + forged_uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, forged_item, proofs[0]), # wrong proof on purpose + content_base64: forged_item[:base64_content] + ) + # Use the hard-coded force-merkle sender so enforcement applies even in import mode + forged_results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: forged_uri)], + esip_overrides: { esip6_is_enabled: true } + ) + forged_receipt = forged_results[:l2_receipts].first + forged_events = ProtocolEventReader.parse_receipt_events(forged_receipt) + + failure = forged_events.find { |e| e[:event] == 'ProtocolHandlerFailed' } + expect(failure).not_to be_nil + expect(failure[:reason].to_s).to match(/Invalid Merkle proof/i) + expect(get_collection_state(collection_id)[:currentSize]).to eq(3) + end + + context 'unhappy paths' do + describe 'content hash mismatch' do + it 'rejects when actual image differs from merkle leaf content' do + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root) + + # Use correct proof and metadata for items_manifest[1], but WRONG image content + tampered_content = Base64.strict_encode64("tampered-image-not-header-bob-image") + + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], proofs[1]), + content_base64: tampered_content + ) + + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Invalid Merkle proof/i) + end + end + + describe 'collection validation' do + it 'rejects add to non-existent collection' do + fake_collection_id = '0x' + 'dead' * 16 + + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(fake_collection_id, items_manifest[1], proofs[1]), + content_base64: items_manifest[1][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Collection does not exist/i) + end + + it 'rejects add to locked collection' do + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root) + + # Lock the collection + lock_uri = json_data_uri({ + "p" => "erc-721-ethscriptions-collection", + "op" => "lock_collection", + "collection_id" => collection_id + }) + import_l1_block( + [create_input(creator: alice, to: alice, data_uri: lock_uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + # Verify collection is locked + expect(get_collection_state(collection_id)[:locked]).to eq(true) + + # Try to add item - should fail + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], proofs[1]), + content_base64: items_manifest[1][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Collection is locked/i) + end + end + + describe 'supply limits' do + it 'rejects when exceeding max_supply' do + # Create collection with max_supply of 1 (item 0 already added via create_collection_and_add_self) + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root, max_supply: "1") + + # Collection already has 1 item (item 0), try to add item 1 - should fail + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], proofs[1]), + content_base64: items_manifest[1][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Exceeds max supply/i) + end + end + + describe 'item slot conflicts' do + it 'rejects duplicate item_index' do + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root) + + # Item 0 is already added via create_header_collection + # Try to add another item at index 0 (different content) + different_item_at_index_0 = { + item_index: 0, + name: "Different Item", + background_color: "#999999", + description: "Trying to overwrite slot 0", + attributes: [{"trait_type" => "Test", "value" => "Duplicate"}], + base64_content: Base64.strict_encode64("different-content-for-slot-0") + } + + # Build a single-item merkle tree for this new item (so proof is valid) + single_plan = build_merkle_plan([different_item_at_index_0]) + + # First, we need to update collection's merkle root to accept this item + # Actually, let's just use the owner to bypass merkle - simpler test + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, different_item_at_index_0, []), + content_base64: different_item_at_index_0[:base64_content] + ) + + # Owner can bypass merkle, but slot is still taken + results = import_l1_block( + [create_input(creator: alice, to: alice, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Item slot taken/i) + end + end + + describe 'merkle proof failures' do + it 'rejects with empty proof when merkle_root is set' do + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root) + + # Try to add with empty proof + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], []), # Empty proof! + content_base64: items_manifest[1][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Invalid Merkle proof/i) + end + + it 'rejects with proof for different item_index' do + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root) + + # Use item 1's content but item 2's proof + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], proofs[2]), # Wrong proof! + content_base64: items_manifest[1][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Invalid Merkle proof/i) + end + + it 'rejects when merkle_root is zero and non-owner tries with enforcement' do + # Create collection with zero merkle root + collection_id = create_header_collection(owner: alice, merkle_root: zero_merkle_root) + + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], []), + content_base64: items_manifest[1][:base64_content] + ) + + # force_merkle_sender triggers enforcement, but merkle_root is 0 → "Merkle proof required" + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Merkle proof required/i) + end + end + end + + context 'owner privileges' do + it 'allows owner to add without proof even when merkle_root is set' do + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root) + + # Owner adds item 1 without a valid proof (empty proof) + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], []), # No proof needed for owner + content_base64: items_manifest[1][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: alice, to: alice, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + receipt = results[:l2_receipts].first + events = ProtocolEventReader.parse_receipt_events(receipt) + expect(events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' }).to eq(true) + expect(events.any? { |e| e[:event] == 'ItemsAdded' }).to eq(true) + expect(get_collection_state(collection_id)[:currentSize]).to eq(2) + end + end + + # Helper methods for tests + def expect_protocol_failure(receipt, error_pattern) + events = ProtocolEventReader.parse_receipt_events(receipt) + failure = events.find { |e| e[:event] == 'ProtocolHandlerFailed' } + expect(failure).not_to be_nil, "Expected ProtocolHandlerFailed event but got: #{events.map { |e| e[:event] }}" + expect(failure[:reason].to_s).to match(error_pattern), + "Expected error matching #{error_pattern.inspect}, got: #{failure[:reason]}" + end + + def create_header_collection(owner:, merkle_root:, max_supply: "4") + uri = header_data_uri( + op: 'create_collection_and_add_self', + payload: { + "metadata" => metadata_payload(merkle_root: merkle_root, initial_owner: owner).merge("max_supply" => max_supply), + "item" => item_payload(items_manifest[0], proofs[0]) + }, + content_base64: items_manifest[0][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: owner, to: owner, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) +# binding.irb + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "Collection creation failed" + results[:ethscription_ids].first + end + + def json_data_uri(hash) + "data:," + JSON.generate(hash) + end + + def metadata_payload(merkle_root:, initial_owner:, name: nil) + { + "name" => name || "Header Merkle Collection #{SecureRandom.hex(4)}", + "symbol" => "HDR", + "max_supply" => "4", + "description" => "Header-based minting flow", + "logo_image_uri" => "esc://logo/header", + "banner_image_uri" => "", + "background_color" => "#000000", + "website_link" => "https://example.com", + "twitter_link" => "https://twitter.com/example", + "discord_link" => "", + "merkle_root" => merkle_root, + "initial_owner" => initial_owner + } + end + + def item_payload(item, proof) + { + "item_index" => item[:item_index].to_s, + "name" => item[:name], + "background_color" => item[:background_color], + "description" => item[:description], + "attributes" => item[:attributes], + "merkle_proof" => proof + } + end + + def add_item_payload(collection_id, item, proof) + { + "collection_id" => collection_id, + "item" => item_payload(item, proof) + } + end + + def header_data_uri(op:, payload:, content_base64:) + encoded_payload = Base64.strict_encode64(JSON.generate(payload)) + "data:#{media_type};p=erc-721-ethscriptions-collection;op=#{op};d=#{encoded_payload};base64,#{content_base64}" + end + + def build_merkle_plan(manifest) + leaves_bin = [] + proofs = {} + + manifest.each do |item| + content_bytes = Base64.strict_decode64(item[:base64_content]) + content_hash_hex = '0x' + Eth::Util.keccak256(content_bytes).unpack1('H*') + leaf_hex = compute_leaf_hash(content_hash_hex: content_hash_hex, item: item) + leaves_bin << [leaf_hex.delete_prefix('0x')].pack('H*') + end + + levels = build_merkle_tree_levels(leaves_bin) + root_hex = '0x' + levels.last.first.unpack1('H*') + + leaves_bin.each_with_index do |leaf, idx| + proofs[idx] = build_proof_for_index(levels, idx) + raise "invalid merkle proof for leaf #{idx}" unless verify_proof(leaf, proofs[idx], root_hex) + end + + { root: root_hex, proofs: proofs } + end + + def build_merkle_tree_levels(leaves) + return [[''.b]] if leaves.empty? + + levels = [leaves] + while levels.last.length > 1 + current = levels.last + next_level = [] + + current.each_slice(2) do |left, right| + right ||= left + a, b = [left, right].sort + next_level << Eth::Util.keccak256(a + b) + end + + levels << next_level + end + + levels + end + + def build_proof_for_index(levels, index) + proof = [] + level_index = index + + levels[0...-1].each do |level| + sibling_index = level_index ^ 1 + sibling = level[sibling_index] || level[level_index] + proof << '0x' + sibling.unpack1('H*') + level_index /= 2 + end + + proof + end + + def verify_proof(leaf, proof_hex, expected_root_hex) + computed = leaf + + proof_hex.each do |hex| + sibling = [hex.delete_prefix('0x')].pack('H*') + a, b = [computed, sibling].sort + computed = Eth::Util.keccak256(a + b) + end + + ('0x' + computed.unpack1('H*')).casecmp(expected_root_hex).zero? + end + + def compute_leaf_hash(content_hash_hex:, item:) + content_hash_bytes = [content_hash_hex.delete_prefix('0x')].pack('H*') + attrs = item[:attributes].map { |attr| [attr["trait_type"], attr["value"]] } + + encoded = Eth::Abi.encode( + ['bytes32', 'uint256', 'string', 'string', 'string', '(string,string)[]'], + [content_hash_bytes, item[:item_index], item[:name], item[:background_color], item[:description], attrs] + ) + + '0x' + Eth::Util.keccak256(encoded).unpack1('H*') + end +end diff --git a/spec/integration/collections_protocol_e2e_spec.rb b/spec/integration/collections_protocol_e2e_spec.rb new file mode 100644 index 0000000..bb8801f --- /dev/null +++ b/spec/integration/collections_protocol_e2e_spec.rb @@ -0,0 +1,502 @@ +require 'rails_helper' +require_relative '../../lib/protocol_event_reader' + +RSpec.describe "Collections Protocol End-to-End", type: :integration do + include EthscriptionsTestHelper + + let(:alice) { valid_address("alice") } + let(:bob) { valid_address("bob") } + let(:dummy_recipient) { valid_address("recipient") } + let(:zero_merkle_root) { '0x' + '0' * 64 } + + # Helper method to create and validate ethscriptions using the same pattern as the first test + def create_and_validate_ethscription(creator:, to:, data_uri:) + tx_spec = create_input( + creator: creator, + to: to, + data_uri: data_uri + ) + + # Enable ESIP-6 to allow duplicate content + results = import_l1_block([tx_spec], esip_overrides: { esip6_is_enabled: true }) + + # Check if ethscription was created + ethscription_id = results[:ethscription_ids]&.first + success = ethscription_id.present? && results[:l2_receipts]&.first&.fetch(:status, nil) == '0x1' + + # Parse protocol data + protocol_extracted = false + protocol = nil + operation = nil + + begin + protocol, operation, _encoded_data = ProtocolParser.for_calldata(data_uri) + protocol_extracted = protocol.present? && operation.present? + rescue => e + # Protocol extraction failed + end + + # Parse events if we have L2 receipts + protocol_success = false + protocol_event = nil + protocol_error = nil + items_added_count = nil + + if results[:l2_receipts].present? + receipt = results[:l2_receipts].first + require_relative '../../lib/protocol_event_reader' + events = ProtocolEventReader.parse_receipt_events(receipt) + + events.each do |event| + case event[:event] + when 'ProtocolHandlerSuccess' + protocol_success = true + when 'ProtocolHandlerFailed' + protocol_success = false + protocol_error = event[:reason] + when 'CollectionCreated' + protocol_event = 'CollectionCreated' + when 'ItemsAdded' + protocol_event = 'ItemsAdded' + items_added_count = event[:count] + when 'CollectionEdited' + protocol_event = 'CollectionEdited' + end + end + end + + { + success: success, + ethscription_id: ethscription_id, + protocol_extracted: protocol_extracted, + protocol_success: protocol_success, + protocol_event: protocol_event, + protocol_error: protocol_error, + items_added_count: items_added_count + } + end + + describe "Complete Collection Workflow with Protocol Validation" do + let(:collection_id) { nil } + + it "creates collection and validates protocol execution" do + # Use the simple, working pattern + collection_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Test NFT", + "symbol" => "TNFT", + "max_supply" => "100", + "description" => "Test", + "logo_image_uri" => "", + "banner_image_uri" => "", + "background_color" => "", + "website_link" => "", + "twitter_link" => "", + "discord_link" => "", + "merkle_root" => zero_merkle_root, + "initial_owner" => alice + } + + tx_spec = create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + collection_data.to_json + ) + + results = import_l1_block([tx_spec]) + + # Validate ethscription creation + expect(results[:ethscription_ids]).not_to be_empty, "Should create ethscription" + expect(results[:l2_receipts]).not_to be_empty, "Should have L2 receipt" + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "L2 transaction should succeed" + + collection_id = results[:ethscription_ids].first + expect(collection_id).to be_present + + # Validate ethscription content stored correctly + stored = get_ethscription_content(collection_id) + json_str = stored[:content] + json_str = json_str.sub(/\Adata:,/, '') if json_str.start_with?('data:,') + parsed = begin + JSON.parse(json_str) + rescue JSON::ParserError + raise RSpec::Expectations::ExpectationNotMetError, "Stored content not valid JSON: #{json_str.inspect}" + end + + expect(parsed['p']).to eq('erc-721-ethscriptions-collection') + expect(parsed['op']).to eq('create_collection') + expect(parsed['name']).to eq('Test NFT') + + # Parse events to check protocol execution + if results[:l2_receipts].first[:logs].any? + require_relative '../../lib/protocol_event_reader' + + events = ProtocolEventReader.parse_receipt_events(results[:l2_receipts].first) + + # Check for protocol success + protocol_success = events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' } + protocol_failed = events.any? { |e| e[:event] == 'ProtocolHandlerFailed' } + + # Fallback: treat presence of expected protocol events as success if no explicit success/failure was emitted + if !protocol_success && !protocol_failed + protocol_success = events.any? { |e| [ + 'CollectionCreated', 'ItemsAdded', 'ItemsRemoved', 'CollectionEdited', 'CollectionLocked' + ].include?(e[:event]) } + end + + expect(protocol_success).to eq(true), "Protocol handler should succeed" + + # Check for collection created event + collection_created = events.any? { |e| e[:event] == 'CollectionCreated' } + expect(collection_created).to eq(true), "Should emit CollectionCreated event" + else + fail "No logs found in L2 receipt!" + end + + # Validate collection state in contract storage + collection_state = get_collection_state(collection_id) + expect(collection_state).not_to be_nil, "Collection state should be available" + # Check if collection exists by verifying the contract address is not zero + expect(collection_state[:collectionContract]).not_to eq('0x0000000000000000000000000000000000000000'), "Collection not found in contract storage" + expect(collection_state[:currentSize]).to eq(0), "Collection should start empty" + expect(collection_state[:locked]).to eq(false), "Collection should not be locked initially" + end + end + + describe "Add Items Batch with Nested Attributes" do + it "adds multiple items with attributes and validates execution" do + # Use the same pattern as the first test which works + collection_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Test Batch Collection", + "symbol" => "TBATCH", + "max_supply" => "100", + "description" => "Test batch collection", + "logo_image_uri" => "", + "banner_image_uri" => "", + "background_color" => "", + "website_link" => "", + "twitter_link" => "", + "discord_link" => "", + "merkle_root" => zero_merkle_root, + "initial_owner" => alice + } + + # Create collection using the same pattern as the first test + tx_spec = create_input( + creator: alice, + to: alice, + data_uri: "data:," + collection_data.to_json + ) + + results = import_l1_block([tx_spec], esip_overrides: { esip6_is_enabled: true }) + + # Validate collection creation + expect(results[:ethscription_ids]).not_to be_empty, "Should create ethscription" + expect(results[:l2_receipts]).not_to be_empty, "Should have L2 receipt" + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "L2 transaction should succeed" + + collection_id = results[:ethscription_ids].first + expect(collection_id).to be_present + + # Now create the ethscriptions that add themselves to the collection + # Item 1: Create ethscription with add_self_to_collection + item1_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "add_self_to_collection", + "collection_id" => collection_id, + "item" => { + "item_index" => "0", + "name" => "Test Item #0", + "background_color" => "#648595", + "description" => "First test item with multiple attributes", + "attributes" => [ + {"trait_type" => "Type", "value" => "Common"}, + {"trait_type" => "Color", "value" => "Blue"}, + {"trait_type" => "Rarity", "value" => "1"}, + {"trait_type" => "Power", "value" => "100"} + ], + "merkle_proof" => [] + } + } + + item1_spec = create_input( + creator: alice, + to: alice, + data_uri: "data:," + item1_data.to_json + ) + + item1_results = import_l1_block([item1_spec], esip_overrides: { esip6_is_enabled: true }) + item1_id = item1_results[:ethscription_ids].first + expect(item1_id).to be_present, "Item 1 should be created" + + # Item 2: Create ethscription with add_self_to_collection + item2_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "add_self_to_collection", + "collection_id" => collection_id, + "item" => { + "item_index" => "1", + "name" => "Test Item #1", + "background_color" => "#FF5733", + "description" => "Second test item with different attributes", + "attributes" => [ + {"trait_type" => "Type", "value" => "Rare"}, + {"trait_type" => "Color", "value" => "Red"}, + {"trait_type" => "Rarity", "value" => "5"}, + {"trait_type" => "Power", "value" => "500"} + ], + "merkle_proof" => [] + } + } + + item2_spec = create_input( + creator: alice, + to: alice, + data_uri: "data:," + item2_data.to_json + ) + + item2_results = import_l1_block([item2_spec], esip_overrides: { esip6_is_enabled: true }) + item2_id = item2_results[:ethscription_ids].first + expect(item2_id).to be_present, "Item 2 should be created" + + # Validate first item addition + expect(item1_results[:ethscription_ids]).not_to be_empty, "Should create first item ethscription" + expect(item1_results[:l2_receipts]).not_to be_empty, "Should have L2 receipt for item 1" + + # Parse events for item 1 + require_relative '../../lib/protocol_event_reader' + item1_events = ProtocolEventReader.parse_receipt_events(item1_results[:l2_receipts].first) + + item1_added_event = item1_events.find { |e| e[:event] == 'ItemsAdded' } + expect(item1_added_event).not_to be_nil, "Should emit ItemsAdded event for item 1" + expect(item1_added_event[:count]).to eq(1), "Should add 1 item" + + # Validate second item addition + expect(item2_results[:ethscription_ids]).not_to be_empty, "Should create second item ethscription" + expect(item2_results[:l2_receipts]).not_to be_empty, "Should have L2 receipt for item 2" + + # Parse events for item 2 + item2_events = ProtocolEventReader.parse_receipt_events(item2_results[:l2_receipts].first) + + item2_added_event = item2_events.find { |e| e[:event] == 'ItemsAdded' } + expect(item2_added_event).not_to be_nil, "Should emit ItemsAdded event for item 2" + expect(item2_added_event[:count]).to eq(1), "Should add 1 item" + + # Check for protocol success for both items + item1_success = item1_events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' } + expect(item1_success).to eq(true), "Protocol operation should succeed for item 1" + + item2_success = item2_events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' } + expect(item2_success).to eq(true), "Protocol operation should succeed for item 2" + + # Validate collection state updated + collection_state = get_collection_state(collection_id) + expect(collection_state[:currentSize]).to eq(2), "Collection size should be 2 after adding items" + + # Validate individual items + item0 = get_collection_item(collection_id, 0) + expect(item0[:name]).to eq("Test Item #0") + expect(item0[:ethscriptionId]).to eq(item1_id) # Use the actual ID from creation + expect(item0[:backgroundColor]).to eq("#648595") + expect(item0[:description]).to eq("First test item with multiple attributes") + expect(item0[:attributes].length).to eq(4) + expect(item0[:attributes][0]).to eq(["Type", "Common"]) + expect(item0[:attributes][1]).to eq(["Color", "Blue"]) + + item1 = get_collection_item(collection_id, 1) + expect(item1[:name]).to eq("Test Item #1") + expect(item1[:ethscriptionId]).to eq(item2_id) # Use the actual ID from creation + expect(item1[:attributes].length).to eq(4) + expect(item1[:attributes][0]).to eq(["Type", "Rare"]) + end + end + + describe "Merkle Root Enforcement" do + let(:owner_merkle_root) { '0x' + '1' * 64 } + + it "allows the collection owner to add an item without a proof when the root is set" do + collection_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Owner Only", + "symbol" => "OWNR", + "max_supply" => "10", + "description" => "Testing owner bypass", + "logo_image_uri" => "", + "banner_image_uri" => "", + "background_color" => "", + "website_link" => "", + "twitter_link" => "", + "discord_link" => "", + "merkle_root" => owner_merkle_root, + "initial_owner" => alice + } + + collection_spec = create_input( + creator: alice, + to: alice, + data_uri: "data:," + JSON.generate(collection_data) + ) + + collection_results = import_l1_block([collection_spec], esip_overrides: { esip6_is_enabled: true }) + expect(collection_results[:ethscription_ids]).not_to be_empty + collection_id = collection_results[:ethscription_ids].first + + owner_item = { + "p" => "erc-721-ethscriptions-collection", + "op" => "add_self_to_collection", + "collection_id" => collection_id, + "item" => { + "item_index" => "0", + "name" => "Owner Item #0", + "background_color" => "#123456", + "description" => "Inserted by the owner without a proof", + "attributes" => [ + {"trait_type" => "Tier", "value" => "Owner"} + ], + "merkle_proof" => [] + } + } + + owner_spec = create_input( + creator: alice, + to: alice, + data_uri: "data:," + JSON.generate(owner_item) + ) + + owner_results = import_l1_block([owner_spec], esip_overrides: { esip6_is_enabled: true }) + expect(owner_results[:ethscription_ids]).not_to be_empty + owner_item_id = owner_results[:ethscription_ids].first + + receipt = owner_results[:l2_receipts].first + events = ProtocolEventReader.parse_receipt_events(receipt) + expect(events.any? { |e| e[:event] == 'ProtocolHandlerFailed' }).to eq(false) + expect(events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' }).to eq(true) + + added_event = events.find { |e| e[:event] == 'ItemsAdded' } + expect(added_event).not_to be_nil + expect(added_event[:count]).to eq(1) + + stored_item = get_collection_item(collection_id, 0) + expect(stored_item[:ethscriptionId]).to eq(owner_item_id) + expect(stored_item[:name]).to eq("Owner Item #0") + end + + it "updates the merkle root via edit_collection to allow a non-owner add" do + initial_merkle_root = zero_merkle_root + collection_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Editable Root", + "symbol" => "EDIT", + "max_supply" => "10", + "description" => "Testing merkle root edits", + "logo_image_uri" => "", + "banner_image_uri" => "", + "background_color" => "", + "website_link" => "", + "twitter_link" => "", + "discord_link" => "", + "merkle_root" => initial_merkle_root, + "initial_owner" => alice + } + + collection_spec = create_input( + creator: alice, + to: alice, + data_uri: "data:," + JSON.generate(collection_data) + ) + + collection_results = import_l1_block([collection_spec], esip_overrides: { esip6_is_enabled: true }) + collection_id = collection_results[:ethscription_ids].first + expect(collection_id).to be_present + metadata_before_edit = get_collection_metadata(collection_id) + expect(metadata_before_edit[:merkleRoot].downcase).to eq(initial_merkle_root.downcase) + + allowlist_attributes = [{"trait_type" => "Tier", "value" => "Founder"}] + item_template = { + "p" => "erc-721-ethscriptions-collection", + "op" => "add_self_to_collection", + "collection_id" => collection_id, + "item" => { + "item_index" => "0", + "name" => "Allowlisted Item #0", + "background_color" => "#abcdef", + "description" => "Non-owner entry gated by the root", + "attributes" => allowlist_attributes, + "merkle_proof" => [] + } + } + + item_json = JSON.generate(item_template) + content_hash_hex = "0x#{Eth::Util.keccak256(item_json).unpack1('H*')}" + attribute_pairs = allowlist_attributes.map { |attr| [attr["trait_type"], attr["value"]] } + computed_root = compute_single_leaf_root( + content_hash_hex: content_hash_hex, + item_index: 0, + name: item_template["item"]["name"], + background_color: item_template["item"]["background_color"], + description: item_template["item"]["description"], + attributes: attribute_pairs + ) + + edit_payload = { + "p" => "erc-721-ethscriptions-collection", + "op" => "edit_collection", + "collection_id" => collection_id, + "description" => "", + "logo_image_uri" => "", + "banner_image_uri" => "", + "background_color" => "", + "website_link" => "", + "twitter_link" => "", + "discord_link" => "", + "merkle_root" => computed_root + } + + edit_spec = create_input( + creator: alice, + to: alice, + data_uri: "data:," + JSON.generate(edit_payload) + ) + + edit_results = import_l1_block([edit_spec], esip_overrides: { esip6_is_enabled: true }) + expect(edit_results[:l2_receipts].first[:status]).to eq('0x1') + + metadata_after_edit = get_collection_metadata(collection_id) + expect(metadata_after_edit[:merkleRoot].downcase).to eq(computed_root.downcase) + + second_spec = create_input( + creator: bob, + to: bob, + data_uri: "data:," + item_json + ) + + success_results = import_l1_block([second_spec], esip_overrides: { esip6_is_enabled: true }) + success_receipt = success_results[:l2_receipts].first + success_events = ProtocolEventReader.parse_receipt_events(success_receipt) + expect(success_events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' }).to eq(true) + added_event = success_events.find { |e| e[:event] == 'ItemsAdded' } + expect(added_event).not_to be_nil + expect(added_event[:count]).to eq(1) + + added_item_id = success_results[:ethscription_ids].first + stored_item = get_collection_item(collection_id, 0) + expect(stored_item[:ethscriptionId]).to eq(added_item_id) + + expect(get_collection_metadata(collection_id)[:merkleRoot].downcase).to eq(computed_root.downcase) + end + end + + def compute_single_leaf_root(content_hash_hex:, item_index:, name:, background_color:, description:, attributes:) + content_hash_bytes = [content_hash_hex.delete_prefix('0x')].pack('H*') + encoded = Eth::Abi.encode( + ['bytes32', 'uint256', 'string', 'string', 'string', '(string,string)[]'], + [content_hash_bytes, item_index, name, background_color, description, attributes] + ) + "0x#{Eth::Util.keccak256(encoded).unpack1('H*')}" + end +end diff --git a/spec/integration/collections_protocol_spec.rb b/spec/integration/collections_protocol_spec.rb new file mode 100644 index 0000000..691e629 --- /dev/null +++ b/spec/integration/collections_protocol_spec.rb @@ -0,0 +1,665 @@ +require 'rails_helper' + +RSpec.describe "Collections Protocol", type: :integration do + include EthscriptionsTestHelper + + let(:alice) { valid_address("alice") } + let(:bob) { valid_address("bob") } + let(:charlie) { valid_address("charlie") } + # Ethscriptions are created by sending to any address with data in the input + # The protocol handler is called automatically by the Ethscriptions contract + let(:dummy_recipient) { valid_address("recipient") } + let(:zero_merkle_root) { '0x' + '0' * 64 } + + describe "Collection Creation" do + it "creates a collection with metadata fields" do + collection_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Test NFTs", + "symbol" => "TEST", + "description" => "Test collection", + "max_supply" => "10000", + "logo_image_uri" => "https://example.com/logo.png", + "merkle_root" => zero_merkle_root + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + collection_data.to_json + ) + ) do |results| + # Verify the ethscription was created + ethscription_id = results[:ethscription_ids].first + stored = get_ethscription_content(ethscription_id) + + content_str = stored[:content] + json_payload = content_str.start_with?('data:,') ? content_str.sub(/\Adata:,/, '') : content_str + parsed = begin + JSON.parse(json_payload) + rescue JSON::ParserError + raise RSpec::Expectations::ExpectationNotMetError, "Stored content not valid JSON: #{json_payload.inspect}" + end + + expect(parsed['p']).to eq('erc-721-ethscriptions-collection') + expect(parsed['op']).to eq('create_collection') + expect(parsed['name']).to eq('Test NFTs') + + # TODO: Once contract is deployed, verify collection was created in contract storage + end + end + + it "creates a minimal collection with only required fields" do + collection_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Minimal Collection", + "symbol" => "MIN", + "max_supply" => "1000", + "merkle_root" => zero_merkle_root + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + collection_data.to_json + ) + ) + end + + it "handles numeric strings for max_supply (JS compatibility)" do + collection_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Big Supply Collection", + "symbol" => "BIG", + "max_supply" => "1000000000000000000", # Large number as string + "merkle_root" => zero_merkle_root + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + collection_data.to_json + ) + ) + end + end + + describe "Collection Items" do + let(:collection_id) { "0x1234567890123456789012345678901234567890" } + + it "creates an item with NFT attributes" do + item_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_item", + "collection_id" => collection_id, + "name" => "Item #1", + "description" => "First item in the collection", + "image_uri" => "https://example.com/item1.png", + "attributes" => [ + {"trait_type" => "Color", "value" => "Blue"}, + {"trait_type" => "Rarity", "value" => "Common"}, + {"trait_type" => "Level", "value" => "5"} + ] + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + item_data.to_json + ) + ) + end + + it "creates an item with camelCase attribute keys" do + item_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_item", + "collection_id" => collection_id, + "name" => "Item #2", + "attributes" => [ + {"trait_type" =>"Size", "value" => "Large"}, + {"trait_type" =>"Speed", "value" => "Fast"} + ] + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + item_data.to_json + ) + ) + end + + it "edits an item to clear attributes using type hint" do + # Clear attributes by providing empty array with type hint + edit_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "edit_item", + "collection_id" => collection_id, + "item_index" =>0, + "name" => "Updated Item", + "attributes" => ["(string,string)[]", []] # Type hint for empty attribute array + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + edit_data.to_json + ) + ) + end + + it "edits an item to update attributes" do + edit_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "edit_item", + "collection_id" => collection_id, + "item_index" =>0, + "attributes" => [ + {"trait_type" => "Color", "value" => "Red"}, + {"trait_type" => "Status", "value" => "Upgraded"} + ] + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + edit_data.to_json + ) + ) + end + end + + describe "Collection Management" do + let(:collection_id) { "0x1234567890123456789012345678901234567890" } + + it "locks a collection" do + lock_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "lock_collection", + "collection_id" => collection_id + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + lock_data.to_json + ) + ) + end + + it "transfers collection ownership explicitly" do + collection_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Ownership Test", + "symbol" => "OWN", + "max_supply" => "10", + "description" => "", + "logo_image_uri" => "", + "banner_image_uri" => "", + "background_color" => "", + "website_link" => "", + "twitter_link" => "", + "discord_link" => "", + "merkle_root" => zero_merkle_root, + "initial_owner" => alice + } + + creation = expect_ethscription_success( + create_input( + creator: alice, + to: alice, + data_uri: "data:," + collection_data.to_json + ) + ) + + created_collection_id = creation[:ethscription_ids].first + initial_owner = CollectionsReader.get_collection_owner(created_collection_id) + expect(initial_owner.downcase).to eq(alice.downcase) + + transfer_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "transfer_ownership", + "collection_id" => created_collection_id, + "new_owner" => bob + } + + expect_ethscription_success( + create_input( + creator: alice, + to: alice, + data_uri: "data:," + transfer_data.to_json + ) + ) + + updated_owner = CollectionsReader.get_collection_owner(created_collection_id) + expect(updated_owner.downcase).to eq(bob.downcase) + end + + it "keeps ownership unchanged when the collection leader ethscription transfers" do + collection_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Leader Transfer", + "symbol" => "LEAD", + "max_supply" => "5", + "description" => "", + "logo_image_uri" => "", + "banner_image_uri" => "", + "background_color" => "", + "website_link" => "", + "twitter_link" => "", + "discord_link" => "", + "merkle_root" => zero_merkle_root, + "initial_owner" => alice + } + + creation = expect_ethscription_success( + create_input( + creator: alice, + to: alice, + data_uri: "data:," + collection_data.to_json + ) + ) + + created_collection_id = creation[:ethscription_ids].first + expect(CollectionsReader.get_collection_owner(created_collection_id).downcase).to eq(alice.downcase) + + expect_transfer_success( + transfer_input(from: alice, to: bob, id: created_collection_id), + created_collection_id, + bob + ) + + # Collection owner stays Alice despite the ethscription transfer + current_owner = CollectionsReader.get_collection_owner(created_collection_id) + expect(current_owner.downcase).to eq(alice.downcase) + end + + it "renounces ownership even when locked" do + collection_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Renounce Test", + "symbol" => "REN", + "max_supply" => "25", + "description" => "", + "logo_image_uri" => "", + "banner_image_uri" => "", + "background_color" => "", + "website_link" => "", + "twitter_link" => "", + "discord_link" => "", + "merkle_root" => zero_merkle_root, + "initial_owner" => alice + } + + creation = expect_ethscription_success( + create_input( + creator: alice, + to: alice, + data_uri: "data:," + collection_data.to_json + ) + ) + + created_collection_id = creation[:ethscription_ids].first + + lock_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "lock_collection", + "collection_id" => created_collection_id + } + + expect_ethscription_success( + create_input( + creator: alice, + to: alice, + data_uri: "data:," + lock_data.to_json + ) + ) + + renounce_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "renounce_ownership", + "collection_id" => created_collection_id + } + + expect_ethscription_success( + create_input( + creator: alice, + to: alice, + data_uri: "data:," + renounce_data.to_json + ) + ) + + owner_after_renounce = CollectionsReader.get_collection_owner(created_collection_id) + expect(owner_after_renounce.downcase).to eq('0x0000000000000000000000000000000000000000') + end + + it "handles batch operations with arrays" do + batch_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "batch_create_items", + "collection_id" => collection_id, + "items" => [ + { + "name" => "Item #1", + "attributes" => [ + {"trait_type" => "Type", "value" => "Common"} + ] + }, + { + "name" => "Item #2", + "attributes" => [ + {"trait_type" => "Type", "value" => "Rare"} + ] + } + ] + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + batch_data.to_json + ) + ) + end + end + + describe "Type Inference" do + it "correctly infers mixed field types" do + mixed_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "complex_operation", + "item_id" => "12345", # String number -> uint256 + "active" => true, # JSON boolean -> bool + "owner" => "0xabcdef1234567890123456789012345678901234", # Address + "data" => "0x" + "a" * 64, # bytes32 + "tags" => ["tag1", "tag2", "tag3"], # string[] + "amounts" => ["100", "200", "300"], # uint256[] + "description" => "Regular string" # string + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + mixed_data.to_json + ) + ) + end + + it "preserves JSON field order for struct compatibility" do + # Fields must be in exact order to match Solidity struct + struct_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "structured_op", + "field1" => "first", + "field2" => "second", + "field3" => "third" + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + struct_data.to_json + ) + ) do |results| + # The protocol parser should preserve field order + # TODO: Once contract is deployed, verify struct was decoded correctly + end + end + end + + describe "Contract State Verification" do + it "creates a collection and verifies it exists in contract" do + # Must include ALL CollectionParams fields in correct order + collection_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Verified Collection", + "symbol" => "VRFY", + "max_supply" => "100", + "description" => "", + "logo_image_uri" => "", + "banner_image_uri" => "", + "background_color" => "", + "website_link" => "", + "twitter_link" => "", + "discord_link" => "", + "merkle_root" => zero_merkle_root, + "initial_owner" => alice + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + collection_data.to_json + ) + ) do |results| + collection_id = results[:ethscription_ids].first + + # Verify collection exists in contract + expect(collection_exists?(collection_id)).to eq(true), "Collection should exist in contract" + + # Verify collection state + state = get_collection_state(collection_id) + expect(state).to be_present + expect(state[:collectionContract]).not_to eq('0x0000000000000000000000000000000000000000') + + expect(state[:createTxHash]).to eq(collection_id) + expect(state[:currentSize]).to eq(0) + expect(state[:locked]).to eq(false) + + # Verify collection metadata + metadata = get_collection_metadata(collection_id) + expect(metadata).to be_present + expect(metadata[:name]).to eq("Verified Collection") + expect(metadata[:symbol]).to eq("VRFY") + expect(metadata[:totalSupply]).to eq(100) + end + end + + it "invalid protocol data creates ethscription but doesn't affect collections" do + # First, count how many collections exist + # initial_collection_count = get_total_collections() + + # Send data with number too large for uint256 + too_big = "115792089237316195423570985008687907853269984665640564039457584007913129639936" + invalid_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Invalid", + "max_supply" => too_big + } + + expect_protocol_extraction_failure( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + invalid_data.to_json + ) + ) do |results, stored| + # Ethscription created and content stored + expect(stored[:content]).to include('"p":"erc-721-ethscriptions-collection"') + + # TODO: Verify collection count didn't increase + # final_collection_count = get_total_collections() + # expect(final_collection_count).to eq(initial_collection_count) + end + end + + it "malformed JSON creates ethscription but doesn't affect collections" do + expect_protocol_extraction_failure( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:,{\"p\":\"collections\",\"op\":\"create\",broken}" + ) + ) do |results, stored| + # TODO: Verify no collection was created + # final_collection_count = get_total_collections() + # expect(final_collection_count).to eq(initial_collection_count) + end + end + end + + describe "End-to-End Collection Workflow" do + it "creates collection, adds items, edits items, and locks collection" do + # Step 1: Create collection + create_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Full Test Collection", + "symbol" => "FULL", + "max_supply" => "10" + } + + create_results = expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + create_data.to_json + ) + ) + + collection_id = create_results[:ethscription_ids].first + + # TODO: Verify collection exists + # collection_info = get_collection_info(collection_id) + # expect(collection_info[:currentSize]).to eq(0) + # expect(collection_info[:locked]).to eq(false) + + # Step 2: Add an item + item_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_item", + "collection_id" => collection_id, + "name" => "Item 1", + "attributes" => [ + {"trait_type" => "Color", "value" => "Blue"} + ] + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + item_data.to_json + ) + ) + + # TODO: Verify item exists + # collection_info = get_collection_info(collection_id) + # expect(collection_info[:currentSize]).to eq(1) + # item = get_collection_item(collection_id, 0) + # expect(item[:name]).to eq("Item 1") + # expect(item[:attributes].length).to eq(1) + + # Step 3: Edit item to clear attributes + edit_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "edit_item", + "collection_id" => collection_id, + "item_index" =>0, + "name" => "Updated Item", + "attributes" => ["(string,string)[]", []] # Type hint for empty array + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + edit_data.to_json + ) + ) + + # TODO: Verify item was updated + # item = get_collection_item(collection_id, 0) + # expect(item[:name]).to eq("Updated Item") + # expect(item[:attributes]).to be_empty + + # Step 4: Lock collection + lock_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "lock_collection", + "collection_id" => collection_id + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + lock_data.to_json + ) + ) + + # TODO: Verify collection is locked + # collection_info = get_collection_info(collection_id) + # expect(collection_info[:locked]).to eq(true) + end + end + + describe "Multi-line JSON Support" do + it "accepts properly formatted multi-line JSON" do + # ProtocolParser now parses JSON instead of using regex + # so multi-line JSON should work + multiline_json = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Multi-line Test", + "description" => "This JSON +spans multiple +lines for readability" + }.to_json + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + multiline_json + ) + ) + end + end + + describe "Boolean String Handling" do + it "treats string 'true' and 'false' as strings, not booleans" do + string_bool_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "test_bools", + "stringTrue" => "true", # Should remain string + "stringFalse" => "false", # Should remain string + "realTrue" => true, # JSON boolean + "realFalse" => false # JSON boolean + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + string_bool_data.to_json + ) + ) do |results| + # TODO: Once contract is deployed, verify correct type handling + # stringTrue/stringFalse should be strings + # realTrue/realFalse should be booleans + end + end + end +end diff --git a/spec/integration/ethscription_transfers_spec.rb b/spec/integration/ethscription_transfers_spec.rb new file mode 100644 index 0000000..f93c16e --- /dev/null +++ b/spec/integration/ethscription_transfers_spec.rb @@ -0,0 +1,392 @@ +require 'rails_helper' + +RSpec.describe "Ethscription Transfers", type: :integration do + include EthscriptionsTestHelper + + let(:alice) { valid_address("alice") } + let(:bob) { valid_address("bob") } + let(:charlie) { valid_address("charlie") } + let(:zero_address) { "0x0000000000000000000000000000000000000000" } + + # Helper for transfer tests - creates ethscription using new DSL + def create_test_ethscription(creator:, to:, content:) + result = expect_ethscription_success( + create_input(creator: creator, to: to, data_uri: "data:text/plain;charset=utf-8,#{content}") + ) + result[:ethscription_ids].first + end + + describe "Single transfer via input" do + it "transfers from owner (happy path)" do + # Setup: create ethscription + create_result = expect_ethscription_success( + create_input(creator: alice, to: alice, data_uri: "data:text/plain;charset=utf-8,Test content") + ) + id1 = create_result[:ethscription_ids].first + + # Transfer to bob + expect_transfer_success( + transfer_input(from: alice, to: bob, id: id1), + id1, + bob + ) + end + + it "reverts transfer by non-owner" do + # Setup: create ethscription owned by bob + create_result = expect_ethscription_success( + create_input(creator: alice, to: bob, data_uri: "data:text/plain;charset=utf-8,Test content2") + ) + id1 = create_result[:ethscription_ids].first + + # Alice tries to transfer bob's ethscription - should revert + expect_transfer_failure( + transfer_input(from: alice, to: charlie, id: id1), + id1, + reason: :revert + ) + end + + it "reverts transfer of nonexistent ID" do + # Random nonexistent ID + fake_id = generate_tx_hash(999) + + results = import_l1_block([ + transfer_input(from: alice, to: bob, id: fake_id) + ]) + + # Should revert for nonexistent ID + expect(results[:l2_receipts].first[:status]).to eq('0x0'), "Should revert for nonexistent ID" + end + end + + describe "Multi-transfer via input (ESIP-5)" do + it "transfers all valid IDs" do + # Setup: create two ethscriptions owned by alice + create1 = expect_ethscription_success( + create_input(creator: alice, to: alice, data_uri: "data:text/plain;charset=utf-8,Content 1") + ) + create2 = expect_ethscription_success( + create_input(creator: alice, to: alice, data_uri: "data:text/plain;charset=utf-8,Content 2") + ) + id1 = create1[:ethscription_ids].first + id2 = create2[:ethscription_ids].first + + # Multi-transfer both to bob + results = import_l1_block([ + transfer_multi_input(from: alice, to: bob, ids: [id1, id2]) + ]) + + # Verify L2 transaction succeeded + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "Multi-transfer should succeed" + + # Verify both ownership changes + expect(get_ethscription_owner(id1).downcase).to eq(bob.downcase) + expect(get_ethscription_owner(id2).downcase).to eq(bob.downcase) + + # Verify content unchanged + expect(get_ethscription_content(id1)[:content]).to eq("Content 1") + expect(get_ethscription_content(id2)[:content]).to eq("Content 2") + end + + it "partial success when some IDs not owned by sender" do + # Setup: id1 owned by alice, id2 owned by bob + id1 = create_test_ethscription(creator: alice, to: alice, content: "Content 3") + id2 = create_test_ethscription(creator: bob, to: bob, content: "Content 4") + + # Alice tries to transfer both (can only transfer id1) + results = import_l1_block([ + transfer_multi_input(from: alice, to: charlie, ids: [id1, id2]) + ]) + + # Verify L2 transaction succeeded (partial success) + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "Multi-transfer should succeed" + + # Only id1 should transfer + expect(get_ethscription_owner(id1).downcase).to eq(charlie.downcase) + expect(get_ethscription_owner(id2).downcase).to eq(bob.downcase) # Unchanged + end + + it "reverts when all IDs invalid" do + # Setup: both ids owned by bob, alice tries to transfer + create1 = expect_ethscription_success( + create_input(creator: bob, to: bob, data_uri: "data:text/plain;charset=utf-8,Content 5") + ) + create2 = expect_ethscription_success( + create_input(creator: bob, to: bob, data_uri: "data:text/plain;charset=utf-8,Content 6") + ) + id1 = create1[:ethscription_ids].first + id2 = create2[:ethscription_ids].first + + results = import_l1_block([ + transfer_multi_input(from: alice, to: charlie, ids: [id1, id2]) + ]) + + # Should revert when no successful transfers + expect(results[:l2_receipts].first[:status]).to eq('0x0'), "Should revert with no successful transfers" + + # No ownership changes + expect(get_ethscription_owner(id1).downcase).to eq(bob.downcase) + expect(get_ethscription_owner(id2).downcase).to eq(bob.downcase) + end + end + + describe "Transfer via event (ESIP-1)" do + it "transfers via event (happy path)" do + # Setup: create ethscription + id1 = create_test_ethscription(creator: alice, to: alice, content: "Test content2a") +# binding.irb + # Transfer via ESIP-1 event + expect_transfer_success( + transfer_event(from: alice, to: bob, id: id1), + id1, + bob + ) + end + + it "ignores event with removed=true" do + id1 = create_test_ethscription(creator: alice, to: alice, content: "Test content3") + + expect_transfer_failure( + l1_tx( + creator: alice, + to: bob, + logs: [ + build_transfer_event(from: alice, to: bob, id: id1).merge('removed' => true) + ] + ), + id1, + reason: :ignored + ) + end + + it "ignores event with wrong topics length" do + id1 = create_test_ethscription(creator: alice, to: alice, content: "Test content4") + + expect_transfer_failure( + l1_tx( + creator: alice, + to: bob, + logs: [ + { + 'address' => alice, + 'topics' => [EthTransaction::Esip1EventSig], # Missing to and id topics + 'data' => '0x', + 'logIndex' => '0x0', + 'removed' => false + } + ] + ), + id1, + reason: :ignored + ) + end + end + + describe "Transfer for previous owner (ESIP-2)" do + it "transfers with correct previous owner" do + # Setup: create ethscription, transfer to bob, then use ESIP-2 + id1 = create_test_ethscription(creator: alice, to: alice, content: "Test content5") + + # First transfer alice -> bob + expect_transfer_success( + transfer_input(from: alice, to: bob, id: id1), + id1, + bob + ) + + # ESIP-2 transfer bob -> charlie with alice as previous owner + expect_transfer_success( + transfer_prev_event(current_owner: bob, prev_owner: alice, to: charlie, id: id1), + id1, + charlie + ) + end + + it "ignores transfer with wrong previous owner" do + # Setup: create ethscription owned by bob + id1 = create_test_ethscription(creator: alice, to: bob, content: "Test content6") + + # ESIP-2 transfer with wrong previous owner (charlie instead of alice) + expect_transfer_failure( + transfer_prev_event(current_owner: charlie, prev_owner: charlie, to: alice, id: id1), + id1, + reason: :revert + ) + end + end + + describe "Burn to zero address" do + it "burns via input transfer to zero address" do + # Setup: create ethscription + id1 = create_test_ethscription(creator: alice, to: alice, content: "Burn test") + + # Transfer to zero address (burn) + expect_transfer_success( + transfer_input(from: alice, to: zero_address, id: id1), + id1, + zero_address + ) + end + + it "burns via ESIP-1 event to zero address" do + # Setup: create ethscription + id1 = create_test_ethscription(creator: alice, to: alice, content: "Burn test2") + + # Transfer to zero via event + expect_transfer_success( + transfer_event(from: alice, to: zero_address, id: id1), + id1, + zero_address + ) + end + end + + describe "In-transaction ordering: create then transfer" do + it "create via input, transfer via input in same transaction" do + data_uri = "data:text/plain;charset=utf-8,Create then transfer test" + + results = import_l1_block([ + l1_tx( + creator: alice, + to: alice, + input: string_to_hex(data_uri) + ) + ]) + + # Get the created ethscription ID + id1 = results[:ethscription_ids].first + + # Now transfer it in a separate transaction + expect_transfer_success( + transfer_input(from: alice, to: bob, id: id1), + id1, + bob + ) + end + + it "create via input, transfer via event in same L1 block" do + data_uri = "data:text/plain;charset=utf-8,Create then transfer via event" + + # Create in first transaction + create_result = expect_ethscription_success( + create_input(creator: alice, to: alice, data_uri: data_uri) + ) + id1 = create_result[:ethscription_ids].first + + # Transfer via event in second transaction of same block + expect_transfer_success( + transfer_event(from: alice, to: bob, id: id1), + id1, + bob + ) + end + end + + describe "ESIP feature gating for transfers" do + it "ignores ESIP-1 events when disabled" do + id1 = create_test_ethscription(creator: alice, to: alice, content: "Test content2aaa") + + expect_transfer_failure( + transfer_event(from: alice, to: bob, id: id1), + id1, + reason: :ignored, + esip_overrides: { esip1_enabled: false } + ) + end + + it "ignores ESIP-2 events when disabled" do + id1 = create_test_ethscription(creator: alice, to: bob, content: "Test content3a") + + expect_transfer_failure( + transfer_prev_event(current_owner: bob, prev_owner: alice, to: charlie, id: id1), + id1, + reason: :ignored, + esip_overrides: { esip2_enabled: false } + ) + end + + it "rejects multi-transfer when ESIP-5 disabled" do + id1 = create_test_ethscription(creator: alice, to: alice, content: "Content 1aaaaa") + id2 = create_test_ethscription(creator: alice, to: alice, content: "Content 2aaaaa") + + expect_transfer_failure( + transfer_multi_input(from: alice, to: bob, ids: [id1, id2]), + id1, + reason: :ignored, + esip_overrides: { esip5_enabled: false } + ) + end + end + + describe "Self-transfer scenarios" do + it "succeeds self-transfer via input" do + id1 = create_test_ethscription(creator: alice, to: alice, content: "Self transfer test") + + expect_transfer_success( + transfer_input(from: alice, to: alice, id: id1), + id1, + alice + ) + end + + it "succeeds self-transfer via event" do + id1 = create_test_ethscription(creator: alice, to: alice, content: "Self transfer testaaa") + + expect_transfer_success( + transfer_event(from: alice, to: alice, id: id1), + id1, + alice + ) + end + end + + describe "Transfer chain scenarios" do + it "chains multiple transfers in same L1 block" do + # Setup: create ethscription + id1 = create_test_ethscription(creator: alice, to: alice, content: "Chain testaaaaaa") + + # Transfer alice -> bob -> charlie in same L1 block + results = import_l1_block([ + transfer_input(from: alice, to: bob, id: id1), + transfer_input(from: bob, to: charlie, id: id1) + ]) + + # Both transfers should succeed + expect(results[:l2_receipts].size).to eq(2) + results[:l2_receipts].each do |receipt| + expect(receipt[:status]).to eq('0x1') + end + + # Final owner should be charlie + owner = get_ethscription_owner(id1) + expect(owner.downcase).to eq(charlie.downcase) + end + + it "transfer after prior transfer respects new owner" do + # Setup: create ethscription owned by alice + id1 = create_test_ethscription(creator: alice, to: alice, content: "Chain testbbbb") + + # First: alice -> bob + expect_transfer_success( + transfer_input(from: alice, to: bob, id: id1), + id1, + bob + ) + + # Second: bob -> charlie (should succeed) + expect_transfer_success( + transfer_input(from: bob, to: charlie, id: id1), + id1, + charlie + ) + + # Third: alice tries to transfer (should fail - no longer owner) + expect_transfer_failure( + transfer_input(from: alice, to: bob, id: id1), + id1, + reason: :revert + ) + end + end +end \ No newline at end of file diff --git a/spec/integration/ethscriptions_creation_spec.rb b/spec/integration/ethscriptions_creation_spec.rb new file mode 100644 index 0000000..7bcc003 --- /dev/null +++ b/spec/integration/ethscriptions_creation_spec.rb @@ -0,0 +1,750 @@ +require 'rails_helper' + +RSpec.describe "Ethscription Creation", type: :integration do + include EthscriptionsTestHelper + + let(:alice) { valid_address("alice") } + let(:bob) { valid_address("bob") } + let(:charlie) { valid_address("charlie") } + + describe "Creation by Input" do + describe "Valid data URIs" do + it "creates text/plain ethscription" do + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: "data:text/plain;charset=utf-8,Hello, Ethscriptions World!" + ) + ) + end + + it "creates application/json ethscription" do + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: 'data:application/json,{"op":"deploy","tick":"TEST","max":"21000000"}' + ) + ) + end + + it "creates image/svg+xml ethscription" do + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: 'data:image/svg+xml,' + ) + ) + end + end + + describe "Base64 data URIs" do + it "creates image/png with base64 encoding" do + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + ) + ) + end + + it "creates with custom charset in base64 data URI" do + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: "data:image/png;charset=utf-8;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + ) + ) + end + end + + describe "GZIP compression" do + it "rejects GZIP input pre-ESIP-7" do + compressed_data_uri = Zlib.gzip("data:text/plain;charset=utf-8,Hello World") # Placeholder for GZIP data + + expect_ethscription_failure( + create_input( + creator: alice, + to: bob, + data_uri: compressed_data_uri + ), + reason: :ignored, + esip_overrides: { esip7_enabled: false } + ) + end + + it "accepts GZIP input post-ESIP-7" do + compressed_data_uri = Zlib.gzip("data:text/plain;charset=utf-8,Hello Worldaaa") # Placeholder for GZIP data + + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: compressed_data_uri + ), + esip_overrides: { esip7_enabled: true } + ) + end + end + + describe "Invalid data URIs" do + it "rejects missing data: prefix" do + expect_ethscription_failure( + create_input( + creator: alice, + to: bob, + data_uri: "text/plain;charset=utf-8,Hello World" # Missing "data:" + ), + reason: :ignored + ) + end + + it "rejects malformed data uri" do + expect_ethscription_failure( + create_input( + creator: alice, + to: bob, + data_uri: "data:invalid-mimetype,Hello World" + ), + reason: :ignored + ) + end + + it "rejects bad encoding" do + expect_ethscription_failure( + create_input( + creator: alice, + to: bob, + data_uri: "data:text/plain;base64,invalid-base64-!@#$%" + ), + reason: :ignored + ) + end + end + + describe "Non-UTF8 and edge cases" do + it "rejects non-UTF8 raw input" do + expect_ethscription_failure( + { + creator: alice, + to: bob, + input: "0x" + "ff" * 100 # Invalid UTF-8 bytes + }, + reason: :ignored + ) + end + + it "rejects empty input" do + expect_ethscription_failure( + { + creator: alice, + to: bob, + input: "0x" + }, + reason: :ignored + ) + end + + it "rejects null input" do + expect_ethscription_failure( + { + creator: alice, + to: bob, + input: "" + }, + reason: :ignored + ) + end + end + + describe "Invalid addresses" do + it "allows input to null address" do + expect_ethscription_success( + create_input( + creator: alice, + to: "0x0000000000000000000000000000000000000000", + data_uri: "data:text/plain;charset=utf-8,Hello World33333" + ) + ) + end + + it "rejects contract creation (no to address)" do + expect_ethscription_failure( + { + creator: alice, + to: nil, # Contract creation + input: string_to_hex("data:text/plain;charset=utf-8,Hello World") + }, + reason: :ignored + ) + end + end + + describe "Multiple creates in same transaction" do + it "input takes precedence over event in same transaction" do + # Create a transaction with both input creation AND event creation + # Only input should succeed, event should be ignored per protocol rules + results = import_l1_block([ + l1_tx( + creator: alice, + to: bob, + input: string_to_hex("data:text/plain;charset=utf-8,Hello from Input"), + logs: [ + build_create_event( + creator: alice, + initial_owner: bob, + content_uri: "data:text/plain;charset=utf-8,Hello from Event" + ) + ] + ) + ]) +# binding.irb + # Should only create one ethscription (via input, not event) + # expect(results[:ethscription_ids].size).to eq(1) + expect(results[:l2_receipts].first[:status]).to eq('0x1') + expect(results[:l2_receipts].second[:status]).to eq('0x0') + + # Verify it used the input content, not the event content + stored = get_ethscription_content(results[:ethscription_ids].first) + expect(stored[:content]).to include("Hello from Input") + end + end + + describe "Multiple creates in same L1 block" do + it "creates multiple ethscriptions successfully" do + results = import_l1_block([ + create_input( + creator: alice, + to: bob, + data_uri: 'data:application/json,{"op":"deploy","tick":"TEST","max":"5"}' + ), + create_input( + creator: charlie, + to: alice, + data_uri: "data:text/plain;charset=utf-8,Second ethscription in same block" + ) + ]) + + # Both should succeed + expect(results[:ethscription_ids].size).to eq(2) + results[:l2_receipts].each do |receipt| + expect(receipt[:status]).to eq('0x1') + end + end + end + end + + describe "Creation by Event (ESIP-3)" do + describe "Valid CreateEthscription events" do + it "creates ethscription via event" do + expect_ethscription_success( + create_event( + creator: alice, + initial_owner: bob, + data_uri: "data:text/plain;charset=utf-8,Hello via Eve22nt!" + ) + ) + end + + it "creates with JSON content via event" do + expect_ethscription_success( + create_event( + creator: alice, + initial_owner: bob, + data_uri: 'data:application/json,{"message":"Hello via Eve44nt"}' + ) + ) + end + end + + describe "ESIP-3 feature gating" do + it "ignores events when ESIP-3 disabled" do + expect_ethscription_failure( + create_event( + creator: alice, + initial_owner: bob, + data_uri: "data:text/plain;charset=utf-8,Should be ignored" + ), + reason: :ignored, + esip_overrides: { esip3_enabled: false } + ) + end + + it "processes events when ESIP-3 enabled" do + expect_ethscription_success( + create_event( + creator: alice, + initial_owner: bob, + data_uri: "data:text/plain;charset=utf-8,Should work" + ), + esip_overrides: { esip3_enabled: true } + ) + end + end + + describe "Invalid events" do + it "ignores malformed event (wrong topic length)" do + expect_ethscription_failure( + { + creator: alice, + to: bob, + input: "0x", + logs: [ + { + 'address' => alice, + 'topics' => [EthTransaction::CreateEthscriptionEventSig], # Missing initial_owner topic + 'data' => string_to_hex("data:text/plain,Hello"), + 'logIndex' => '0x0', + 'removed' => false + } + ] + }, + reason: :ignored + ) + end + + it "ignores removed=true logs" do + expect_ethscription_failure( + l1_tx( + creator: alice, + to: bob, + logs: [ + { + 'address' => alice, + 'topics' => [ + EthTransaction::CreateEthscriptionEventSig, + "0x#{Eth::Abi.encode(['address'], [bob]).unpack1('H*')}" + ], + 'data' => "0x#{Eth::Abi.encode(['string'], ['data:text/plain,Hello']).unpack1('H*')}", + 'logIndex' => '0x0', + 'removed' => true # Should be ignored + } + ] + ), + reason: :ignored + ) + end + end + + describe "Event content validation" do + it "sanitizes and accepts valid data URI from non-UTF8 event data" do + # Event data that becomes valid after sanitization + expect_ethscription_success( + create_event( + creator: alice, + initial_owner: bob, + data_uri: "data:text/plain;charset=utf-8,Hello\x00World" # Contains null byte + ) + ) + end + + it "rejects event with completely invalid content" do + expect_ethscription_failure( + l1_tx( + creator: alice, + to: bob, + logs: [ + { + 'address' => alice, + 'topics' => [ + EthTransaction::CreateEthscriptionEventSig, + "0x#{Eth::Abi.encode(['address'], [bob]).unpack1('H*')}" + ], + 'data' => "0x" + "ff" * 100, # Invalid UTF-8 that doesn't sanitize to data URI + 'logIndex' => '0x0', + 'removed' => false + } + ] + ), + reason: :ignored + ) + end + end + + describe "ESIP-6 (duplicate content) end-to-end" do + it "reverts duplicate content without ESIP-6" do + content_uri = "data:text/plain;charset=utf-8,Duplicate content test" + + # First creation should succeed + expect_ethscription_success( + create_input(creator: alice, to: bob, data_uri: content_uri) + ) + + # Second creation with same content should revert + expect_ethscription_failure( + create_input(creator: charlie, to: alice, data_uri: content_uri), + reason: :revert + ) + end + + it "succeeds duplicate content with ESIP-6" do + base_content = "Duplicate content test" + normal_uri = "data:text/plain;charset=utf-8,#{base_content}" + esip6_uri = "data:text/plain;charset=utf-8;rule=esip6,#{base_content}" + + # First creation without ESIP-6 + first_result = expect_ethscription_success( + create_input(creator: alice, to: bob, data_uri: esip6_uri) + ) + + # Second creation with ESIP-6 rule should succeed + second_result = expect_ethscription_success( + create_input(creator: charlie, to: alice, data_uri: esip6_uri) + ) + + # Verify both have same content URI hash but second has esip6: true + first_stored = get_ethscription_content(first_result[:ethscription_ids].first) + second_stored = get_ethscription_content(second_result[:ethscription_ids].first) + + expect(first_stored[:content_uri_sha]).to eq(second_stored[:content_uri_sha]) + + expect(first_stored[:esip6]).to be_truthy + expect(second_stored[:esip6]).to be_truthy + end + end + + describe "Multiple create events in same transaction" do + it "processes only first event, ignores second" do + results = import_l1_block([ + l1_tx( + creator: alice, + to: bob, + logs: [ + build_create_event( + creator: alice, + initial_owner: bob, + content_uri: "data:text/plain;charset=utf-8,First event" + ).merge('logIndex' => '0x0'), + build_create_event( + creator: alice, + initial_owner: charlie, + content_uri: "data:text/plain;charset=utf-8,Second event" + ).merge('logIndex' => '0x1') + ] + ) + ]) + + # Should only create one ethscription (from first event) + expect(results[:l2_receipts].size).to eq(2) + expect(results[:l2_receipts].map { |r| r[:status] }).to eq(['0x1', '0x0']) + + # Verify content is from first event + stored = get_ethscription_content(results[:ethscription_ids].first) + expect(stored[:content]).to include("First event") + end + + it "respects logIndex order when choosing first event" do + results = import_l1_block([ + l1_tx( + creator: alice, + to: bob, + logs: [ + build_create_event( + creator: alice, + initial_owner: charlie, + content_uri: "data:text/plain;charset=utf-8,Second by logIndex" + ).merge('logIndex' => '0x1'), + build_create_event( + creator: alice, + initial_owner: bob, + content_uri: "data:text/plain;charset=utf-8,First by logIndex" + ).merge('logIndex' => '0x0') + ] + ) + ]) + + # Should process event with logIndex 0x0 first + expect(results[:ethscription_ids].size).to eq(1) + stored = get_ethscription_content(results[:ethscription_ids].first) + expect(stored[:content]).to include("First by logIndex") + end + end + + describe "Empty content data URI" do + it "succeeds with empty data URI via input" do + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: "data:," + ) + ) do |results| + stored = get_ethscription_content(results[:ethscription_ids].first) + expect(stored[:content]).to be_empty + # TODO: Verify mimetype defaults + end + end + end + + describe "Creator/owner edge cases (events)" do + it "handles initialOwner = zero address" do + expect_ethscription_success( + create_event( + creator: alice, + initial_owner: "0x0000000000000000000000000000000000000000", + data_uri: "data:text/plain;charset=utf-8,Transfer to zero test" + ) + ) do |results| + # Should end up owned by zero address + owner = get_ethscription_owner(results[:ethscription_ids].first) + expect(owner.downcase).to eq("0x0000000000000000000000000000000000000000") + end + end + + it "rejects event with creator = zero address" do + expect_ethscription_failure( + l1_tx( + creator: "0x0000000000000000000000000000000000000000", + to: bob, + logs: [ + { + 'address' => "0x0000000000000000000000000000000000000000", + 'topics' => [ + EthTransaction::CreateEthscriptionEventSig, + "0x#{Eth::Abi.encode(['address'], [bob]).unpack1('H*')}" + ], + 'data' => "0x#{Eth::Abi.encode(['string'], ['data:text/plain,Hello']).unpack1('H*')}", + 'logIndex' => '0x0', + 'removed' => false + } + ] + ), + reason: :revert + ) + end + end + + describe "Storage field correctness" do + it "stores correct metadata for input creation" do + content_uri = "data:image/svg+xml;charset=utf-8,test" + + expect_ethscription_success( + create_input(creator: alice, to: bob, data_uri: content_uri) + ) do |results| + stored = get_ethscription_content(results[:ethscription_ids].first) + + # Verify content fields + expect(stored[:content]).to eq("test") + expect(stored[:mimetype]).to eq("image/svg+xml") + + # Verify content URI hash + expected_hash = Digest::SHA256.hexdigest(content_uri) + expect(stored[:content_uri_sha]).to eq("0x#{expected_hash}") + + # Verify block references are set + expect(stored[:l1_block_number]).to be > 0 + expect(stored[:l2_block_number]).to be > 0 + expect(stored[:l1_block_hash]).to match(/^0x[0-9a-f]{64}$/i) + end + end + + it "stores correct metadata for event creation" do + content_uri = "data:application/json,{\"test\":\"data\"}" + + expect_ethscription_success( + create_event(creator: alice, initial_owner: bob, data_uri: content_uri) + ) do |results| + stored = get_ethscription_content(results[:ethscription_ids].first) + + expect(stored[:mimetype]).to eq("application/json") + + # Verify content URI hash matches + expected_hash = Digest::SHA256.hexdigest(content_uri) + expect(stored[:content_uri_sha]).to eq("0x#{expected_hash}") + end + end + end + + describe "Mixed input/event with same content" do + it "input takes precedence, stores input content exactly" do + input_content = "data:text/plain;charset=utf-8,Hello from Input123" + event_content = "data:text/plain;charset=utf-8,Hello from Event123" + + results = import_l1_block([ + l1_tx( + creator: alice, + to: bob, + input: string_to_hex(input_content), + logs: [ + build_create_event( + creator: alice, + initial_owner: bob, + content_uri: event_content + ) + ] + ) + ]) + + # Should only create one ethscription (via input) + expect(results[:ethscription_ids].size).to eq(1) + expect(results[:l2_receipts].first[:status]).to eq('0x1') + + # Verify stored content exactly matches input URI + stored = get_ethscription_content(results[:ethscription_ids].first) + expect(stored[:content]).to eq("Hello from Input123") + end + end + + describe "Large content with SSTORE2Unlimited" do + it "successfully stores and retrieves content larger than 24KB" do + # Create content larger than 24KB (24576 bytes) + # The old bytecode size limit was 24KB, but our SSTORE2Unlimited removes this restriction + large_content_size = 30_000 # 30KB, well over the old 24KB limit + large_text = "A" * large_content_size + large_data_uri = "data:text/plain;charset=utf-8,#{large_text}" + + # Create the large ethscription + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: large_data_uri + ) + ) do |results| + # Verify the ethscription was created + expect(results[:ethscription_ids].size).to eq(1) + expect(results[:l2_receipts].first[:status]).to eq('0x1') + + # Retrieve and verify the stored content + stored = get_ethscription_content(results[:ethscription_ids].first) + + # Verify the content size + expect(stored[:content].length).to eq(large_content_size) + + # Verify the content matches exactly + expect(stored[:content]).to eq(large_text) + + # Verify other metadata + expect(stored[:mimetype]).to eq("text/plain") + + # Log success for visibility + puts "✓ Successfully created and retrieved ethscription with #{large_content_size} bytes (#{large_content_size / 1024}KB) of content" + end + end + + it "handles very large content (100KB+)" do + # Test with even larger content to ensure SSTORE2Unlimited works for very large data + very_large_content_size = 100_000 # 100KB + + # Create repeating pattern to make it compressible but still large + pattern = "Hello World! This is a test of very large content storage. " + repeat_count = very_large_content_size / pattern.length + very_large_text = pattern * repeat_count + very_large_data_uri = "data:text/plain;charset=utf-8,#{very_large_text}" + + # Create the very large ethscription + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: very_large_data_uri + ) + ) do |results| + # Verify creation + expect(results[:ethscription_ids].size).to eq(1) + + # Retrieve and verify + stored = get_ethscription_content(results[:ethscription_ids].first) + + # Verify size (approximately, due to pattern repetition) + expect(stored[:content].length).to be >= (very_large_content_size * 0.9) + + # Verify content starts correctly + expect(stored[:content]).to start_with(pattern) + + puts "✓ Successfully handled very large ethscription with ~#{stored[:content].length} bytes (~#{stored[:content].length / 1024}KB) of content" + end + end + + it "correctly handles large Base64-encoded binary content" do + # Test with large binary content (e.g., image data) encoded as Base64 + # Generate pseudo-random binary data + binary_size = 25_000 # 25KB of binary data + binary_data = (0...binary_size).map { rand(256).chr }.join + base64_content = Base64.strict_encode64(binary_data) + + # Create data URI with Base64-encoded content + large_base64_uri = "data:application/octet-stream;base64,#{base64_content}" + + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: large_base64_uri + ) + ) do |results| + # Verify creation + expect(results[:ethscription_ids].size).to eq(1) + + # Retrieve stored content + stored = get_ethscription_content(results[:ethscription_ids].first) + + # Content should be the decoded binary, not the base64 string + expect(stored[:content].length).to eq(binary_size) + expect(stored[:content].encoding).to eq(Encoding::ASCII_8BIT) + + # Verify mimetype + expect(stored[:mimetype]).to eq("application/octet-stream") + + # Verify content matches original binary data + expect(stored[:content]).to eq(binary_data) + + puts "✓ Successfully stored and retrieved #{binary_size} bytes of binary content via Base64 encoding" + end + end + + it "handles large JSON content" do + # Create a large JSON structure + large_json_obj = { + "description" => "Large JSON test for SSTORE2Unlimited", + "data" => (1..1000).map do |i| + { + "id" => i, + "name" => "Item #{i}", + "description" => "This is a description for item number #{i} in our large JSON test", + "attributes" => { + "color" => ["red", "blue", "green", "yellow"].sample, + "size" => rand(1..100), + "weight" => rand(1.0..100.0).round(2), + "tags" => ["tag1", "tag2", "tag3", "tag4", "tag5"] + } + } + end + } + + large_json_string = JSON.generate(large_json_obj) + json_data_uri = "data:application/json,#{large_json_string}" + + puts "JSON content size: #{large_json_string.length} bytes (#{large_json_string.length / 1024}KB)" + + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: json_data_uri + ) + ) do |results| + # Verify creation + expect(results[:ethscription_ids].size).to eq(1) + + # Retrieve and parse stored JSON + stored = get_ethscription_content(results[:ethscription_ids].first) + parsed_json = JSON.parse(stored[:content]) + + # Verify JSON structure + expect(parsed_json["data"].length).to eq(1000) + expect(parsed_json["description"]).to eq("Large JSON test for SSTORE2Unlimited") + + # Verify mimetype + expect(stored[:mimetype]).to eq("application/json") + + puts "✓ Successfully stored and retrieved large JSON with #{stored[:content].length} bytes" + end + end + end + end +end diff --git a/spec/integration/simple_test_spec.rb b/spec/integration/simple_test_spec.rb new file mode 100644 index 0000000..6001c07 --- /dev/null +++ b/spec/integration/simple_test_spec.rb @@ -0,0 +1,129 @@ +require 'rails_helper' + +RSpec.describe "Simple Ethscription Creation", type: :integration do + include EthscriptionsTestHelper + + let(:alice) { valid_address("alice") } + let(:bob) { valid_address("bob") } + + it "creates a simple ethscription with plain text" do + # Try the simplest possible ethscription - just plain text + tx_spec = create_input( + creator: alice, + to: bob, + data_uri: "data:,Hello World" + ) + + results = import_l1_block([tx_spec]) + + puts "Results keys: #{results.keys}" + puts "Ethscription IDs: #{results[:ethscription_ids]}" + puts "L2 receipts: #{results[:l2_receipts]&.length}" + puts "Ethscriptions: #{results[:ethscriptions]&.length}" + + if results[:l2_receipts]&.any? + puts "First receipt status: #{results[:l2_receipts].first[:status]}" + end + + expect(results[:ethscription_ids]).not_to be_empty + expect(results[:l2_receipts]).not_to be_empty + expect(results[:l2_receipts].first[:status]).to eq('0x1') + end + + it "creates a simple JSON ethscription" do + # Try a simple JSON structure + json_data = { "test" => "value" } + + tx_spec = create_input( + creator: alice, + to: bob, + data_uri: "data:," + json_data.to_json + ) + + results = import_l1_block([tx_spec]) + + puts "JSON test results:" + puts "Ethscription IDs: #{results[:ethscription_ids]}" + puts "L2 receipts: #{results[:l2_receipts]&.length}" + + expect(results[:ethscription_ids]).not_to be_empty + end + + it "tests content size limits" do + # Test with increasing content sizes + base_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Test", + "symbol" => "TST", + "totalSupply" => "100", + "description" => "A" * 50, # 50 chars + "extra1" => "B" * 50, + "extra2" => "C" * 50, + "extra3" => "D" * 50 + } + + data_uri = "data:," + base_data.to_json + puts "Testing with content length: #{data_uri.length} bytes" + + tx_spec = create_input( + creator: alice, + to: bob, + data_uri: data_uri + ) + + results = import_l1_block([tx_spec]) + ethscription_id = results[:ethscription_ids].first + stored = get_ethscription_content(ethscription_id) + + puts "Original content length: #{base_data.to_json.length}" + puts "Stored content length: #{stored[:content].length}" + puts "Content starts with 'p': #{stored[:content].start_with?('{"p":"erc-721-ethscriptions-collection"')}" + puts "First 50 chars: #{stored[:content][0..49]}" + + # For debugging, show if content was truncated + if stored[:content].length < base_data.to_json.length + puts "WARNING: Content was truncated!" + puts "Lost #{base_data.to_json.length - stored[:content].length} bytes" + end + + expect(stored[:content].length).to eq(base_data.to_json.length), "Content should not be truncated" + end + + it "creates a simple collections protocol ethscription" do + # Try the simplest collections protocol data + collection_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "create_collection", + "name" => "Test", + "symbol" => "TST", + "totalSupply" => "100" + } + + data_uri = "data:," + collection_data.to_json + puts "Data URI: #{data_uri}" + puts "Data URI length: #{data_uri.length}" + + tx_spec = create_input( + creator: alice, + to: bob, + data_uri: data_uri + ) + + results = import_l1_block([tx_spec]) + + # Check the stored content + ethscription_id = results[:ethscription_ids].first + stored = get_ethscription_content(ethscription_id) + + puts "Collections test results:" + puts "Ethscription ID: #{ethscription_id}" + puts "Stored content: #{stored[:content].inspect}" + puts "Content includes 'p': #{stored[:content].include?('"p":"erc-721-ethscriptions-collection"')}" + puts "Full match: #{stored[:content] == collection_data.to_json}" + + expect(results[:ethscription_ids]).not_to be_empty, "Should create ethscription ID" + expect(results[:l2_receipts]).not_to be_empty, "Should have L2 receipt" + expect(stored[:content]).to include('"p":"erc-721-ethscriptions-collection"'), "Content should include protocol" + end +end \ No newline at end of file diff --git a/spec/integration/token_protocol_e2e_spec.rb b/spec/integration/token_protocol_e2e_spec.rb new file mode 100644 index 0000000..ceb9784 --- /dev/null +++ b/spec/integration/token_protocol_e2e_spec.rb @@ -0,0 +1,452 @@ +require 'rails_helper' + +RSpec.describe "Token Protocol End-to-End", type: :integration do + include EthscriptionsTestHelper + + let(:alice) { valid_address("alice") } + let(:bob) { valid_address("bob") } + let(:charlie) { valid_address("charlie") } + let(:dummy_recipient) { valid_address("recipient") } + + describe "Complete Token Workflow with Protocol Validation" do + it "deploys token and validates protocol execution" do + # Note: 'erc-20' is a legacy protocol identifier; the canonical protocol name is now 'erc-20-fixed-denomination'. + # This test uses the legacy format, which is normalized to the canonical name by the system. + deploy_json = '{"p":"erc-20","op":"deploy","tick":"testcoin","max":"1000000","lim":"1000"}' + + result = create_and_validate_ethscription( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + deploy_json + ) + + # Validate ethscription creation + expect(result[:success]).to eq(true), "Ethscription creation failed" + deploy_id = result[:ethscription_id] + expect(deploy_id).to be_present + + # Validate ethscription content + stored = get_ethscription_content(deploy_id) + expect(stored[:content]).to include('"p":"erc-20"') + expect(stored[:content]).to include('"op":"deploy"') + expect(stored[:content]).to include('"tick":"testcoin"') + + # Validate protocol execution + expect(result[:protocol_event]).to eq("ERC20FixedDenominationTokenDeployed"), "Protocol handler did not emit ERC20FixedDenominationTokenDeployed event" + expect(result[:protocol_success]).to eq(true), "Protocol operation failed" + + # Validate token state in contract + token_state = get_token_state("testcoin") + + expect(token_state).not_to be_nil, "get_token_state returned nil" + expect(token_state[:exists]).to eq(true), "Token not found in contract" + # Note: TokenInfo struct doesn't have deployer field + expect(token_state[:maxSupply]).to eq(1000000), "Max supply mismatch" + expect(token_state[:mintLimit]).to eq(1000), "Mint limit mismatch" + expect(token_state[:totalMinted]).to eq(0), "Should start with 0 minted" + + # Validate ERC20 contract deployment + expect(token_state[:tokenContract]).not_to eq("0x0000000000000000000000000000000000000000") + expect(token_state[:tokenContract]).to match(/^0x[a-fA-F0-9]{40}$/), "Invalid token contract address" + end + + it "mints tokens and validates execution" do + # Deploy token first + deploy_token("minttest", alice) + + # Mint tokens - amount must match the lim from deployment + mint_data = { + "p" => "erc-20", + "op" => "mint", + "tick" => "minttest", + "id" => "1", + "amt" => "1000" # Must match lim from deployment + } + + # JSON must be in exact format + mint_json = '{"p":"erc-20","op":"mint","tick":"minttest","id":"1","amt":"1000"}' + + result = create_and_validate_ethscription( + creator: bob, + to: bob, # Mint to self so Bob owns the ethscription + data_uri: "data:," + mint_json + ) + + # Validate ethscription creation + expect(result[:success]).to eq(true), "Ethscription creation failed" + mint_id = result[:ethscription_id] + + # Validate protocol execution + expect(result[:protocol_event]).to eq("ERC20FixedDenominationTokenMinted"), "Protocol handler did not emit ERC20FixedDenominationTokenMinted event" + expect(result[:protocol_success]).to eq(true), "Protocol operation failed" + expect(result[:mint_amount]).to eq(1000), "Mint amount mismatch" + + # Validate token state updated + token_state = get_token_state("minttest") + expect(token_state[:totalMinted]).to eq(1000), "Total minted should be 1000" + + # Validate mint record + mint_record = get_mint_record(mint_id) + expect(mint_record[:exists]).to eq(true), "Mint record not found" + expect(mint_record[:amount]).to eq(1000), "Mint amount mismatch" + expect(mint_record[:ethscriptionId]).to eq(mint_id), "Ethscription ID mismatch" + + # Validate token balance + balance = get_token_balance("minttest", bob) + expect(balance).to eq(1000), "Token balance mismatch" + end + + it "handles mint transfer and validates token transfer" do + # Deploy and mint first + deploy_token("transfertest", alice) + + mint_json = '{"p":"erc-20","op":"mint","tick":"transfertest","id":"1","amt":"1000"}' + mint_result = create_and_validate_ethscription( + creator: bob, + to: bob, + data_uri: "data:," + mint_json + ) + mint_id = mint_result[:ethscription_id] + + # Transfer the mint ethscription (transfers the tokens) + transfer_result = transfer_ethscription( + from: bob, + to: charlie, + ethscription_id: mint_id + ) + + expect(transfer_result[:success]).to eq(true), "Ethscription transfer failed" + expect(transfer_result[:protocol_event]).to eq("ERC20FixedDenominationTokenTransferred"), "ERC20 fixed denomination transfer event not emitted" + + # Validate token balances updated + bob_balance = get_token_balance("transfertest", bob) + expect(bob_balance).to eq(0), "Bob should have 0 tokens after transfer" + + charlie_balance = get_token_balance("transfertest", charlie) + expect(charlie_balance).to eq(1000), "Charlie should have 1000 tokens after transfer" + + # Validate mint record updated + # Note: ERC20FixedDenominationManager doesn't track current owner in TokenItem + # Token ownership is tracked via ERC20 balances instead + end + end + + describe "Protocol Validation Edge Cases" do + it "rejects duplicate token deployment" do + # Deploy first token + deploy_json = '{"p":"erc-20","op":"deploy","tick":"duplicate","max":"1000","lim":"100"}' + + first_result = create_and_validate_ethscription( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + deploy_json + ) + expect(first_result[:protocol_success]).to eq(true) + + # Try to deploy same tick with different parameters + # This creates a different ethscription but same tick should be rejected + deploy_json_different = '{"p":"erc-20","op":"deploy","tick":"duplicate","max":"2000","lim":"200"}' + second_result = create_and_validate_ethscription( + creator: bob, + to: dummy_recipient, + data_uri: "data:," + deploy_json_different + ) + + # Ethscription created but protocol operation fails + expect(second_result[:success]).to eq(true), "Ethscription should be created" + expect(second_result[:protocol_extracted]).to eq(true), "Protocol should be extracted" + expect(second_result[:protocol_success]).to eq(false), "Protocol operation should fail" + # Error parsing has encoding issues, so just verify we got an error + expect(second_result[:protocol_error]).not_to be_nil, "Should have an error for duplicate deployment" + end + + it "rejects mint with wrong amount" do + # Deploy token with lim=100 + deploy_json = '{"p":"erc-20","op":"deploy","tick":"wrongamt","max":"1000","lim":"100"}' + deploy_result = create_and_validate_ethscription( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + deploy_json + ) + expect(deploy_result[:protocol_success]).to eq(true) + + # Try to mint with wrong amount (not matching lim) + mint_json = '{"p":"erc-20","op":"mint","tick":"wrongamt","id":"1","amt":"50"}' + + mint_result = create_and_validate_ethscription( + creator: bob, + to: bob, + data_uri: "data:," + mint_json + ) + + expect(mint_result[:success]).to eq(true), "Ethscription should be created" + expect(mint_result[:protocol_success]).to eq(false), "Protocol operation should fail" + # Error parsing has encoding issues, so just verify we got an error + expect(mint_result[:protocol_error]).not_to be_nil, "Should have an error for amount mismatch" + end + + it "enforces exact mint amount" do + # Deploy with limit of 100 + deploy_json = '{"p":"erc-20","op":"deploy","tick":"limited","max":"300","lim":"100"}' + deploy_result = create_and_validate_ethscription( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + deploy_json + ) + expect(deploy_result[:protocol_success]).to eq(true) + + # Try to mint over limit (101 instead of 100) + mint_json = '{"p":"erc-20","op":"mint","tick":"limited","id":"1","amt":"101"}' + mint_result = create_and_validate_ethscription( + creator: bob, + to: bob, + data_uri: "data:," + mint_json + ) + + expect(mint_result[:success]).to eq(true), "Ethscription should be created" + expect(mint_result[:protocol_success]).to eq(false), "Protocol operation should fail" + # Error parsing has encoding issues, so just verify we got an error + expect(mint_result[:protocol_error]).not_to be_nil, "Should have an error for amount mismatch" + end + + it "enforces max supply" do + # Deploy with low max supply + deploy_json = '{"p":"erc-20","op":"deploy","tick":"maxed","max":"200","lim":"100"}' + deploy_result = create_and_validate_ethscription( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + deploy_json + ) + + # Mint twice to reach max + mint1_json = '{"p":"erc-20","op":"mint","tick":"maxed","id":"1","amt":"100"}' + mint2_json = '{"p":"erc-20","op":"mint","tick":"maxed","id":"2","amt":"100"}' + + create_and_validate_ethscription(creator: bob, to: bob, data_uri: "data:," + mint1_json) + create_and_validate_ethscription(creator: bob, to: bob, data_uri: "data:," + mint2_json) + + # Third mint should fail (exceeds max supply) + mint3_json = '{"p":"erc-20","op":"mint","tick":"maxed","id":"3","amt":"100"}' + mint3_result = create_and_validate_ethscription( + creator: charlie, + to: charlie, + data_uri: "data:," + mint3_json + ) + + expect(mint3_result[:success]).to eq(true), "Ethscription should be created" + expect(mint3_result[:protocol_success]).to eq(false), "Protocol operation should fail" + # The error will be from ERC20Capped's custom error + expect(mint3_result[:protocol_error]).not_to be_nil, "Should have an error" + + # Verify total minted is at max + token_state = get_token_state("maxed") + expect(token_state[:totalMinted]).to eq(200), "Should be at max supply" + end + + it "handles malformed token JSON" do + # Missing quotes around tick value + malformed_json = '{"p":"erc-20","op":"deploy","tick":testbad,"max":"1000","lim":"100"}' + + result = create_and_validate_ethscription( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + malformed_json + ) + + expect(result[:success]).to eq(true), "Ethscription should be created" + expect(result[:protocol_extracted]).to eq(false), "Protocol should not be extracted" + expect(result[:protocol_success]).to eq(false), "Protocol should not execute" + end + + it "rejects token format with unexpected whitespace" do + # Extra whitespace breaks exact format requirement for the token parser + invalid_json = '{"p": "erc-20", "op": "deploy", "tick": "spaced", "max": "1000", "lim": "100"}' + + result = create_and_validate_ethscription( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + invalid_json + ) + + expect(result[:success]).to eq(true), "Ethscription should be created" + expect(result[:protocol_extracted]).to eq(false), "Protocol should not be extracted" + end + end + + # Helper methods + private + + def create_and_validate_ethscription(creator:, to:, data_uri:) + # Create the ethscription spec + tx_spec = create_input( + creator: creator, + to: to, + data_uri: data_uri + ) + + # Import the L1 block with the transaction + results = import_l1_block([tx_spec], esip_overrides: { esip6_is_enabled: true }) + + # Get the ethscription ID + ethscription_id = results[:ethscription_ids]&.first + + # Check if ethscription was created + success = ethscription_id.present? && results[:l2_receipts]&.first&.fetch(:status, nil) == '0x1' + + # Initialize results + protocol_results = { + success: success, + ethscription_id: ethscription_id, + protocol_extracted: false, + protocol_success: false, + protocol_event: nil, + protocol_error: nil + } + + return protocol_results unless success + + # Check if protocol was extracted + begin + protocol, operation, encoded_data = ProtocolParser.for_calldata(data_uri) + protocol_results[:protocol_extracted] = protocol.present? && operation.present? + rescue => e + protocol_results[:protocol_error] = e.message + return protocol_results + end + + return protocol_results unless protocol_results[:protocol_extracted] + + # Check L2 receipts for protocol execution + if results[:l2_receipts].present? + receipt = results[:l2_receipts].first + + # Use protocol event reader for accurate parsing + require_relative '../../lib/protocol_event_reader' + events = ProtocolEventReader.parse_receipt_events(receipt) + + # Process parsed events + events.each do |event| + case event[:event] + when 'ProtocolHandlerSuccess' + protocol_results[:protocol_success] = true + when 'ProtocolHandlerFailed' + protocol_results[:protocol_success] = false + protocol_results[:protocol_error] = event[:reason] + when 'ERC20FixedDenominationTokenDeployed' + protocol_results[:protocol_event] = 'ERC20FixedDenominationTokenDeployed' + when 'ERC20FixedDenominationTokenMinted' + protocol_results[:protocol_event] = 'ERC20FixedDenominationTokenMinted' + protocol_results[:mint_amount] = event[:amount] + when 'ERC20FixedDenominationTokenTransferred' + protocol_results[:protocol_event] = 'ERC20FixedDenominationTokenTransferred' + end + end + end + + protocol_results + end + + def transfer_ethscription(from:, to:, ethscription_id:) + tx_spec = transfer_input( + from: from, + to: to, + id: ethscription_id + ) + + # Import the L1 block with the transfer transaction + results = import_l1_block([tx_spec]) + + transfer_results = { + success: results[:l2_receipts]&.first&.fetch(:status, nil) == '0x1', + protocol_event: nil + } + + # Check for token transfer event + if results[:l2_receipts].present? + receipt = results[:l2_receipts].first + require_relative '../../lib/protocol_event_reader' + events = ProtocolEventReader.parse_receipt_events(receipt) + + events.each do |event| + if event[:event] == "ERC20FixedDenominationTokenTransferred" + transfer_results[:protocol_event] = "ERC20FixedDenominationTokenTransferred" + end + end + end + + transfer_results + end + + def deploy_token(tick, deployer) + deploy_json = "{\"p\":\"erc-20\",\"op\":\"deploy\",\"tick\":\"#{tick}\",\"max\":\"1000000\",\"lim\":\"1000\"}" + result = create_and_validate_ethscription( + creator: deployer, + to: dummy_recipient, + data_uri: "data:," + deploy_json + ) + expect(result[:protocol_success]).to eq(true), "Token deployment failed" + result[:ethscription_id] + end + + # Use actual contract state readers + def get_token_state(tick) + token = Erc20FixedDenominationReader.get_token(tick) + + return nil if token.nil? + + # Add convenience fields + { + exists: token[:tokenContract] != '0x0000000000000000000000000000000000000000', + maxSupply: token[:maxSupply], + mintLimit: token[:mintLimit], + totalMinted: token[:totalMinted], + tokenContract: token[:tokenContract], + ethscriptionId: token[:ethscriptionId], + protocol: token[:protocol], + tick: token[:tick] + } + end + + def get_mint_record(ethscription_id) + require_relative '../../lib/erc20_fixed_denomination_reader' + item = Erc20FixedDenominationReader.get_token_item(ethscription_id) + + return nil if item.nil? + + # Add convenience field + { + exists: item[:deployTxHash] != '0x0000000000000000000000000000000000000000000000000000000000000000', + amount: item[:amount], + ethscriptionId: ethscription_id, + deployTxHash: item[:deployTxHash] + } + end + + def get_token_balance(tick, address) + require_relative '../../lib/erc20_fixed_denomination_reader' + Erc20FixedDenominationReader.get_token_balance(tick, address) + end + + def get_ethscription_content(ethscription_id) + require_relative '../../lib/storage_reader' + data = StorageReader.get_ethscription_with_content(ethscription_id) + + return nil if data.nil? + + { + content: data[:content], + creator: data[:creator], + owner: data[:initial_owner] + } + end + + def token_exists?(tick) + Erc20FixedDenominationReader.token_exists?(tick) + end + + def token_item_exists?(ethscription_id) + item = Erc20FixedDenominationReader.get_token_item(ethscription_id) + return false if item.nil? + item[:deployTxHash] != '0x0000000000000000000000000000000000000000000000000000000000000000' + end +end diff --git a/spec/integration/tokens_protocol_spec.rb b/spec/integration/tokens_protocol_spec.rb new file mode 100644 index 0000000..97ab541 --- /dev/null +++ b/spec/integration/tokens_protocol_spec.rb @@ -0,0 +1,642 @@ +require 'rails_helper' + +RSpec.describe "Tokens Protocol", type: :integration do + include EthscriptionsTestHelper + + let(:alice) { valid_address("alice") } + let(:bob) { valid_address("bob") } + let(:charlie) { valid_address("charlie") } + # Ethscriptions are created by sending to any address with data in the input + # The protocol handler is called automatically by the Ethscriptions contract + let(:dummy_recipient) { valid_address("recipient") } + + describe "Token Deployment" do + it "deploys a new token with all parameters" do + tick = unique_tick('punk') + + token_data = { + "p" => "erc-20", + "op" => "deploy", + "tick" => tick, + "max" => "21000000", + "lim" => "1000" + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + token_data.to_json + ) + ) do |results| + # Verify the ethscription was created + ethscription_id = results[:ethscription_ids].first + stored = get_ethscription_content(ethscription_id) + + # Verify the content includes our data + expect(stored[:content]).to include('"p":"erc-20"') + expect(stored[:content]).to include('"op":"deploy"') + expect(stored[:content]).to include("\"tick\":\"#{tick}\"") + + token_state = get_token_state(tick) + expect(token_state).not_to be_nil + expect(token_state[:exists]).to eq(true) + expect(token_state[:maxSupply]).to eq(21_000_000) + expect(token_state[:mintLimit]).to eq(1000) + expect(token_state[:totalMinted]).to eq(0) + expect(token_state[:tokenContract]).to match(/^0x[0-9a-fA-F]{40}$/) + expect(token_state[:ethscriptionId].downcase).to eq(ethscription_id.downcase) + end + end + + it "handles large numbers as strings for JavaScript compatibility" do + tick = unique_tick('bignum') + + token_data = { + "p" => "erc-20", + "op" => "deploy", + "tick" => tick, + "max" => "1000000000000000000", # 1e18 + "lim" => "100000000000000000" # 1e17 + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + token_data.to_json + ) + ) do |results| + # Verify the ethscription was created with large numbers + ethscription_id = results[:ethscription_ids].first + stored = get_ethscription_content(ethscription_id) + + expect(stored[:content]).to include('"max":"1000000000000000000"') + expect(stored[:content]).to include('"lim":"100000000000000000"') + end + end + + it "rejects malformed deploy data" do + # Missing required field + tick = unique_tick('badtoken') + + malformed_data = { + "p" => "erc-20", + "op" => "deploy", + "tick" => tick + # Missing max and lim + } + + expect_protocol_extraction_failure( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + malformed_data.to_json + ) + ) do |results, stored| + # Ethscription created but protocol extraction failed + expect(stored[:content]).to include('"p":"erc-20"') + + expect(token_exists?(tick)).to eq(false) + end + end + end + + describe "Token Minting" do + context "with shared token deployment" do + let(:mint_tick) { unique_tick('minttest') } + + before do + # Deploy a token first + deploy_data = { + "p" => "erc-20", + "op" => "deploy", + "tick" => mint_tick, + "max" => "1000000", + "lim" => "100" + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + deploy_data.to_json + ) + ) + end + + it "mints tokens successfully" do + mint_data = { + "p" => "erc-20", + "op" => "mint", + "tick" => mint_tick, + "id" => "1", + "amt" => "100" + } + + expect_ethscription_success( + create_input( + creator: bob, + to: dummy_recipient, + data_uri: "data:," + mint_data.to_json + ) + ) do |results| + # Verify the ethscription was created + ethscription_id = results[:ethscription_ids].first + stored = get_ethscription_content(ethscription_id) + + expect(stored[:content]).to include('"op":"mint"') + expect(stored[:content]).to include('"amt":"100"') + + token_state = get_token_state(mint_tick) + expect(token_state).not_to be_nil + expect(token_state[:totalMinted]).to eq(100) + + balance = get_token_balance(mint_tick, dummy_recipient) + expect(balance).to eq(100) + + mint_item = get_mint_item(ethscription_id) + expect(mint_item[:exists]).to eq(true) + expect(mint_item[:amount]).to eq(100) + expect(mint_item[:deployTxHash].downcase).to eq(token_state[:ethscriptionId].downcase) + end + end + end # end of "with shared token deployment" context + + it "handles sequential mints with incremental IDs" do + # Deploy a separate token for this test to avoid conflicts + tick = unique_tick('seqmint') + + deploy_data = { + "p" => "erc-20", + "op" => "deploy", + "tick" => tick, # Different token name + "max" => "1000000", + "lim" => "100" + } + + deploy_results = import_l1_block([ + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + deploy_data.to_json + ) + ], esip_overrides: {}) + + expect(deploy_results[:l2_receipts].first[:status]).to eq('0x1'), "Token deployment should succeed" + + # First mint with ID 1 + mint1_data = { + "p" => "erc-20", + "op" => "mint", + "tick" => tick, + "id" => "1", + "amt" => "100" + } + + results1 = import_l1_block([ + create_input( + creator: bob, + to: dummy_recipient, + data_uri: "data:," + mint1_data.to_json + ) + ], esip_overrides: {}) + + expect(results1[:l2_receipts].first[:status]).to eq('0x1'), "First mint should succeed" + + # Second mint with ID 2 + mint2_data = { + "p" => "erc-20", + "op" => "mint", + "tick" => tick, + "id" => "2", + "amt" => "100" + } + + results2 = import_l1_block([ + create_input( + creator: bob, + to: dummy_recipient, + data_uri: "data:," + mint2_data.to_json + ) + ], esip_overrides: {}) + + expect(results2[:l2_receipts].first[:status]).to eq('0x1'), "Second mint should succeed" + + mint1_ethscription_id = results1[:ethscription_ids].first + mint2_ethscription_id = results2[:ethscription_ids].first + + token_state = get_token_state(tick) + expect(token_state[:totalMinted]).to eq(200) + + balance = get_token_balance(tick, dummy_recipient) + expect(balance).to eq(200) # 100 + 100 + + mint_item1 = get_mint_item(mint1_ethscription_id) + mint_item2 = get_mint_item(mint2_ethscription_id) + + [mint_item1, mint_item2].each do |mint_item| + expect(mint_item[:exists]).to eq(true) + expect(mint_item[:amount]).to eq(100) + expect(mint_item[:deployTxHash].downcase).to eq(token_state[:ethscriptionId].downcase) + end + end + + it "rejects mint with duplicate ID" do + # Deploy a separate token for this test + tick = unique_tick('duptest') + deploy_data = { + "p" => "erc-20", + "op" => "deploy", + "tick" => tick, + "max" => "1000000", + "lim" => "100" + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + deploy_data.to_json + ) + ) + + mint_data = { + "p" => "erc-20", + "op" => "mint", + "tick" => tick, + "id" => "1", + "amt" => "100" + } + + # First mint succeeds + expect_ethscription_success( + create_input( + creator: bob, + to: dummy_recipient, + data_uri: "data:," + mint_data.to_json + ) + ) + + # Second mint with same ID creates ethscription but protocol handler rejects + expect_ethscription_failure( + create_input( + creator: charlie, + to: dummy_recipient, + data_uri: "data:," + mint_data.to_json + ), + reason: :revert + ) + end + end + + describe "Protocol Format Validation" do + it "requires exact JSON format for token operations" do + # Extra whitespace in JSON should fail + tick = unique_tick('badformat') + invalid_format = '{"p": "erc-20", "op": "deploy", "tick": "' + tick + '", "max": "100", "lim": "10"}' + + content_uri = "data:," + invalid_format + + expect_protocol_extraction_failure( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: content_uri + ) + ) do |results, stored| + # The token regex requires exact format with no extra spaces + expect(stored[:content]).to include('erc-20') + + token_params = ProtocolParser.for_calldata(content_uri) + expect(token_params).to eq([''.b, ''.b, ''.b]) + end + end + + it "requires lowercase ticks" do + tick = unique_tick('uppertest').upcase + uppercase_tick = { + "p" => "erc-20", + "op" => "deploy", + "tick" => tick, # Uppercase not allowed + "max" => "1000", + "lim" => "100" + } + + expect_protocol_extraction_failure( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + uppercase_tick.to_json + ) + ) + end + + it "limits tick length to 28 characters" do + tick = unique_tick('toolongtick') + long_tick = { + "p" => "erc-20", + "op" => "deploy", + "tick" => tick + "a" * 29, # Too long + "max" => "1000", + "lim" => "100" + } + + expect_protocol_extraction_failure( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + long_tick.to_json + ) + ) + end + + it "rejects negative numbers" do + tick = unique_tick('negative') + negative_max = { + "p" => "erc-20", + "op" => "deploy", + "tick" => tick, + "max" => "-1000", # Negative not allowed + "lim" => "100" + } + + expect_protocol_extraction_failure( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + negative_max.to_json + ) + ) + end + + it "rejects numbers with leading zeros" do + tick = unique_tick('leadzero') + leading_zero = { + "p" => "erc-20", + "op" => "mint", + "tick" => tick, + "id" => "01", # Leading zero not allowed + "amt" => "100" + } + + expect_protocol_extraction_failure( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + leading_zero.to_json + ) + ) + end + end + + describe "Contract State Verification" do + it "creates a token and verifies it exists in contract" do + tick = unique_tick('verifytoken') + token_data = { + "p" => "erc-20", + "op" => "deploy", + "tick" => tick, + "max" => "1000000", + "lim" => "1000" + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + token_data.to_json + ) + ) do |results| + ethscription_id = results[:ethscription_ids].first + + token_state = get_token_state(tick) + expect(token_state).not_to be_nil + expect(token_state[:exists]).to eq(true) + expect(token_state[:maxSupply]).to eq(1_000_000) + expect(token_state[:mintLimit]).to eq(1000) + expect(token_state[:totalMinted]).to eq(0) + expect(token_state[:ethscriptionId].downcase).to eq(ethscription_id.downcase) + expect(token_state[:tokenContract]).to match(/^0x[0-9a-fA-F]{40}$/) + expect(token_state[:protocol]).to eq('erc-20-fixed-denomination') + end + end + + it "tracks mint count and enforces limits" do + # Deploy with low limit for testing + tick = unique_tick('limitedtoken') + deploy_data = { + "p" => "erc-20", + "op" => "deploy", + "tick" => tick, + "max" => "300", + "lim" => "100" + } + + expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + deploy_data.to_json + ) + ) + + mint_ethscription_ids = [] + + # Mint up to limit + [1, 2, 3].each do |i| + mint_data = { + "p" => "erc-20", + "op" => "mint", + "tick" => tick, + "id" => i.to_s, + "amt" => "100" + } + + expect_ethscription_success( + create_input( + creator: bob, + to: dummy_recipient, + data_uri: "data:," + mint_data.to_json + ) + ) do |results| + mint_ethscription_ids << results[:ethscription_ids].first + end + end + + token_state = get_token_state(tick) + expect(token_state[:totalMinted]).to eq(300) + + balance = get_token_balance(tick, dummy_recipient) + expect(balance).to eq(300) + + mint_ethscription_ids.each do |mint_id| + mint_item = get_mint_item(mint_id) + expect(mint_item[:exists]).to eq(true) + expect(mint_item[:amount]).to eq(100) + end + + # Fourth mint should fail (exceeds max supply) + mint_data = { + "p" => "erc-20", + "op" => "mint", + "tick" => tick, + "id" => "4", + "amt" => "100" + } + + expect_ethscription_success( + create_input( + creator: charlie, + to: dummy_recipient, + data_uri: "data:," + mint_data.to_json + ) + ) do |results| + failed_mint_id = results[:ethscription_ids].first + mint_item = get_mint_item(failed_mint_id) + expect(mint_item[:exists]).to eq(false) + + token_state = get_token_state(tick) + expect(token_state[:totalMinted]).to eq(300) + + balance = get_token_balance(tick, dummy_recipient) + expect(balance).to eq(300) + end + end + end + + describe "End-to-End Token Workflow" do + it "deploys token, mints tokens, and transfers via ethscription transfer" do + # Step 1: Deploy token + tick = unique_tick('flowtoken') + deploy_data = { + "p" => "erc-20", + "op" => "deploy", + "tick" => tick, + "max" => "10000", + "lim" => "500" + } + + deploy_results = expect_ethscription_success( + create_input( + creator: alice, + to: dummy_recipient, + data_uri: "data:," + deploy_data.to_json + ) + ) + + deploy_ethscription_id = deploy_results[:ethscription_ids].first + token_state = get_token_state(tick) + expect(token_state).not_to be_nil + expect(token_state[:exists]).to eq(true) + expect(token_state[:maxSupply]).to eq(10_000) + expect(token_state[:mintLimit]).to eq(500) + expect(token_state[:totalMinted]).to eq(0) + expect(token_state[:ethscriptionId].downcase).to eq(deploy_ethscription_id.downcase) + expect(token_state[:tokenContract]).to match(/^0x[0-9a-fA-F]{40}$/) + + # Step 2: Mint tokens to Bob + mint_data = { + "p" => "erc-20", + "op" => "mint", + "tick" => tick, + "id" => "1", + "amt" => "500" + } + + mint_results = expect_ethscription_success( + create_input( + creator: bob, + to: bob, # Mint to Bob so he owns the ethscription and can transfer it + data_uri: "data:," + mint_data.to_json + ) + ) + + mint_ethscription_id = mint_results[:ethscription_ids].first + + token_state_after_mint = get_token_state(tick) + expect(token_state_after_mint[:totalMinted]).to eq(500) + + balance = get_token_balance(tick, bob) + expect(balance).to eq(500) + + mint_item = get_mint_item(mint_ethscription_id) + expect(mint_item[:exists]).to eq(true) + expect(mint_item[:amount]).to eq(500) + expect(mint_item[:deployTxHash].downcase).to eq(token_state[:ethscriptionId].downcase) + + # Step 3: Transfer the mint ethscription (transfers the tokens) + # When an erc-20 mint ethscription is transferred, it transfers the tokens + expect_transfer_success( + transfer_input( + from: bob, + to: charlie, + id: mint_ethscription_id + ), + mint_ethscription_id, + charlie + ) do |results| + bob_balance = get_token_balance(tick, bob) + expect(bob_balance).to eq(0) + + charlie_balance = get_token_balance(tick, charlie) + expect(charlie_balance).to eq(500) + end + end + end + + # Helper methods for token protocol state verification + private + + def get_token_state(tick) + token = Erc20FixedDenominationReader.get_token(tick) + return nil if token.nil? + + zero_address = '0x0000000000000000000000000000000000000000' + + { + exists: token[:tokenContract] != zero_address, + maxSupply: token[:maxSupply], + mintLimit: token[:mintLimit], + totalMinted: token[:totalMinted], + tokenContract: token[:tokenContract], + ethscriptionId: token[:ethscriptionId], + protocol: token[:protocol], + tick: token[:tick] + } + end + + def token_exists?(tick) + Erc20FixedDenominationReader.token_exists?(tick) + end + + def get_token_balance(tick, address) + Erc20FixedDenominationReader.get_token_balance(tick, address) + end + + def get_mint_item(ethscription_id) + item = Erc20FixedDenominationReader.get_token_item(ethscription_id) + + zero_bytes32 = '0x0000000000000000000000000000000000000000000000000000000000000000' + + return { + exists: false, + amount: 0, + deployTxHash: zero_bytes32, + ethscriptionId: ethscription_id + } if item.nil? + + { + exists: item[:deployTxHash] != zero_bytes32, + amount: item[:amount], + deployTxHash: item[:deployTxHash], + ethscriptionId: ethscription_id + } + end + + def unique_tick(base) + suffix = SecureRandom.hex(4) + tick = "#{base}#{suffix}".downcase + tick[0, 28] + end +end diff --git a/spec/integration/transfer_selector_spec.rb b/spec/integration/transfer_selector_spec.rb new file mode 100644 index 0000000..30c837e --- /dev/null +++ b/spec/integration/transfer_selector_spec.rb @@ -0,0 +1,128 @@ +require 'rails_helper' + +RSpec.describe "Transfer Selector End-to-End", type: :integration do + include EthscriptionsTestHelper + + let(:alice) { valid_address("alice") } + let(:bob) { valid_address("bob") } + let(:charlie) { valid_address("charlie") } + + describe "Single vs Multiple Transfer Function Selection" do + it "uses transferEthscription for single input transfers" do + # Create an ethscription owned by alice + id1 = create_test_ethscription(alice) + + # Transfer single ethscription via input + results = import_l1_block([ + transfer_input(from: alice, to: bob, id: id1) + ]) + + # Get the transaction that was created + tx = results[:ethscriptions].first + expect(tx).to be_present + + # Verify it used the singular transfer method + expect(tx.ethscription_operation).to eq('transfer') + expect(tx.transfer_ids).to eq([id1]) # Always an array now + + # Check the function selector + selector = tx.function_selector.unpack1('H*') + # This should be the selector for transferEthscription(address,bytes32) + expected_selector = Eth::Util.keccak256('transferEthscription(address,bytes32)')[0...4].unpack1('H*') + expect(selector).to eq(expected_selector) + + # Verify the calldata encoding + calldata = tx.input.to_hex.delete_prefix('0x') + expect(calldata).to start_with(expected_selector) + end + + it "uses transferEthscriptions for multiple input transfers" do + # Create ethscriptions owned by alice + id1 = create_test_ethscription(alice) + id2 = create_test_ethscription(alice) + + # Transfer multiple ethscriptions via input + results = import_l1_block([ + transfer_multi_input(from: alice, to: charlie, ids: [id1, id2]) + ]) + + # Get the transaction that was created + tx = results[:ethscriptions].first + expect(tx).to be_present + + # Verify it used the multiple transfer method + expect(tx.ethscription_operation).to eq('transfer') + expect(tx.transfer_ids).to eq([id1, id2]) + + # Check the function selector + selector = tx.function_selector.unpack1('H*') + # This should be the selector for transferEthscriptions(address,bytes32[]) + expected_selector = Eth::Util.keccak256('transferEthscriptions(address,bytes32[])')[0...4].unpack1('H*') + expect(selector).to eq(expected_selector) + + # Verify the calldata encoding (address first, then array) + calldata = tx.input.to_hex.delete_prefix('0x') + expect(calldata).to start_with(expected_selector) + end + + it "correctly handles parameter order in transferEthscriptions" do + # Create ethscriptions + id1 = create_test_ethscription(alice) + id2 = create_test_ethscription(alice) + id3 = create_test_ethscription(alice) + + # Transfer multiple + results = import_l1_block([ + transfer_multi_input(from: alice, to: bob, ids: [id1, id2, id3]) + ]) + + tx = results[:ethscriptions].first + + # Decode the calldata to verify parameter order + calldata_hex = tx.input.to_hex.delete_prefix('0x') + + # Skip the 4-byte selector + params_hex = calldata_hex[8..] + + # First 32 bytes should be the address (padded) + address_param = params_hex[0...64] + expected_address = bob.downcase.delete_prefix('0x').rjust(64, '0') + expect(address_param).to include(expected_address[24..]) # Address is right-padded in last 20 bytes + end + + it "uses correct selector even with ESIP-5 disabled for single transfers" do + # Test pre-ESIP-5 behavior (single transfers only) + id1 = create_test_ethscription(alice) + + # Import with ESIP-5 disabled (simulating old block) + results = import_l1_block( + [transfer_input(from: alice, to: bob, id: id1)], + esip_overrides: { esip5: false } + ) + + tx = results[:ethscriptions].first + expect(tx).to be_present + + # Should still use singular transfer for single ID + expect(tx.transfer_ids).to eq([id1]) # Always an array now + + # Verify correct function selector + selector = tx.function_selector.unpack1('H*') + expected_selector = Eth::Util.keccak256('transferEthscription(address,bytes32)')[0...4].unpack1('H*') + expect(selector).to eq(expected_selector) + end + end + + private + + def create_test_ethscription(owner) + results = import_l1_block([ + create_input( + creator: owner, + to: owner, + data_uri: "data:,test-#{SecureRandom.hex(4)}" + ) + ]) + results[:ethscription_ids].first + end +end \ No newline at end of file diff --git a/spec/models/blob_utils_spec.rb b/spec/models/blob_utils_spec.rb deleted file mode 100644 index f757af3..0000000 --- a/spec/models/blob_utils_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -require 'rails_helper' - -RSpec.describe BlobUtils do - let(:blob_data) { "we are all gonna make it" } - - describe '.to_blobs' do - context 'with valid data' do - it 'converts data to blobs and back correctly in hex format' do - input = blob_data.bytes.pack('C*') - - blobs = described_class.to_blobs(data: input) - described_class.from_blobs(blobs: blobs) - - expect(described_class.from_blobs(blobs: blobs)).to eq(input) - end - end - - context 'when data is empty' do - it 'raises an EmptyBlobError' do - expect { described_class.to_blobs(data: '') }.to raise_error(BlobUtils::EmptyBlobError) - end - end - - context 'when data is too big' do - it 'raises a BlobSizeTooLargeError' do - large_data = 'we are all gonna make it' * 20000 - expect { described_class.to_blobs(data: large_data) }.to raise_error(BlobUtils::BlobSizeTooLargeError) - end - end - end -end diff --git a/spec/models/erc20_fixed_denomination_parser_spec.rb b/spec/models/erc20_fixed_denomination_parser_spec.rb new file mode 100644 index 0000000..f81aa44 --- /dev/null +++ b/spec/models/erc20_fixed_denomination_parser_spec.rb @@ -0,0 +1,335 @@ +require 'rails_helper' + +RSpec.describe Erc20FixedDenominationParser do + let(:default_params) { [''.b, ''.b, ''.b] } + let(:uint256_max) { 2**256 - 1 } + + describe 'via ProtocolParser' do + context 'valid operations' do + it 'extracts deploy operation params with all required fields' do + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"punk","max":"21000000","lim":"1000"}' + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('deploy'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['punk', 21000000, 1000]) + end + + it 'extracts mint operation params with all required fields' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100"}' + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['punk', 1, 100]) + end + + it 'handles zero values correctly' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"0","amt":"0"}' + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['punk', 0, 0]) + end + + it 'handles single character tick' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"a","id":"1","amt":"100"}' + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['a', 1, 100]) + end + + it 'handles max length tick (28 chars)' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"abcdefghijklmnopqrstuvwxyz12","id":"1","amt":"100"}' + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['abcdefghijklmnopqrstuvwxyz12', 1, 100]) + end + + it 'handles exactly max uint256 value' do + content_uri = "data:,{\"p\":\"erc-20\",\"op\":\"mint\",\"tick\":\"punk\",\"id\":\"1\",\"amt\":\"#{uint256_max}\"}" + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['punk', 1, uint256_max]) + end + + it 'handles deploy with zero lim' do + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"test","max":"1000","lim":"0"}' + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('deploy'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['test', 1000, 0]) + end + end + + context 'strict format requirements' do + it 'rejects mint with integer values instead of strings' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":1,"amt":100}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects mint with optional fields omitted (id missing)' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects mint with optional fields omitted (amt missing)' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects mint with only required protocol fields' do + content_uri = 'data:,{"p":"erc-20","op":"mint"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects extra spaces in JSON' do + content_uri = 'data:, {"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects wrong key order' do + content_uri = 'data:,{"op":"mint","p":"erc-20","tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects extra fields' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100","extra":"field"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + end + + context 'security and data smuggling prevention' do + it 'rejects array in id field (security issue)' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":["1"],"amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects object in amt field' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":{"value":"100"}}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects SQL injection in tick' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk\'; DROP TABLE users;--","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects nested JSON objects' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100","nested":{"key":"value"}}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + end + + context 'data type validation' do + it 'rejects boolean in max field' do + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"test","max":true,"lim":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects null values in deploy' do + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"test","max":null,"lim":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects array as op' do + content_uri = 'data:,{"p":"erc-20","op":["mint"],"tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects non-JSON object (array)' do + content_uri = 'data:,[{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100"}]' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + end + + context 'number format validation' do + it 'rejects non-numeric string' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"abc","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects negative number' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"-1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects hex number' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"0x1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects float number' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1.5","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects number with whitespace' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":" 1 ","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects leading zeros (except standalone zero)' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"01","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects number too large for uint256' do + content_uri = "data:,{\"p\":\"erc-20\",\"op\":\"mint\",\"tick\":\"punk\",\"id\":\"1\",\"amt\":\"#{uint256_max + 1}\"}" + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + end + + context 'tick validation' do + it 'rejects tick with uppercase' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"PUNK","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects tick with special characters (hyphen)' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"pu-nk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects tick too long (29 chars)' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"abcdefghijklmnopqrstuvwxyz123","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects tick with emoji' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"🚀","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects empty string tick' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + end + + context 'protocol and operation validation' do + it 'rejects wrong protocol (erc-721)' do + content_uri = 'data:,{"p":"erc-721","op":"mint","tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects protocol with underscore' do + content_uri = 'data:,{"p":"erc_20","op":"mint","tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects protocol with uppercase' do + content_uri = 'data:,{"p":"ERC-20","op":"mint","tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects unknown operations' do + content_uri = 'data:,{"p":"erc-20","op":"burn","tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'handles unknown operations with protocol/tick' do + content_uri = 'data:,{"p":"erc-20","op":"unknown","tick":"punk"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + end + + context 'required fields validation' do + it 'rejects missing op field' do + content_uri = 'data:,{"p":"erc-20","tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects missing protocol field' do + content_uri = 'data:,{"op":"mint","tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects missing tick field' do + content_uri = 'data:,{"p":"erc-20","op":"mint","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects deploy missing max' do + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"test","lim":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects deploy missing lim' do + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"test","max":"1000"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + end + + context 'JSON format validation' do + it 'rejects invalid JSON' do + content_uri = 'data:,{invalid json}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'rejects JSON with incorrect format' do + content_uri = 'data:,p=erc-20&op=mint&tick=punk&id=1&amt=100' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + end + + context 'edge cases' do + it 'returns default params for empty data URI' do + content_uri = 'data:,' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'returns default params for non-data URI' do + content_uri = 'http://example.com' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'returns default params for nil input' do + expect(ProtocolParser.for_calldata(nil)).to eq(default_params) + end + + it 'returns default params for non-token content' do + content_uri = 'data:,Hello World' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'returns default params for plain text data' do + content_uri = 'data:text/plain,Hello World' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) + end + + it 'returns default params for empty string' do + expect(ProtocolParser.for_calldata('')).to eq(default_params) + end + end + + context 'non-string input types' do + it 'returns default params for integer input' do + expect(ProtocolParser.for_calldata(123)).to eq(default_params) + end + + it 'returns default params for array input' do + expect(ProtocolParser.for_calldata([])).to eq(default_params) + end + + it 'returns default params for hash input' do + expect(ProtocolParser.for_calldata({})).to eq(default_params) + end + end + end +end \ No newline at end of file diff --git a/spec/models/erc721_collections_import_fallback_spec.rb b/spec/models/erc721_collections_import_fallback_spec.rb new file mode 100644 index 0000000..4f68ff2 --- /dev/null +++ b/spec/models/erc721_collections_import_fallback_spec.rb @@ -0,0 +1,234 @@ +require 'rails_helper' + +RSpec.describe Erc721EthscriptionsCollectionParser do + describe 'ID-aware import fallback and normal add flow' do + let(:zero_merkle_root) { '0x' + '0' * 64 } + let(:leader_id) { '0x' + '1' * 64 } + let(:member_id) { '0x' + '2' * 64 } + + let(:collections_json) do + { + 'Test Collection' => { + 'name' => 'Test Collection', + 'slug' => 'TEST', + 'description' => 'Imported collection', + 'logo_image_uri' => '', + 'banner_image_uri' => '', + 'website_link' => 'https://example.com', + 'twitter_link' => '', + 'discord_link' => '', + 'background_color' => '#FFFFFF', + 'total_supply' => 2, + 'merkle_root' => zero_merkle_root + } + } + end + + let(:items_json) do + { + leader_id => { + 'index' => 0, + 'name' => 'Item Zero', + 'description' => 'First', + 'attributes' => [ { 'trait_type' => 'Type', 'value' => 'Genesis' } ], + 'ethscription_number' => 100, + 'collection_name' => 'Test Collection', + 'collection_slug' => 'TEST' + }, + member_id => { + 'index' => 1, + 'name' => 'Item One', + 'description' => 'Second', + 'attributes' => [], + 'ethscription_number' => 200, + 'collection_name' => 'Test Collection', + 'collection_slug' => 'TEST' + } + } + end + + let(:items_path) { Rails.root.join('tmp', 'spec_import_items.json').to_s } + let(:collections_path) { Rails.root.join('tmp', 'spec_import_collections.json').to_s } + + before do + FileUtils.mkdir_p(File.dirname(items_path)) + File.write(items_path, JSON.pretty_generate(items_json)) + File.write(collections_path, JSON.pretty_generate(collections_json)) + stub_const('Erc721EthscriptionsCollectionParser::DEFAULT_ITEMS_PATH', items_path) + stub_const('Erc721EthscriptionsCollectionParser::DEFAULT_COLLECTIONS_PATH', collections_path) + end + + it 'builds create_collection_and_add_self for the leader via import fallback' do + # Create a mock eth_transaction with from_address + mock_tx = double('eth_transaction', from_address: Address20.from_hex('0x0000000000000000000000000000000000000001')) + + protocol, operation, encoded = ProtocolParser.for_calldata( + 'data:,{}', + eth_transaction: mock_tx, + ethscription_id: ByteString.from_hex(leader_id) + ) + expect(protocol).to eq('erc-721-ethscriptions-collection'.b) + expect(operation).to eq('create_collection_and_add_self'.b) + + decoded = Eth::Abi.decode([ + '((string,string,uint256,string,string,string,string,string,string,string,bytes32,address),(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))' + ], encoded)[0] + + metadata = decoded[0] + item = decoded[1] + + expect(metadata[0]).to eq('Test Collection') # name + expect(metadata[1]).to eq('TEST') # symbol (from slug) + expect(metadata[2]).to eq(2) # maxSupply from total_supply + + # Item now has contentHash as first field + expect(item[1]).to eq(0) # item index (now at position 1) + expect(item[2]).to eq('Item Zero') # name (now at position 2) + expect(item[3]).to eq('') # background_color (now at position 3) + end + + it 'builds add_self_to_collection for a member via import fallback' do + protocol, operation, encoded = ProtocolParser.for_calldata( + 'data:,{}', + ethscription_id: ByteString.from_hex(member_id) + ) + + expect(protocol).to eq('erc-721-ethscriptions-collection'.b) + expect(operation).to eq('add_self_to_collection'.b) + + decoded = Eth::Abi.decode([ + '(bytes32,(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))' + ], encoded)[0] + + collection_id = decoded[0] + item = decoded[1] + + expect(collection_id.unpack1('H*')).to eq(leader_id[2..]) + # Item now has contentHash as first field + expect(item[1]).to eq(1) # item index (now at position 1) + expect(item[2]).to eq('Item One') # name (now at position 2) + expect(item[3]).to eq('') # background_color (now at position 3) + end + + it 'parses normal content for add_self_to_collection (non-import)' do + content_json = { + 'p' => 'erc-721-ethscriptions-collection', + 'op' => 'add_self_to_collection', + 'collection_id' => leader_id, + 'item' => { + 'item_index' => '5', + 'name' => 'Normal Item', + 'background_color' => '#000000', + 'description' => 'desc', + 'attributes' => [], + 'merkle_proof' => [] + } + } + + protocol, operation, encoded = ProtocolParser.for_calldata('data:,' + content_json.to_json) + + expect(protocol).to eq('erc-721-ethscriptions-collection'.b) + expect(operation).to eq('add_self_to_collection'.b) + + decoded = Eth::Abi.decode([ + '(bytes32,(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))' + ], encoded)[0] + + expect(decoded[0].unpack1('H*')).to eq(leader_id[2..]) + item = decoded[1] + # Item now has contentHash as first field + expect(item[1]).to eq(5) # item_index (now at position 1) + expect(item[2]).to eq('Normal Item') # name (now at position 2) + expect(item[3]).to eq('#000000') # background_color (now at position 3) + expect(item[4]).to eq('desc') # description (now at position 4) + end + end + + describe 'Live JSON files import fallback' do + it 'builds create_collection_and_add_self for specific ethscription using live JSON files' do + # This ethscription should be the leader of a collection in the live JSON files + specific_id = '0x05aac415994e0e01e66c4970133a51a4cdcea1f3a967743b87e6eb08f2f4d9f9' + + # Create a mock eth_transaction with from_address + mock_tx = double('eth_transaction', from_address: Address20.from_hex('0x0000000000000000000000000000000000000001')) + + protocol, operation, encoded = ProtocolParser.for_calldata( + 'data:,{}', + eth_transaction: mock_tx, + ethscription_id: ByteString.from_hex(specific_id) + ) + expect(protocol).to eq('erc-721-ethscriptions-collection'.b) + expect(operation).to eq('create_collection_and_add_self'.b) + + # Decode to verify it's properly formed + decoded = Eth::Abi.decode([ + '((string,string,uint256,string,string,string,string,string,string,string,bytes32,address),(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))' + ], encoded)[0] + + metadata = decoded[0] + item = decoded[1] + + # Should have valid metadata + expect(metadata[0]).to be_a(String) # name + expect(metadata[0].length).to be > 0 + expect(metadata[1]).to be_a(String) # symbol + expect(metadata[2]).to be_a(Integer) # maxSupply + + # Should have valid item data (contentHash is first field now) + expect(item[0]).to be_a(String) # content_hash (packed bytes) + expect(item[1]).to be_a(Integer) # item_index + expect(item[2]).to be_a(String) # name + expect(item[3]).to be_a(String) # background_color + expect(item[4]).to be_a(String) # description + expect(item[5]).to be_an(Array) # attributes + expect(item[6]).to be_an(Array) # merkle_proof + end + end + + describe 'End-to-end collection creation with live JSON', type: :integration do + include EthscriptionsTestHelper + + let(:creator) { valid_address("creator") } + let(:specific_id) { '0x05aac415994e0e01e66c4970133a51a4cdcea1f3a967743b87e6eb08f2f4d9f9' } + + it 'creates collection and adds ethscription using import fallback for specific ID' do + # Create ethscription with PNG content - should use import fallback + tx_spec = l1_tx( + creator: creator, + to: creator, + input: '0x646174613a696d6167652f706e673b6261736536342c6956424f5277304b47676f414141414e535568455567414141426741414141594341594141414467647a3334414141416d306c4551565234326d4e6747495467507854547876426c65546f30737742734f4b30732b4e38614a6b637a4331414d52374b414b7062387637327841593568467344346c466f434e2b6a35365a556f6c694262536f6b6c47495a6a7778526251416a5431594b37642b38326b4755426575516969354672415959724c38314e774370474651746f4555542f36526f485741796b6e51563053365a49355245364a7438435a494f4f48547547675239467135466b43663139514d33777835725a4b48457473525a517435716b6867554152366347615565684f443441414141415355564f524b35435949493d', + tx_hash: specific_id, + expect: :success + ) + + results = import_l1_block([tx_spec], esip_overrides: { esip6_is_enabled: true }) + + # Verify ethscription was created + expect(results[:ethscription_ids]).to include(specific_id) + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "L2 transaction should succeed" + + # Parse events to verify collection creation and item addition + events = ProtocolEventReader.parse_receipt_events(results[:l2_receipts].first) + + # Should have CollectionCreated event + collection_created = events.find { |e| e[:event] == 'CollectionCreated' } + expect(collection_created).not_to be_nil, "Should emit CollectionCreated event" + expect(collection_created[:collection_id]).to eq(specific_id) + + # Should have ItemsAdded event + items_added = events.find { |e| e[:event] == 'ItemsAdded' } + expect(items_added).not_to be_nil, "Should emit ItemsAdded event" + expect(items_added[:collection_id]).to eq(specific_id) + expect(items_added[:count]).to eq(1), "Should add 1 item" + + # Should have protocol success + protocol_success = events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' } + expect(protocol_success).to eq(true), "Protocol operation should succeed" + + # Verify collection state via eth_call + collection_state = get_collection_state(specific_id) + expect(collection_state[:collectionContract]).not_to eq('0x0000000000000000000000000000000000000000') + expect(collection_state[:currentSize]).to eq(1), "Collection should have 1 item" + end + end +end diff --git a/spec/models/erc721_ethscriptions_collection_parser_spec.rb b/spec/models/erc721_ethscriptions_collection_parser_spec.rb new file mode 100644 index 0000000..d8dd4bc --- /dev/null +++ b/spec/models/erc721_ethscriptions_collection_parser_spec.rb @@ -0,0 +1,417 @@ +require 'rails_helper' + +RSpec.describe Erc721EthscriptionsCollectionParser do + describe 'via ProtocolParser' do + let(:default_params) { [''.b, ''.b, ''.b] } + let(:zero_merkle_root) { '0x' + '0' * 64 } + + describe 'validation rules' do + # @generic-compatible + it 'requires data:, prefix' do + json = '{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'a' * 64 + '"}' + result = ProtocolParser.for_calldata(json) + expect(result).to eq(default_params) + end + + # @generic-compatible + it 'requires valid JSON' do + result = ProtocolParser.for_calldata('data:,{invalid json}') + expect(result).to eq(default_params) + end + + it 'requires p:erc-721-ethscriptions-collection' do + json = 'data:,{"p":"other","op":"lock_collection","collection_id":"0x' + 'a' * 64 + '"}' + result = ProtocolParser.for_calldata(json) + expect(result).to eq(default_params) + end + + it 'requires known operation' do + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"unknown_op","collection_id":"0x' + 'a' * 64 + '"}' + result = ProtocolParser.for_calldata(json) + expect(result).to eq(default_params) + end + + # @generic-compatible + it 'enforces exact key order with p and op first' do + # Wrong order - op before p + json1 = 'data:,{"op":"lock_collection","p":"erc-721-ethscriptions-collection","collection_id":"0x' + 'a' * 64 + '"}' + expect(ProtocolParser.for_calldata(json1)).to eq(default_params) + + # Wrong order - collection_id before op + json2 = 'data:,{"p":"erc-721-ethscriptions-collection","collection_id":"0x' + 'a' * 64 + '","op":"lock_collection"}' + expect(ProtocolParser.for_calldata(json2)).to eq(default_params) + + # Correct order + json3 = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'a' * 64 + '"}' + result = ProtocolParser.for_calldata(json3) + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + expect(result[1]).to eq('lock_collection'.b) + end + + # @generic-compatible + it 'rejects extra keys' do + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'a' * 64 + '","extra":"field"}' + result = ProtocolParser.for_calldata(json) + expect(result).to eq(default_params) + end + + # @generic-compatible + it 'validates uint256 format - no leading zeros' do + # Valid + valid_json = %(data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TEST","max_supply":"1000","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}","initial_owner":"0x0000000000000000000000000000000000000001"}) + result = ProtocolParser.for_calldata(valid_json) + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + + # Invalid - leading zero + invalid_json = %(data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TEST","max_supply":"01000","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}","initial_owner":"0x0000000000000000000000000000000000000001"}) + result = ProtocolParser.for_calldata(invalid_json) + expect(result).to eq(default_params) + end + + # @generic-compatible + it 'validates bytes32 format - lowercase hex only' do + # Valid lowercase + valid_json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'a' * 64 + '"}' + result = ProtocolParser.for_calldata(valid_json) + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + + # Invalid - uppercase + invalid_json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'A' * 64 + '"}' + result = ProtocolParser.for_calldata(invalid_json) + expect(result).to eq(default_params) + + # Invalid - wrong length + invalid_json2 = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'a' * 63 + '"}' + result = ProtocolParser.for_calldata(invalid_json2) + expect(result).to eq(default_params) + + # Invalid - no 0x prefix + invalid_json3 = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"' + 'a' * 64 + '"}' + result = ProtocolParser.for_calldata(invalid_json3) + expect(result).to eq(default_params) + end + end + + describe 'create_collection operation' do + let(:valid_create_json) do + %(data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"My Collection","symbol":"MYC","max_supply":"10000","description":"A test collection","logo_image_uri":"esc://logo","banner_image_uri":"esc://banner","background_color":"#FF5733","website_link":"https://example.com","twitter_link":"https://twitter.com/test","discord_link":"https://discord.gg/test","merkle_root":"#{zero_merkle_root}","initial_owner":"0x0000000000000000000000000000000000000001"}) + end + + it 'encodes create_collection correctly' do + result = ProtocolParser.for_calldata(valid_create_json) + + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + expect(result[1]).to eq('create_collection'.b) + + # Decode and verify + decoded = Eth::Abi.decode( + ['(string,string,uint256,string,string,string,string,string,string,string,bytes32,address)'], + result[2] + )[0] + + expect(decoded[0]).to eq("My Collection") + expect(decoded[1]).to eq("MYC") + expect(decoded[2]).to eq(10000) + expect(decoded[3]).to eq("A test collection") + expect(decoded[4]).to eq("esc://logo") + expect(decoded[5]).to eq("esc://banner") + expect(decoded[6]).to eq("#FF5733") + expect(decoded[7]).to eq("https://example.com") + expect(decoded[8]).to eq("https://twitter.com/test") + expect(decoded[9]).to eq("https://discord.gg/test") + expect(decoded[10]).to eq([zero_merkle_root[2..]].pack('H*')) + end + + it 'handles empty optional fields' do + json = %(data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TST","max_supply":"100","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}","initial_owner":"0x0000000000000000000000000000000000000001"}) + result = ProtocolParser.for_calldata(json) + + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + expect(result[1]).to eq('create_collection'.b) + + decoded = Eth::Abi.decode( + ['(string,string,uint256,string,string,string,string,string,string,string,bytes32,address)'], + result[2] + )[0] + + expect(decoded[0]).to eq("Test") + expect(decoded[1]).to eq("TST") + expect(decoded[2]).to eq(100) + expect(decoded[3]).to eq("") + expect(decoded[10]).to eq([zero_merkle_root[2..]].pack('H*')) + end + + it 'rejects uint256 values that exceed maximum' do + # Value that exceeds uint256 max + too_large = (2**256).to_s # One more than max + + json = %(data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TST","max_supply":"#{too_large}","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}","initial_owner":"0x0000000000000000000000000000000000000001"}) + result = ProtocolParser.for_calldata(json) + + # Should return default params due to validation failure + expect(result).to eq(default_params) + end + + it 'accepts maximum valid uint256 value' do + # Maximum valid uint256 + max_uint256 = (2**256 - 1).to_s + + json = %(data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TST","max_supply":"#{max_uint256}","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}","initial_owner":"0x0000000000000000000000000000000000000001"}) + result = ProtocolParser.for_calldata(json) + + # Should succeed with max value + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + expect(result[1]).to eq('create_collection'.b) + + decoded = Eth::Abi.decode( + ['(string,string,uint256,string,string,string,string,string,string,string,bytes32,address)'], + result[2] + )[0] + + expect(decoded[2]).to eq(2**256 - 1) + expect(decoded[10]).to eq([zero_merkle_root[2..]].pack('H*')) + end + end + + describe 'add_self_to_collection operation' do + let(:collection_id_hex) { '0x' + '1' * 64 } + let(:current_item_id) { '0x' + 'a' * 64 } + + it 'encodes add_self_to_collection correctly' do + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_self_to_collection","collection_id":"' + collection_id_hex + '","item":{"item_index":"0","name":"Item 1","background_color":"#FF0000","description":"First item","attributes":[{"trait_type":"Rarity","value":"Common"},{"trait_type":"Level","value":"1"}],"merkle_proof":[]}}' + + result = ProtocolParser.for_calldata(json) + + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + expect(result[1]).to eq('add_self_to_collection'.b) + + decoded = Eth::Abi.decode( + ['(bytes32,(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))'], + result[2] + )[0] + + expect(decoded[0].unpack1('H*')).to eq(collection_id_hex[2..]) + + item = decoded[1] + # Note: Item structure now has contentHash as first field + expect(item[1]).to eq(0) # item_index + expect(item[2]).to eq('Item 1') # name + expect(item[3]).to eq('#FF0000') # background_color + expect(item[4]).to eq('First item') # description + expect(item[5]).to eq([["Rarity", "Common"], ["Level", "1"]]) # attributes + expect(item[6]).to eq([]) # merkle_proof + end + + it 'validates attribute key order' do + # Wrong key order in attributes (value before trait_type) + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_self_to_collection","collection_id":"' + collection_id_hex + '","item":{"item_index":"0","name":"Item 1","background_color":"#FF0000","description":"First item","attributes":[{"value":"Common","trait_type":"Rarity"}],"merkle_proof":[]}}' + result = ProtocolParser.for_calldata(json) + expect(result).to eq(default_params) + end + + it 'handles empty attributes array' do + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_self_to_collection","collection_id":"' + collection_id_hex + '","item":{"item_index":"0","name":"Item 1","background_color":"","description":"","attributes":[],"merkle_proof":[]}}' + result = ProtocolParser.for_calldata(json) + + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + + decoded = Eth::Abi.decode( + ['(bytes32,(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))'], + result[2] + )[0] + + item = decoded[1] + expect(item[5]).to eq([]) # Empty attributes + expect(item[6]).to eq([]) # Empty merkle_proof + end + end + + describe 'remove_items operation' do + it 'encodes remove_items correctly' do + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"remove_items","collection_id":"0x' + '1' * 64 + '","ethscription_ids":["0x' + '2' * 64 + '","0x' + '3' * 64 + '"]}' + result = ProtocolParser.for_calldata(json) + + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + expect(result[1]).to eq('remove_items'.b) + + decoded = Eth::Abi.decode(['(bytes32,bytes32[])'], result[2])[0] + + expect(decoded[0].unpack1('H*')).to eq('1' * 64) + expect(decoded[1][0].unpack1('H*')).to eq('2' * 64) + expect(decoded[1][1].unpack1('H*')).to eq('3' * 64) + end + end + + describe 'edit_collection operation' do + it 'encodes edit_collection correctly' do + json = %(data:,{"p":"erc-721-ethscriptions-collection","op":"edit_collection","collection_id":"0x#{"1" * 64}","description":"Updated","logo_image_uri":"new_logo","banner_image_uri":"","background_color":"#00FF00","website_link":"https://new.com","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}"}) + result = ProtocolParser.for_calldata(json) + + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + expect(result[1]).to eq('edit_collection'.b) + + decoded = Eth::Abi.decode( + ['(bytes32,string,string,string,string,string,string,string,bytes32)'], + result[2] + )[0] + + expect(decoded[0].unpack1('H*')).to eq('1' * 64) + expect(decoded[1]).to eq("Updated") + expect(decoded[2]).to eq("new_logo") + expect(decoded[3]).to eq("") + expect(decoded[4]).to eq("#00FF00") + expect(decoded[5]).to eq("https://new.com") + expect(decoded[8]).to eq([zero_merkle_root[2..]].pack('H*')) + end + end + + describe 'edit_collection_item operation' do + it 'encodes edit_collection_item correctly' do + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"edit_collection_item","collection_id":"0x' + '1' * 64 + '","item_index":"5","name":"Updated Name","background_color":"#0000FF","description":"Updated desc","attributes":[{"trait_type":"New","value":"Value"}]}' + result = ProtocolParser.for_calldata(json) + + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + expect(result[1]).to eq('edit_collection_item'.b) + + decoded = Eth::Abi.decode( + ['(bytes32,uint256,string,string,string,(string,string)[])'], + result[2] + )[0] + + expect(decoded[0].unpack1('H*')).to eq('1' * 64) + expect(decoded[1]).to eq(5) + expect(decoded[2]).to eq("Updated Name") + expect(decoded[3]).to eq("#0000FF") + expect(decoded[4]).to eq("Updated desc") + expect(decoded[5]).to eq([["New", "Value"]]) + end + end + + describe 'lock_collection operation' do + it 'encodes lock_collection as single bytes32' do + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + '1' * 64 + '"}' + result = ProtocolParser.for_calldata(json) + + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + expect(result[1]).to eq('lock_collection'.b) + + # Single bytes32, not a tuple + decoded = Eth::Abi.decode(['bytes32'], result[2])[0] + expect(decoded.unpack1('H*')).to eq('1' * 64) + end + end + + describe 'transfer_ownership operation' do + it 'encodes transfer_ownership with collection_id and new_owner' do + new_owner = '0x' + '1' * 40 + json = %(data:,{"p":"erc-721-ethscriptions-collection","op":"transfer_ownership","collection_id":"0x#{"2" * 64}","new_owner":"#{new_owner}"}) + result = ProtocolParser.for_calldata(json) + + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + expect(result[1]).to eq('transfer_ownership'.b) + + decoded = Eth::Abi.decode(['(bytes32,address)'], result[2])[0] + expect(decoded[0].unpack1('H*')).to eq('2' * 64) + expect(decoded[1]).to eq(new_owner) + end + end + + describe 'renounce_ownership operation' do + it 'encodes renounce_ownership as single bytes32' do + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"renounce_ownership","collection_id":"0x' + '3' * 64 + '"}' + result = ProtocolParser.for_calldata(json) + + expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) + expect(result[1]).to eq('renounce_ownership'.b) + + # Eth::Abi.decode treats strings containing only hex characters as hex input, + # so compare the packed bytes directly via hex. + expect(result[2].unpack1('H*')).to eq('3' * 64) + end + end + + describe 'round-trip tests' do + # @generic-compatible + it 'preserves all data through encode/decode cycle' do + test_cases = [ + { + json: %(data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TST","max_supply":"100","description":"Desc","logo_image_uri":"logo","banner_image_uri":"banner","background_color":"#FFF","website_link":"http://test","twitter_link":"@test","discord_link":"discord","merkle_root":"#{zero_merkle_root}","initial_owner":"0x0000000000000000000000000000000000000001"}), + abi_type: '(string,string,uint256,string,string,string,string,string,string,string,bytes32,address)', + expected: ["Test", "TST", 100, "Desc", "logo", "banner", "#FFF", "http://test", "@test", "discord", [zero_merkle_root[2..]].pack('H*'), "0x0000000000000000000000000000000000000001"] + }, + { + json: 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'a' * 64 + '"}', + abi_type: 'bytes32', + expected: ['a' * 64].pack('H*') + } + ] + + test_cases.each do |test_case| + result = ProtocolParser.for_calldata(test_case[:json]) + expect(result[0]).not_to eq(''.b) + + decoded = Eth::Abi.decode([test_case[:abi_type]], result[2]) + + if test_case[:abi_type].start_with?('(') + # Tuple + expect(decoded[0]).to eq(test_case[:expected]) + else + # Single value + expect(decoded[0]).to eq(test_case[:expected]) + end + end + end + end + + describe 'error cases' do + it 'returns default params for malformed JSON' do + test_cases = [ + 'data:,{broken json', + 'data:,', + 'data:,[]', + 'data:,"string"' + ] + + test_cases.each do |json| + result = ProtocolParser.for_calldata(json) + expect(result).to eq(default_params) + end + end + + it 'rejects null values in string fields (no silent coercion)' do + # Test null in create_collection string fields + json_with_null = %(data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":null,"symbol":"TEST","max_supply":"100","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}","initial_owner":"0x0000000000000000000000000000000000000001"}) + result = ProtocolParser.for_calldata(json_with_null) + expect(result).to eq(default_params) + + # Test null in description field + json_with_null_desc = %(data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TEST","max_supply":"100","description":null,"logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}","initial_owner":"0x0000000000000000000000000000000000000001"}) + result = ProtocolParser.for_calldata(json_with_null_desc) + expect(result).to eq(default_params) + + # Test null in item fields + json_with_null_item = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_items_batch","collection_id":"0x' + '1' * 64 + '","items":[{"item_index":"0","name":null,"ethscription_id":"0x' + '2' * 64 + '","background_color":"","description":"","attributes":[]}]}' + result = ProtocolParser.for_calldata(json_with_null_item) + expect(result).to eq(default_params) + + # Test null in attribute fields + json_with_null_attr = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_items_batch","collection_id":"0x' + '1' * 64 + '","items":[{"item_index":"0","name":"Item","ethscription_id":"0x' + '2' * 64 + '","background_color":"","description":"","attributes":[{"trait_type":null,"value":"test"}]}]}' + result = ProtocolParser.for_calldata(json_with_null_attr) + expect(result).to eq(default_params) + end + + it 'returns default params for missing required fields' do + # Missing collection_id + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection"}' + result = ProtocolParser.for_calldata(json) + expect(result).to eq(default_params) + end + + it 'rejects zero address for transfer_ownership new_owner' do + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"transfer_ownership","collection_id":"0x' + '4' * 64 + '","new_owner":"0x' + '0' * 40 + '"}' + result = ProtocolParser.for_calldata(json) + expect(result).to eq(default_params) + end + end + end +end diff --git a/spec/models/eth_block_spec.rb b/spec/models/eth_block_spec.rb deleted file mode 100644 index 21b7d79..0000000 --- a/spec/models/eth_block_spec.rb +++ /dev/null @@ -1,94 +0,0 @@ -require 'rails_helper' - -class OldEthBlock - def self.sorted_blocknumbers_within_purview - current_block_number = EthBlock.genesis_blocks.max + 100_000 - - largest_genesis_block = EthBlock.genesis_blocks.max - - full_block_range = (largest_genesis_block..current_block_number).to_a - - (EthBlock.genesis_blocks + full_block_range).sort - end - - def self.next_block_to_import - no_records = sorted_blocknumbers_within_purview - EthBlock.pluck(:block_number) - - records_but_not_imported = EthBlock.where(imported_at: nil).pluck(:block_number) - - (no_records + records_but_not_imported).min - end -end - -RSpec.describe EthBlock, type: :model do - def create_eth_block(block_number) - prev_block = EthBlock.find_by(block_number: block_number - 1) - parent_blockhash = prev_block&.blockhash || "0x" + SecureRandom.hex(32) - - EthBlock.create!( - block_number: block_number, - imported_at: Time.now, - blockhash: "0x" + SecureRandom.hex(32), - parent_blockhash: parent_blockhash, - timestamp: block_number, - is_genesis_block: EthBlock.genesis_blocks.include?(block_number) - ) - end - - describe '.next_block_to_import' do - context 'no records at all' do - it 'returns the same block as the old method' do - expect(EthBlock.next_block_to_import).to eq(OldEthBlock.next_block_to_import) - end - end - - context 'all genesis blocks imported but nothing more' do - before do - EthBlock.genesis_blocks.each do |block_number| - create_eth_block(block_number) - end - end - - it 'returns the same block as the old method' do - expect(EthBlock.next_block_to_import).to eq(OldEthBlock.next_block_to_import) - end - end - - context 'midway through importing genesis blocks' do - before do - midway_index = EthBlock.genesis_blocks.length / 2 - EthBlock.genesis_blocks[0...midway_index].each do |block_number| - create_eth_block(block_number) - end - end - - it 'returns the same block as the old method' do - expect(EthBlock.next_block_to_import).to eq(OldEthBlock.next_block_to_import) - end - end - - context 'completed importing all genesis blocks, and started importing subsequent blocks' do - before do - EthBlock.genesis_blocks.each do |block_number| - create_eth_block(block_number) - end - - next_block = EthBlock.genesis_blocks.max + 1 - create_eth_block(next_block) - end - - it 'returns the same block as the old method' do - expect(EthBlock.next_block_to_import).to eq(OldEthBlock.next_block_to_import) - end - end - - it 'consistently returns the same block number for both old and new methods' do - 100.times do |i| - expect(OldEthBlock.next_block_to_import).to eq(EthBlock.next_block_to_import) - - next_block = EthBlock.next_block_to_import - create_eth_block(next_block) - end - end - end -end diff --git a/spec/models/eth_transaction_spec.rb b/spec/models/eth_transaction_spec.rb deleted file mode 100644 index 4f54f2b..0000000 --- a/spec/models/eth_transaction_spec.rb +++ /dev/null @@ -1,470 +0,0 @@ -require 'rails_helper' -require 'ethscription_test_helper' - -RSpec.describe EthTransaction, type: :model do - before do - allow(EthTransaction).to receive(:esip3_enabled?).and_return(true) - allow(EthTransaction).to receive(:esip5_enabled?).and_return(true) - allow(EthTransaction).to receive(:esip2_enabled?).and_return(true) - allow(EthTransaction).to receive(:esip1_enabled?).and_return(true) - allow(EthTransaction).to receive(:esip7_enabled?).and_return(true) - end - - describe '#create_ethscription_if_needed!' do - context 'when both input and logs are empty' do - it 'does not create an ethscription' do - EthscriptionTestHelper.create_eth_transaction( - input: "", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - expect(Ethscription.count).to eq(0) - end - end - - context 'when there are no logs' do - it 'creates ethscription from input when valid' do - EthscriptionTestHelper.create_eth_transaction( - input: "data:,test", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - expect(Ethscription.count).to eq(1) - expect(Ethscription.count).to eq(1) - - created = Ethscription.first - expect(created.creator).to eq("0xc2172a6315c1d7f6855768f843c420ebb36eda97") - expect(created.initial_owner).to eq("0xc2172a6315c1d7f6855768f843c420ebb36eda97") - end - - it 'does not create ethscription with invalid data uri' do - EthscriptionTestHelper.create_eth_transaction( - input: "data:test", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - expect(Ethscription.count).to eq(0) - expect(Ethscription.count).to eq(0) - end - - it 'does not create ethscription with dupe' do - EthscriptionTestHelper.create_eth_transaction( - input: "data:,test", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - EthscriptionTestHelper.create_eth_transaction( - input: "data:,test", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - expect(Ethscription.count).to eq(1) - expect(Ethscription.count).to eq(1) - end - end - - context 'when there are valid logs and dupe input' do - it 'creates an ethscription' do - EthscriptionTestHelper.create_eth_transaction( - input: "data:,test", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - EthscriptionTestHelper.create_eth_transaction( - input: "data:,test", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [ - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['string'], ['data:,test-log']).unpack1('H*'), - 'logIndex' => 1.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - }, - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['string'], ['data:,test-log-2']).unpack1('H*'), - 'logIndex' => 2.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - } - ] - ) - - expect(Ethscription.count).to eq(2) - - created = Ethscription.last - expect(Ethscription.first.content).to eq("test") - expect(created.content).to eq("test-log") - expect(created.creator).to eq("0xe7dfe249c262a6a9b57651782d57296d2e4bccc9") - expect(created.event_log_index).to eq(1) - end - end - - context 'when there are duplicate logs' do - it 'creates only one ethscription' do - EthscriptionTestHelper.create_eth_transaction( - input: "invalid", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [ - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['string'], ['data:,test-log']).unpack1('H*'), - 'logIndex' => 1.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc4' - }, - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['string'], ['data:,test-log']).unpack1('H*'), - 'logIndex' => 2.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - } - ] - ) - - expect(Ethscription.count).to eq(1) - expect(Ethscription.count).to eq(1) - created = Ethscription.last - expect(created.content).to eq("test-log") - expect(created.creator).to eq("0xe7dfe249c262a6a9b57651782d57296d2e4bccc4") - expect(created.event_log_index).to eq(1) - end - end - - context 'when there are invalid logs' do - it 'creates only one ethscription' do - EthscriptionTestHelper.create_eth_transaction( - input: "invalid", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [ - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['string'], ['invalid']).unpack1('H*'), - 'logIndex' => 1.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc4' - }, - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['string'], ['data:,test-log']).unpack1('H*'), - 'logIndex' => 2.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - } - ] - ) - - expect(Ethscription.count).to eq(1) - expect(Ethscription.count).to eq(1) - - created = Ethscription.last - expect(created.content).to eq("test-log") - expect(created.creator).to eq("0xe7dfe249c262a6a9b57651782d57296d2e4bccc9") - expect(created.event_log_index).to eq(2) - end - end - - context 'when there are multiple valid logs' do - it 'does not create multiple ethscriptions' do - EthscriptionTestHelper.create_eth_transaction( - input: "data:,test", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [ - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['string'], ['data:,test1']).unpack1('H*'), - 'logIndex' => 1.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - }, - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['string'], ['data:,test2']).unpack1('H*'), - 'logIndex' => 2.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - } - ] - ) - - expect(Ethscription.count).to eq(1) - expect(Ethscription.count).to eq(1) - created = Ethscription.last - expect(created.content).to eq("test") - end - end - - context 'when there are mixed valid and invalid logs' do - it 'creates an ethscription for valid logs only' do - EthscriptionTestHelper.create_eth_transaction( - input: "data:,test", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [ - { - 'topics' => ['invalid'], - 'data' => 'invalid', - 'logIndex' => 1.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - }, - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['string'], ['data:,test']).unpack1('H*'), - 'logIndex' => 2.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - } - ] - ) - - expect(Ethscription.count).to eq(1) - created = Ethscription.last - expect(created.content).to eq("test") - expect(created.event_log_index).to eq(nil) - end - end - - context 'when there are valid logs' do - it 'creates an ethscription' do - EthscriptionTestHelper.create_eth_transaction( - input: "data:,test-input", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [ - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['string'], ['data:,test']).unpack1('H*'), - 'logIndex' => 1.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - } - ] - ) - - expect(Ethscription.count).to eq(1) - expect(Ethscription.count).to eq(1) - - created = Ethscription.first - expect(created.content).to eq("test-input") - end - end - - context 'when there are invalid logs followed by valid ones' do - it 'creates an ethscription' do - EthscriptionTestHelper.create_eth_transaction( - input: "data:,test-input", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [ - { - 'topics' => ['invalid'], - 'data' => 'invalid', - 'logIndex' => 1.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - }, - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['string'], ['data:,test']).unpack1('H*'), - 'logIndex' => 2.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - } - ] - ) - - expect(Ethscription.count).to eq(1) - expect(Ethscription.count).to eq(1) - created = Ethscription.first - expect(created.content).to eq("test-input") - end - - it 'creates an ethscription' do - EthscriptionTestHelper.create_eth_transaction( - input: "invalid", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [ - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'logIndex' => 1.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - }, - { - 'topics' => [ - EthTransaction::CreateEthscriptionEventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['string'], ['data:,test-log-2']).unpack1('H*'), - 'logIndex' => 2.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - } - ] - ) - - expect(Ethscription.count).to eq(1) - expect(Ethscription.count).to eq(1) - created = Ethscription.first - expect(created.content).to eq("test-log-2") - end - end - - context 'when input is valid and GZIP-compressed' do - it 'creates ethscription from GZIP-compressed input when valid' do - # Generate a GZIP-compressed version of the string "data:,test-gzip" - compressed_data = Zlib.gzip("data:,test-gzip") - hex_compressed_data = compressed_data.unpack1('H*') - - EthscriptionTestHelper.create_eth_transaction( - input: "0x#{hex_compressed_data}", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - expect(Ethscription.count).to eq(1) - created = Ethscription.first - expect(created.content).to eq("test-gzip") - end - end - end - - context 'when input is GZIP-compressed but invalid or exceeds decompression ratio' do - it 'does not create ethscription with invalid or too large GZIP-compressed data' do - # Example of invalid GZIP-compressed data (you may need to tailor this) - invalid_compressed_data = "invalidgzipdata" - hex_invalid_compressed_data = invalid_compressed_data.unpack1('H*') - - EthscriptionTestHelper.create_eth_transaction( - input: "0x#{hex_invalid_compressed_data}", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - expect(Ethscription.count).to eq(0) - end - end - - context 'when input is GZIP-compressed with a high decompression ratio' do - it 'does not create ethscription if decompressed data exceeds ratio limit' do - # Create a large string that, when compressed, significantly reduces in size - large_string = "A" * 100_000 # Adjust size as needed to exceed the ratio limit upon decompression - compressed_large_string = Zlib.gzip(large_string) - hex_compressed_large_string = compressed_large_string.unpack1('H*') - - EthscriptionTestHelper.create_eth_transaction( - input: "0x#{hex_compressed_large_string}", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - expect(Ethscription.count).to eq(0) - end - end - - describe '#create_ethscription_attachment_if_needed!' do - context 'when the transaction meets criteria for attachment creation' do - it 'creates an ethscription attachment' do - transaction = EthTransaction.new - - cbor = { - content: "we are all gonna make it", - contentType: "text/plain" - }.to_cbor - - hardcoded_blobs = BlobUtils.to_blobs(data: cbor) - hardcoded_blobs = hardcoded_blobs.map { |blob| { 'blob' => blob } } - - allow(transaction).to receive(:blobs).and_return(hardcoded_blobs) - - attachment = EthscriptionAttachment.from_eth_transaction(transaction) - - expect(attachment.content).to eq("we are all gonna make it") - expect(attachment.content_type).to eq("text/plain") - end - - it 'creates an ethscription attachment gzip' do - transaction = EthTransaction.new - - cbor = { - content: Zlib.gzip("we are all gonna make it"), - contentType: Zlib.gzip("text/plain") - }.to_cbor - - hardcoded_blobs = BlobUtils.to_blobs(data: Zlib.gzip(cbor)) - hardcoded_blobs = hardcoded_blobs.map { |blob| { 'blob' => blob } } - - allow(transaction).to receive(:blobs).and_return(hardcoded_blobs) - - attachment = EthscriptionAttachment.from_eth_transaction(transaction) - - expect(attachment.content).to eq("we are all gonna make it") - expect(attachment.content_type).to eq("text/plain") - end - - it 'raises an InvalidInputError for incorrect data' do - transaction = EthTransaction.new - - cbor = { - content: Zlib.gzip("we are all gonna make it"), - contentType: Zlib.gzip("text/plain"), - a: 'b' - }.to_cbor - - hardcoded_blobs = BlobUtils.to_blobs(data: Zlib.gzip(cbor)) - hardcoded_blobs = hardcoded_blobs.map { |blob| { 'blob' => blob } } - - allow(transaction).to receive(:blobs).and_return(hardcoded_blobs) - - expect { - EthscriptionAttachment.from_eth_transaction(transaction) - }.to raise_error(EthscriptionAttachment::InvalidInputError) - end - end - end -end diff --git a/spec/models/ethscription_attachment_spec.rb b/spec/models/ethscription_attachment_spec.rb deleted file mode 100644 index 82c0816..0000000 --- a/spec/models/ethscription_attachment_spec.rb +++ /dev/null @@ -1,118 +0,0 @@ -require 'rails_helper' - -RSpec.describe EthscriptionAttachment do - describe '.from_cbor' do - context 'with valid CBOR data' do - it 'creates a new EthscriptionAttachment with decoded data' do - cbor_encoded_data = CBOR.encode({ 'content' => 'test content', 'contentType' => 'text/plain' }) - attachment = EthscriptionAttachment.from_cbor(cbor_encoded_data) - expect(attachment.content).to eq('test content') - expect(attachment.content_type).to eq('text/plain') - expect(attachment.size).to eq('test content'.bytesize) - expect(attachment.sha).to eq(attachment.calculate_sha) - end - end - - context 'with invalid CBOR data' do - it 'raises an InvalidInputError' do - expect { - EthscriptionAttachment.from_cbor("not a cbor") - }.to raise_error(EthscriptionAttachment::InvalidInputError) - end - it 'raises an InvalidInputError for non-hash input' do - attachment = EthscriptionAttachment.new - expect { - attachment.decoded_data = "string" - }.to raise_error(EthscriptionAttachment::InvalidInputError) - end - end - end - - describe '#content_type_with_encoding' do - it 'appends charset=UTF-8 for text types without a charset' do - attachment = EthscriptionAttachment.new(content_type: 'text/plain') - expect(attachment.content_type_with_encoding).to eq('text/plain; charset=UTF-8') - end - - it 'does not modify content_type that already has a charset' do - attachment = EthscriptionAttachment.new(content_type: 'text/plain; charset=UTF-16') - expect(attachment.content_type_with_encoding).to eq('text/plain; charset=UTF-16') - end - end - - describe '#decoded_data=' do - let(:attachment) { EthscriptionAttachment.new } - - context 'when decoded_data is missing the content key' do - it 'raises an InvalidInputError' do - expect { - attachment.decoded_data = { 'contentType' => 'text/plain' } - }.to raise_error(EthscriptionAttachment::InvalidInputError, /Expected keys to be 'content' and 'contentType'/) - end - end - - context 'when decoded_data is missing the content_type key' do - it 'raises an InvalidInputError' do - expect { - attachment.decoded_data = { 'content' => 'test content' } - }.to raise_error(EthscriptionAttachment::InvalidInputError, /Expected keys to be 'content' and 'contentType'/) - end - end - - context 'when content is not a string' do - it 'raises an InvalidInputError' do - expect { - attachment.decoded_data = { 'content' => 123, 'contentType' => 'text/plain' } - }.to raise_error(EthscriptionAttachment::InvalidInputError, /Invalid value type/) - end - end - - context 'when content_type is not a string' do - it 'raises an InvalidInputError' do - expect { - attachment.decoded_data = { 'content' => 'test content', 'contentType' => 123 } - }.to raise_error(EthscriptionAttachment::InvalidInputError, /Invalid value type/) - end - end - - context 'when contentType is over 1000 characters' do - it 'stores only the first 1000 characters of contentType' do - long_content_type = 'text/plain' + 'a' * 995 + 'b' * 10 # Total length is 1005 - attachment = EthscriptionAttachment.new - attachment.decoded_data = { 'content' => 'test content', 'contentType' => long_content_type } - - expect(attachment.content_type.length).to eq(1000) - expect(attachment.content_type).to end_with('a') # Ensures the last character is the 1000th 'a' - end - end - end - - describe '.ungzip_if_necessary!' do - context 'with gzipped data' do - it 'correctly decompresses the data' do - original_text = "This is a test string." - gzipped_text = Zlib.gzip(original_text) - - expect(EthscriptionAttachment.ungzip_if_necessary!(gzipped_text)).to eq(original_text) - end - end - - context 'with non-gzipped data' do - it 'returns the original data' do - non_gzipped_text = "This is a test string." - - expect(EthscriptionAttachment.ungzip_if_necessary!(non_gzipped_text)).to eq(non_gzipped_text) - end - end - - context 'with invalid gzipped data' do - it 'raises an InvalidInputError' do - invalid_gzipped_text = ["1f8b08a0000000000003666f6f626172"].pack("H*") # Altered gzip header, likely invalid - - expect { - EthscriptionAttachment.ungzip_if_necessary!(invalid_gzipped_text) - }.to raise_error(EthscriptionAttachment::InvalidInputError, /Failed to decompress content/) - end - end - end -end diff --git a/spec/models/ethscription_transaction_builder_spec.rb b/spec/models/ethscription_transaction_builder_spec.rb new file mode 100644 index 0000000..f3b39b9 --- /dev/null +++ b/spec/models/ethscription_transaction_builder_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' + +RSpec.describe "EthscriptionTransactionBuilder" do + describe 'ERC-20 protocol parsing via ProtocolParser' do + it 'extracts deploy operation params' do + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"eths","max":"21000000","lim":"1000"}' + + params = ProtocolParser.for_calldata(content_uri) + + expect(params[0]).to eq('erc-20-fixed-denomination'.b) + expect(params[1]).to eq('deploy'.b) + # params[2] is ABI-encoded (string, uint256, uint256) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], params[2])[0] + expect(decoded).to eq(['eths', 21000000, 1000]) + end + + it 'extracts mint operation params' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"eths","id":"1","amt":"1000"}' + + params = ProtocolParser.for_calldata(content_uri) + + expect(params[0]).to eq('erc-20-fixed-denomination'.b) + expect(params[1]).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], params[2])[0] + expect(decoded).to eq(['eths', 1, 1000]) + end + + it 'returns default params for non-token content' do + content_uri = 'data:,Hello World!' + + params = ProtocolParser.for_calldata(content_uri) + + expect(params).to eq([''.b, ''.b, ''.b]) + end + + it 'returns default params for invalid JSON' do + content_uri = 'data:,{invalid json' + + params = ProtocolParser.for_calldata(content_uri) + + expect(params).to eq([''.b, ''.b, ''.b]) + end + + it 'handles unknown operations with protocol/tick' do + content_uri = 'data:,{"p":"new-proto","op":"custom","tick":"test"}' + + params = ProtocolParser.for_calldata(content_uri) + + expect(params).to eq([''.b, ''.b, ''.b]) + end + end +end diff --git a/spec/models/ethscription_transfer_spec.rb b/spec/models/ethscription_transfer_spec.rb deleted file mode 100644 index 5d0307c..0000000 --- a/spec/models/ethscription_transfer_spec.rb +++ /dev/null @@ -1,231 +0,0 @@ -require 'rails_helper' -require 'ethscription_test_helper' - -RSpec.describe EthscriptionTransfer, type: :model do - before do - allow(EthTransaction).to receive(:esip3_enabled?).and_return(true) - allow(EthTransaction).to receive(:esip5_enabled?).and_return(true) - allow(EthTransaction).to receive(:esip2_enabled?).and_return(true) - allow(EthTransaction).to receive(:esip1_enabled?).and_return(true) - end - - context 'when an ethscription is transferred' do - it 'handles a single transfer' do - tx = EthscriptionTestHelper.create_eth_transaction( - input: "data:,test", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - ethscription = tx.ethscription - - EthscriptionTestHelper.create_eth_transaction( - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0x104a84b87e1e7054c48b63077b8b7ccd62de9260", - input: ethscription.transaction_hash, - logs: [ - { - 'topics' => [ - EthTransaction::Esip1EventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - Eth::Abi.encode(['bytes32'], [ethscription.transaction_hash]).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['bytes32'], [ethscription.transaction_hash]).unpack1('H*'), - 'logIndex' => 1.to_s(16), - 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' - } - ] - ) - - ethscription.reload - - expect(ethscription.current_owner).to eq("0x104a84b87e1e7054c48b63077b8b7ccd62de9260") - end - - it 'handles chain reorgs' do - tx = EthscriptionTestHelper.create_eth_transaction( - input: 'data:,{"p":"erc-20","op":"mint","tick":"gwei","id":"6359","amt":"1000"}', - from: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", - to: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", - tx_hash: '0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22' - ) - - eths = tx.ethscription - - current_owner = eths.current_owner - previous_owner = eths.previous_owner - - second_tx = EthscriptionTestHelper.create_eth_transaction( - input: '0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22', - from: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", - to: "0x36442bda6780c95113d7c38dd17cdd94be611de8", - ) - - EthBlock.where("block_number >= ?", second_tx.block_number).delete_all - - eths.reload - - expect(eths.current_owner).to eq(current_owner) - expect(eths.previous_owner).to eq(previous_owner) - end - - it 'handles invalid transfers' do - tx = EthscriptionTestHelper.create_eth_transaction( - input: 'data:,{"p":"erc-20","op":"mint","tick":"gwei","id":"6359","amt":"1000"}', - from: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", - to: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", - tx_hash: '0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22' - ) - - eths = tx.ethscription - - EthscriptionTestHelper.create_eth_transaction( - input: '0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22', - from: "0xD729A94d6366a4fEac4A6869C8b3573cEe4701A9", - to: "0x0000000000000000000000000000000000000000", - ) - - eths.reload - - expect(eths.current_owner).to eq("0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d".downcase) - end - - it 'handles a sequence of transfers' do - - tx = EthscriptionTestHelper.create_eth_transaction( - input: 'data:,{"p":"erc-20","op":"mint","tick":"gwei","id":"6359","amt":"1000"}', - from: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", - to: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", - tx_hash: '0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22' - ) - - eths = tx.ethscription - - EthscriptionTestHelper.create_eth_transaction( - input: '0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22', - from: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", - to: "0x36442bda6780c95113d7c38dd17cdd94be611de8", - ) - - EthscriptionTestHelper.create_eth_transaction( - input: '0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22', - from: "0x36442bda6780c95113d7c38dd17cdd94be611de8", - to: "0xD729A94d6366a4fEac4A6869C8b3573cEe4701A9", - ) - - eths.reload - - expect(eths.current_owner).to eq("0xD729A94d6366a4fEac4A6869C8b3573cEe4701A9".downcase) - expect(eths.previous_owner).to eq("0x36442bda6780c95113d7c38dd17cdd94be611de8".downcase) - - EthscriptionTestHelper.create_eth_transaction( - input: "0xccad70f16a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22", - from: "0x8558dB5F3f9201492028fad05087B6a1d9C11273", - to: "0xD729A94d6366a4fEac4A6869C8b3573cEe4701A9", - logs: [ - { - 'topics' => [ - EthTransaction::Esip1EventSig, - Eth::Abi.encode(['address'], ['0x8558dB5F3f9201492028fad05087B6a1d9C11273']).unpack1('H*'), - Eth::Abi.encode(['bytes32'], ['0x6A8F9706637F16C9A93A7BAC072BBB291530D9D59F1EBA43E28FB5BC2CF12A22']).unpack1('H*'), - ], - 'logIndex' => 214.to_s(16), - 'address' => '0xd729a94d6366a4feac4a6869c8b3573cee4701a9' - } - ] - ) - - eths.reload - - expect(eths.current_owner).to eq("0x8558dB5F3f9201492028fad05087B6a1d9C11273".downcase) - expect(eths.previous_owner).to eq("0xd729a94d6366a4feac4a6869c8b3573cee4701a9".downcase) - - EthscriptionTestHelper.create_eth_transaction( - input: "0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22", - from: "0x8558dB5F3f9201492028fad05087B6a1d9C11273", - to: "0x57b8792c775D34Aa96092400983c3e112fCbC296", - ) - - eths.reload - - expect(eths.current_owner).to eq("0x57b8792c775D34Aa96092400983c3e112fCbC296".downcase) - expect(eths.previous_owner).to eq("0x8558dB5F3f9201492028fad05087B6a1d9C11273".downcase) - - EthscriptionTestHelper.create_eth_transaction( - input: "0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22", - from: "0x8558dB5F3f9201492028fad05087B6a1d9C11273", - to: "0x57b8792c775D34Aa96092400983c3e112fCbC296", - logs: [ - { - 'topics' => [ - EthTransaction::Esip2EventSig, - Eth::Abi.encode(['address'], ['0x8558dB5F3f9201492028fad05087B6a1d9C11273']).unpack1('H*'), - Eth::Abi.encode(['address'], ['0x8D5b48934c0C408ADC25F14174c7307922F6Aa60']).unpack1('H*'), - Eth::Abi.encode(['bytes32'], ['6A8F9706637F16C9A93A7BAC072BBB291530D9D59F1EBA43E28FB5BC2CF12A22']).unpack1('H*'), - ], - 'logIndex' => 543.to_s(16), - 'address' => '0x57b8792c775d34aa96092400983c3e112fcbc296' - } - ] - ) - - eths.reload - - expect(eths.current_owner).to eq("0x8d5b48934c0c408adc25f14174c7307922f6aa60".downcase) - expect(eths.previous_owner).to eq("0x57b8792c775D34Aa96092400983c3e112fCbC296".downcase) - end - - it 'ignores logs with incorrect number of topics for Esip1EventSig and Esip2EventSig' do - tx = EthscriptionTestHelper.create_eth_transaction( - input: 'data:,test', - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - ethscription = tx.ethscription - original_owner = ethscription.current_owner - - EthscriptionTestHelper.create_eth_transaction( - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0x104a84b87e1e7054c48b63077b8b7ccd62de9260", - input: ethscription.transaction_hash, - logs: [ - { - 'topics' => [ - EthTransaction::Esip1EventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['bytes32'], [ethscription.transaction_hash]).unpack1('H*'), - 'logIndex' => 1.to_s(16), - 'address' => '0x104a84b87e1e7054c48b63077b8b7ccd62de9260' - }, - { - 'topics' => [ - EthTransaction::Esip2EventSig, - Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['bytes32'], [ethscription.transaction_hash]).unpack1('H*'), - 'logIndex' => 1.to_s(16), - 'address' => '0x104a84b87e1e7054c48b63077b8b7ccd62de9260' - }, - { - 'topics' => [ - EthTransaction::Esip1EventSig, - Eth::Abi.encode(['address'], ['0x0000000000000000000000000000000000000000']).unpack1('H*'), - Eth::Abi.encode(['bytes32'], [ethscription.transaction_hash]).unpack1('H*'), - ], - 'data' => Eth::Abi.encode(['bytes32'], [ethscription.transaction_hash]).unpack1('H*'), - 'logIndex' => 1.to_s(16), - 'address' => '0x104a84b87e1e7054c48b63077b8b7ccd62de9260' - }, - ] - ) - - ethscription.reload - - expect(ethscription.current_owner).to eq("0x0000000000000000000000000000000000000000") - end - end -end diff --git a/spec/models/protocol_parser_spec.rb b/spec/models/protocol_parser_spec.rb new file mode 100644 index 0000000..a178460 --- /dev/null +++ b/spec/models/protocol_parser_spec.rb @@ -0,0 +1,221 @@ +require 'rails_helper' + +RSpec.describe ProtocolParser do + let(:zero_merkle_root) { '0x' + '0' * 64 } + + describe '.extract' do + context 'erc-20-fixed-denomination protocol' do + it 'parses a valid deploy inscription' do + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"punk","max":"21000000","lim":"1000"}' + + result = described_class.extract(content_uri) + + expect(result).not_to be_nil + expect(result[:type]).to eq(:erc20_fixed_denomination) + expect(result[:protocol]).to eq('erc-20-fixed-denomination') + expect(result[:operation]).to eq('deploy'.b) + end + + it 'parses a valid mint inscription' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100"}' + + result = described_class.extract(content_uri) + + expect(result).not_to be_nil + expect(result[:type]).to eq(:erc20_fixed_denomination) + expect(result[:operation]).to eq('mint'.b) + end + + it 'returns nil when the inscription is malformed' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","amt":"100"}' + + expect(described_class.extract(content_uri)).to be_nil + end + end + + context 'erc-721 collections protocol' do + it 'parses a create_collection inscription' do + content_uri = %(data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"My NFTs","symbol":"MNFT","max_supply":"100","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}","initial_owner":"0x0000000000000000000000000000000000000001"}) + + result = described_class.extract(content_uri) + + expect(result).not_to be_nil + expect(result[:type]).to eq(:erc721_ethscriptions_collection) + expect(result[:protocol]).to eq('erc-721-ethscriptions-collection'.b) + expect(result[:operation]).to eq('create_collection'.b) + end + + it 'parses add_self_to_collection and succeeds with required fields' do + inscription_id = '0x' + '1' * 64 + content_uri = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_self_to_collection","collection_id":"0x' + '2' * 64 + '","item":{"item_index":"0","name":"Item","background_color":"#000000","description":"","attributes":[],"merkle_proof":[]}}' + + result = described_class.extract(content_uri, ethscription_id: inscription_id) + + expect(result).not_to be_nil + expect(result[:type]).to eq(:erc721_ethscriptions_collection) + expect(result[:operation]).to eq('add_self_to_collection'.b) + end + + it 'returns nil for non-collection JSON' do + expect(described_class.extract('data:,{"p":"foo","op":"bar"}')).to be_nil + end + end + + context 'non-protocol data' do + it 'returns nil for text payloads' do + expect(described_class.extract('data:,Hello World')).to be_nil + end + + it 'returns nil for invalid JSON' do + expect(described_class.extract('data:,{invalid json')).to be_nil + end + + it 'returns nil for nil input' do + expect(described_class.extract(nil)).to be_nil + end + end + end + + describe '.for_calldata' do + it 'encodes erc-20 deploy params' do + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"punk","max":"21000000","lim":"1000"}' + + protocol, operation, encoded = described_class.for_calldata(content_uri) + + expect(protocol).to eq('erc-20-fixed-denomination'.b) + expect(operation).to eq('deploy'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], encoded)[0] + expect(decoded).to eq(['punk'.b, 21_000_000, 1000]) + end + + it 'encodes erc-20 mint params' do + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100"}' + + protocol, operation, encoded = described_class.for_calldata(content_uri) + + expect(protocol).to eq('erc-20-fixed-denomination'.b) + expect(operation).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], encoded)[0] + expect(decoded).to eq(['punk'.b, 1, 100]) + end + + it 'returns encoded data for collections protocol' do + content_uri = %(data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"My NFTs","symbol":"MNFT","max_supply":"42","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}","initial_owner":"0x0000000000000000000000000000000000000001"}) + + protocol, operation, encoded = described_class.for_calldata(content_uri) + + expect(protocol).to eq('erc-721-ethscriptions-collection'.b) + expect(operation).to eq('create_collection'.b) + expect(encoded).not_to be_empty + end + + it 'returns empty protocol params when nothing matches' do + expect(described_class.for_calldata('data:,Hello World')).to eq([''.b, ''.b, ''.b]) + end + end + + describe '.extract_header_protocol' do + def extract_header(content_uri) + data_uri = DataUri.new(content_uri) + described_class.send(:extract_header_protocol, data_uri) + end + + context 'with valid header protocol' do + it 'parses p and op parameters' do + result = extract_header('data:;p=erc-20;op=deploy,content') + + expect(result).not_to be_nil + expect(result[:protocol]).to eq('erc-20') + expect(result[:operation]).to eq('deploy') + expect(result[:params]).to eq({}) + expect(result[:source]).to eq(:header) + end + + it 'parses with base64-encoded JSON data parameter' do + json_data = Base64.strict_encode64('{"tick":"punk","max":"1000"}') + result = extract_header("data:;p=erc-20;op=deploy;d=#{json_data},content") + + expect(result).not_to be_nil + expect(result[:protocol]).to eq('erc-20') + expect(result[:operation]).to eq('deploy') + expect(result[:params]).to eq({ 'tick' => 'punk', 'max' => '1000' }) + end + + it 'accepts data= as alias for d=' do + json_data = Base64.strict_encode64('{"key":"value"}') + result = extract_header("data:;p=myproto;op=action;data=#{json_data},content") + + expect(result).not_to be_nil + expect(result[:params]).to eq({ 'key' => 'value' }) + end + + it 'accepts underscores and dashes in protocol names' do + result = extract_header('data:;p=my_proto-name;op=my_op-name,content') + + expect(result).not_to be_nil + expect(result[:protocol]).to eq('my_proto-name') + expect(result[:operation]).to eq('my_op-name') + end + end + + context 'with invalid header protocol' do + it 'returns nil when p is missing' do + expect(extract_header('data:;op=deploy,content')).to be_nil + end + + it 'returns nil when op is missing' do + expect(extract_header('data:;p=erc-20,content')).to be_nil + end + + it 'returns nil when multiple p values present' do + expect(extract_header('data:;p=erc-20;p=other;op=deploy,content')).to be_nil + end + + it 'returns nil when multiple op values present' do + expect(extract_header('data:;p=erc-20;op=deploy;op=mint,content')).to be_nil + end + + it 'returns nil for uppercase protocol name' do + expect(extract_header('data:;p=ERC-20;op=deploy,content')).to be_nil + end + + it 'returns nil for protocol name over 50 chars' do + long_name = 'a' * 51 + expect(extract_header("data:;p=#{long_name};op=deploy,content")).to be_nil + end + + it 'returns nil for invalid characters in protocol name' do + expect(extract_header('data:;p=erc.20;op=deploy,content')).to be_nil + end + + it 'returns nil when multiple d values present' do + d1 = Base64.strict_encode64('{"a":1}') + d2 = Base64.strict_encode64('{"b":2}') + expect(extract_header("data:;p=erc-20;op=deploy;d=#{d1};d=#{d2},content")).to be_nil + end + + it 'returns nil when both d and data present' do + d1 = Base64.strict_encode64('{"a":1}') + d2 = Base64.strict_encode64('{"b":2}') + expect(extract_header("data:;p=erc-20;op=deploy;d=#{d1};data=#{d2},content")).to be_nil + end + + it 'returns nil for invalid base64 in d parameter' do + expect(extract_header('data:;p=erc-20;op=deploy;d=not-valid-base64!,content')).to be_nil + end + + it 'returns nil for invalid JSON in d parameter' do + invalid_json = Base64.strict_encode64('not json') + expect(extract_header("data:;p=erc-20;op=deploy;d=#{invalid_json},content")).to be_nil + end + + it 'returns nil when d contains non-hash JSON' do + array_json = Base64.strict_encode64('[1,2,3]') + result = extract_header("data:;p=erc-20;op=deploy;d=#{array_json},content") + + expect(result).not_to be_nil + expect(result[:params]).to eq({}) # non-hash JSON is ignored, params becomes empty + end + end + end +end diff --git a/spec/models/token_spec.rb b/spec/models/token_spec.rb deleted file mode 100644 index 1c29322..0000000 --- a/spec/models/token_spec.rb +++ /dev/null @@ -1,150 +0,0 @@ -# spec/models/token_spec.rb -require 'rails_helper' - -RSpec.describe Token, type: :model do - describe '.process_ethscription_transfer' do - it 'processes a transfer as the first transfer' do - tx = EthscriptionTestHelper.create_eth_transaction( - input: "data:,{\"p\":\"erc-20\",\"op\":\"deploy\",\"tick\":\"nodes\",\"max\":\"10000000000\",\"lim\":\"10000\"}", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - token = Token.create_from_token_details!( - tick: "nodes", - p: "erc-20", - max: 10000000000, - lim: 10000 - ) - - initial_balances = token.balances - initial_total_supply = token.total_supply - - transfer_tx = nil - - token_item_count = TokenItem.count - - expect { transfer_tx = EthscriptionTestHelper.create_eth_transaction( - input: "data:,{\"p\":\"erc-20\",\"op\":\"mint\",\"tick\":\"nodes\",\"id\":\"335997\",\"amt\":\"10000\"}", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) }.to change { TokenState.count }.by(1) - - transfer = transfer_tx.ethscription_transfers.first - - expect(TokenItem.count).to eq(token_item_count + 1) - - expect(token.reload.total_supply).to eq(initial_total_supply + token.mint_amount) - - expect(token.reload.balances).to eq({ transfer.to_address => token.mint_amount }) - end - end - - describe 'TokenState destruction' do - it 'reverts the token back to its original state upon TokenState destruction' do - tx = EthscriptionTestHelper.create_eth_transaction( - input: "data:,{\"p\":\"erc-20\",\"op\":\"deploy\",\"tick\":\"nodes\",\"max\":\"10000000000\",\"lim\":\"10000\"}", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - # Set up the initial token and token state - token = Token.create_from_token_details!( - tick: "nodes", - p: "erc-20", - max: 10000000000, - lim: 10000 - ) - - initial_balances = token.balances.deep_dup - initial_total_supply = token.total_supply - - # Create a transaction that would alter the token state - transfer_tx = EthscriptionTestHelper.create_eth_transaction( - input: "data:,{\"p\":\"erc-20\",\"op\":\"mint\",\"tick\":\"nodes\",\"id\":\"335997\",\"amt\":\"10000\"}", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - - token_item_count = TokenItem.count - - expect { transfer_tx.eth_block.delete }.to change { TokenState.count }.by(-1) - - expect(TokenItem.count).to eq(token_item_count - 1) - - # Reload the token to get the updated state - token.reload - - # Check that the token's state has been reverted - expect(token.total_supply).to eq(initial_total_supply) - expect(token.balances).to eq(initial_balances) - end - end - - it 'correctly processes the transfer and updates the token state' do - tx = EthscriptionTestHelper.create_eth_transaction( - input: "data:,{\"p\":\"erc-20\",\"op\":\"deploy\",\"tick\":\"nodes\",\"max\":\"10000000000\",\"lim\":\"10000\"}", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - # Set up the initial token and token state - token = Token.create_from_token_details!( - tick: "nodes", - p: "erc-20", - max: 10000000000, - lim: 10000 - ) - - initial_count = TokenState.count - - first_transfer_tx = EthscriptionTestHelper.create_eth_transaction( - input: "data:,{\"p\":\"erc-20\",\"op\":\"mint\",\"tick\":\"nodes\",\"id\":\"335997\",\"amt\":\"10000\"}", - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - logs: [] - ) - first_transfer = first_transfer_tx.ethscription_transfers.first - # binding.pry - expect(TokenState.count).to eq(initial_count + 1) - initial_count = TokenState.count - - expect(token.reload.total_supply).to eq(token.mint_amount) - - expect(token.reload.balances).to eq({ - first_transfer.to_address => token.mint_amount - }) - - # Create the second transfer - second_transfer_tx = EthscriptionTestHelper.create_eth_transaction( - input: first_transfer.transaction_hash, - from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", - to: "0xf0c2d5DD70C26e34f5fB4AC1BC4EA5B2eDF8137A", - logs: [] - ) - second_transfer = second_transfer_tx.ethscription_transfers.first - expect(TokenState.count).to eq(initial_count + 1) - - # Expect the TokenState count to increase by 1 after the second transfer - # Reload the token to get the updated state - token.reload - - # Check that the token's total supply has been updated correctly - expect(token.total_supply).to eq(token.mint_amount) - - expect(token.balances).to eq({ - second_transfer.to_address => token.mint_amount - }) - - second_transfer_tx.eth_block.delete - - expect(token.reload.total_supply).to eq(token.mint_amount) - - expect(token.reload.balances).to eq({ - first_transfer.to_address => token.mint_amount - }) - end -end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index e59e357..9fc5302 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -4,7 +4,7 @@ require_relative '../config/environment' # Prevent database truncation if the environment is production abort("The Rails environment is running in production mode!") if Rails.env.production? -require 'rspec/rails' +# require 'rspec/rails' # Add additional requires below this line. Rails is not loaded until this point! # Requires supporting ruby files with custom matchers and macros, etc, in @@ -30,38 +30,15 @@ abort e.to_s.strip end RSpec.configure do |config| - # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_paths = [ - Rails.root.join('spec/fixtures') - ] - - # If you're not using ActiveRecord, or you'd prefer not to run each of your - # examples within a transaction, remove the following line or assign false - # instead of true. - config.use_transactional_fixtures = true - config.filter_run_excluding doc: true + + config.fail_fast = true - # You can uncomment this line to turn off ActiveRecord support entirely. - # config.use_active_record = false - - # RSpec Rails can automatically mix in different behaviours to your tests - # based on their file location, for example enabling you to call `get` and - # `post` in specs under `spec/controllers`. - # - # You can disable this behaviour by removing the line below, and instead - # explicitly tag your specs with their type, e.g.: - # - # RSpec.describe UsersController, type: :controller do - # # ... - # end - # - # The different available types are documented in the features, such as in - # https://rspec.info/features/6-0/rspec-rails - config.infer_spec_type_from_file_location! + config.before(:suite) do + GethTestHelper.setup_rspec_geth + end - # Filter lines from Rails gems in backtraces. - config.filter_rails_from_backtrace! - # arbitrary gems may also be filtered via: - # config.filter_gems_from_backtrace("gem name") + config.after(:suite) do + GethTestHelper.teardown_rspec_geth + end end diff --git a/spec/requests/ethscription_transfers_spec.rb b/spec/requests/ethscription_transfers_spec.rb deleted file mode 100644 index 5ecd739..0000000 --- a/spec/requests/ethscription_transfers_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -require 'swagger_helper' - -RSpec.describe 'Ethscription Transfers API', doc: true do - path '/ethscription_transfers' do - get 'List Ethscription Transfers' do - tags 'Ethscription Transfers' - operationId 'listEthscriptionTransfers' - produces 'application/json' - description <<~DESC - Retrieves a list of Ethscription transfers based on filter criteria such as from address, to address, and transaction hash. Supports filtering by token characteristics (tick and protocol) and address involvement (to or from). - DESC - - parameter name: :from_address, in: :query, type: :string, - description: 'Filter transfers by the sender’s address.', required: false - - parameter name: :to_address, in: :query, type: :string, - description: 'Filter transfers by the recipient’s address.', required: false - - parameter name: :transaction_hash, in: :query, type: :string, - description: 'Filter transfers by the Ethscription transaction hash.', required: false - - parameter name: :to_or_from, in: :query, type: :array, items: { type: :string }, - description: 'Filter transfers by addresses involved either as sender or recipient.', required: false - - parameter name: :ethscription_token_tick, in: :query, type: :string, - description: 'Filter transfers by the Ethscription token tick.', required: false - - parameter name: :ethscription_token_protocol, in: :query, type: :string, - description: 'Filter transfers by the Ethscription token protocol.', required: false - - # Include pagination parameters as needed - parameter ApiCommonParameters.sort_by_parameter - parameter ApiCommonParameters.reverse_parameter - parameter ApiCommonParameters.max_results_parameter - parameter ApiCommonParameters.page_key_parameter - - response '200', 'Transfers retrieved successfully' do - schema type: :object, - properties: { - result: { - type: :array, - items: { '$ref' => '#/components/schemas/EthscriptionTransfer' } - }, - pagination: { '$ref' => '#/components/schemas/PaginationObject' } - }, - description: 'A list of Ethscription transfers that match the filter criteria.' - - run_test! - end - end - end - -end - diff --git a/spec/requests/ethscriptions_spec.rb b/spec/requests/ethscriptions_spec.rb deleted file mode 100644 index 626c050..0000000 --- a/spec/requests/ethscriptions_spec.rb +++ /dev/null @@ -1,363 +0,0 @@ -require 'swagger_helper' - -RSpec.describe 'Ethscriptions API', doc: true do - path '/ethscriptions' do - get 'List Ethscriptions' do - tags 'Ethscriptions' - operationId 'listEthscriptions' - produces 'application/json' - description <<~DESC - Retrieves a list of ethscriptions, supporting various filters. - By default, the results limit is set to 100. - - - If `transaction_hash_only` is set to true, the results limit increases to 1000. - - If `include_latest_transfer` is set to true, the results limit is reduced to 50. - - The filter parameters below can be either individual values or arrays of values. - DESC - - parameter name: :current_owner, in: :query, type: :string, description: 'Filter by current owner address' - parameter name: :creator, in: :query, type: :string, description: 'Filter by creator address' - parameter name: :initial_owner, in: :query, type: :string, description: 'Filter by initial owner address' - parameter name: :previous_owner, in: :query, type: :string, description: 'Filter by previous owner address' - parameter name: :mimetype, in: :query, type: :string, description: 'Filter by MIME type' - parameter name: :media_type, in: :query, type: :string, description: 'Filter by media type' - parameter name: :mime_subtype, in: :query, type: :string, description: 'Filter by MIME subtype' - parameter name: :content_sha, in: :query, type: :string, description: 'Filter by content SHA hash' - parameter name: :transaction_hash, in: :query, type: :string, description: 'Filter by Ethereum transaction hash' - parameter name: :block_number, in: :query, type: :string, description: 'Filter by block number' - parameter name: :block_timestamp, in: :query, type: :string, description: 'Filter by block timestamp' - parameter name: :block_blockhash, in: :query, type: :string, description: 'Filter by block hash' - parameter name: :ethscription_number, in: :query, type: :string, description: 'Filter by ethscription number' - parameter name: :attachment_sha, in: :query, type: :string, description: 'Filter by attachment SHA hash' - parameter name: :attachment_content_type, in: :query, type: :string, description: 'Filter by attachment content type' - parameter name: :attachment_present, in: :query, type: :string, description: 'Filter by presence of an attachment', enum: ['true', 'false'] - parameter name: :token_tick, in: :query, type: :string, description: 'Filter by token tick', example: "eths" - parameter name: :token_protocol, in: :query, type: :string, description: 'Filter by token protocol', example: "erc-20" - parameter name: :transferred_in_tx, in: :query, type: :string, description: 'Filter by transfer transaction hash' - - parameter name: :after_block, - in: :query, - type: :integer, - description: 'Filter by block number, returning only ethscriptions after the specified block.', - required: false - - parameter name: :before_block, - in: :query, - type: :integer, - description: 'Filter by block number, returning only ethscriptions before the specified block.', - required: false - - parameter name: :transaction_hash_only, - in: :query, - type: :boolean, - description: 'Return only transaction hashes. When set to true, increases results limit to 1000.', - required: false - - parameter name: :include_latest_transfer, - in: :query, - type: :boolean, - description: 'Include latest transfer information. When set to true, reduces results limit to 50.', - required: false - - - # Include common pagination parameters - parameter ApiCommonParameters.sort_by_parameter - parameter ApiCommonParameters.reverse_parameter - parameter ApiCommonParameters.max_results_parameter - parameter ApiCommonParameters.page_key_parameter - - response '200', 'ethscriptions list' do - schema type: :object, - properties: { - result: { - type: :array, - items: { '$ref' => '#/components/schemas/Ethscription' } - }, - pagination: { '$ref' => '#/components/schemas/PaginationObject' } - }, - description: 'A list of ethscriptions based on filter criteria.' - - run_test! - end - end - end - - path '/ethscriptions/{tx_hash_or_ethscription_number}' do - get 'Show Ethscription' do - tags 'Ethscriptions' - operationId 'getEthscriptionByTransactionHash' - produces 'application/json' - description 'Retrieves an ethscription, including its transfers, by its transaction hash.' - parameter name: :tx_hash_or_ethscription_number, - in: :path, - type: :string, - description: 'Transaction hash or ethscription number of the ethscription', - example: "0x0ef100873db4e3b7446e9a3be0432ab8bc92119d009aa200f70c210ac9dcd4a6", - required: true - - response '200', 'Ethscription retrieved successfully' do - schema type: :object, - properties: { - result: { '$ref' => '#/components/schemas/EthscriptionWithTransfers' } - }, - description: "The ethscription's details" - - run_test! - end - - response '404', 'Ethscription not found' do - schema type: :object, - properties: { - error: { type: :string, example: 'Record not found' } - }, - description: 'Error message indicating the ethscription was not found' - - run_test! - end - end - end - - path '/ethscriptions/{tx_hash_or_ethscription_number}/data' do - get 'Show Ethscription Data' do - tags 'Ethscriptions' - operationId 'getEthscriptionData' - produces 'application/octet-stream', 'image/png', 'text/plain' - description 'Retrieves the raw content data of an ethscription and serves it according to its content type.' - - parameter name: :tx_hash_or_ethscription_number, - in: :path, - type: :string, - description: 'The ethscription number or transaction hash to retrieve data for.', - required: true, - example: "0" - - response '200', 'Data retrieved successfully' do - header 'Content-Type', description: 'The MIME type of the data.', schema: { type: :string } - - schema type: :string, - format: :binary, - description: 'Returns the raw data of an ethscription as indicated by the content type of the stored data URI. The content type in the response depends on the ethscription’s data.', - example: '\u0000\u0001\u0002\u0003\u0004\u0005\u0006\a\b\t\n\v\f\r\u000E\u000F' - - run_test! - end - - response '404', 'Ethscription not found' do - schema type: :object, - properties: { - error: { type: :string, example: 'Record not found' } - }, - description: 'Error message indicating the ethscription was not found' - - run_test! - end - end - end - - path '/ethscriptions/{tx_hash_or_ethscription_number}/attachment' do - get 'Show Ethscription Attachment Data' do - tags 'Ethscriptions' - operationId 'getEthscriptionAttachment' - produces 'application/octet-stream', 'image/png', 'text/plain' - description <<~DESC - Retrieves the raw attachment of an ethscription and serves it according to its content type. Only available when an attachment is present. Attachments are created via blobs per esip-8. - DESC - - parameter name: :tx_hash_or_ethscription_number, in: :path, type: :string, required: true, - description: 'The ethscription number or transaction hash to retrieve the attachment for.', - example: '0xcf23d640184114e9d870a95f0fdc3aa65e436c5457d5b6ee2e3c6e104420abd1' - - response '200', 'Attachment retrieved successfully' do - header 'Content-Type', description: 'The MIME type of the attachment.', schema: { type: :string } - - schema type: :string, - format: :binary, - description: 'Returns the raw attachment data of an ethscription. The content type in the response depends on the ethscription’s attachment content type.', - example: '\u0000\u0001\u0002\u0003\u0004\u0005\u0006\a\b\t\n\v\f\r\u000E\u000F' - - run_test! - end - - response '404', 'Attachment not found' do - schema type: :object, - properties: { - error: { type: :string, example: 'Attachment not found' } - }, - description: 'Indicates that no attachment was found for the provided ID or transaction hash.' - - run_test! - end - end - end - - path '/ethscriptions/exists/{sha}' do - get 'Check if Ethscription Exists' do - tags 'Ethscriptions' - operationId 'checkEthscriptionExists' - produces 'application/json' - description <<~DESC - Checks if an Ethscription exists by its content SHA hash. Returns a boolean indicating existence and, if present, the ethscription itself. - DESC - - parameter name: :sha, in: :path, type: :string, required: true, - description: 'The SHA hash of the ethscription content to check for existence.', - example: '0x2817fd9cf901e4435253881550731a5edc5e519c19de46b08e2b19a18e95143e' - - response '200', 'Check performed successfully' do - schema type: :object, - properties: { - result: { - type: :object, - properties: { - exists: { type: :boolean, example: true }, - ethscription: { '$ref' => '#/components/schemas/Ethscription' } - } - } - }, - description: 'A boolean indicating whether the Ethscription exists, and the Ethscription itself if it does.' - - run_test! - end - - response '404', 'SHA hash parameter missing' do - schema type: :object, - properties: { - error: { type: :string, example: 'SHA hash parameter missing or invalid' } - }, - description: 'Error message indicating the required SHA hash parameter is missing or invalid.' - end - end - end - - path '/ethscriptions/exists_multi' do - post 'Check Multiple Ethscriptions Existence' do - tags 'Ethscriptions' - operationId 'checkMultipleEthscriptionsExistence' - consumes 'application/json' - produces 'application/json' - description <<~DESC - Accepts a list of SHA hashes and checks for the existence of Ethscriptions corresponding to each SHA. Returns a mapping from SHA hashes to their corresponding Ethscription transaction hashes, if found; otherwise maps to `null`. Max input: 100 shas. - DESC - - parameter name: :shas, in: :body, schema: { - type: :object, - properties: { - shas: { - type: :array, - items: { type: :string }, - description: 'An array of SHA hashes to check for Ethscription existence.', - example: ['0x2817fd9cf901e4435253881550731a5edc5e519c19de46b08e2b19a18e95143e', '0xdcb130d85be00f8fd735ddafcba1cc83f99ba8dab0fc79c833401827b615c92b'] - } - }, - required: ['shas'] - } - - response '200', 'Existence check performed successfully' do - schema type: :object, - properties: { - result: { - type: :object, - additionalProperties: { - type: :string, - nullable: true, - description: 'Transaction hash associated with the SHA. Null if the SHA does not correspond to an existing Ethscription.' - }, - description: 'Mapping from SHA hashes to Ethscription transaction hashes or null if the Ethscription does not exist.' - } - }, - description: 'Successfully returns a mapping from provided SHA hashes to their corresponding Ethscription transaction hashes or null if not found.', - example: { result: { - "0x2817fd9cf901e4435253881550731a5edc5e519c19de46b08e2b19a18e95143e" => "0xcf23d640184114e9d870a95f0fdc3aa65e436c5457d5b6ee2e3c6e104420abd1", - "0xdcb130d85be00f8fd735ddafcba1cc83f99ba8dab0fc79c833401827b615c92b" => nil - }} - - run_test! - end - - response '400', 'Too many SHAs' do - schema type: :object, - properties: { - error: { type: :string, example: 'Too many SHAs' } - }, - description: 'Error response indicating that the request contained too many SHA hashes (limit is 100).' - end - end - end - path '/ethscriptions/newer' do - get 'List Newer Ethscriptions' do - tags 'Ethscriptions' - operationId 'getNewerEthscriptions' - consumes 'application/json' - produces 'application/json' - description <<~DESC - Retrieves Ethscriptions that are newer than a specified block number, optionally filtered by mimetype, initial owner, and other criteria. Returns Ethscriptions grouped by block, including block metadata and a count of total future Ethscriptions. The Facet VM relies on this endpoint to retrieve new Ethscriptions. - DESC - - parameter name: :mimetypes, in: :query, type: :array, items: { type: :string }, - description: 'Optional list of mimetypes to filter Ethscriptions by.', required: false - - parameter name: :initial_owner, in: :query, type: :string, - description: 'Optional initial owner to filter Ethscriptions by.', required: false - - parameter name: :block_number, in: :query, type: :integer, - description: 'Block number to start retrieving newer Ethscriptions from.', required: true - - parameter name: :past_ethscriptions_count, in: :query, type: :integer, - description: 'Optional count of past Ethscriptions for checksum validation.', required: false - - parameter name: :past_ethscriptions_checksum, in: :query, type: :string, - description: 'Optional checksum of past Ethscriptions for validation.', required: false - - parameter name: :max_ethscriptions, in: :query, type: :integer, - description: 'Maximum number of Ethscriptions to return.', required: false, example: 50 - - parameter name: :max_blocks, in: :query, type: :integer, - description: 'Maximum number of blocks to include in the response.', required: false, example: 500 - - response '200', 'Newer Ethscriptions retrieved successfully' do - schema type: :object, - properties: { - total_future_ethscriptions: { type: :integer, example: 100 }, - blocks: { - type: :array, - items: { - type: :object, - properties: { - blockhash: { type: :string, example: '0x0204cb...' }, - parent_blockhash: { type: :string, example: '0x0204cb...' }, - block_number: { type: :integer, example: 123456789 }, - timestamp: { type: :integer, example: 1678900000 }, - ethscriptions: { - type: :array, - items: { '$ref' => '#/components/schemas/Ethscription' } - } - } - }, - description: 'List of blocks with their Ethscriptions.' - } - }, - description: 'A list of newer Ethscriptions grouped by block, including metadata about each block and a count of total future Ethscriptions.' - - run_test! - end - - response '422', 'Unprocessable entity' do - schema type: :object, - properties: { - error: { - type: :object, - properties: { - message: { type: :string }, - resolution: { type: :string } - } - } - }, - description: 'Error response for various failure scenarios, such as block not yet imported or checksum mismatch.' - - run_test! - end - end - end -end - diff --git a/spec/requests/status_spec.rb b/spec/requests/status_spec.rb deleted file mode 100644 index bc7ee85..0000000 --- a/spec/requests/status_spec.rb +++ /dev/null @@ -1,47 +0,0 @@ -require 'swagger_helper' - -RSpec.describe 'Status API', doc: true do - path '/status' do - get 'Show Indexer Status' do - tags 'Status' - operationId 'getIndexerStatus' - produces 'application/json' - description 'Retrieves the current status of the blockchain indexer, including the latest block number known, the last block number imported into the system, and the number of blocks the indexer is behind.' - - response '200', 'Indexer status retrieved successfully' do - schema type: :object, - properties: { - current_block_number: { - type: :integer, - example: 19620494, - description: 'The most recent block number known to the global blockchain network.' - }, - last_imported_block: { - type: :integer, - example: 19620494, - description: 'The last block number that was successfully imported into the system.' - }, - blocks_behind: { - type: :integer, - example: 0, - description: 'The number of blocks the indexer is behind the current block number.' - } - }, - required: ['current_block_number', 'last_imported_block', 'blocks_behind'], - description: 'Response body containing the current status of the blockchain indexer.' - - run_test! - end - - response '500', 'Error retrieving indexer status' do - schema type: :object, - properties: { - error: { type: :string, example: 'Internal Server Error' } - }, - description: 'Error message indicating a failure to retrieve the indexer status.' - - run_test! - end - end - end -end diff --git a/spec/requests/tokens_spec.rb b/spec/requests/tokens_spec.rb deleted file mode 100644 index 206e4be..0000000 --- a/spec/requests/tokens_spec.rb +++ /dev/null @@ -1,218 +0,0 @@ -require 'swagger_helper' - -RSpec.describe 'Tokens API', doc: true do - path '/tokens' do - get 'List Tokens' do - tags 'Tokens' - operationId 'listTokens' - produces 'application/json' - description 'Retrieves a list of tokens based on specified criteria such as protocol and tick.' - - parameter name: :protocol, - in: :query, - type: :string, - description: 'Filter tokens by protocol (e.g., "erc-20"). Optional.', - required: false - - parameter name: :tick, - in: :query, - type: :string, - description: 'Filter tokens by tick (symbol). Optional.', - required: false - - # Assuming you've already defined common pagination parameters in ApiCommonParameters - parameter ApiCommonParameters.sort_by_parameter - parameter ApiCommonParameters.reverse_parameter - parameter ApiCommonParameters.max_results_parameter - parameter ApiCommonParameters.page_key_parameter - - response '200', 'Tokens retrieved successfully' do - schema type: :object, - properties: { - result: { - type: :array, - items: { '$ref' => '#/components/schemas/Token' } - }, - pagination: { '$ref' => '#/components/schemas/PaginationObject' } - }, - description: 'A list of tokens that match the query criteria, along with pagination details.' - - run_test! - end - - response '400', 'Invalid request parameters' do - schema type: :object, - properties: { - error: { type: :string, example: 'Invalid parameters' } - }, - description: 'Error message indicating that the request parameters were invalid.' - - run_test! - end - end - end - - path '/tokens/{protocol}/{tick}' do - get 'Show Token Details' do - tags 'Tokens' - operationId 'showTokenDetails' - produces 'application/json' - description 'Retrieves detailed information about a specific token, identified by its protocol and tick, including its balances.' - - parameter name: :protocol, - in: :path, - type: :string, - description: 'The protocol of the token to retrieve (e.g., "erc-20").', - required: true - - parameter name: :tick, - in: :path, - type: :string, - description: 'The tick (symbol) of the token to retrieve.', - required: true - - response '200', 'Token details retrieved successfully' do - schema type: :object, - properties: { - result: { '$ref' => '#/components/schemas/TokenWithBalances' }, - }, - description: 'Detailed information about the requested token, including its balances.' - - run_test! - end - - response '404', 'Token not found' do - schema type: :object, - properties: { - error: { type: :string, example: 'Token not found' } - }, - description: 'Error message indicating that the requested token was not found.' - - run_test! - end - end - end - - path '/tokens/{protocol}/{tick}/historical_state' do - get 'Get Token Historical State' do - tags 'Tokens' - operationId 'getTokenHistoricalState' - produces 'application/json' - description 'Retrieves the state of a specific token, identified by its protocol and tick, at a given block number.' - - parameter name: :protocol, - in: :path, - type: :string, - description: 'The protocol of the token for which historical state is being requested (e.g., "erc-20").', - required: true - - parameter name: :tick, - in: :path, - type: :string, - description: 'The tick (symbol) of the token for which historical state is being requested.', - required: true - - parameter name: :as_of_block, - in: :query, - type: :integer, - description: 'The block number at which the token state is requested.', - required: true - - response '200', 'Token historical state retrieved successfully' do - schema type: :object, - properties: { - result: { '$ref' => '#/components/schemas/TokenWithBalances' } - }, - description: 'The state of the requested token at the specified block number, including its balances.' - - run_test! - end - - response '404', 'Token or state not found' do - schema type: :object, - properties: { - error: { type: :string, example: 'Token or state not found at the specified block number' } - }, - description: 'Error message indicating that either the token or its state at the specified block number was not found.' - - run_test! - end - end - end - path '/tokens/{protocol}/{tick}/validate_token_items' do - get 'Validate Token Items' do - tags 'Tokens' - operationId 'validateTokenItems' - produces 'application/json' - description <<~DESC - Validates a list of transaction hashes against the token items of a specified token. Returns arrays of valid and invalid transaction hashes along with a checksum for the token items. - DESC - - parameter name: :protocol, - in: :path, - type: :string, - description: 'The protocol of the token for which items are being validated (e.g., "erc-20").', - required: true - - parameter name: :tick, - in: :path, - type: :string, - description: 'The tick (symbol) of the token for which items are being validated.', - required: true - - parameter name: :transaction_hashes, - in: :query, - type: :array, - items: { - type: :string, - description: 'A transaction hash.' - }, - collectionFormat: :multi, - description: 'An array of transaction hashes to validate against the token\'s items.', - required: true - - response '200', 'Token items validated successfully' do - schema type: :object, - properties: { - result: { - type: :object, - properties: { - valid: { - type: :array, - items: { - type: :string - }, - description: 'Valid transaction hashes.' - }, - invalid: { - type: :array, - items: { - type: :string - }, - description: 'Invalid transaction hashes.' - }, - token_items_checksum: { - type: :string, - description: 'A checksum for the token items.' - } - } - } - }, - description: 'Returns arrays of valid and invalid transaction hashes along with a checksum for the token items.' - - run_test! - end - - response '404', 'Token not found' do - schema type: :object, - properties: { - error: { type: :string, example: 'Requested token not found' } - }, - description: 'Error message indicating the token was not found.' - - run_test! - end - end - end - -end diff --git a/spec/support/api_common_parameters.rb b/spec/support/api_common_parameters.rb deleted file mode 100644 index b910633..0000000 --- a/spec/support/api_common_parameters.rb +++ /dev/null @@ -1,47 +0,0 @@ -module ApiCommonParameters - def self.sort_by_parameter - { - name: :sort_by, - in: :query, - type: :string, - description: 'Defines the order of the records to be returned. Can be either "newest_first" (default) or "oldest_first".', - enum: ['newest_first', 'oldest_first'], - required: false, - default: 'newest_first' - } - end - - def self.reverse_parameter - { - name: :reverse, - in: :query, - type: :boolean, - description: 'When set to true, reverses the sort order specified by the `sort_by` parameter.', - required: false, - example: "false" - } - end - - def self.max_results_parameter - { - name: :max_results, - in: :query, - type: :integer, - description: 'Limits the number of results returned. Default value is 25, maximum value is 50.', - required: false, - maximum: 50, - default: 25, - example: 25 - } - end - - def self.page_key_parameter - { - name: :page_key, - in: :query, - type: :string, - description: 'Pagination key from the previous response. Used for fetching the next set of results.', - required: false - } - end -end diff --git a/spec/support/ethscriptions_test_helper.rb b/spec/support/ethscriptions_test_helper.rb new file mode 100644 index 0000000..4288803 --- /dev/null +++ b/spec/support/ethscriptions_test_helper.rb @@ -0,0 +1,702 @@ +module EthscriptionsTestHelper + def normalize_to_hex(value) + return nil if value.nil? + + if value.respond_to?(:to_hex) + return value.to_hex + end + + string = value.to_s + return string if string.start_with?('0x') || string.start_with?('0X') + + string_to_hex(string) + end + + def string_to_hex(string) + "0x#{string.to_s.unpack1('H*')}" + end + + def normalize_address(address) + return nil if address.nil? + + address = address.is_a?(Address20) ? address : Address20.from_hex(address) + + address.to_hex.downcase + end + + # Generate a simple image ethscription + def generate_image_ethscription(**params) + params.merge( + content_type: "image/svg+xml", + content: '' + ) + end + + # Generate a JSON ethscription (like a token) + def generate_json_ethscription(**params) + json_content = params[:json] || { op: "mint", tick: "test", amt: "1000" } + params.merge( + content_type: "application/json", + content: json_content.to_json + ) + end + + # Validate ethscription was stored correctly in contract + def verify_ethscription_in_contract(tx_hash, expected_creator: nil, expected_content: nil, block_tag: 'latest') + stored = StorageReader.get_ethscription_with_content(tx_hash, block_tag: block_tag) + + expect(stored).to be_present, "Ethscription #{tx_hash} not found in contract storage" + + if expected_creator + expect(stored[:creator].downcase).to eq(expected_creator.downcase) + end + + if expected_content + expect(stored[:content]).to eq(expected_content) + end + + stored + end + + # Make static call to contract to verify state + def get_ethscription_owner(tx_hash) + StorageReader.get_owner(tx_hash) + end + + def get_ethscription_content(tx_hash, block_tag: 'latest') + StorageReader.get_ethscription_with_content(tx_hash, block_tag: block_tag) + end + + # Collections protocol helpers + def get_collection_state(collection_id) + CollectionsReader.get_collection_state(collection_id) + end + + def get_collection_metadata(collection_id) + CollectionsReader.get_collection_metadata(collection_id) + end + + def collection_exists?(collection_id) + state = CollectionsReader.get_collection_state(collection_id) + state && state[:collectionContract] != '0x0000000000000000000000000000000000000000' + end + + def get_collection_item(collection_id, index) + CollectionsReader.get_collection_item(collection_id, index) + end + + # Generate a valid Ethereum address from a seed string + def valid_address(seed) + "0x#{Digest::SHA256.hexdigest(seed.to_s)[0,40]}" + end + + # Minimal DSL for transaction descriptors + def create_input(creator:, to:, data_uri:, expect: :success) + { + type: :create_input, + creator: creator, + to: to, + input: data_uri, + expect: expect + } + end + + def create_event(creator:, initial_owner:, data_uri:, expect: :success) + { + type: :create_event, + creator: creator, + to: initial_owner, + input: "0x", + logs: [build_create_event(creator: creator, initial_owner: initial_owner, content_uri: data_uri)], + expect: expect + } + end + + # Low-level transaction builder - compose input and logs + def l1_tx(creator:, to: nil, input: "0x", logs: [], tx_hash: nil, status: '0x1', expect: :success) + { + type: :custom, + creator: creator, + to: to, + input: input, + logs: logs, + tx_hash: tx_hash, + status: status, + expect: expect + } + end + + def transfer_input(from:, to:, id:, expect: :success) + { + type: :transfer_input, + creator: from, + to: to, + input: normalize_to_hex(id), + expect: expect + } + end + + def transfer_multi_input(from:, to:, ids:, expect: :success) + # Join multiple 32-byte hashes (remove 0x prefixes and concatenate) + combined_ids = ids.map { |id| id.delete_prefix('0x') }.join('') + + { + type: :transfer_multi_input, + creator: from, + to: to, + input: "0x#{combined_ids}", + expect: expect + } + end + + def transfer_event(from:, to:, id:, expect: :success) + { + type: :transfer_event, + creator: from, + to: to, + input: "0x", + logs: [build_transfer_event(from: from, to: to, id: id)], + expect: expect + } + end + + def transfer_prev_event(current_owner:, prev_owner:, to:, id:, expect: :success) + { + type: :transfer_prev_event, + creator: prev_owner, + to: to, + input: "0x", + logs: [build_transfer_prev_event(current_owner: current_owner, prev_owner: prev_owner, to: to, id: id)], + expect: expect + } + end + + # Event builders + def build_create_event(creator:, initial_owner:, content_uri:) + encoded_data = Eth::Abi.encode(['string'], [content_uri]) + encoded_owner = Eth::Abi.encode(['address'], [initial_owner]) + + { + 'address' => normalize_address(creator), + 'topics' => [ + EthTransaction::CreateEthscriptionEventSig, + "0x#{encoded_owner.unpack1('H*')}" + ], + 'data' => "0x#{encoded_data.unpack1('H*')}", + 'logIndex' => '0x0', + 'removed' => false + } + end + + def build_transfer_event(from:, to:, id:) + encoded_to = Eth::Abi.encode(['address'], [to]) + encoded_id = Eth::Abi.encode(['bytes32'], [ByteString.from_hex(id).to_bin]) + + { + 'address' => normalize_address(from), + 'topics' => [ + EthTransaction::Esip1EventSig, + "0x#{encoded_to.unpack1('H*')}", + "0x#{encoded_id.unpack1('H*')}" + ], + 'data' => '0x', + 'logIndex' => '0x0', + 'removed' => false + } + end + + def build_transfer_prev_event(prev_owner:, current_owner:, to:, id:) + encoded_to = Eth::Abi.encode(['address'], [to]) + encoded_prev = Eth::Abi.encode(['address'], [prev_owner]) + encoded_id = Eth::Abi.encode(['bytes32'], [ByteString.from_hex(id).to_bin]) + + { + 'address' => normalize_address(current_owner), + 'topics' => [ + EthTransaction::Esip2EventSig, + "0x#{encoded_prev.unpack1('H*')}", # previousOwner first + "0x#{encoded_to.unpack1('H*')}", # then to + "0x#{encoded_id.unpack1('H*')}" # then id + ], + 'data' => '0x', + 'logIndex' => '0x0', + 'removed' => false + } + end + + # Main entry point: import L1 block with transaction descriptors + def import_l1_block(tx_descriptors, esip_overrides: {}) + # Convert descriptors to L1 transactions + l1_transactions = tx_descriptors.map.with_index do |descriptor, index| + build_l1_transaction(descriptor, index) + end + + # Apply ESIP stubs before the import process + esip_stubs = setup_esip_stubs(esip_overrides) + + begin + # Import and return structured results + results = import_l1_block_with_geth(l1_transactions) + + # Add mapping information for easier assertion + results[:tx_descriptors] = tx_descriptors + results[:mapping] = build_mapping(tx_descriptors, results) + + results + ensure + # Clean up stubs + cleanup_esip_stubs(esip_stubs) + end + end + + # Helper: create ethscription and return its ID for use in transfers + def create_ethscription_for_transfer(**params) + results = expect_ethscription_creation_success([params]) + results[:ethscription_ids].first + end + + private + + def setup_esip_stubs(overrides = {}) + # Default all ESIPs to enabled unless overridden + defaults = { + esip1_enabled: true, # Event-based transfers + esip2_enabled: true, # Transfer for previous owner + esip3_enabled: true, # Event-based creation + esip5_enabled: true, # Multi-transfer input + esip7_enabled: true # GZIP compression + } + + settings = defaults.merge(overrides) + + # Store original methods for cleanup + original_methods = {} + + # Stub the SysConfig methods and store originals + %w[esip1_enabled? esip2_enabled? esip3_enabled? esip5_enabled? esip7_enabled?].each do |method| + original_methods[method] = SysConfig.method(method) if SysConfig.respond_to?(method) + end + + allow(SysConfig).to receive(:esip1_enabled?).and_return(settings[:esip1_enabled]) + allow(SysConfig).to receive(:esip2_enabled?).and_return(settings[:esip2_enabled]) + allow(SysConfig).to receive(:esip3_enabled?).and_return(settings[:esip3_enabled]) + allow(SysConfig).to receive(:esip5_enabled?).and_return(settings[:esip5_enabled]) + allow(SysConfig).to receive(:esip7_enabled?).and_return(settings[:esip7_enabled]) + + original_methods + end + + def cleanup_esip_stubs(original_methods) + # Restore original methods + original_methods.each do |method_name, original_method| + if original_method + allow(SysConfig).to receive(method_name).and_call_original + end + end + end + + def build_l1_transaction(descriptor, index) + # binding.irb + { + transaction_hash: descriptor[:tx_hash] || generate_tx_hash(rand(1000000)), + from_address: normalize_address(descriptor[:creator]), + to_address: normalize_address(descriptor[:to]), + input: normalize_to_hex(descriptor.fetch(:input)), + value: 0, + gas_used: descriptor[:gas_used] || 21000, + transaction_index: index, + logs: descriptor[:logs] || [] + } + end + + def build_mapping(tx_descriptors, results) + # Map each descriptor to its corresponding L2 transaction results + tx_descriptors.map.with_index do |descriptor, index| + l2_receipt = results[:l2_receipts][index] + { + descriptor: descriptor, + l2_receipt: l2_receipt, + success: l2_receipt && l2_receipt[:status] == '0x1' + } + end + end + + # Assertion helpers for the new DSL + def expect_ethscription_success(tx_spec, esip_overrides: {}, &block) + results = import_l1_block([tx_spec], esip_overrides: esip_overrides) + + # Find the ethscription ID + ethscription_id = results[:ethscription_ids].first + expect(ethscription_id).to be_present, "Expected ethscription to be created" + + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "L2 transaction should succeed" + + # Check contract storage + stored = get_ethscription_content(ethscription_id) + expect(stored).to be_present, "Ethscription should be stored in contract" + + yield results if block_given? + results + end + + def expect_ethscription_failure(tx_spec, reason: nil, esip_overrides: {}) + results = import_l1_block([tx_spec], esip_overrides: esip_overrides) + + case reason + when :revert + # L2 transaction fails + expect(results[:l2_receipts].first[:status]).to eq('0x0'), "L2 transaction should revert" + # expect(results[:ethscription_ids]).to be_empty, "No ethscriptions should be created on revert" + when :ignored + # Feature disabled or transaction ignored + expect(results[:l2_receipts]).to be_empty, "No L2 transaction should be created when ignored" + # expect(results[:ethscription_ids]).to be_empty, "No ethscriptions should be created when ignored" + else + # Default: expect failure + raise "invalid reason" + end + + results + end + + # Ethscription created but protocol extraction failed + # Useful for testing invalid protocol data that's still valid ethscription data + def expect_protocol_extraction_failure(tx_spec, esip_overrides: {}, &block) + results = import_l1_block([tx_spec], esip_overrides: esip_overrides) + + # Ethscription should be created (it's valid data) + ethscription_id = results[:ethscription_ids].first + expect(ethscription_id).to be_present, "Ethscription should be created" + + # L2 transaction should succeed + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "L2 transaction should succeed" + + # Content should be stored + stored = get_ethscription_content(ethscription_id) + expect(stored).to be_present, "Ethscription should be stored in contract" + + yield results, stored if block_given? + results + end + + # Assertion helper specifically for transfers + def expect_transfer_success(tx_spec, ethscription_id, expected_new_owner, esip_overrides: {}, &block) + # Get pre-transfer state + pre_owner = get_ethscription_owner(ethscription_id) + pre_content = get_ethscription_content(ethscription_id) + + results = import_l1_block([tx_spec], esip_overrides: esip_overrides) + + # Check L2 receipt status + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "L2 transaction should succeed" + + # Verify ownership changed + new_owner = get_ethscription_owner(ethscription_id) + expect(new_owner.downcase).to eq(expected_new_owner.downcase), "Ownership should change to #{expected_new_owner}" + + # Verify content unchanged + current_content = get_ethscription_content(ethscription_id) + expect(current_content[:content]).to eq(pre_content[:content]), "Content should remain unchanged" + + yield results if block_given? + results + end + + def expect_transfer_failure(tx_spec, ethscription_id, reason: :revert, esip_overrides: {}, &block) + # Get pre-transfer state + pre_owner = get_ethscription_owner(ethscription_id) + pre_content = get_ethscription_content(ethscription_id) + + results = import_l1_block([tx_spec], esip_overrides: esip_overrides) + + case reason + when :revert + # L2 transaction fails + expect(results[:l2_receipts].first[:status]).to eq('0x0'), "L2 transaction should revert" + when :ignored + # Feature disabled or transaction ignored + expect(results[:l2_receipts]).to be_empty, "No L2 transaction should be created when ignored" + end + + # Verify ownership and content unchanged + current_owner = get_ethscription_owner(ethscription_id) + expect(current_owner).to eq(pre_owner), "Ownership should remain unchanged on failure" + + current_content = get_ethscription_content(ethscription_id) + expect(current_content[:content]).to eq(pre_content[:content]), "Content should remain unchanged" + + yield results if block_given? + results + end + + # Helper: create input-based transfer + def create_input_transfer(ethscription_id, from:, to:) + { + creator: from, + to: to, + input: ethscription_id # 32-byte hash for single transfer + } + end + + # Helper: create event-based transfer (ESIP-1) + def create_event_transfer(ethscription_id, from:, to:) + # ABI encode all topics properly + encoded_to = Eth::Abi.encode(['address'], [to]) + encoded_id = Eth::Abi.encode(['bytes32'], [ByteString.from_hex(ethscription_id).to_bin]) + + { + creator: from, + logs: [ + { + 'address' => from, + 'topics' => [ + EthTransaction::Esip1EventSig, + "0x#{encoded_to.unpack1('H*')}", + "0x#{encoded_id.unpack1('H*')}" + ], + 'data' => '0x', + 'logIndex' => '0x0', + 'removed' => false + } + ] + } + end + + # Helper: create event-based transfer with previous owner (ESIP-2) + def create_esip2_transfer(ethscription_id, from:, to:, previous_owner:) + # ABI encode all topics properly + encoded_to = Eth::Abi.encode(['address'], [to]) + encoded_previous = Eth::Abi.encode(['address'], [previous_owner]) + encoded_id = Eth::Abi.encode(['bytes32'], [ByteString.from_hex(ethscription_id).to_bin]) + + { + creator: from, + logs: [ + { + 'address' => from, + 'topics' => [ + EthTransaction::Esip2EventSig, + "0x#{encoded_previous.unpack1('H*')}", + "0x#{encoded_to.unpack1('H*')}", + "0x#{encoded_id.unpack1('H*')}" + ], + 'data' => '0x', + 'logIndex' => '0x0', + 'removed' => false + } + ] + } + end + + # Helper: create ethscription via event (ESIP-3) + def create_ethscription_via_event(creator:, initial_owner:, content:) + # ABI encode the content properly for the CreateEthscription event + encoded_data = Eth::Abi.encode(['string'], [content]) + # ABI encode the initial_owner address for the topic + encoded_owner_topic = Eth::Abi.encode(['address'], [initial_owner]) + + { + creator: creator, + to: initial_owner, # Event-based transactions still need a to_address + input: "0x", # Empty input for event-only transactions + logs: [ + { + 'address' => creator, + 'topics' => [ + EthTransaction::CreateEthscriptionEventSig, + "0x#{encoded_owner_topic.unpack1('H*')}" + ], + 'data' => "0x#{encoded_data.unpack1('H*')}", + 'logIndex' => '0x0', + 'removed' => false + } + ] + } + end + + private + + def import_l1_block_with_geth(l1_transactions) + # Get current importer state + importer = ImporterSingleton.instance + current_max_eth_block = importer.current_max_eth_block + block_number = current_max_eth_block.number + 1 + + # Create mock L1 block data + block_data = build_mock_l1_block(l1_transactions, current_max_eth_block) + receipts_data = l1_transactions.map { |tx| build_mock_receipt(tx) } + + # Rebuild the EthscriptionTransaction objects exactly the way the importer does + eth_block = EthBlock.from_rpc_result(block_data) + template_ethscriptions_block = EthscriptionsBlock.from_eth_block(eth_block) + + ethscription_transactions = EthTransaction.ethscription_txs_from_rpc_results( + block_data, + receipts_data, + template_ethscriptions_block + ) + ethscription_transactions.each { |tx| tx.ethscriptions_block = template_ethscriptions_block } + + # Mock the prefetcher to return our mock data in the correct format + eth_block = EthBlock.from_rpc_result(block_data) + ethscriptions_block = EthscriptionsBlock.from_eth_block(eth_block) + + mock_prefetcher_response = { + error: nil, + eth_block: eth_block, + ethscriptions_block: ethscriptions_block, + ethscription_txs: ethscription_transactions + } + + mock_prefetcher = instance_double(L1RpcPrefetcher) + allow(mock_prefetcher).to receive(:fetch).with(block_number).and_return(mock_prefetcher_response) + allow(mock_prefetcher).to receive(:ensure_prefetched) + allow(mock_prefetcher).to receive(:clear_older_than) + + # Replace both client and prefetcher with mocks + old_client = importer.ethereum_client + old_prefetcher = importer.prefetcher + + importer.instance_variable_set(:@prefetcher, mock_prefetcher) + + l2_blocks, eth_blocks = importer.import_next_block + + # Get the latest L2 block that was created + latest_l2_block = EthRpcClient.l2.get_block("latest", true) + + # Get L2 transaction receipts for verification (excluding system/L1 attributes transactions) + l2_transactions = latest_l2_block ? latest_l2_block['transactions'] || [] : [] + l2_receipts = l2_transactions + .reject { |l2_tx| l2_tx['from']&.downcase == SysConfig::SYSTEM_ADDRESS.to_hex.downcase } # Exclude system transactions + .map do |l2_tx| + receipt = EthRpcClient.l2.get_transaction_receipt(l2_tx['hash']) + + # if receipt['status'] != '0x1' + # ap EthRpcClient.l2.trace_transaction(l2_tx['hash']) + # end + { + tx_hash: l2_tx['hash'], + status: receipt['status'], + gas_used: receipt['gasUsed'], + logs: receipt['logs'] + } + end + + # Return all ethscription transactions (both successful and failed) + imported_ethscriptions = ethscription_transactions + ethscription_ids = imported_ethscriptions.flat_map do |tx| + op = tx.ethscription_operation.to_s + case op + when 'create' + # For create operations, the ethscription ID is the transaction hash + [tx.eth_transaction.tx_hash.to_hex] + when 'transfer', 'transfer_with_previous_owner' + # Always use array form for transfers + Array.wrap(tx.transfer_ids) + else + [tx.eth_transaction.tx_hash.to_hex] # Fallback + end + end.compact.uniq + + { + l2_blocks: l2_blocks, + eth_blocks: eth_blocks, + ethscriptions: imported_ethscriptions, + ethscription_ids: ethscription_ids, + l1_transactions: l1_transactions, + l2_receipts: l2_receipts, + l2_block_data: latest_l2_block + } + ensure + importer.ethereum_client = old_client + importer.prefetcher = old_prefetcher + end + + def build_mock_l1_block(l1_transactions, current_max_eth_block) + block_number = current_max_eth_block.number + 1 + next_timestamp = current_max_eth_block.timestamp + 12 + + { + 'number' => "0x#{block_number.to_s(16)}", + 'hash' => generate_block_hash(block_number), + 'parentHash' => current_max_eth_block.block_hash.to_hex, + 'timestamp' => "0x#{next_timestamp.to_s(16)}", + 'baseFeePerGas' => "0x#{1.gwei.to_s(16)}", + 'mixHash' => generate_block_hash(block_number + 1000), + 'transactions' => l1_transactions.map { |tx| format_transaction_for_rpc(tx) } + } + end + + def build_mock_receipt(tx) + { + 'transactionHash' => tx[:transaction_hash], + 'transactionIndex' => tx[:transaction_index], + 'status' => tx[:status] || '0x1', + 'gasUsed' => "0x#{tx[:gas_used].to_s(16)}", + 'logs' => tx[:logs] || [] # Include logs from the original transaction + } + end + + def format_transaction_for_rpc(tx) + { + 'hash' => tx[:transaction_hash], + 'transactionIndex' => "0x#{tx[:transaction_index].to_s(16)}", + 'from' => tx[:from_address], + 'to' => tx[:to_address], + 'input' => tx[:input], + 'value' => "0x#{tx[:value].to_s(16)}" + } + end + + def format_receipt_for_rpc(tx) + { + 'transactionHash' => tx[:transaction_hash], + 'transactionIndex' => tx[:transaction_index], + 'status' => '0x1', + 'gasUsed' => "0x#{tx[:gas_used].to_s(16)}", + 'logs' => [] + } + end + + def generate_tx_hash(index) + "0x#{Digest::SHA256.hexdigest("tx_hash_#{index}").first(64)}" + end + + def generate_address(index) + "0x#{Digest::SHA256.hexdigest("addr_#{index}")[0..39]}" + end + + def generate_block_hash(block_number) + "0x#{Digest::SHA256.hexdigest("block_#{block_number}")}" + end + + def combine_transaction_data(receipt, tx_data) + combined = receipt.merge(tx_data) do |key, receipt_val, tx_val| + if receipt_val != tx_val + [receipt_val, tx_val] + else + receipt_val + end + end + + # Convert hex strings to integers where appropriate + %w[blockNumber gasUsed cumulativeGasUsed effectiveGasPrice status transactionIndex nonce value gas depositNonce mint depositReceiptVersion gasPrice].each do |key| + combined[key] = combined[key].to_i(16) if combined[key].is_a?(String) && combined[key].start_with?('0x') + end + + # Remove duplicate keys with different casing + combined.delete('transactionHash') # Keep 'transactionHash' instead + + obj = OpenStruct.new(combined) + + def obj.method_missing(method, *args, &block) + if respond_to?(method.to_s.camelize(:lower)) + send(method.to_s.camelize(:lower), *args, &block) + else + super + end + end + + obj + end +end diff --git a/spec/support/geth_test_helper.rb b/spec/support/geth_test_helper.rb new file mode 100644 index 0000000..f70c6d1 --- /dev/null +++ b/spec/support/geth_test_helper.rb @@ -0,0 +1,114 @@ +module GethTestHelper + extend self + + def setup_rspec_geth + geth_dir = ENV.fetch('LOCAL_GETH_DIR') + http_port = ENV.fetch('NON_AUTH_GETH_RPC_URL').split(':').last + authrpc_port = ENV.fetch('GETH_RPC_URL').split(':').last + discovery_port = ENV.fetch('GETH_DISCOVERY_PORT') + + teardown_rspec_geth + + @temp_datadir = Dir.mktmpdir('geth_datadir_', '/tmp') + geth_dir_hash = Digest::SHA256.hexdigest(ENV.fetch('LOCAL_GETH_DIR')).first(5) + log_file_location = Rails.root.join('tmp', "geth_#{geth_dir_hash}.log").to_s + if File.exist?(log_file_location) + File.delete(log_file_location) + end + + quiet = ENV['RSPEC_QUIET_GETH'] == 'true' + genesis_path = GenesisGenerator.new(quiet: quiet).run! + + file = Tempfile.new + file.write(ENV.fetch('JWT_SECRET')) + file.close + + cmd = "make geth && ./build/bin/geth init --cache.preimages --state.scheme=hash --datadir #{@temp_datadir} #{genesis_path}" + stdout, stderr, status = Open3.capture3(cmd, chdir: geth_dir) + unless status.success? + message = stderr.empty? ? stdout : stderr + raise "Geth init failed: #{message}" + end + + puts "✅ Geth init completed" + + geth_command = [ + "#{geth_dir}/build/bin/geth", + "--datadir", @temp_datadir, + "--http", + "--http.api", "eth,net,web3,debug", + "--http.vhosts", "*", + "--authrpc.jwtsecret", file.path, + "--http.port", http_port, + "--authrpc.port", authrpc_port, + "--discovery.port", discovery_port, + "--port", discovery_port, + "--authrpc.addr", "localhost", + "--authrpc.vhosts", "*", + "--nodiscover", + "--maxpeers", "0", + "--log.file", log_file_location, + "--syncmode", "full", + "--gcmode", "full", + "--history.state", "100000", + "--history.transactions", "100000", + # "--nocompaction", + "--rollup.enabletxpooladmission=false", + "--rollup.disabletxpoolgossip", + "--cache", "12000", + # "--cache.preimages", + "--override.canyon", "0" # Enable canyon from genesis + ] + + FileUtils.rm(log_file_location) if File.exist?(log_file_location) + + pid = Process.spawn(*geth_command, [:out, :err] => [log_file_location, 'w']) + + Process.detach(pid) + + geth_dir_hash = Digest::SHA256.hexdigest(geth_dir) + + File.write(geth_pid_file, pid) + + begin + Timeout.timeout(30) do + loop do + break if File.exist?(log_file_location) && File.read(log_file_location).include?("NAT mapped port") + sleep 0.5 + end + end + rescue Timeout::Error + raise "Geth setup did not complete within the expected time" + end + end + + def generate_genesis_file + generator = GenesisGenerator.new + generator.run! + end + + def teardown_rspec_geth + if File.exist?(geth_pid_file) + pid = File.read(geth_pid_file).to_i + begin + # Kill the specific geth process + Process.kill('TERM', pid) + Process.wait(pid) + rescue Errno::ESRCH, Errno::ECHILD => e + puts e.message + ensure + File.delete(geth_pid_file) + end + end + + # Clean up the temporary data directory + Dir.glob('/tmp/geth_datadir_*').each do |dir| + FileUtils.rm_rf(dir) + end + end + + def geth_pid_file + geth_dir_hash = Digest::SHA256.hexdigest(ENV.fetch('LOCAL_GETH_DIR')).first(5) + "tmp/geth_pid_#{geth_dir_hash}.pid" + end +end \ No newline at end of file diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb deleted file mode 100644 index 4c586ec..0000000 --- a/spec/swagger_helper.rb +++ /dev/null @@ -1,245 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.configure do |config| - # Specify a root folder where Swagger JSON files are generated - # NOTE: If you're using the rswag-api to serve API descriptions, you'll need - # to ensure that it's configured to serve Swagger from the same folder - config.openapi_root = Rails.root.join('swagger').to_s - - # Define one or more Swagger documents and provide global metadata for each one - # When you run the 'rswag:specs:swaggerize' rake task, the complete Swagger will - # be generated at the provided relative path under openapi_root - # By default, the operations defined in spec files are added to the first - # document below. You can override this behavior by adding a openapi_spec tag to the - # the root example_group in your specs, e.g. describe '...', openapi_spec: 'v2/swagger.json' - - intro = <<~DESC - ## Overview - - Welcome to the Ethscriptions Indexer API docs! - - This API enables you to learn everything about the ethscriptions protocol. All instances of the open source [Ethscriptions Indexer](https://github.com/0xFacet/ethscriptions-indexer) expose this API. - - If you don't want to run your own instance of the indexer you can use ours for free using the base URL `https://api.ethscriptions.com/v2`. - - ## Community and Support - - Join our community on [GitHub](https://github.com/0xFacet/ethscriptions-indexer) and [Discord](https://discord.gg/ethscriptions) to contribute, get support, and share your experiences with the Ethscriptions Indexer. - - DESC - - config.openapi_specs = { - 'v1/swagger.yaml' => { - openapi: '3.0.1', - info: { - title: 'Ethscriptions API V2', - version: 'v2', - description: intro, - }, - paths: {}, - tags: [ - { - name: 'Ethscriptions', - description: 'Endpoints for querying ethscriptions.' - }, - { - name: 'Ethscription Transfers', - description: 'Endpoints for querying ethscription transfers.' - }, - { - name: 'Tokens', - description: 'Endpoints for querying tokens. Note: token indexing is an optional feature and different indexers might index different tokens.' - }, - { - name: 'Status', - description: 'Endpoints for querying indexer status.' - }, - ], - components: { - schemas: { - Ethscription: { - type: :object, - properties: { - transaction_hash: { type: :string, example: '0x0ef100873db4e3b7446e9a3be0432ab8bc92119d009aa200f70c210ac9dcd4a6', description: 'Hash of the Ethereum transaction.' }, - block_number: { type: :string, example: '19619510', description: 'Block number where the transaction was included.' }, - transaction_index: { type: :string, example: '88', description: 'Transaction index within the block.' }, - block_timestamp: { type: :string, example: '1712682959', description: 'Timestamp for when the block was mined.' }, - block_blockhash: { type: :string, example: '0xa44323fa6404b446665037ec61a09fc8526144154cb3742bcd254c7ef054ab0c', description: 'Hash of the block.' }, - ethscription_number: { type: :string, example: '5853618', description: 'Unique identifier for the ethscription.' }, - creator: { type: :string, example: '0xc27b42d010c1e0f80c6c0c82a1a7170976adb340', description: 'Address of the ethscription creator.' }, - initial_owner: { type: :string, example: '0x00000000000000000000000000000000000face7', description: 'Initial owner of the ethscription.' }, - current_owner: { type: :string, example: '0x00000000000000000000000000000000000face7', description: 'Current owner of the ethscription.' }, - previous_owner: { type: :string, example: '0xc27b42d010c1e0f80c6c0c82a1a7170976adb340', description: 'Previous owner of the ethscription before the current owner.' }, - content_uri: { type: :string, example: 'data:application/vnd.facet.tx+json;rule=esip6,{...}', description: 'URI encoding the data and rule for the ethscription.' }, - content_sha: { type: :string, example: '0xda6dce30c4c09885ed8538c9e33ae43cfb392f5f6d42a62189a446093929e115', description: 'SHA hash of the content.' }, - esip6: { type: :boolean, example: true, description: 'Indicator of whether the ethscription conforms to ESIP-6.' }, - mimetype: { type: :string, example: 'application/vnd.facet.tx+json', description: 'MIME type of the ethscription.' }, - gas_price: { type: :string, example: '37806857216', description: 'Gas price used for the transaction.' }, - gas_used: { type: :string, example: '27688', description: 'Amount of gas used by the transaction.' }, - transaction_fee: { type: :string, example: '1046796262596608', description: 'Total fee of the transaction.' }, - value: { type: :string, example: '0', description: 'Value transferred in the transaction.' }, - attachment_sha: { type: :string, nullable: true, example: '0x0ef100873db4e3b7446e9a3be0432ab8bc92119d009aa200f70c210ac9dcd4a6', description: 'SHA hash of the attachment.' }, - attachment_content_type: { type: :string, nullable: true, example: 'text/plain', description: 'MIME type of the attachment.' } - }, - }, - EthscriptionTransfer: { - type: :object, - properties: { - ethscription_transaction_hash: { - type: :string, - example: '0x4c5d41...', - description: 'Hash of the ethscription associated with the transfer.' - }, - transaction_hash: { - type: :string, - example: '0x707bb3...', - description: 'Hash of the Ethereum transaction that performed the transfer.' - }, - from_address: { - type: :string, - example: '0xfb833c...', - description: 'Address of the sender in the transfer.' - }, - to_address: { - type: :string, - example: '0x1f1edb...', - description: 'Address of the recipient in the transfer.' - }, - block_number: { - type: :integer, - example: 19619724, - description: 'Block number where the transfer was recorded.' - }, - block_timestamp: { - type: :integer, - example: 1712685539, - description: 'Timestamp for when the block containing the transfer was mined.' - }, - block_blockhash: { - type: :string, - example: '0x0204cb...', - description: 'Hash of the block containing the transfer.' - }, - event_log_index: { - type: :integer, - example: nil, - description: 'Index of the event log that recorded the transfer.', - nullable: true - }, - transfer_index: { - type: :string, - example: '51', - description: 'Index of the transfer in the transaction.' - }, - transaction_index: { - type: :integer, - example: 95, - description: 'Transaction index within the block.' - }, - enforced_previous_owner: { - type: :string, - example: nil, - description: 'Enforced previous owner of the ethscription, if applicable.', - nullable: true - } - }, - }, - Token: { - type: :object, - properties: { - deploy_ethscription_transaction_hash: { type: :string, example: '0xc8115ff794c6a077bdca1be18408e45394083debe026e9136ed26355b52f6d0d', description: 'The transaction hash of the Ethscription that deployed the token.' }, - deploy_block_number: { type: :string, example: '18997063', description: 'The block number in which the token was deployed.' }, - deploy_transaction_index: { type: :string, example: '67', description: 'The index of the transaction in the block in which the token was deployed.' }, - protocol: { type: :string, example: 'erc-20', description: 'The protocol of the token.' }, - tick: { type: :string, example: 'nodes', description: 'The tick (symbol) of the token.' }, - max_supply: { type: :string, example: '10000000000', description: 'The maximum supply of the token.' }, - total_supply: { type: :string, example: '10000000000', description: 'The current total supply of the token.' }, - mint_amount: { type: :string, example: '10000', description: 'The amount of tokens minted.' } - }, - description: 'Represents a token, including its deployment information, protocol, and supply details.' - }, - PaginationObject: { - type: :object, - properties: { - page_key: { type: :string, example: '18680069-4-1', description: 'Key for the next page of results. Supply this in the page_key query parameter to retrieve the next set of items.' }, - has_more: { type: :boolean, example: true, description: 'Indicates if more items are available beyond the current page.' } - }, - description: 'Contains pagination details to navigate through the list of records.' - } - } - }, - servers: [ - { - url: 'https://api.ethscriptions.com/v2' - } - ] - } - } - - ethscription_object = config.openapi_specs['v1/swagger.yaml'][:components][:schemas][:Ethscription] - ethscription_properties = ethscription_object[:properties] - - # Defining the additional property for transfers - transfers_addition = { - transfers: { - type: :array, - items: { - '$ref': '#/components/schemas/EthscriptionTransfer' - }, - description: 'Array of transfers associated with the ethscription.' - } - } - - # Merge the original properties with the new addition - updated_properties = ethscription_properties.merge(transfers_addition) - - # Create a new component schema that includes the updated properties - ethscription_with_transfers_component = ethscription_object.merge({ - type: ethscription_object[:type], - properties: updated_properties - }) - - # Add the new component to the OpenAPI specification - config.openapi_specs['v1/swagger.yaml'][:components][:schemas][:EthscriptionWithTransfers] = ethscription_with_transfers_component - - # Retrieve the existing TokenObject component schema - token_component = config.openapi_specs['v1/swagger.yaml'][:components][:schemas][:Token] - - # Define the additional property for balances - balances_property = { - balances: { - type: :object, - additionalProperties: { - type: :string - }, - description: 'A mapping of wallet addresses to their respective token balances.', - example: { - "0x000000000006f291b587f39b6960dd32e31400bf": "5595650000", - "0x0000000a0705080fae54fd5cd2041a996a1d59ed": "5660000", - "0x00007fd644a03bc613b222a5c2e661861d71c424": "10000", - "0x000112a490277649e5d4d02ffd8a58bb002d0ed4": "690000" - } - } - } - - # Merge the additional property into the existing properties of TokenObject - updated_properties = token_component[:properties].merge(balances_property) - - # Create a new component schema that includes the updated properties - token_with_balances_component = token_component.merge({ - type: token_component[:type], - properties: updated_properties - }) - - # Add the new component schema to the openapi_specs - config.openapi_specs['v1/swagger.yaml'][:components][:schemas][:TokenWithBalances] = token_with_balances_component - - - # Specify the format of the output Swagger file when running 'rswag:specs:swaggerize'. - # The openapi_specs configuration option has the filename including format in - # the key, this may want to be changed to avoid putting yaml in json files. - # Defaults to json. Accepts ':json' and ':yaml'. - config.openapi_format = :yaml -end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml deleted file mode 100644 index 5a1bd86..0000000 --- a/swagger/v1/swagger.yaml +++ /dev/null @@ -1,1351 +0,0 @@ ---- -openapi: 3.0.1 -info: - title: Ethscriptions API V2 - version: v2 - description: "## Overview\n\nWelcome to the Ethscriptions Indexer API docs!\n\nThis - API enables you to learn everything about the ethscriptions protocol. All instances - of the open source [Ethscriptions Indexer](https://github.com/0xFacet/ethscriptions-indexer) - expose this API.\n\nIf you don't want to run your own instance of the indexer - you can use ours for free using the base URL `https://api.ethscriptions.com/v2`.\n\n## - Community and Support\n \nJoin our community on [GitHub](https://github.com/0xFacet/ethscriptions-indexer) - and [Discord](https://discord.gg/ethscriptions) to contribute, get support, and - share your experiences with the Ethscriptions Indexer.\n\n" -paths: - "/ethscription_transfers": - get: - summary: List Ethscription Transfers - tags: - - Ethscription Transfers - operationId: listEthscriptionTransfers - description: 'Retrieves a list of Ethscription transfers based on filter criteria - such as from address, to address, and transaction hash. Supports filtering - by token characteristics (tick and protocol) and address involvement (to or - from). - - ' - parameters: - - name: from_address - in: query - description: Filter transfers by the sender’s address. - required: false - schema: - type: string - - name: to_address - in: query - description: Filter transfers by the recipient’s address. - required: false - schema: - type: string - - name: transaction_hash - in: query - description: Filter transfers by the Ethscription transaction hash. - required: false - schema: - type: string - - name: to_or_from - in: query - items: - type: string - description: Filter transfers by addresses involved either as sender or recipient. - required: false - schema: - type: array - - name: ethscription_token_tick - in: query - description: Filter transfers by the Ethscription token tick. - required: false - schema: - type: string - - name: ethscription_token_protocol - in: query - description: Filter transfers by the Ethscription token protocol. - required: false - schema: - type: string - - name: sort_by - in: query - description: Defines the order of the records to be returned. Can be either - "newest_first" (default) or "oldest_first". - enum: - - newest_first - - oldest_first - required: false - default: newest_first - schema: - type: string - - name: reverse - in: query - description: When set to true, reverses the sort order specified by the `sort_by` - parameter. - required: false - example: 'false' - schema: - type: boolean - - name: max_results - in: query - description: Limits the number of results returned. Default value is 25, maximum - value is 50. - required: false - maximum: 50 - default: 25 - example: 25 - schema: - type: integer - - name: page_key - in: query - description: Pagination key from the previous response. Used for fetching - the next set of results. - required: false - schema: - type: string - responses: - '200': - description: Transfers retrieved successfully - content: - application/json: - schema: - type: object - properties: - result: - type: array - items: - "$ref": "#/components/schemas/EthscriptionTransfer" - pagination: - "$ref": "#/components/schemas/PaginationObject" - description: A list of Ethscription transfers that match the filter - criteria. - "/ethscriptions": - get: - summary: List Ethscriptions - tags: - - Ethscriptions - operationId: listEthscriptions - description: "Retrieves a list of ethscriptions, supporting various filters. - \nBy default, the results limit is set to 100.\n\n- If `transaction_hash_only` - is set to true, the results limit increases to 1000.\n- If `include_latest_transfer` - is set to true, the results limit is reduced to 50.\n\nThe filter parameters - below can be either individual values or arrays of values.\n" - parameters: - - name: current_owner - in: query - description: Filter by current owner address - schema: - type: string - - name: creator - in: query - description: Filter by creator address - schema: - type: string - - name: initial_owner - in: query - description: Filter by initial owner address - schema: - type: string - - name: previous_owner - in: query - description: Filter by previous owner address - schema: - type: string - - name: mimetype - in: query - description: Filter by MIME type - schema: - type: string - - name: media_type - in: query - description: Filter by media type - schema: - type: string - - name: mime_subtype - in: query - description: Filter by MIME subtype - schema: - type: string - - name: content_sha - in: query - description: Filter by content SHA hash - schema: - type: string - - name: transaction_hash - in: query - description: Filter by Ethereum transaction hash - schema: - type: string - - name: block_number - in: query - description: Filter by block number - schema: - type: string - - name: block_timestamp - in: query - description: Filter by block timestamp - schema: - type: string - - name: block_blockhash - in: query - description: Filter by block hash - schema: - type: string - - name: ethscription_number - in: query - description: Filter by ethscription number - schema: - type: string - - name: attachment_sha - in: query - description: Filter by attachment SHA hash - schema: - type: string - - name: attachment_content_type - in: query - description: Filter by attachment content type - schema: - type: string - - name: attachment_present - in: query - description: Filter by presence of an attachment - enum: - - 'true' - - 'false' - schema: - type: string - - name: token_tick - in: query - description: Filter by token tick - example: eths - schema: - type: string - - name: token_protocol - in: query - description: Filter by token protocol - example: erc-20 - schema: - type: string - - name: transferred_in_tx - in: query - description: Filter by transfer transaction hash - schema: - type: string - - name: after_block - in: query - description: Filter by block number, returning only ethscriptions after the - specified block. - required: false - schema: - type: integer - - name: before_block - in: query - description: Filter by block number, returning only ethscriptions before the - specified block. - required: false - schema: - type: integer - - name: transaction_hash_only - in: query - description: Return only transaction hashes. When set to true, increases results - limit to 1000. - required: false - schema: - type: boolean - - name: include_latest_transfer - in: query - description: Include latest transfer information. When set to true, reduces - results limit to 50. - required: false - schema: - type: boolean - - name: sort_by - in: query - description: Defines the order of the records to be returned. Can be either - "newest_first" (default) or "oldest_first". - enum: - - newest_first - - oldest_first - required: false - default: newest_first - schema: - type: string - - name: reverse - in: query - description: When set to true, reverses the sort order specified by the `sort_by` - parameter. - required: false - example: 'false' - schema: - type: boolean - - name: max_results - in: query - description: Limits the number of results returned. Default value is 25, maximum - value is 50. - required: false - maximum: 50 - default: 25 - example: 25 - schema: - type: integer - - name: page_key - in: query - description: Pagination key from the previous response. Used for fetching - the next set of results. - required: false - schema: - type: string - responses: - '200': - description: ethscriptions list - content: - application/json: - schema: - type: object - properties: - result: - type: array - items: - "$ref": "#/components/schemas/Ethscription" - pagination: - "$ref": "#/components/schemas/PaginationObject" - description: A list of ethscriptions based on filter criteria. - "/ethscriptions/{tx_hash_or_ethscription_number}": - get: - summary: Show Ethscription - tags: - - Ethscriptions - operationId: getEthscriptionByTransactionHash - description: Retrieves an ethscription, including its transfers, by its transaction - hash. - parameters: - - name: tx_hash_or_ethscription_number - in: path - description: Transaction hash or ethscription number of the ethscription - example: '0x0ef100873db4e3b7446e9a3be0432ab8bc92119d009aa200f70c210ac9dcd4a6' - required: true - schema: - type: string - responses: - '200': - description: Ethscription retrieved successfully - content: - application/json: - schema: - type: object - properties: - result: - "$ref": "#/components/schemas/EthscriptionWithTransfers" - description: The ethscription's details - '404': - description: Ethscription not found - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: Record not found - description: Error message indicating the ethscription was not found - "/ethscriptions/{tx_hash_or_ethscription_number}/data": - get: - summary: Show Ethscription Data - tags: - - Ethscriptions - operationId: getEthscriptionData - description: Retrieves the raw content data of an ethscription and serves it - according to its content type. - parameters: - - name: tx_hash_or_ethscription_number - in: path - description: The ethscription number or transaction hash to retrieve data - for. - required: true - example: '0' - schema: - type: string - responses: - '200': - description: Data retrieved successfully - headers: - Content-Type: - description: The MIME type of the data. - schema: - type: string - content: - application/octet-stream: - schema: - type: string - format: binary - description: Returns the raw data of an ethscription as indicated - by the content type of the stored data URI. The content type in - the response depends on the ethscription’s data. - example: "\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\a\\b\\t\\n\\v\\f\\r\\u000E\\u000F" - image/png: - schema: - type: string - format: binary - description: Returns the raw data of an ethscription as indicated - by the content type of the stored data URI. The content type in - the response depends on the ethscription’s data. - example: "\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\a\\b\\t\\n\\v\\f\\r\\u000E\\u000F" - text/plain: - schema: - type: string - format: binary - description: Returns the raw data of an ethscription as indicated - by the content type of the stored data URI. The content type in - the response depends on the ethscription’s data. - example: "\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\a\\b\\t\\n\\v\\f\\r\\u000E\\u000F" - '404': - description: Ethscription not found - content: - application/octet-stream: - schema: - type: object - properties: - error: - type: string - example: Record not found - description: Error message indicating the ethscription was not found - image/png: - schema: - type: object - properties: - error: - type: string - example: Record not found - description: Error message indicating the ethscription was not found - text/plain: - schema: - type: object - properties: - error: - type: string - example: Record not found - description: Error message indicating the ethscription was not found - "/ethscriptions/{tx_hash_or_ethscription_number}/attachment": - get: - summary: Show Ethscription Attachment Data - tags: - - Ethscriptions - operationId: getEthscriptionAttachment - description: 'Retrieves the raw attachment of an ethscription and serves it - according to its content type. Only available when an attachment is present. - Attachments are created via blobs per esip-8. - - ' - parameters: - - name: tx_hash_or_ethscription_number - in: path - required: true - description: The ethscription number or transaction hash to retrieve the attachment - for. - example: '0xcf23d640184114e9d870a95f0fdc3aa65e436c5457d5b6ee2e3c6e104420abd1' - schema: - type: string - responses: - '200': - description: Attachment retrieved successfully - headers: - Content-Type: - description: The MIME type of the attachment. - schema: - type: string - content: - application/octet-stream: - schema: - type: string - format: binary - description: Returns the raw attachment data of an ethscription. The - content type in the response depends on the ethscription’s attachment - content type. - example: "\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\a\\b\\t\\n\\v\\f\\r\\u000E\\u000F" - image/png: - schema: - type: string - format: binary - description: Returns the raw attachment data of an ethscription. The - content type in the response depends on the ethscription’s attachment - content type. - example: "\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\a\\b\\t\\n\\v\\f\\r\\u000E\\u000F" - text/plain: - schema: - type: string - format: binary - description: Returns the raw attachment data of an ethscription. The - content type in the response depends on the ethscription’s attachment - content type. - example: "\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\a\\b\\t\\n\\v\\f\\r\\u000E\\u000F" - '404': - description: Attachment not found - content: - application/octet-stream: - schema: - type: object - properties: - error: - type: string - example: Attachment not found - description: Indicates that no attachment was found for the provided - ID or transaction hash. - image/png: - schema: - type: object - properties: - error: - type: string - example: Attachment not found - description: Indicates that no attachment was found for the provided - ID or transaction hash. - text/plain: - schema: - type: object - properties: - error: - type: string - example: Attachment not found - description: Indicates that no attachment was found for the provided - ID or transaction hash. - "/ethscriptions/exists/{sha}": - get: - summary: Check if Ethscription Exists - tags: - - Ethscriptions - operationId: checkEthscriptionExists - description: 'Checks if an Ethscription exists by its content SHA hash. Returns - a boolean indicating existence and, if present, the ethscription itself. - - ' - parameters: - - name: sha - in: path - required: true - description: The SHA hash of the ethscription content to check for existence. - example: '0x2817fd9cf901e4435253881550731a5edc5e519c19de46b08e2b19a18e95143e' - schema: - type: string - responses: - '200': - description: Check performed successfully - content: - application/json: - schema: - type: object - properties: - result: - type: object - properties: - exists: - type: boolean - example: true - ethscription: - "$ref": "#/components/schemas/Ethscription" - description: A boolean indicating whether the Ethscription exists, - and the Ethscription itself if it does. - "/ethscriptions/exists_multi": - post: - summary: Check Multiple Ethscriptions Existence - tags: - - Ethscriptions - operationId: checkMultipleEthscriptionsExistence - description: 'Accepts a list of SHA hashes and checks for the existence of Ethscriptions - corresponding to each SHA. Returns a mapping from SHA hashes to their corresponding - Ethscription transaction hashes, if found; otherwise maps to `null`. Max input: - 100 shas. - - ' - parameters: [] - responses: - '200': - description: Existence check performed successfully - content: - application/json: - schema: - type: object - properties: - result: - type: object - additionalProperties: - type: string - nullable: true - description: Transaction hash associated with the SHA. Null - if the SHA does not correspond to an existing Ethscription. - description: Mapping from SHA hashes to Ethscription transaction - hashes or null if the Ethscription does not exist. - description: Successfully returns a mapping from provided SHA hashes - to their corresponding Ethscription transaction hashes or null if - not found. - example: - result: - '0x2817fd9cf901e4435253881550731a5edc5e519c19de46b08e2b19a18e95143e': '0xcf23d640184114e9d870a95f0fdc3aa65e436c5457d5b6ee2e3c6e104420abd1' - '0xdcb130d85be00f8fd735ddafcba1cc83f99ba8dab0fc79c833401827b615c92b': - requestBody: - content: - application/json: - schema: - type: object - properties: - shas: - type: array - items: - type: string - description: An array of SHA hashes to check for Ethscription existence. - example: - - '0x2817fd9cf901e4435253881550731a5edc5e519c19de46b08e2b19a18e95143e' - - '0xdcb130d85be00f8fd735ddafcba1cc83f99ba8dab0fc79c833401827b615c92b' - required: - - shas - "/ethscriptions/newer": - get: - summary: List Newer Ethscriptions - tags: - - Ethscriptions - operationId: getNewerEthscriptions - description: 'Retrieves Ethscriptions that are newer than a specified block - number, optionally filtered by mimetype, initial owner, and other criteria. - Returns Ethscriptions grouped by block, including block metadata and a count - of total future Ethscriptions. The Facet VM relies on this endpoint to retrieve - new Ethscriptions. - - ' - parameters: - - name: mimetypes - in: query - items: - type: string - description: Optional list of mimetypes to filter Ethscriptions by. - required: false - schema: - type: array - - name: initial_owner - in: query - description: Optional initial owner to filter Ethscriptions by. - required: false - schema: - type: string - - name: block_number - in: query - description: Block number to start retrieving newer Ethscriptions from. - required: true - schema: - type: integer - - name: past_ethscriptions_count - in: query - description: Optional count of past Ethscriptions for checksum validation. - required: false - schema: - type: integer - - name: past_ethscriptions_checksum - in: query - description: Optional checksum of past Ethscriptions for validation. - required: false - schema: - type: string - - name: max_ethscriptions - in: query - description: Maximum number of Ethscriptions to return. - required: false - example: 50 - schema: - type: integer - - name: max_blocks - in: query - description: Maximum number of blocks to include in the response. - required: false - example: 500 - schema: - type: integer - responses: - '200': - description: Newer Ethscriptions retrieved successfully - content: - application/json: - schema: - type: object - properties: - total_future_ethscriptions: - type: integer - example: 100 - blocks: - type: array - items: - type: object - properties: - blockhash: - type: string - example: 0x0204cb... - parent_blockhash: - type: string - example: 0x0204cb... - block_number: - type: integer - example: 123456789 - timestamp: - type: integer - example: 1678900000 - ethscriptions: - type: array - items: - "$ref": "#/components/schemas/Ethscription" - description: List of blocks with their Ethscriptions. - description: A list of newer Ethscriptions grouped by block, including - metadata about each block and a count of total future Ethscriptions. - '422': - description: Unprocessable entity - content: - application/json: - schema: - type: object - properties: - error: - type: object - properties: - message: - type: string - resolution: - type: string - description: Error response for various failure scenarios, such as - block not yet imported or checksum mismatch. - "/status": - get: - summary: Show Indexer Status - tags: - - Status - operationId: getIndexerStatus - description: Retrieves the current status of the blockchain indexer, including - the latest block number known, the last block number imported into the system, - and the number of blocks the indexer is behind. - responses: - '200': - description: Indexer status retrieved successfully - content: - application/json: - schema: - type: object - properties: - current_block_number: - type: integer - example: 19620494 - description: The most recent block number known to the global - blockchain network. - last_imported_block: - type: integer - example: 19620494 - description: The last block number that was successfully imported - into the system. - blocks_behind: - type: integer - example: 0 - description: The number of blocks the indexer is behind the current - block number. - required: - - current_block_number - - last_imported_block - - blocks_behind - description: Response body containing the current status of the blockchain - indexer. - '500': - description: Error retrieving indexer status - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: Internal Server Error - description: Error message indicating a failure to retrieve the indexer - status. - "/tokens": - get: - summary: List Tokens - tags: - - Tokens - operationId: listTokens - description: Retrieves a list of tokens based on specified criteria such as - protocol and tick. - parameters: - - name: protocol - in: query - description: Filter tokens by protocol (e.g., "erc-20"). Optional. - required: false - schema: - type: string - - name: tick - in: query - description: Filter tokens by tick (symbol). Optional. - required: false - schema: - type: string - - name: sort_by - in: query - description: Defines the order of the records to be returned. Can be either - "newest_first" (default) or "oldest_first". - enum: - - newest_first - - oldest_first - required: false - default: newest_first - schema: - type: string - - name: reverse - in: query - description: When set to true, reverses the sort order specified by the `sort_by` - parameter. - required: false - example: 'false' - schema: - type: boolean - - name: max_results - in: query - description: Limits the number of results returned. Default value is 25, maximum - value is 50. - required: false - maximum: 50 - default: 25 - example: 25 - schema: - type: integer - - name: page_key - in: query - description: Pagination key from the previous response. Used for fetching - the next set of results. - required: false - schema: - type: string - responses: - '200': - description: Tokens retrieved successfully - content: - application/json: - schema: - type: object - properties: - result: - type: array - items: - "$ref": "#/components/schemas/Token" - pagination: - "$ref": "#/components/schemas/PaginationObject" - description: A list of tokens that match the query criteria, along - with pagination details. - '400': - description: Invalid request parameters - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: Invalid parameters - description: Error message indicating that the request parameters - were invalid. - "/tokens/{protocol}/{tick}": - get: - summary: Show Token Details - tags: - - Tokens - operationId: showTokenDetails - description: Retrieves detailed information about a specific token, identified - by its protocol and tick, including its balances. - parameters: - - name: protocol - in: path - description: The protocol of the token to retrieve (e.g., "erc-20"). - required: true - schema: - type: string - - name: tick - in: path - description: The tick (symbol) of the token to retrieve. - required: true - schema: - type: string - responses: - '200': - description: Token details retrieved successfully - content: - application/json: - schema: - type: object - properties: - result: - "$ref": "#/components/schemas/TokenWithBalances" - description: Detailed information about the requested token, including - its balances. - '404': - description: Token not found - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: Token not found - description: Error message indicating that the requested token was - not found. - "/tokens/{protocol}/{tick}/historical_state": - get: - summary: Get Token Historical State - tags: - - Tokens - operationId: getTokenHistoricalState - description: Retrieves the state of a specific token, identified by its protocol - and tick, at a given block number. - parameters: - - name: protocol - in: path - description: The protocol of the token for which historical state is being - requested (e.g., "erc-20"). - required: true - schema: - type: string - - name: tick - in: path - description: The tick (symbol) of the token for which historical state is - being requested. - required: true - schema: - type: string - - name: as_of_block - in: query - description: The block number at which the token state is requested. - required: true - schema: - type: integer - responses: - '200': - description: Token historical state retrieved successfully - content: - application/json: - schema: - type: object - properties: - result: - "$ref": "#/components/schemas/TokenWithBalances" - description: The state of the requested token at the specified block - number, including its balances. - '404': - description: Token or state not found - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: Token or state not found at the specified block number - description: Error message indicating that either the token or its - state at the specified block number was not found. - "/tokens/{protocol}/{tick}/validate_token_items": - get: - summary: Validate Token Items - tags: - - Tokens - operationId: validateTokenItems - description: 'Validates a list of transaction hashes against the token items - of a specified token. Returns arrays of valid and invalid transaction hashes - along with a checksum for the token items. - - ' - parameters: - - name: protocol - in: path - description: The protocol of the token for which items are being validated - (e.g., "erc-20"). - required: true - schema: - type: string - - name: tick - in: path - description: The tick (symbol) of the token for which items are being validated. - required: true - schema: - type: string - - name: transaction_hashes - in: query - items: - type: string - description: A transaction hash. - collectionFormat: multi - description: An array of transaction hashes to validate against the token's - items. - required: true - schema: - type: array - responses: - '200': - description: Token items validated successfully - content: - application/json: - schema: - type: object - properties: - result: - type: object - properties: - valid: - type: array - items: - type: string - description: Valid transaction hashes. - invalid: - type: array - items: - type: string - description: Invalid transaction hashes. - token_items_checksum: - type: string - description: A checksum for the token items. - description: Returns arrays of valid and invalid transaction hashes - along with a checksum for the token items. - '404': - description: Token not found - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: Requested token not found - description: Error message indicating the token was not found. -tags: -- name: Ethscriptions - description: Endpoints for querying ethscriptions. -- name: Ethscription Transfers - description: Endpoints for querying ethscription transfers. -- name: Tokens - description: 'Endpoints for querying tokens. Note: token indexing is an optional - feature and different indexers might index different tokens.' -- name: Status - description: Endpoints for querying indexer status. -components: - schemas: - Ethscription: - type: object - properties: - transaction_hash: - type: string - example: '0x0ef100873db4e3b7446e9a3be0432ab8bc92119d009aa200f70c210ac9dcd4a6' - description: Hash of the Ethereum transaction. - block_number: - type: string - example: '19619510' - description: Block number where the transaction was included. - transaction_index: - type: string - example: '88' - description: Transaction index within the block. - block_timestamp: - type: string - example: '1712682959' - description: Timestamp for when the block was mined. - block_blockhash: - type: string - example: '0xa44323fa6404b446665037ec61a09fc8526144154cb3742bcd254c7ef054ab0c' - description: Hash of the block. - ethscription_number: - type: string - example: '5853618' - description: Unique identifier for the ethscription. - creator: - type: string - example: '0xc27b42d010c1e0f80c6c0c82a1a7170976adb340' - description: Address of the ethscription creator. - initial_owner: - type: string - example: '0x00000000000000000000000000000000000face7' - description: Initial owner of the ethscription. - current_owner: - type: string - example: '0x00000000000000000000000000000000000face7' - description: Current owner of the ethscription. - previous_owner: - type: string - example: '0xc27b42d010c1e0f80c6c0c82a1a7170976adb340' - description: Previous owner of the ethscription before the current owner. - content_uri: - type: string - example: data:application/vnd.facet.tx+json;rule=esip6,{...} - description: URI encoding the data and rule for the ethscription. - content_sha: - type: string - example: '0xda6dce30c4c09885ed8538c9e33ae43cfb392f5f6d42a62189a446093929e115' - description: SHA hash of the content. - esip6: - type: boolean - example: true - description: Indicator of whether the ethscription conforms to ESIP-6. - mimetype: - type: string - example: application/vnd.facet.tx+json - description: MIME type of the ethscription. - gas_price: - type: string - example: '37806857216' - description: Gas price used for the transaction. - gas_used: - type: string - example: '27688' - description: Amount of gas used by the transaction. - transaction_fee: - type: string - example: '1046796262596608' - description: Total fee of the transaction. - value: - type: string - example: '0' - description: Value transferred in the transaction. - attachment_sha: - type: string - nullable: true - example: '0x0ef100873db4e3b7446e9a3be0432ab8bc92119d009aa200f70c210ac9dcd4a6' - description: SHA hash of the attachment. - attachment_content_type: - type: string - nullable: true - example: text/plain - description: MIME type of the attachment. - EthscriptionTransfer: - type: object - properties: - ethscription_transaction_hash: - type: string - example: 0x4c5d41... - description: Hash of the ethscription associated with the transfer. - transaction_hash: - type: string - example: 0x707bb3... - description: Hash of the Ethereum transaction that performed the transfer. - from_address: - type: string - example: 0xfb833c... - description: Address of the sender in the transfer. - to_address: - type: string - example: 0x1f1edb... - description: Address of the recipient in the transfer. - block_number: - type: integer - example: 19619724 - description: Block number where the transfer was recorded. - block_timestamp: - type: integer - example: 1712685539 - description: Timestamp for when the block containing the transfer was mined. - block_blockhash: - type: string - example: 0x0204cb... - description: Hash of the block containing the transfer. - event_log_index: - type: integer - example: - description: Index of the event log that recorded the transfer. - nullable: true - transfer_index: - type: string - example: '51' - description: Index of the transfer in the transaction. - transaction_index: - type: integer - example: 95 - description: Transaction index within the block. - enforced_previous_owner: - type: string - example: - description: Enforced previous owner of the ethscription, if applicable. - nullable: true - Token: - type: object - properties: - deploy_ethscription_transaction_hash: - type: string - example: '0xc8115ff794c6a077bdca1be18408e45394083debe026e9136ed26355b52f6d0d' - description: The transaction hash of the Ethscription that deployed the - token. - deploy_block_number: - type: string - example: '18997063' - description: The block number in which the token was deployed. - deploy_transaction_index: - type: string - example: '67' - description: The index of the transaction in the block in which the token - was deployed. - protocol: - type: string - example: erc-20 - description: The protocol of the token. - tick: - type: string - example: nodes - description: The tick (symbol) of the token. - max_supply: - type: string - example: '10000000000' - description: The maximum supply of the token. - total_supply: - type: string - example: '10000000000' - description: The current total supply of the token. - mint_amount: - type: string - example: '10000' - description: The amount of tokens minted. - description: Represents a token, including its deployment information, protocol, - and supply details. - PaginationObject: - type: object - properties: - page_key: - type: string - example: 18680069-4-1 - description: Key for the next page of results. Supply this in the page_key - query parameter to retrieve the next set of items. - has_more: - type: boolean - example: true - description: Indicates if more items are available beyond the current page. - description: Contains pagination details to navigate through the list of records. - EthscriptionWithTransfers: - type: object - properties: - transaction_hash: - type: string - example: '0x0ef100873db4e3b7446e9a3be0432ab8bc92119d009aa200f70c210ac9dcd4a6' - description: Hash of the Ethereum transaction. - block_number: - type: string - example: '19619510' - description: Block number where the transaction was included. - transaction_index: - type: string - example: '88' - description: Transaction index within the block. - block_timestamp: - type: string - example: '1712682959' - description: Timestamp for when the block was mined. - block_blockhash: - type: string - example: '0xa44323fa6404b446665037ec61a09fc8526144154cb3742bcd254c7ef054ab0c' - description: Hash of the block. - ethscription_number: - type: string - example: '5853618' - description: Unique identifier for the ethscription. - creator: - type: string - example: '0xc27b42d010c1e0f80c6c0c82a1a7170976adb340' - description: Address of the ethscription creator. - initial_owner: - type: string - example: '0x00000000000000000000000000000000000face7' - description: Initial owner of the ethscription. - current_owner: - type: string - example: '0x00000000000000000000000000000000000face7' - description: Current owner of the ethscription. - previous_owner: - type: string - example: '0xc27b42d010c1e0f80c6c0c82a1a7170976adb340' - description: Previous owner of the ethscription before the current owner. - content_uri: - type: string - example: data:application/vnd.facet.tx+json;rule=esip6,{...} - description: URI encoding the data and rule for the ethscription. - content_sha: - type: string - example: '0xda6dce30c4c09885ed8538c9e33ae43cfb392f5f6d42a62189a446093929e115' - description: SHA hash of the content. - esip6: - type: boolean - example: true - description: Indicator of whether the ethscription conforms to ESIP-6. - mimetype: - type: string - example: application/vnd.facet.tx+json - description: MIME type of the ethscription. - gas_price: - type: string - example: '37806857216' - description: Gas price used for the transaction. - gas_used: - type: string - example: '27688' - description: Amount of gas used by the transaction. - transaction_fee: - type: string - example: '1046796262596608' - description: Total fee of the transaction. - value: - type: string - example: '0' - description: Value transferred in the transaction. - attachment_sha: - type: string - nullable: true - example: '0x0ef100873db4e3b7446e9a3be0432ab8bc92119d009aa200f70c210ac9dcd4a6' - description: SHA hash of the attachment. - attachment_content_type: - type: string - nullable: true - example: text/plain - description: MIME type of the attachment. - transfers: - type: array - items: - "$ref": "#/components/schemas/EthscriptionTransfer" - description: Array of transfers associated with the ethscription. - TokenWithBalances: - type: object - properties: - deploy_ethscription_transaction_hash: - type: string - example: '0xc8115ff794c6a077bdca1be18408e45394083debe026e9136ed26355b52f6d0d' - description: The transaction hash of the Ethscription that deployed the - token. - deploy_block_number: - type: string - example: '18997063' - description: The block number in which the token was deployed. - deploy_transaction_index: - type: string - example: '67' - description: The index of the transaction in the block in which the token - was deployed. - protocol: - type: string - example: erc-20 - description: The protocol of the token. - tick: - type: string - example: nodes - description: The tick (symbol) of the token. - max_supply: - type: string - example: '10000000000' - description: The maximum supply of the token. - total_supply: - type: string - example: '10000000000' - description: The current total supply of the token. - mint_amount: - type: string - example: '10000' - description: The amount of tokens minted. - balances: - type: object - additionalProperties: - type: string - description: A mapping of wallet addresses to their respective token balances. - example: - '0x000000000006f291b587f39b6960dd32e31400bf': '5595650000' - '0x0000000a0705080fae54fd5cd2041a996a1d59ed': '5660000' - '0x00007fd644a03bc613b222a5c2e661861d71c424': '10000' - '0x000112a490277649e5d4d02ffd8a58bb002d0ed4': '690000' - description: Represents a token, including its deployment information, protocol, - and supply details. -servers: -- url: https://api.ethscriptions.com/v2