diff --git a/.github/workflows/buildService.yml b/.github/workflows/buildService.yml
new file mode 100644
index 0000000..c2ae643
--- /dev/null
+++ b/.github/workflows/buildService.yml
@@ -0,0 +1,36 @@
+name: Build Service
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths-ignore: ['*.md']
+ branches: ['main', 'master']
+ push:
+ paths-ignore: ['*.md']
+ branches: ['main', 'master']
+
+jobs:
+ BuildPackage:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Prepare StartOS SDK
+ uses: Start9Labs/sdk@v1
+
+ - name: Checkout services repository
+ uses: actions/checkout@v4
+
+ - name: Build the service package
+ id: build
+ run: |
+ git submodule update --init --recursive
+ start-sdk init
+ make
+ PACKAGE_ID=$(yq -oy ".id" manifest.*)
+ echo "package_id=$PACKAGE_ID" >> $GITHUB_ENV
+ shell: bash
+
+# - name: Upload .s9pk
+# uses: actions/upload-artifact@v4
+# with:
+# name: ${{ env.package_id }}.s9pk
+# path: ./${{ env.package_id }}.s9pk
diff --git a/.github/workflows/releaseService.yml b/.github/workflows/releaseService.yml
new file mode 100644
index 0000000..f1eebb4
--- /dev/null
+++ b/.github/workflows/releaseService.yml
@@ -0,0 +1,71 @@
+name: Release Service
+
+on:
+ push:
+ tags:
+ - 'v*.*'
+
+jobs:
+ ReleasePackage:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Prepare StartOS SDK
+ uses: Start9Labs/sdk@v1
+
+ - name: Checkout services repository
+ uses: actions/checkout@v4
+
+ - name: Build the service package
+ run: |
+ git submodule update --init --recursive
+ start-sdk init
+ make
+
+ - name: Setting package ID and title from the manifest
+ id: package
+ run: |
+ echo "package_id=$(yq -oy ".id" manifest.*)" >> $GITHUB_ENV
+ echo "package_title=$(yq -oy ".title" manifest.*)" >> $GITHUB_ENV
+ shell: bash
+
+ - name: Generate sha256 checksum
+ run: |
+ PACKAGE_ID=${{ env.package_id }}
+ sha256sum ${PACKAGE_ID}.s9pk > ${PACKAGE_ID}.s9pk.sha256
+ shell: bash
+
+ - name: Generate changelog
+ run: |
+ PACKAGE_ID=${{ env.package_id }}
+ echo "## What's Changed" > change-log.txt
+ yq -oy '.release-notes' manifest.* >> change-log.txt
+ echo "## SHA256 Hash" >> change-log.txt
+ echo '```' >> change-log.txt
+ sha256sum ${PACKAGE_ID}.s9pk >> change-log.txt
+ echo '```' >> change-log.txt
+ shell: bash
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.ref_name }}
+ name: ${{ env.package_title }} ${{ github.ref_name }}
+ prerelease: true
+ body_path: change-log.txt
+ files: |
+ ./${{ env.package_id }}.s9pk
+ ./${{ env.package_id }}.s9pk.sha256
+
+ - name: Publish to Registry
+ env:
+ S9USER: ${{ secrets.S9USER }}
+ S9PASS: ${{ secrets.S9PASS }}
+ S9REGISTRY: ${{ secrets.S9REGISTRY }}
+ run: |
+ if [[ -z "$S9USER" || -z "$S9PASS" || -z "$S9REGISTRY" ]]; then
+ echo "Publish skipped: missing registry credentials."
+ else
+ start-sdk publish https://$S9USER:$S9PASS@$S9REGISTRY ${{ env.package_id }}.s9pk
+ fi
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4eba9d7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+bin/
+obj/
+/packages/
+riderModule.iml
+/_ReSharper.Caches/
+.idea
+*.s9pk
+.vscode/
+scripts/*.js
+docker-images
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..5a838cf
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,38 @@
+FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim as build
+
+# these are specified in Makefile
+ARG PLATFORM
+ARG YQ_VERSION
+ARG YQ_SHA
+
+WORKDIR /app
+
+COPY ./src /app
+
+RUN \
+ dotnet restore && \
+ dotnet publish -c Release -o out
+
+# Install necessary packages
+RUN \
+ apt-get update && \
+ DEBIAN_FRONTEND=noninteractive \
+ apt-get install -y --no-install-recommends \
+ # install wget and certificates
+ ca-certificates wget && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
+
+RUN \
+ # install yq
+ wget -qO /tmp/yq https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_${PLATFORM} && \
+ echo "${YQ_SHA} /tmp/yq" | sha256sum -c || exit 1 && \
+ mv /tmp/yq /usr/local/bin/yq && chmod +x /usr/local/bin/yq
+
+FROM mcr.microsoft.com/dotnet/runtime:8.0-bookworm-slim
+
+WORKDIR /app
+
+COPY --from=build /app/out /app
+COPY --from=build /usr/local/bin/yq /usr/local/bin/yq
+COPY ./docker_entrypoint.sh /usr/local/bin/docker_entrypoint.sh
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5b765c9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..cefcdc6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,73 @@
+PKG_ID := $(shell yq e ".id" manifest.yaml)
+PKG_VERSION := $(shell yq e ".version" manifest.yaml)
+TS_FILES := $(shell find ./scripts -name \*.ts)
+
+# sha256 hashes can be found in https://github.com/mikefarah/yq/releases/download/v4.40.7/checksums-bsd
+YQ_VERSION := 4.40.7
+YQ_SHA_AMD64 := 4f13ee9303a49f7e8f61e7d9c87402e07cc920ae8dfaaa8c10d7ea1b8f9f48ed
+YQ_SHA_ARM64 := a84f2c8f105b70cd348c3bf14048aeb1665c2e7314cbe9aaff15479f268b8412
+
+.DELETE_ON_ERROR:
+
+all: verify
+
+arm:
+# this is not a typo, when building arm, remove the x86_64 image so it doesn't get packed by start-sdk
+ @rm -f docker-images/x86_64.tar
+ @ARCH=aarch64 $(MAKE)
+
+x86:
+# this is not a typo, when building x86, remove the aarch64 image so it doesn't get packed by start-sdk
+ @rm -f docker-images/aarch64.tar
+ @ARCH=x86_64 $(MAKE)
+
+verify: $(PKG_ID).s9pk
+ @start-sdk verify s9pk $(PKG_ID).s9pk
+ @echo " Done!"
+ @echo " Filesize: $(shell du -h $(PKG_ID).s9pk) is ready"
+
+install:
+ @if [ ! -f ~/.embassy/config.yaml ]; then echo "You must define \"host: http://server-name.local\" in ~/.embassy/config.yaml config file first."; exit 1; fi
+ @echo "\nInstalling to $$(grep -v '^#' ~/.embassy/config.yaml | cut -d'/' -f3) ...\n"
+ @[ -f $(PKG_ID).s9pk ] || ( $(MAKE) && echo "\nInstalling to $$(grep -v '^#' ~/.embassy/config.yaml | cut -d'/' -f3) ...\n" )
+ @start-cli package install $(PKG_ID).s9pk
+
+clean:
+ rm -rf docker-images
+ rm -f $(PKG_ID).s9pk
+ rm -f scripts/*.js
+
+scripts/embassy.js: $(TS_FILES)
+ deno bundle scripts/embassy.ts scripts/embassy.js
+
+docker-images/aarch64.tar: manifest.yaml Dockerfile docker_entrypoint.sh assets/nginx.conf
+ifeq ($(ARCH),x86_64)
+else
+ mkdir -p docker-images
+ docker buildx build --tag start9/$(PKG_ID)/main:$(PKG_VERSION) \
+ --build-arg PLATFORM=arm64 \
+ --build-arg YQ_VERSION=$(YQ_VERSION) \
+ --build-arg YQ_SHA=$(YQ_SHA_ARM64) \
+ --platform=linux/arm64 -o type=docker,dest=docker-images/aarch64.tar .
+endif
+
+docker-images/x86_64.tar: manifest.yaml Dockerfile docker_entrypoint.sh assets/nginx.conf
+ifeq ($(ARCH),aarch64)
+else
+ mkdir -p docker-images
+ docker buildx build --tag start9/$(PKG_ID)/main:$(PKG_VERSION) \
+ --build-arg PLATFORM=amd64 \
+ --build-arg YQ_VERSION=$(YQ_VERSION) \
+ --build-arg YQ_SHA=$(YQ_SHA_AMD64) \
+ --platform=linux/amd64 -o type=docker,dest=docker-images/x86_64.tar .
+endif
+
+$(PKG_ID).s9pk: manifest.yaml instructions.md icon.png LICENSE scripts/embassy.js docker-images/aarch64.tar docker-images/x86_64.tar
+ifeq ($(ARCH),aarch64)
+ @echo "start-sdk: Preparing aarch64 package ..."
+else ifeq ($(ARCH),x86_64)
+ @echo "start-sdk: Preparing x86_64 package ..."
+else
+ @echo "start-sdk: Preparing Universal Package ..."
+endif
+ @start-sdk pack
diff --git a/PushTX.sln b/PushTX.sln
new file mode 100644
index 0000000..da2f2a2
--- /dev/null
+++ b/PushTX.sln
@@ -0,0 +1,16 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PushTX", "src\PushTX\PushTX.csproj", "{AF57E668-EC10-4619-A037-64570CF07234}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {AF57E668-EC10-4619-A037-64570CF07234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AF57E668-EC10-4619-A037-64570CF07234}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AF57E668-EC10-4619-A037-64570CF07234}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AF57E668-EC10-4619-A037-64570CF07234}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d49c31c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,67 @@
+
+
+
+
+# ColdCard NFC PushTX for StartOS
+
+This repository creates the `s9pk` package that is installed to run [NFC PushTX](https://pushtx.org) on [StartOS](https://github.com/Start9Labs/start-os/). Learn more about service packaging in the [Developer Docs](https://start9.com/latest/developer-docs/).
+
+## Dependencies
+
+Install the system dependencies below to build this project by following the instructions in the provided links. You can also find detailed steps to setup your environment in the service packaging [documentation](https://docs.start9.com/latest/developer-docs/packaging#development-environment).
+
+- [docker](https://docs.docker.com/get-docker)
+- [docker-buildx](https://docs.docker.com/buildx/working-with-buildx/)
+- [yq](https://mikefarah.gitbook.io/yq)
+- [deno](https://deno.land/)
+- [make](https://www.gnu.org/software/make/)
+- [start-sdk](https://github.com/Start9Labs/start-os/tree/sdk)
+
+## Cloning
+
+Clone the Webtop package repository locally.
+
+```
+git clone git@github.com:remcoros/pushtx-startos.git
+cd pushtx-startos
+```
+
+## Building
+
+To build the service as a universal package, run the following command:
+
+```
+make
+```
+
+Alternatively the package can be built for individual architectures by specifying the architecture as follows:
+
+```
+make x86
+```
+
+or
+
+```
+make arm
+```
+
+## Installing (on StartOS)
+
+Before installation, define `host: https://server-name.local` in your `~/.embassy/config.yaml` config file then run the following commands to determine successful install:
+
+> Change server-name.local to your Start9 server address
+
+```
+start-cli auth login
+#Enter your StartOS password
+make install
+```
+
+**Tip:** You can also install the pushtx.s9pk by sideloading it under the **StartOS > System > Sideload a Service** section.
+
+## Verify Install
+
+Go to your StartOS Services page, select **NFC PushTX**, configure and start the service.
+
+**Done!**
diff --git a/docker_entrypoint.sh b/docker_entrypoint.sh
new file mode 100644
index 0000000..b9d2cc6
--- /dev/null
+++ b/docker_entrypoint.sh
@@ -0,0 +1,153 @@
+#!/bin/sh
+
+set -ea
+
+echo
+echo "Starting Labelbase..."
+echo
+
+# Setup MariaDB
+mkdir -p /run/mysqld
+chown -R mysql:mysql /run/mysqld
+
+# check if the 'mysql' system database exists
+if [ -d /var/lib/mysql/mysql ]; then
+ echo "[i] MariaDB directory already present, skipping creation"
+ chown -R mysql:mysql /var/lib/mysql
+
+ # get root password from passwords file, we'll need it later. The passwords file is created and updated on every run (and included in backups)
+ export MYSQL_ROOT_PASSWORD=$(yq e '.root' /root/data/start9/passwords.yaml)
+ export MYSQL_PASSWORD=$(yq e '.ulabelbase' /root/data/start9/passwords.yaml)
+else
+ echo "[i] MariaDB data directory not found, creating initial DBs"
+
+ mkdir -p /var/lib/mysql
+ chown -R mysql:mysql /var/lib/mysql
+
+ # install system db
+ mysql_install_db --user=mysql --ldata=/var/lib/mysql >/dev/null
+
+ # generate the root password
+ if [ "$MYSQL_ROOT_PASSWORD" = "" ]; then
+ export MYSQL_ROOT_PASSWORD=$(pwgen 16 1)
+ echo "[i] MariaDB root Password: $MYSQL_ROOT_PASSWORD"
+ fi
+
+ # create a database and give privileges
+ # note: Labelbase has the database and username hardcoded to 'labelbase / ulabelbase' with no way to change that
+ MYSQL_DATABASE=${MYSQL_DATABASE:-"labelbase"}
+ MYSQL_USER=${MYSQL_USER:-"ulabelbase"}
+ if [ "$MYSQL_PASSWORD" = "" ]; then
+ export MYSQL_PASSWORD=$(pwgen 16 1)
+ echo "[i] MariaDB $MYSQL_USER Password: $MYSQL_PASSWORD"
+ fi
+
+ tfile=$(mktemp)
+ if [ ! -f "$tfile" ]; then
+ return 1
+ fi
+
+ cat <$tfile
+USE mysql;
+FLUSH PRIVILEGES ;
+GRANT ALL ON *.* TO 'root'@'%' identified by '$MYSQL_ROOT_PASSWORD' WITH GRANT OPTION ;
+GRANT ALL ON *.* TO 'root'@'localhost' identified by '$MYSQL_ROOT_PASSWORD' WITH GRANT OPTION ;
+SET PASSWORD FOR 'root'@'localhost'=PASSWORD('${MYSQL_ROOT_PASSWORD}') ;
+DROP DATABASE IF EXISTS test ;
+FLUSH PRIVILEGES ;
+EOF
+
+ echo "[i] Creating database: $MYSQL_DATABASE"
+ echo "[i] with character set: 'utf8' and collation: 'utf8_general_ci'"
+ echo "CREATE DATABASE IF NOT EXISTS \`$MYSQL_DATABASE\` CHARACTER SET utf8 COLLATE utf8_general_ci;" >>$tfile
+
+ echo "[i] Creating user: $MYSQL_USER with password $MYSQL_PASSWORD"
+ echo "GRANT ALL ON \`$MYSQL_DATABASE\`.* to '$MYSQL_USER'@'%' IDENTIFIED BY '$MYSQL_PASSWORD';" >>$tfile
+ echo "GRANT ALL ON \`$MYSQL_DATABASE\`.* to '$MYSQL_USER'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';" >>$tfile
+ echo "FLUSH PRIVILEGES;" >>$tfile
+
+ # run the script
+ /usr/sbin/mysqld --user=mysql --datadir='/var/lib/mysql' --bootstrap --verbose=0 --skip-networking=0 <$tfile
+ rm -f $tfile
+
+ echo
+ echo 'MariaDB init process done.'
+ echo
+fi
+
+# Update stats (properties) file
+
+mkdir -p /root/data/start9
+cat </root/data/start9/stats.yaml
+data:
+ MariaDB root password:
+ copyable: true
+ description: This is the MariaDB root password. Use it with caution!
+ masked: true
+ qr: false
+ type: string
+ value: $MYSQL_ROOT_PASSWORD
+ MariaDB ulabelbase password:
+ copyable: true
+ description: This is the MariaDB password for the 'ulabelbase' user. Use it with caution!
+ masked: true
+ qr: false
+ type: string
+ value: $MYSQL_PASSWORD
+version: 2
+EOF
+
+cat </root/data/start9/passwords.yaml
+root: $MYSQL_ROOT_PASSWORD
+ulabelbase: $MYSQL_PASSWORD
+EOF
+
+# Run MariaDB
+
+/usr/sbin/mysqld --user=mysql --datadir='/var/lib/mysql' --console --skip-networking=0 --bind-address=0.0.0.0 &
+db_process=$!
+
+# Loop until MariaDB is up
+while ! mysql -h 127.0.0.1 -u"${MYSQL_USER}" -p"${MYSQL_PASSWORD}" -e "SELECT 1" >/dev/null 2>&1; do
+ echo "Waiting for MariaDB to be up..."
+ sleep 1
+done
+
+echo "MariaDB is up!"
+
+# Run Labelbase
+
+cd /app
+
+# workaround: run 'manage.py help' to force generation of config.ini, without it, the next step (makemigrations) will fail.
+# copy config.ini to a persistent volume and re-use it after restarts
+if [ -f /root/data/config.ini ]; then
+ cp /root/data/config.ini /app
+else
+ echo "Executing manage.py help"
+ python manage.py help
+ cp /app/config.ini /root/data
+fi
+
+python manage.py migrate --noinput
+python manage.py process_tasks &
+gunicorn labellabor.wsgi:application -b 127.0.0.1:8000 --reload &
+app_process=$!
+
+# Run nginx
+
+echo "Starting nginx"
+
+nginx -g "daemon off;" &
+nginx_process=$!
+
+# hook the TERM signal and wait for all our processes
+_term() {
+ echo "Caught TERM signal!"
+ kill -TERM "$nginx_process" 2>/dev/null
+ kill -TERM "$app_process" 2>/dev/null
+ kill -TERM "$db_process" 2>/dev/null
+}
+
+trap _term TERM
+wait $db_process $app_process $nginx_process
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..d8fc77c
Binary files /dev/null and b/icon.png differ
diff --git a/instructions.md b/instructions.md
new file mode 100644
index 0000000..544a312
--- /dev/null
+++ b/instructions.md
@@ -0,0 +1,3 @@
+# NFC Push TX
+
+TODO: instructions
diff --git a/manifest.yaml b/manifest.yaml
new file mode 100644
index 0000000..aba640f
--- /dev/null
+++ b/manifest.yaml
@@ -0,0 +1,101 @@
+id: pushtx
+title: "NFC Push TX"
+version: 0.1.0
+release-notes: |
+ * Initial release of NFC Push TX
+license: MIT
+wrapper-repo: "https://github.com/remcoros/pushtx-startos"
+upstream-repo: "https://github.com/remcoros/pushtx-startos"
+support-site: "https://github.com/remcoros/pushtx-startos/issues"
+marketing-site: "https://pushtx.org/"
+donation-url: "https://lnpay.me"
+build: ["make"]
+description:
+ short: NFC Push TX allows single-tap broadcast of freshly-signed transactions from a COLDCARD and hopefully others soon(tm)
+ long: |
+ Once enabled with a URL, the COLDCARD will show the NFC animation after signing the transaction. When the user taps their phone,
+ the phone will see an NFC tag with URL inside. That URL contains the signed transaction ready to go, and once opening in the mobile
+ browser of the phone, that URL will load. The page will connect to your Bitcoin node and send the transaction on the public Bitcoin network.
+assets:
+ license: LICENSE
+ icon: icon.png
+ instructions: instructions.md
+main:
+ type: docker
+ image: main
+ entrypoint: "docker_entrypoint.sh"
+ args: []
+ mounts:
+ main: /root/data
+ gpu-acceleration: false
+hardware-requirements:
+ arch:
+ - x86_64
+ - aarch64
+health-checks:
+ app-ui:
+ name: NFC Push TX User Interface
+ success-message: Ready to be visited in a web browser
+ type: script
+config:
+ get:
+ type: script
+ set:
+ type: script
+properties:
+ type: script
+volumes:
+ main:
+ type: data
+interfaces:
+ main:
+ name: NFC Push TX UI
+ description: NFC Push TX user interface
+ lan-config:
+ 443:
+ ssl: true
+ internal: 80
+ tor-config:
+ port-mapping:
+ 80: "80"
+ ui: true
+ protocols:
+ - tcp
+ - http
+dependencies: {}
+backup:
+ create:
+ type: docker
+ image: compat
+ system: true
+ entrypoint: compat
+ args:
+ - duplicity
+ - create
+ - /mnt/backup
+ - /root/data
+ mounts:
+ BACKUP: /mnt/backup
+ main: /root/data
+ restore:
+ type: docker
+ image: compat
+ system: true
+ entrypoint: compat
+ args:
+ - duplicity
+ - restore
+ - /mnt/backup
+ - /root/data
+ mounts:
+ BACKUP: /mnt/backup
+ main: /root/data
+migrations:
+ from:
+ "*":
+ type: script
+ args: ["from"]
+ to:
+ "*":
+ type: script
+ args: ["to"]
diff --git a/scripts/deps.ts b/scripts/deps.ts
new file mode 100644
index 0000000..3105b54
--- /dev/null
+++ b/scripts/deps.ts
@@ -0,0 +1 @@
+export * from "https://deno.land/x/embassyd_sdk@v0.3.3.0.11/mod.ts";
diff --git a/scripts/embassy.ts b/scripts/embassy.ts
new file mode 100644
index 0000000..e0fd5b2
--- /dev/null
+++ b/scripts/embassy.ts
@@ -0,0 +1,5 @@
+export { setConfig } from "./procedures/setConfig.ts";
+export { getConfig } from "./procedures/getConfig.ts";
+export { migration } from "./procedures/migrations.ts";
+export { health } from "./procedures/healthChecks.ts";
+export { properties } from "./procedures/properties.ts";
diff --git a/scripts/procedures/getConfig.ts b/scripts/procedures/getConfig.ts
new file mode 100644
index 0000000..0b4d034
--- /dev/null
+++ b/scripts/procedures/getConfig.ts
@@ -0,0 +1,3 @@
+import { compat, types as T } from "../deps.ts";
+
+export const getConfig: T.ExpectedExports.getConfig = compat.getConfig({});
diff --git a/scripts/procedures/healthChecks.ts b/scripts/procedures/healthChecks.ts
new file mode 100644
index 0000000..0cc175c
--- /dev/null
+++ b/scripts/procedures/healthChecks.ts
@@ -0,0 +1,5 @@
+import { healthUtil, types as T } from "../deps.ts";
+
+export const health: T.ExpectedExports.health = {
+ "app-ui": healthUtil.checkWebUrl("http://pushtx.embassy:80"),
+};
diff --git a/scripts/procedures/migrations.ts b/scripts/procedures/migrations.ts
new file mode 100644
index 0000000..5986c61
--- /dev/null
+++ b/scripts/procedures/migrations.ts
@@ -0,0 +1,4 @@
+import { compat, types as T } from "../deps.ts";
+
+export const migration: T.ExpectedExports.migration = compat.migrations
+ .fromMapping({}, "0.1.0" );
diff --git a/scripts/procedures/properties.ts b/scripts/procedures/properties.ts
new file mode 100644
index 0000000..dff99aa
--- /dev/null
+++ b/scripts/procedures/properties.ts
@@ -0,0 +1,3 @@
+import { compat, types as T } from "../deps.ts";
+
+export const properties: T.ExpectedExports.properties = compat.properties;
diff --git a/scripts/procedures/setConfig.ts b/scripts/procedures/setConfig.ts
new file mode 100644
index 0000000..ffd9d44
--- /dev/null
+++ b/scripts/procedures/setConfig.ts
@@ -0,0 +1,3 @@
+import { compat } from "../deps.ts";
+
+export const setConfig = compat.setConfig;
diff --git a/src/PushTX/Program.cs b/src/PushTX/Program.cs
new file mode 100644
index 0000000..af32e06
--- /dev/null
+++ b/src/PushTX/Program.cs
@@ -0,0 +1,391 @@
+/*
+
+A simple proxy to expose a pushtx (mempool.space) compatible api that uses bitcoin rpc methods sendrawtransaction and getrawtransaction.
+
+Serves a custom version of Coldcard's pushtx single html file that only uses this proxy to publish a transaction.
+
+Links:
+
+- pushtx spec: https://pushtx.org / https://github.com/Coldcard/firmware/blob/master/docs/nfc-pushtx.md
+- pushtx impl: https://github.com/Coldcard/push-tx/blob/master/cc-implementation/src/main.ts
+- pushtx in mempool: https://github.com/mempool/mempool/pull/5132
+- getrawtransaction: https://developer.bitcoin.org/reference/rpc/getrawtransaction.html
+
+*/
+
+using System.Net.Http.Headers;
+using System.Text.Json.Nodes;
+
+/*
+ * Build WebApplication
+ */
+var builder = WebApplication.CreateSlimBuilder(args);
+
+builder.Services.AddHttpClient();
+
+// this will throw when RPC_HOST / RPC_USERNAME / RPC_PASSWORD are not set
+var rpcSettings = RpcSettings.FromEnvironmentVariables(args);
+builder.Services.AddSingleton(rpcSettings);
+
+var app = builder.Build();
+
+/*
+ * Endpoints
+ */
+
+// for static index.html file
+app.UseDefaultFiles();
+app.UseStaticFiles();
+
+// POST api/tx
+// returns: a transaction id (string)
+app.MapPost("/api/tx", async (
+ HttpContext context,
+ [FromServices] ILogger log,
+ [FromServices] RpcClient rpcClient,
+ [FromServices] RpcSettings settings) =>
+{
+ using var rdr = new StreamReader(context.Request.Body);
+ var transactionHex = await rdr.ReadToEndAsync();
+ if (string.IsNullOrEmpty(transactionHex))
+ {
+ return Results.BadRequest("Transaction hex is required");
+ }
+
+ try
+ {
+ var sendRawTx = new RpcRequest
+ {
+ Id = "pushtx-send",
+ Method = "sendrawtransaction",
+ Params = [transactionHex]
+ };
+
+ var txId = await rpcClient.Send(sendRawTx);
+ if (string.IsNullOrEmpty(txId))
+ {
+ var error = "empty response from bitcoin rpc api";
+ return Results.Content(error, statusCode: StatusCodes.Status500InternalServerError);
+ }
+
+ return Results.Ok(txId);
+ }
+ catch (Exception ex)
+ {
+ var error = $"error sending request to bitcoin rpc api: {ex.Message}";
+ return Results.Content(error, statusCode: StatusCodes.Status500InternalServerError);
+ }
+});
+
+// GET api/tx/{txid}
+// returns: mempool.space compatible transaction result (json)
+app.MapGet("/api/tx/{txid}", async (
+ HttpContext context,
+ [FromServices] ILogger log,
+ [FromServices] RpcClient rpcClient,
+ [FromServices] RpcSettings settings,
+ [FromRoute] string txid) =>
+{
+ if (string.IsNullOrEmpty(txid))
+ {
+ return Results.BadRequest("transaction id is required");
+ }
+
+ try
+ {
+ var getRawTx = new RpcRequest
+ {
+ Id = "pushtx-get",
+ Method = "getrawtransaction",
+ Params = [txid, true]
+ };
+
+ var rpcResult = await rpcClient.Send(getRawTx);
+ if (rpcResult is null)
+ {
+ var error = "empty response from bitcoin rpc api";
+ return Results.Content(error, statusCode: StatusCodes.Status500InternalServerError);
+ }
+
+ // query tx data for all inputs to get address and value
+ var vins = rpcResult["vin"]?.AsArray() ?? [];
+ foreach (var vin in vins.OfType())
+ {
+ var vinTxid = (string)vin["txid"]!;
+ var vout = (int)vin["vout"]!;
+
+ if (string.IsNullOrEmpty(vinTxid) || vout < 0)
+ {
+ continue;
+ }
+
+ var getVinTx = new RpcRequest
+ {
+ Id = "pushtx-get",
+ Method = "getrawtransaction",
+ Params = [vinTxid, true]
+ };
+ var vinResult = await rpcClient.Send(getVinTx);
+
+ if (vinResult is not null)
+ {
+ // add 'prevout' properties to match mempool.space api
+ vin["prevout"] = vinResult["vout"]![vout]!.DeepClone();
+ }
+ }
+
+ // map BTC Core result to mempool.space api result
+ // map inputs
+ var mempoolVin = rpcResult["vin"]!.AsArray().OfType()
+ .Select(vin =>
+ {
+ var prevout = new MempoolTxDataVout(
+ (string)vin["prevout"]!["scriptPubKey"]!["hex"]!,
+ (string)vin["prevout"]!["scriptPubKey"]!["address"]!,
+ (int)((decimal)vin["prevout"]!["value"]! * 1e8m));
+ return new MempoolTxDataVin((string)vin["txid"]!, (int)vin["vout"]!, prevout);
+ })
+ .ToArray();
+
+ // map outputs
+ var mempoolVout = rpcResult["vout"]!.AsArray().OfType()
+ .Select(vout =>
+ new MempoolTxDataVout(
+ (string)vout["scriptPubKey"]!["hex"]!,
+ (string)vout["scriptPubKey"]!["address"]!,
+ (int)((decimal)vout["value"]! * 1e8m)))
+ .ToArray();
+
+ // if we have a blockhash, transaction is confirmed and we can get the block height
+ var blockHeight = 0;
+ var blockHash = rpcResult["blockhash"]?.GetValue();
+ if (!string.IsNullOrEmpty(blockHash))
+ {
+ var getBlock = new RpcRequest
+ {
+ Id = "pushtx-getblock",
+ Method = "getblock",
+ Params = [blockHash, 1]
+ };
+ var blockResult = await rpcClient.Send(getBlock);
+ blockHeight = blockResult?["height"]?.GetValue() ?? 0;
+ }
+
+ // final mempool.space api compatible result
+ var result = new MempoolTxData(
+ txid, mempoolVin, mempoolVout,
+ new MempoolConfirmedStatus(
+ blockHeight > 0,
+ blockHeight));
+ return Results.Json(result);
+ }
+ catch (Exception ex)
+ {
+ log.LogError("error sending request to bitcoin rpc api: {Message}", ex.ToString());
+ return Results.Content(ex.Message, statusCode: StatusCodes.Status500InternalServerError);
+ }
+});
+
+/*
+ * Run it!
+ */
+app.Run();
+
+/*
+ * json-rpc 2.0 client and request/response types
+ */
+public class RpcClient
+{
+ private readonly HttpClient _client;
+ private readonly RpcSettings _settings;
+
+ public RpcClient(HttpClient client, RpcSettings settings)
+ {
+ _client = client;
+ _settings = settings;
+ }
+
+ public Task Send(RpcRequest request)
+ {
+ return Send