Initial Commit

This commit is contained in:
2026-02-23 09:49:45 +00:00
commit d25ed7cb9d
12 changed files with 1805 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/node_modules
difficulty-chart.html
difficulty-series.json
examples.json

603
package-lock.json generated Normal file
View File

@@ -0,0 +1,603 @@
{
"name": "ss-filter",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ss-filter",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@bitauth/libauth": "^3.0.0"
},
"devDependencies": {
"@types/node": "^25.2.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
},
"node_modules/@bitauth/libauth": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.0.0.tgz",
"integrity": "sha512-3yoL31XpnhAnf5nDVMFk4xPqebxDwXrgYAwpa31ARJnV5A/eXWlpNYvCd6FTZPFM4VvKfjCBi+jRCrw1hOZ0Jg==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
}
}
}

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "ss-filter",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"generate-examples": "tsx src/generate-examples.ts",
"graph": "tsx src/index.ts",
"start": "npm run graph",
"build-graph": "npm run generate-examples && npm run graph",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@types/node": "^25.2.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
},
"dependencies": {
"@bitauth/libauth": "^3.0.0"
}
}

74
src/example.ts Normal file
View File

@@ -0,0 +1,74 @@
export const contractData = {
version: 'AnyHedge v0.12',
address: 'bitcoincash:pwym7ha7gt7k3h36wp735yhsxp00ayh7cxvrzls43xf4j7zs9s3fuynsxzv6y',
parameters: {
maturityTimestamp: 1771228572n,
startTimestamp: 1771220472n,
highLiquidationPrice: 111200n,
lowLiquidationPrice: 44480n,
payoutSats: 134892n,
nominalUnitsXSatsPerBch: 9999993600n,
satsForNominalUnitsAtHighLiquidation: 89928n,
oraclePublicKey: '02d09db08af1ff4e8453919cc866a4be427d7bfe18f2c05e5444c196fcf6fd2818',
longLockScript: '76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac',
shortLockScript: '76a91423e32af4b40eb093f19bd00f34f74862aa5aa45c88ac',
enableMutualRedemption: 0n,
longMutualRedeemPublicKey: '023021d3e1ec7943e54f56d163e4551c2be01d5f5cd933a86d2f6b81f53d64bf72',
shortMutualRedeemPublicKey: '02275d08908cf337c1b4b74a78b9d31d258ff3469b5a9e392dd4cb774d8c563991'
},
metadata: {
takerSide: 'long',
makerSide: 'short',
shortPayoutAddress: 'bitcoincash:qq37x2h5ks8tpyl3n0gq7d8hfp325k4ytsuvqzna24',
longPayoutAddress: 'bitcoincash:qp6uw90vkaqh3l58jvl90628uh5jmyztsykm722fln',
startingOracleMessage: 'f8ad9269e76e1700cd6e170030d90000',
startingOracleSignature: 'f48e57e850e8d7235beb317526e5a5e17358df8576d146a6c0a0a0c18f26af6dba56848a60990f5314649f5294a8a50f65b401b9fbb2aa126c6208cc9f5bc9d3',
durationInSeconds: 8100n,
highLiquidationPriceMultiplier: 2,
lowLiquidationPriceMultiplier: 0.8,
isSimpleHedge: 0n,
startPrice: 55600n,
nominalUnits: 99.999936,
shortInputInOracleUnits: 49.999968,
longInputInOracleUnits: 24.999984,
shortInputInSatoshis: 89928n,
longInputInSatoshis: 44964n,
minerCostInSatoshis: 638n
},
fundings: [
{
fundingTransactionHash: '278b56d2b067001eadad9e89fad1de1cd1b425cdc1cc6f9c53658e909555957d',
fundingOutputIndex: 0n,
fundingSatoshis: 136862n,
settlement: [Object]
}
],
fees: [
{
address: 'bitcoincash:qq37x2h5ks8tpyl3n0gq7d8hfp325k4ytsuvqzna24',
satoshis: 2666n,
name: 'Liquidity premium',
description: 'Provides counterparty liquidity at a premium.'
},
{
address: 'bitcoincash:qzduxz0ancq5m2ucxj6y5kkj9rnl9p5t5yu964fu9z',
satoshis: 1332n,
name: 'Settlement Service Fee',
description: 'Validates contract parameters and then monitors the contracts oracle prices and automates settlement.'
}
]
}
export const automatedPayoutData = {
settlementType: 'maturation',
settlementTransactionHash: 'a18f82d373a67c908896399da0ee64de27a237d0089a9547de8806db3890737a',
shortPayoutInSatoshis: 90492n,
longPayoutInSatoshis: 44400n,
settlementTimestamp: 1771228572n,
settlementMessage: '9ecd92696e6f1700546f170082d80000',
settlementSignature: '7160d776b3cb9a601bdb85a12fb010457c5c26d3e15af861a3e4b61b96591a7485ff50833e3f03cd4279b62c1d592c043e951a2c0c74323d4a586a14d621c87e',
previousMessage: '62cd92696d6f1700536f170082d80000',
previousSignature: '14dc631ee6dc00b39f1a2847516ad9c8b5c3d6ef6e1c4ac672ad53508eb631991a0fb0284e481a4d10bc5a80d94f8cc5622e81257f2c603644898471cfd1553e',
settlementPrice: 55426n
}

124
src/ext-json.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* TODO: These are intended as temporary stand-ins until this functionality has been implemented directly in LibAuth.
* We are doing this so that we may better standardize with the rest of the BCH eco-system in future.
* See: https://github.com/bitauth/libauth/pull/108
*/
import { binToHex, hexToBin } from '@bitauth/libauth';
export const extendedJsonReplacer = function (value: any): any {
if (typeof value === 'bigint') {
return `<bigint: ${value.toString()}n>`;
} else if (value instanceof Uint8Array) {
return `<Uint8Array: ${binToHex(value)}>`;
}
return value;
};
export const extendedJsonReviver = function (value: any): any {
// Define RegEx that matches our Extended JSON fields.
const bigIntRegex = /^<bigint: (?<bigint>[+-]?[0-9]*)n>$/;
const uint8ArrayRegex = /^<Uint8Array: (?<hex>[a-f0-9]*)>$/;
// Only perform a check if the value is a string.
// NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string.
if (typeof value === 'string') {
// Check if this value matches an Extended JSON encoded bigint.
const bigintMatch = value.match(bigIntRegex);
if (bigintMatch) {
// Access the named group directly instead of using array indices
const { bigint } = bigintMatch.groups!;
// Return the value casted to bigint.
return BigInt(bigint);
}
const uint8ArrayMatch = value.match(uint8ArrayRegex);
if (uint8ArrayMatch) {
// Access the named group directly instead of using array indices
const { hex } = uint8ArrayMatch.groups!;
// Return the value casted to bigint.
return hexToBin(hex);
}
}
// Return the original value.
return value;
};
export const encodeExtendedJsonObject = function (value: any): any {
// If this is an object type (and it is not null - which is technically an "object")...
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
if (
typeof value === 'object' &&
value !== null &&
!ArrayBuffer.isView(value)
) {
// If this is an array, recursively call this function on each value.
if (Array.isArray(value)) {
return value.map(encodeExtendedJsonObject);
}
// Declare object to store extended JSON entries.
const encodedObject: any = {};
// Iterate through each entry and encode it to extended JSON.
for (const [key, valueToEncode] of Object.entries(value)) {
encodedObject[key] = encodeExtendedJsonObject(valueToEncode);
}
// Return the extended JSON encoded object.
return encodedObject;
}
// Return the replaced value.
return extendedJsonReplacer(value);
};
export const decodeExtendedJsonObject = function (value: any): any {
// If this is an object type (and it is not null - which is technically an "object")...
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
if (
typeof value === 'object' &&
value !== null &&
!ArrayBuffer.isView(value)
) {
// If this is an array, recursively call this function on each value.
if (Array.isArray(value)) {
return value.map(decodeExtendedJsonObject);
}
// Declare object to store decoded JSON entries.
const decodedObject: any = {};
// Iterate through each entry and decode it from extended JSON.
for (const [key, valueToEncode] of Object.entries(value)) {
decodedObject[key] = decodeExtendedJsonObject(valueToEncode);
}
// Return the extended JSON encoded object.
return decodedObject;
}
// Return the revived value.
return extendedJsonReviver(value);
};
export const encodeExtendedJson = function (
value: any,
space: number | undefined = undefined,
): string {
const replacedObject = encodeExtendedJsonObject(value);
const stringifiedObject = JSON.stringify(replacedObject, null, space);
return stringifiedObject;
};
export const decodeExtendedJson = function (json: string): any {
const parsedObject = JSON.parse(json);
const revivedObject = decodeExtendedJsonObject(parsedObject);
return revivedObject;
};

53
src/filter.d.ts vendored Normal file
View File

@@ -0,0 +1,53 @@
export interface SettlementSummary {
liquidated: boolean;
inputSats: bigint;
outputSats: bigint;
durationInSeconds: number;
takerLeverage: number;
makerLeverage: number;
percentageChange: number;
oraclePublicKey: string;
settlementTimestamp: number;
}
export interface InterestingContractResult {
isInteresting: boolean;
reason?: string;
summary: SettlementSummary;
}
export interface InterestingSettlementFilterOptions {
difficultyFilterExponent?: number;
difficultyAdjustmentInterval?: number;
baseLiquidationThresholdSats?: bigint;
basePercentageThreshold?: number;
difficultyIncrementRate?: number;
minDifficultyFilterExponent?: number;
maxDifficultyFilterExponent?: number;
}
export class InterestingSettlementFilter {
difficultyFilterExponent: number;
difficultyIncrementRate: number;
minDifficultyFilterExponent: number;
maxDifficultyFilterExponent: number;
difficultyAdjustmentInterval: number;
baseLiquidationThresholdSats: bigint;
basePercentageThreshold: number;
public constructor(filterOptions?: InterestingSettlementFilterOptions);
public start(): void;
public stop(): void;
public incrementDifficulty(steps?: number): void;
public decrementDifficulty(steps?: number): void;
public getContractOutcome(
contractData: Record<string, unknown>,
automatedPayoutData: Record<string, unknown>,
): SettlementSummary;
public isInterestingSettlement(
contractData: Record<string, unknown>,
automatedPayoutData: Record<string, unknown>,
): InterestingContractResult;
}

315
src/filter.js Normal file
View File

@@ -0,0 +1,315 @@
/**
* @typedef {Object} InterestingContractResult
*
* @property {boolean} isInteresting
* @property {string | undefined} reason}
* @property {SettlementSummary} summary
*/
/**
* @typedef {Object} SettlementSummary
*
* @property {boolean} liquidated
* @property {bigint} inputSats
* @property {bigint} outputSats
* @property {number} durationInSeconds
* @property {number} takerLeverage
* @property {number} makerLeverage
* @property {number} percentageChange
* @property {string} oraclePublicKey
* @property {number} settlementTimestamp
* @property {string | undefined} reason}
*/
export class InterestingSettlementFilter {
/**
* The base BCH threshold in satoshis for liquidation filter.
* Contracts with total payout above this threshold are considered interesting.
*
* // Default is 1 BCH
* @default 100_000_000n
* @type {bigint}
*/
baseLiquidationThresholdSats = 100_000_000n;
/**
* The base percentage threshold for payout difference filter.
* Contracts with payout difference above this percentage are considered interesting.
*
* // Default is 5%
* @default 5
* @type {number}
*/
basePercentageThreshold = 20;
/**
* The rate at which difficulty increases when an interesting settlement is found.
*
* // Default is 5%
* @default 0.05
* @type {number}
*/
difficultyIncrementRate = 0.05;
/**
* An exponent that controls the "difficulty" of a contract to be considered interesting.
* This is increased every time a contract is considered interesting, and decreased over time.
* This prevents us from spamming clients with settlements if there is an event that causes a large amount of settlements.
*/
difficultyFilterExponent = 1.0;
/**
* The minimum difficulty filter exponent.
* This is the minimum value that the difficulty filter exponent can reach.
* @default 0.1
* @type {number}
*/
minDifficultyFilterExponent = 0.4;
/**
* The maximum difficulty filter exponent.
* This is the maximum value that the difficulty filter exponent can reach.
* @default 100
* @type {number}
*/
maxDifficultyFilterExponent = 5;
/**
* A timer that adjusts the difficulty of a contract to be considered interesting.
*/
difficultyAdjustmentTimer = null;
/**
* The time interval in milliseconds between difficulty adjustments.
*/
difficultyAdjustmentInterval = 1000 * 60 * 2 // 2 minutes
/**
* The constructor for the InterestingSettlementFilter.
*/
constructor(filterOptions = {}) {
// Exponential values
this.difficultyFilterExponent = filterOptions.difficultyFilterExponent ?? this.difficultyFilterExponent;
this.minDifficultyFilterExponent = filterOptions.minDifficultyFilterExponent ?? this.minDifficultyFilterExponent;
this.maxDifficultyFilterExponent = filterOptions.maxDifficultyFilterExponent ?? this.maxDifficultyFilterExponent;
this.difficultyIncrementRate = filterOptions.difficultyIncrementRate ?? this.difficultyIncrementRate;
// Interval
this.difficultyAdjustmentInterval = filterOptions.difficultyAdjustmentInterval ?? this.difficultyAdjustmentInterval;
// Base values
this.baseLiquidationThresholdSats = filterOptions.baseLiquidationThresholdSats ?? this.baseLiquidationThresholdSats;
this.basePercentageThreshold = filterOptions.basePercentageThreshold ?? this.basePercentageThreshold;
}
/**
* Start the filter
*/
start() {
this.startDifficultyAdjustmentTimer();
}
/**
* Stop the filter
*/
stop() {
this.stopDifficultyAdjustmentTimer();
}
/**
* Start the difficulty adjustment timer.
*/
startDifficultyAdjustmentTimer() {
this.difficultyAdjustmentTimer = setInterval(() => {
this.decrementDifficulty();
}, this.difficultyAdjustmentInterval);
}
/**
* Stop the difficulty adjustment timer.
*/
stopDifficultyAdjustmentTimer() {
if (this.difficultyAdjustmentTimer) {
clearInterval(this.difficultyAdjustmentTimer);
}
}
/**
* Increment the difficulty of a contract to be considered interesting.
*/
incrementDifficulty(steps = 1) {
for (let i = 0; i < steps; i++) {
this.difficultyFilterExponent = Math.min(
this.maxDifficultyFilterExponent,
Number(this.difficultyFilterExponent) * (1 + Number(this.difficultyIncrementRate))
);
}
}
/**
* Decrement the difficulty of a contract to be considered interesting.
*/
decrementDifficulty(steps = 1) {
for (let i = 0; i < steps; i++) {
this.difficultyFilterExponent = Math.max(
this.minDifficultyFilterExponent,
Number(this.difficultyFilterExponent) * (1 - Number(this.difficultyIncrementRate))
);
}
}
/**
* Determine if a settlement is interesting to broadcast to clients.
*
* Do this by checking if the settlement is beyond a certain BCH threshold or a % movement threshold.
*/
isInterestingSettlement(contract, settlement) {
const settlementSummary = this.getContractOutcome(contract, settlement);
// Check if the settlement is beyond the percentage threshold (only if it wasn't liquidated)
const isBeyondPercentageThreshold = this.isBeyondPercentageThreshold(settlementSummary);
if (isBeyondPercentageThreshold && !settlementSummary.liquidated) {
this.incrementDifficulty();
return {
isInteresting: true,
reason: 'Taker had a percentage change that exceeds the threshold',
summary: settlementSummary,
};
}
// Check if the settlement is beyond the payout threshold
const isBeyondPayoutThreshold = this.isBeyondPayoutThreshold(settlementSummary);
if (isBeyondPayoutThreshold) {
this.incrementDifficulty();
return {
isInteresting: true,
reason: 'Taker payout was beyond the BCH threshold',
summary: settlementSummary,
};
}
// If the checks all returned false, this wasn't an interesting settlement
// Return isInteresting: false and undefined for the reason
return {
isInteresting: false,
reason: undefined,
summary: settlementSummary,
};
}
/**
* Determine if the contract payout is beyond a certain BCH threshold.
* This threshold is dynamically adjusted based on the difficulty adjustment exponent.
*
* @param {SettlementSummary} settlementSummary
* @returns {boolean}
*/
isBeyondPayoutThreshold(settlementSummary) {
// Convert the difficulty filter exponent to a number
const exponent = Number(this.difficultyFilterExponent);
// Calculate the exponential power as (exponent ^ exponent)
const exponentialPower = Math.pow(exponent, exponent);
// Calculate the dynamic threshold as (base threshold * exponential power)
const dynamicThreshold = Number(this.baseLiquidationThresholdSats) * exponentialPower;
// Get the sats difference between the input and payout
const satsDifference = settlementSummary.outputSats - settlementSummary.inputSats;
// Check if the payout sats is greater than or equal to the dynamic threshold
const isBeyondThreshold = Math.abs(Number(satsDifference)) >= dynamicThreshold;
return isBeyondThreshold;
}
/**
* Determine if the Settled Contract Payout resulted in a % movement greater than a certain threshold.
* This threshold is dynamically adjusted based on the difficulty adjustment exponent.
*
* @param {SettlementSummary} settlementSummary
* @returns {boolean}
*/
isBeyondPercentageThreshold(settlementSummary) {
// Convert the difficulty filter exponent to a number
const exponent = Number(this.difficultyFilterExponent);
const exponentialPower = Math.pow(exponent, exponent);
// Calculate the dynamic threshold based on the difficulty adjustment exponent
const dynamicThreshold = Number(this.basePercentageThreshold) * exponentialPower;
// Return a boolean indicating if the percentage change is greater than or equal to the dynamic threshold
return Number(settlementSummary.percentageChange) >= dynamicThreshold;
}
/**
* Example of the contract params being pulled out and compared against a static value.
*
* @param {ContractData} contractData
* @param {ContractAutomatedPayoutV2} automatedPayoutData
* @returns {SettlementSummary}
*/
getContractOutcome(
contractData,
automatedPayoutData
) {
/**
* Extract basic values that we can use to create a summary of the settlement.
*/
const takerSide = contractData.metadata.takerSide;
/** @type {bigint} */
const takerInputSats = takerSide === 'short' ? contractData.metadata.shortInputInSatoshis : contractData.metadata.longInputInSatoshis;
/** @type {bigint} */
const takerPayoutSats = takerSide === 'short' ? automatedPayoutData.shortPayoutInSatoshis : automatedPayoutData.longPayoutInSatoshis;
/** @type {bigint} */
const maturityTimestamp = contractData.parameters.maturityTimestamp;
/** @type {bigint} */
const settlementTimestamp = automatedPayoutData.settlementTimestamp;
/**
* Derive values to create a summary of the settlement.
*/
// Get the difference between the taker input sats and payout sats
const difference = takerInputSats - takerPayoutSats;
// Use a multiplier to preserve precision during bigint division
const differenceMultiplier = 1000n;
// Multiply the difference by the multiplier
const differenceMultipliedForDivision = difference * differenceMultiplier;
// Get the scaled percentage of the difference. We will divide by the difference multiplier to get the actual percentage.
const scaledPercentageDifference = Math.abs(Number((differenceMultipliedForDivision / takerInputSats))) * 100;
// Divide the scaled percentage difference by the difference multiplier
const percentageDifference = scaledPercentageDifference / Number(differenceMultiplier);
// Determine if the taker was liquidated by checking if they are getting any payout
const takerLiquidated = takerPayoutSats === 0n;
// Calculate the actual duration in seconds by subtracting the settlement timestamp from the maturity timestamp
const actualDurationMilliseconds = Number(settlementTimestamp - maturityTimestamp);
// Divide the actual duration milliseconds by the milliseconds per second
const actualDurationInSeconds = actualDurationMilliseconds / 1000;
// Return the settlement summary
return {
liquidated: takerLiquidated,
inputSats: takerInputSats,
outputSats: takerPayoutSats,
percentageChange: percentageDifference,
oraclePublicKey: contractData.parameters.oraclePublicKey,
settlementTimestamp: actualDurationInSeconds,
durationInSeconds: contractData.parameters.durationInSeconds,
takerLeverage: contractData.parameters.takerLeverage,
makerLeverage: contractData.parameters.makerLeverage,
};
}
}

152
src/generate-examples.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* Generate randomized examples from a single seed contract.
*
* Model:
* - Create N copies.
* - Set random input sats in [5_000_000, 1_000_000_000].
* - Set random percentage change in [-100, +100].
* - Set output sats directly from that percentage.
* - Increment settlement timestamp by random 3-10 minutes.
*/
import { writeFileSync } from 'node:fs';
import { contractData, automatedPayoutData } from './example.js';
import { encodeExtendedJson } from './ext-json.js';
export type Example = {
contractData: typeof contractData;
automatedPayoutData: typeof automatedPayoutData;
};
const EXAMPLE_COUNT = Number(process.env.EXAMPLE_COUNT ?? 100);
const MIN_INPUT_SATS = 5_000_000n;
const MAX_INPUT_SATS = 1_000_000_000n;
const MIN_PERCENT_CHANGE = -100;
const MAX_PERCENT_CHANGE = 100;
const MIN_TIMESTAMP_INCREMENT_SECONDS = 3 * 60;
const MAX_TIMESTAMP_INCREMENT_SECONDS = 60 * 60;
/**
* Deep clone an object while preserving BigInt values.
*/
function deepClone<T>(obj: T): T {
return JSON.parse(
JSON.stringify(obj, (_, value) => (typeof value === 'bigint' ? `${value.toString()}n` : value)),
(_, value) => {
if (typeof value === 'string' && /^\d+n$/.test(value)) {
return BigInt(value.slice(0, -1));
}
return value;
},
);
}
/**
* Generate a random integer in [min, max].
*/
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* Generate a random bigint in [min, max].
*/
function randomBigIntInRange(min: bigint, max: bigint): bigint {
return BigInt(randomInt(Number(min), Number(max)));
}
/**
* Return the next settlement timestamp by appending 3-10 minutes.
*/
function getNextSettlementTimestamp(previousSettlementTimestampSeconds: number): number {
return previousSettlementTimestampSeconds + randomInt(
MIN_TIMESTAMP_INCREMENT_SECONDS,
MAX_TIMESTAMP_INCREMENT_SECONDS,
);
}
/**
* Return a random percentage with one decimal place in [-100, +100].
*/
function randomPercentageChange(): number {
const scaled = randomInt(MIN_PERCENT_CHANGE * 10, MAX_PERCENT_CHANGE * 10);
return scaled / 10;
}
/**
* Compute taker payout from taker input and a signed percentage.
* -100 => 0x input
* 0 => 1x input
* +100 => 2x input
*/
function computeTakerOutputFromPercent(takerInputSats: bigint, percentChange: number): bigint {
const multiplier = 1 + percentChange / 100;
return BigInt(Math.max(0, Math.round(Number(takerInputSats) * multiplier)));
}
/**
* Apply one random input amount to both sides.
*
* This keeps funding balanced and supports +/-100% outcomes sensibly.
*/
function applyRandomInputs(contract: typeof contractData): bigint {
const randomInput = randomBigIntInRange(MIN_INPUT_SATS, MAX_INPUT_SATS);
contract.metadata.shortInputInSatoshis = randomInput;
contract.metadata.longInputInSatoshis = randomInput;
const startPrice = Number(contract.metadata.startPrice);
contract.metadata.shortInputInOracleUnits = Number(randomInput) / startPrice;
contract.metadata.longInputInOracleUnits = Number(randomInput) / startPrice;
const totalInputs = randomInput + randomInput;
contract.parameters.payoutSats = totalInputs;
contract.metadata.nominalUnits = Number(totalInputs) / startPrice;
contract.parameters.nominalUnitsXSatsPerBch = BigInt(
Math.floor(contract.metadata.nominalUnits * 100_000_000),
);
return randomInput;
}
const examples: Example[] = [];
let latestSettlementTimestampSeconds = Number(contractData.parameters.maturityTimestamp);
for (let i = 0; i < EXAMPLE_COUNT; i++) {
const contract = deepClone(contractData);
const payout = deepClone(automatedPayoutData);
// 1) Randomize inputs.
const takerInputSats = applyRandomInputs(contract);
// 2) Randomize percentage change and derive taker output sats.
const randomPercentChange = randomPercentageChange();
const takerOutputSats = computeTakerOutputFromPercent(takerInputSats, randomPercentChange);
// 3) Set payout outputs, bounded by total available contract payout.
const totalPayoutSats = contract.metadata.shortInputInSatoshis
+ contract.metadata.longInputInSatoshis
- contract.metadata.minerCostInSatoshis;
const boundedTakerOutputSats = takerOutputSats > totalPayoutSats ? totalPayoutSats : takerOutputSats;
if (contract.metadata.takerSide === 'short') {
payout.shortPayoutInSatoshis = boundedTakerOutputSats;
payout.longPayoutInSatoshis = totalPayoutSats - boundedTakerOutputSats;
} else {
payout.longPayoutInSatoshis = boundedTakerOutputSats;
payout.shortPayoutInSatoshis = totalPayoutSats - boundedTakerOutputSats;
}
// 4) Append settlement timestamps by 3-10 minute increments.
latestSettlementTimestampSeconds = getNextSettlementTimestamp(latestSettlementTimestampSeconds);
payout.settlementTimestamp = BigInt(latestSettlementTimestampSeconds);
examples.push({
contractData: contract,
automatedPayoutData: payout,
});
}
writeFileSync('examples.json', encodeExtendedJson(examples));
console.log(`Generated ${examples.length} examples and saved to examples.json`);

16
src/generate-html.ts Normal file
View File

@@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
/**
* Generate the HTML file by replacing the serializedRows placeholder in the template.
*/
export function generateHtml(difficultySeries: string): string {
// Get the directory of the current file.
const pwd = path.dirname(import.meta.filename);
// Read the template file.
const template = readFileSync(path.join(pwd, 'html-template.html'), 'utf8');
// Replace the serializedRows placeholder in the template.
return template.replace('{{ serializedRows }}', difficultySeries);
}

223
src/html-template.html Normal file
View File

@@ -0,0 +1,223 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Difficulty Threshold Timeline</title>
<style>
:root { color-scheme: light dark; }
body { margin: 0; font-family: sans-serif; background: #111; color: #ddd; }
.container { padding: 16px; position: relative; }
h1 { margin: 0 0 8px; font-size: 20px; }
h2 { margin: 20px 0 8px; font-size: 16px; color: #ddd; }
p { margin: 0 0 16px; color: #aaa; }
svg { width: 100%; height: 520px; border: 1px solid #333; background: #171717; }
.axis-label { fill: #bdbdbd; font-size: 12px; }
.tick-label { fill: #9a9a9a; font-size: 11px; }
.line-threshold { fill: none; stroke: #f5c542; stroke-width: 1.8; }
.line-threshold-neg { fill: none; stroke: #f5c542; stroke-width: 1.8; stroke-dasharray: 4 3; }
.line-metric { fill: none; stroke: #9ca3af; stroke-width: 1.2; opacity: 0.7; }
.band-not-interesting { fill: rgba(245, 197, 66, 0.18); }
.point-interesting { fill: #3b82f6; stroke: #dbeafe; stroke-width: 1; }
.point-not-interesting { fill: #ef4444; stroke: #fee2e2; stroke-width: 1; }
.grid { stroke: #2a2a2a; stroke-width: 1; }
.zero-line { stroke: #767676; stroke-width: 1.5; stroke-dasharray: 3 3; }
.legend { display: flex; gap: 12px; margin-top: 12px; color: #bbb; font-size: 12px; flex-wrap: wrap; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 6px; }
.dot-blue { background: #3b82f6; }
.dot-red { background: #ef4444; }
.dot-line { width: 14px; height: 3px; border-radius: 2px; background: #f5c542; margin-top: 4px; }
.hover-hit { fill: transparent; cursor: pointer; }
.tooltip {
position: absolute;
pointer-events: none;
display: none;
min-width: 220px;
max-width: 340px;
padding: 10px 12px;
border: 1px solid #3d3d3d;
border-radius: 6px;
background: rgba(12, 12, 12, 0.95);
color: #eee;
font-size: 12px;
line-height: 1.35;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 3;
white-space: pre-line;
}
</style>
</head>
<body>
<div class="container">
<h1>Difficulty Threshold Timelines</h1>
<p>Each chart is centered around 0. The soft yellow band between +threshold and -threshold is the non-interesting range.</p>
<h2>BCH Value Threshold</h2>
<svg id="bch-chart" viewBox="0 0 1200 520" role="img" aria-label="BCH threshold chart"></svg>
<h2>% Change Threshold</h2>
<svg id="percent-chart" viewBox="0 0 1200 520" role="img" aria-label="Percentage threshold chart"></svg>
<div class="legend">
<span><span class="dot dot-line"></span>+ Threshold</span>
<span><span class="dot dot-line"></span>- Threshold (dashed)</span>
<span><span class="dot dot-blue"></span>Interesting settlement</span>
<span><span class="dot dot-red"></span>Not interesting settlement</span>
</div>
<div id="tooltip" class="tooltip"></div>
</div>
<script>
const rows = {{ serializedRows }};
const tooltip = document.getElementById('tooltip');
const formatNumber = (value) => {
const abs = Math.abs(value);
if (abs >= 1_000_000 || (abs > 0 && abs < 0.001)) return value.toExponential(2);
return value.toFixed(4);
};
const renderThresholdChart = (svg, config) => {
const width = 1200;
const height = 520;
const margin = { top: 20, right: 24, bottom: 56, left: 100 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const xValues = rows.map((r) => r.settlementTimestamp);
const minX = Math.min(...xValues);
const maxX = Math.max(...xValues);
const minY = config.minY;
const maxY = config.maxY;
const clampY = (value) => Math.min(maxY, Math.max(minY, value));
const xScale = (value) => {
if (maxX === minX) return margin.left + innerWidth / 2;
return margin.left + ((value - minX) / (maxX - minX)) * innerWidth;
};
const yScale = (value) => margin.top + innerHeight - ((value - minY) / (maxY - minY)) * innerHeight;
const xTicks = 6;
const yTicks = 6;
let svgParts = [];
for (let i = 0; i <= yTicks; i++) {
const value = minY + ((maxY - minY) * i) / yTicks;
const y = yScale(value);
svgParts.push('<line class="grid" x1="' + margin.left + '" y1="' + y + '" x2="' + (margin.left + innerWidth) + '" y2="' + y + '" />');
svgParts.push('<text class="tick-label" x="' + (margin.left - 8) + '" y="' + (y + 4) + '" text-anchor="end">' + formatNumber(value) + '</text>');
}
for (let i = 0; i <= xTicks; i++) {
const value = minX + ((maxX - minX) * i) / xTicks;
const x = xScale(value);
const date = new Date(value * 1000).toISOString().slice(11, 19);
svgParts.push('<line class="grid" x1="' + x + '" y1="' + margin.top + '" x2="' + x + '" y2="' + (margin.top + innerHeight) + '" />');
svgParts.push('<text class="tick-label" x="' + x + '" y="' + (margin.top + innerHeight + 18) + '" text-anchor="middle">' + date + '</text>');
}
svgParts.push('<line x1="' + margin.left + '" y1="' + (margin.top + innerHeight) + '" x2="' + (margin.left + innerWidth) + '" y2="' + (margin.top + innerHeight) + '" stroke="#999" />');
svgParts.push('<line x1="' + margin.left + '" y1="' + margin.top + '" x2="' + margin.left + '" y2="' + (margin.top + innerHeight) + '" stroke="#999" />');
svgParts.push('<text class="axis-label" x="' + (margin.left + innerWidth / 2) + '" y="' + (height - 16) + '" text-anchor="middle">Time</text>');
svgParts.push('<text class="axis-label" x="22" y="' + (margin.top + innerHeight / 2) + '" text-anchor="middle" transform="rotate(-90 22 ' + (margin.top + innerHeight / 2) + ')">' + config.yAxisLabel + '</text>');
// Shade the non-interesting band between +threshold and -threshold.
let topPath = '';
let bottomPath = '';
rows.forEach((row, index) => {
const x = xScale(row.settlementTimestamp);
const threshold = clampY(config.thresholdValue(row));
const topY = yScale(threshold);
const bottomY = yScale(-threshold);
topPath += (index === 0 ? 'M' : 'L') + x + ' ' + topY + ' ';
bottomPath = 'L' + x + ' ' + bottomY + ' ' + bottomPath;
});
svgParts.push('<path class="band-not-interesting" d="' + topPath + bottomPath + 'Z" />');
const zeroY = yScale(0);
svgParts.push('<line class="zero-line" x1="' + margin.left + '" y1="' + zeroY + '" x2="' + (margin.left + innerWidth) + '" y2="' + zeroY + '" />');
let positiveThresholdPath = '';
let negativeThresholdPath = '';
let metricPath = '';
rows.forEach((row, index) => {
const x = xScale(row.settlementTimestamp);
const threshold = clampY(config.thresholdValue(row));
positiveThresholdPath += (index === 0 ? 'M' : 'L') + x + ' ' + yScale(threshold) + ' ';
negativeThresholdPath += (index === 0 ? 'M' : 'L') + x + ' ' + yScale(-threshold) + ' ';
metricPath += (index === 0 ? 'M' : 'L') + x + ' ' + yScale(clampY(config.metricValue(row))) + ' ';
});
svgParts.push('<path class="line-threshold" d="' + positiveThresholdPath + '" />');
svgParts.push('<path class="line-threshold-neg" d="' + negativeThresholdPath + '" />');
svgParts.push('<path class="line-metric" d="' + metricPath + '" />');
rows.forEach((row) => {
const x = xScale(row.settlementTimestamp);
const metric = config.metricValue(row);
const threshold = config.thresholdValue(row);
const y = yScale(clampY(metric));
const cssClass = row.isInteresting ? 'point-interesting' : 'point-not-interesting';
const tooltipText = [
config.title,
'Time: ' + row.timeIso,
'Interesting: ' + row.isInteresting,
'Reason: ' + (row.reason || 'N/A'),
config.metricLabel + ': ' + formatNumber(metric),
config.thresholdLabel + ': ' + formatNumber(threshold),
'Input BCH: ' + formatNumber(row.inputBch),
'Payout Sats: ' + row.outputSats,
].join('\\n');
const escapedTooltipText = tooltipText
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
svgParts.push('<circle class="' + cssClass + '" cx="' + x + '" cy="' + y + '" r="4.5"></circle>');
svgParts.push('<circle class="hover-hit" data-tooltip="' + escapedTooltipText + '" cx="' + x + '" cy="' + y + '" r="10"></circle>');
});
svg.innerHTML = svgParts.join('');
const container = document.querySelector('.container');
const hoverPoints = svg.querySelectorAll('.hover-hit');
hoverPoints.forEach((point) => {
point.addEventListener('mousemove', (event) => {
const rect = container.getBoundingClientRect();
const tooltipText = event.target.getAttribute('data-tooltip') || '';
tooltip.textContent = tooltipText;
tooltip.style.display = 'block';
tooltip.style.left = (event.clientX - rect.left + 14) + 'px';
tooltip.style.top = (event.clientY - rect.top + 14) + 'px';
});
point.addEventListener('mouseleave', () => {
tooltip.style.display = 'none';
});
});
};
if (!rows.length) {
document.getElementById('bch-chart').innerHTML = '<text x="24" y="40" class="axis-label">No data available.</text>';
document.getElementById('percent-chart').innerHTML = '<text x="24" y="40" class="axis-label">No data available.</text>';
} else {
renderThresholdChart(document.getElementById('bch-chart'), {
title: 'BCH Value Perspective',
yAxisLabel: 'BCH Delta',
metricLabel: 'Actual BCH Delta',
thresholdLabel: 'BCH Threshold',
metricValue: (row) => row.bchDelta,
thresholdValue: (row) => row.bchThreshold,
minY: -20,
maxY: 20,
});
renderThresholdChart(document.getElementById('percent-chart'), {
title: '% Change Perspective',
yAxisLabel: 'Percentage Change',
metricLabel: 'Actual % Change',
thresholdLabel: '% Threshold',
metricValue: (row) => row.signedPercentageChange,
thresholdValue: (row) => row.percentageThreshold,
minY: -100,
maxY: 100,
});
}
</script>
</body>
</html>

172
src/index.ts Normal file
View File

@@ -0,0 +1,172 @@
import type { Example } from './generate-examples.js';
import { InterestingSettlementFilter } from './filter.js';
import { decodeExtendedJson } from './ext-json.js';
import { readFileSync, writeFileSync } from 'node:fs';
import { generateHtml } from './generate-html.js';
export interface SettlementSummary {
liquidated: boolean;
inputSats: bigint;
outputSats: bigint;
durationInSeconds: number;
takerLeverage: number;
makerLeverage: number;
percentageChange: number;
oraclePublicKey: string;
settlementTimestamp: number;
}
export interface InterestingContractResult {
isInteresting: boolean;
reason?: string;
summary: SettlementSummary;
}
interface DifficultySeriesRow {
index: number;
timeIso: string;
settlementTimestamp: number;
contractTimestamp: number;
elapsedSeconds: number;
isInteresting: boolean;
reason: string;
liquidated: boolean;
inputSats: number;
inputBch: number;
outputSats: number;
outputSatsBch: number;
percentageChange: number;
signedPercentageChange: number;
bchDelta: number;
bchThreshold: number;
percentageThreshold: number;
}
// Read the examples file.
const examplesJson = readFileSync('examples.json', 'utf8');
// Parse the extended JSON into objects.
const examples: Example[] = decodeExtendedJson(examplesJson);
// Initialize the filter
const filter = new InterestingSettlementFilter({
// Base values
baseLiquidationThresholdSats: 100_000_000n,
basePercentageThreshold: 20,
// Exponential values
difficultyFilterExponent: 1.0,
minDifficultyFilterExponent: 0.4,
maxDifficultyFilterExponent: 5,
difficultyIncrementRate: 0.1,
// Interval
difficultyAdjustmentInterval: 1000 * 60 * 30,
});
// Start the filter, this just starts the invterval timer
filter.start();
// Initialize an array to store the interesting examples and the difficulty series.
const interestingExamples: {
example: Example;
result: InterestingContractResult;
}[] = [];
// Initialize an array to store the difficulty series.
const difficultySeries: DifficultySeriesRow[] = [];
// Initialize a function to get the contract timestamp in seconds.
const getContractTimestampSeconds = (example: Example): number => Number(example.contractData.parameters.startTimestamp);
// Initialize a function to get the settlement timestamp in seconds.
const getSettlementTimestampSeconds = (example: Example): number => {
const settlementTimestamp = example.automatedPayoutData.settlementTimestamp;
return settlementTimestamp !== undefined
? Number(settlementTimestamp)
: Number(example.contractData.parameters.maturityTimestamp);
};
// Initialize a variable to store the previous settlement timestamp so we can calculate the decremental steps.
let previousSettlementTimestamp = getSettlementTimestampSeconds(examples.at(0)!);
// Loop through the examples.
for (const [index, example] of examples.entries()) {
// Get the contract and settlement timestamps
const contractTimestamp = getContractTimestampSeconds(example);
const settlementTimestamp = getSettlementTimestampSeconds(example);
const elapsedSeconds = Math.max(0, settlementTimestamp - contractTimestamp);
// Calculate the decremental steps to adjust the difficulty.
const stepCount = Math.floor((getSettlementTimestampSeconds(example) - previousSettlementTimestamp) / (filter.difficultyAdjustmentInterval / 1000));
// Update the previous settlement timestamp.
if (stepCount > 0) {
previousSettlementTimestamp = settlementTimestamp;
}
// Decrement the difficulty.
filter.decrementDifficulty(stepCount);
// Capture thresholds before this settlement mutates difficulty.
const bchThresholdSats = Number(filter.baseLiquidationThresholdSats) * Math.pow(filter.difficultyFilterExponent, filter.difficultyFilterExponent);
const bchThreshold = bchThresholdSats / 100_000_000;
const percentageThreshold = Number(filter.basePercentageThreshold) * Math.pow(filter.difficultyFilterExponent, filter.difficultyFilterExponent);
// Run the calculation and grab the results
const result = filter.isInterestingSettlement(example.contractData, example.automatedPayoutData);
const summary = result.summary;
// Calculate the input and output sats.
const inputSats = Number(summary.inputSats);
const outputSats = Number(summary.outputSats);
const inputBch = inputSats / 100_000_000;
const outputSatsBch = outputSats / 100_000_000;
const bchDelta = outputSatsBch - inputBch;
// Calculate the signed percentage change.
const signedPercentageChange = ((outputSats - inputSats) / inputSats) * 100;
// Add the row to the difficulty series.
difficultySeries.push({
index,
timeIso: new Date(settlementTimestamp * 1000).toISOString(),
settlementTimestamp,
contractTimestamp,
elapsedSeconds,
isInteresting: result.isInteresting,
reason: result.reason ?? '',
liquidated: summary.liquidated,
inputSats,
inputBch,
outputSats,
outputSatsBch,
percentageChange: Number(summary.percentageChange),
signedPercentageChange,
bchDelta,
bchThreshold,
percentageThreshold,
});
if (result.isInteresting) {
interestingExamples.push({ example, result: result as InterestingContractResult });
}
}
// Write the difficulty series to file.
writeFileSync('difficulty-series.json', JSON.stringify(difficultySeries, null, 2));
// Generate the HTML file and write it to the file system.
writeFileSync('difficulty-chart.html', generateHtml(JSON.stringify(difficultySeries)));
// Log the interesting examples.
console.log(interestingExamples.map(example => example.result.reason));
console.log(`Found ${interestingExamples.length} interesting examples out of ${examples.length}`);
console.log('Wrote difficulty-series.json, difficulty-series.csv, difficulty-chart.html');
// Stop the filter
filter.stop();

44
tsconfig.json Normal file
View File

@@ -0,0 +1,44 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
"rootDir": "./src",
"outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext",
"target": "esnext",
"types": ["node"],
// For nodejs:
"lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
}
}