@unicitylabs/bridge-plugin 0.2.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEMO.md +255 -0
- package/README.md +89 -0
- package/lib/BridgeLockJustification.d.ts +31 -0
- package/lib/BridgeLockJustification.js +41 -0
- package/lib/BridgeMintJustificationVerifier.d.ts +12 -0
- package/lib/BridgeMintJustificationVerifier.js +35 -0
- package/lib/LockMintJustificationVerifier.d.ts +42 -0
- package/lib/LockMintJustificationVerifier.js +108 -0
- package/lib/address.d.ts +13 -0
- package/lib/address.js +71 -0
- package/lib/bridge-back/abi.d.ts +30 -0
- package/lib/bridge-back/abi.js +89 -0
- package/lib/bridge-back/burn.d.ts +79 -0
- package/lib/bridge-back/burn.js +78 -0
- package/lib/bridge-back/burned-blob.d.ts +6 -0
- package/lib/bridge-back/burned-blob.js +16 -0
- package/lib/bridge-back/cbor.d.ts +32 -0
- package/lib/bridge-back/cbor.js +146 -0
- package/lib/bridge-back/derivations.d.ts +112 -0
- package/lib/bridge-back/derivations.js +201 -0
- package/lib/bridge-back/index.d.ts +11 -0
- package/lib/bridge-back/index.js +11 -0
- package/lib/cli/main.d.ts +2 -0
- package/lib/cli/main.js +149 -0
- package/lib/cli/scenario.d.ts +36 -0
- package/lib/cli/scenario.js +94 -0
- package/lib/config.d.ts +25 -0
- package/lib/config.js +1 -0
- package/lib/contract-call.d.ts +13 -0
- package/lib/contract-call.js +19 -0
- package/lib/evm/EvmRpcClient.d.ts +25 -0
- package/lib/evm/EvmRpcClient.js +54 -0
- package/lib/evm/config.d.ts +6 -0
- package/lib/evm/config.js +6 -0
- package/lib/evm/family.d.ts +3 -0
- package/lib/evm/family.js +21 -0
- package/lib/evm/index.d.ts +6 -0
- package/lib/evm/index.js +6 -0
- package/lib/evm/presentation.d.ts +4 -0
- package/lib/evm/presentation.js +21 -0
- package/lib/evm/providers.d.ts +3 -0
- package/lib/evm/providers.js +9 -0
- package/lib/evm/signer.d.ts +43 -0
- package/lib/evm/signer.js +91 -0
- package/lib/families.d.ts +4 -0
- package/lib/families.js +11 -0
- package/lib/family.d.ts +16 -0
- package/lib/family.js +1 -0
- package/lib/hex.d.ts +4 -0
- package/lib/hex.js +33 -0
- package/lib/identifiers.d.ts +14 -0
- package/lib/identifiers.js +22 -0
- package/lib/index.d.ts +48 -0
- package/lib/index.js +55 -0
- package/lib/lock-event.d.ts +28 -0
- package/lib/lock-event.js +48 -0
- package/lib/source-chain.d.ts +29 -0
- package/lib/source-chain.js +1 -0
- package/lib/tron/TronRpcClient.d.ts +39 -0
- package/lib/tron/TronRpcClient.js +100 -0
- package/lib/tron/config.d.ts +11 -0
- package/lib/tron/config.js +31 -0
- package/lib/tron/family.d.ts +4 -0
- package/lib/tron/family.js +18 -0
- package/lib/tron/presentation.d.ts +10 -0
- package/lib/tron/presentation.js +18 -0
- package/lib/tron/providers.d.ts +46 -0
- package/lib/tron/providers.js +32 -0
- package/lib/tron/signer.d.ts +166 -0
- package/lib/tron/signer.js +238 -0
- package/lib/value.d.ts +23 -0
- package/lib/value.js +58 -0
- package/lib/wallet/allowance.d.ts +20 -0
- package/lib/wallet/allowance.js +27 -0
- package/lib/wallet/backing.d.ts +2 -0
- package/lib/wallet/backing.js +15 -0
- package/lib/wallet/facade.d.ts +97 -0
- package/lib/wallet/facade.js +100 -0
- package/lib/wallet/finality.d.ts +8 -0
- package/lib/wallet/finality.js +28 -0
- package/lib/wallet/index.d.ts +25 -0
- package/lib/wallet/index.js +25 -0
- package/lib/wallet/manifest.d.ts +69 -0
- package/lib/wallet/manifest.js +81 -0
- package/lib/wallet/manifests.d.ts +31 -0
- package/lib/wallet/manifests.js +70 -0
- package/lib/wallet/payout.d.ts +5 -0
- package/lib/wallet/payout.js +14 -0
- package/lib/wallet/registry.d.ts +40 -0
- package/lib/wallet/registry.js +34 -0
- package/lib/wallet/return-client.d.ts +91 -0
- package/lib/wallet/return-client.js +100 -0
- package/lib/wallet/self-mint-verifier.d.ts +10 -0
- package/lib/wallet/self-mint-verifier.js +5 -0
- package/lib/wallet/signer.d.ts +18 -0
- package/lib/wallet/signer.js +1 -0
- package/lib/wallet/source-adapter.d.ts +33 -0
- package/lib/wallet/source-adapter.js +99 -0
- package/lib/wallet/token-plugin.d.ts +4 -0
- package/lib/wallet/token-plugin.js +20 -0
- package/manifests/bridges.nile.json +23 -0
- package/package.json +71 -0
package/DEMO.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# Real end-to-end bridge demo — Tron Nile → Unicity
|
|
2
|
+
|
|
3
|
+
This is a **live** demo. It deploys a real lock contract to the **Tron Nile
|
|
4
|
+
testnet**, locks USDT there, mints a **real Unicity token** (testnet2) whose
|
|
5
|
+
mint reason is the on-chain Tron lock, transfers that token to a second owner
|
|
6
|
+
over the Unicity aggregator, and has the receiver re-verify it — which
|
|
7
|
+
re-queries the live Tron node to confirm the lock still backs the token.
|
|
8
|
+
|
|
9
|
+
**Finality model.** The party who locked the asset trusts its own lock, so it
|
|
10
|
+
mints as soon as the lock tx is **in a block** — it does not wait for `K`
|
|
11
|
+
confirmations. The confirmation threshold is a *verifier-side* parameter: an
|
|
12
|
+
independent receiver enforces `K` (default 20) and, if the lock isn't final yet,
|
|
13
|
+
reports "awaiting source finality" and **retries until it is** (see step 5).
|
|
14
|
+
|
|
15
|
+
Nothing here is mocked: every step produces an on-chain transaction or a real
|
|
16
|
+
aggregator commitment you can open in a block explorer.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
Tron Nile (TVM) Unicity testnet2 (aggregator)
|
|
20
|
+
─────────────── ─────────────────────────────
|
|
21
|
+
MockTRC20 (USDT) ──approve──┐
|
|
22
|
+
▼
|
|
23
|
+
UnicityLock.lock(amount, tokenId, recipientCommitment)
|
|
24
|
+
│ emits Lock(nonce, from, amount, tokenId, recipientCommitment)
|
|
25
|
+
│
|
|
26
|
+
└────────────── mint reason (self-contained proof) ──────────────┐
|
|
27
|
+
▼
|
|
28
|
+
MintTransaction(justification) ─┴─► Token
|
|
29
|
+
│ (Token.mint re-checks the
|
|
30
|
+
│ lock against live Nile RPC)
|
|
31
|
+
▼
|
|
32
|
+
transfer ─► new owner
|
|
33
|
+
▼
|
|
34
|
+
receiver: token.verify() ── re-queries Nile ──► OK
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## What you need
|
|
38
|
+
|
|
39
|
+
| Requirement | How to get it |
|
|
40
|
+
|---|---|
|
|
41
|
+
| Node ≥ 22 | `node -v` |
|
|
42
|
+
| A funded **Tron Nile** account | private key (hex) holding test TRX — faucet: https://nileex.io/join/getJoinPage |
|
|
43
|
+
| (optional) TronGrid API key | https://www.trongrid.io — raises rate limits |
|
|
44
|
+
| Network access to Unicity testnet2 | `https://gateway.testnet2.unicity.network` (default, no key) |
|
|
45
|
+
|
|
46
|
+
The Unicity recipient/owner keys are **generated by the demo** (throwaway). The
|
|
47
|
+
USDT is a `MockTRC20` the demo deploys, so you don't need real Nile USDT.
|
|
48
|
+
|
|
49
|
+
## Setup
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cd packages/bridge-plugin
|
|
53
|
+
npm install # or run npm install from the repository root
|
|
54
|
+
|
|
55
|
+
# Required: a Nile-testnet account funded with test TRX.
|
|
56
|
+
export TRON_PRIVATE_KEY=<your-nile-private-key-hex>
|
|
57
|
+
|
|
58
|
+
# Optional overrides (defaults shown):
|
|
59
|
+
export TRON_RPC=https://nile.trongrid.io
|
|
60
|
+
# export TRON_API_KEY=<trongrid-api-key>
|
|
61
|
+
export AMOUNT=1000000 # 1.000000 USDT (6 decimals)
|
|
62
|
+
export CONFIRMATIONS=20 # receiver-side finality threshold (K)
|
|
63
|
+
export MINT_CONFIRMATIONS=0 # minter self-trust: 0 = just needs to be in a block
|
|
64
|
+
# export VERIFY_TIMEOUT_MS=300000 # receiver keeps retrying to finality this long
|
|
65
|
+
# export VERIFY_RETRY_MS=6000 # delay between receiver retries
|
|
66
|
+
export UNICITY_AGGREGATOR=https://gateway.testnet2.unicity.network
|
|
67
|
+
# export UNICITY_API_KEY=<aggregator-api-key> # only if your gateway requires one
|
|
68
|
+
export UNICITY_NETWORK_ID=4
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
State is written to `demo/.demo-state.json` between steps, so you can run each
|
|
72
|
+
command independently and inspect what happened. Delete that file to start over.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Step 1 — Deploy the lock + test USDT to Tron Nile
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npm run e2e deploy
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Deploys `MockTRC20` (the bridged "USDT") and `UnicityLock(asset, admin)`, then
|
|
83
|
+
mints test-USDT to your account. Prints the two contract addresses, the deploy
|
|
84
|
+
tx hashes (open them on https://nile.tronscan.org), and the **deterministic
|
|
85
|
+
bridged-asset identifiers** (`TokenType`, `coinId`) derived from
|
|
86
|
+
`chainId + assetContract`.
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
Deployer: T... (chainId 3448148188)
|
|
90
|
+
Deploying MockTRC20 (USDT stand-in)...
|
|
91
|
+
asset (USDT) = T... https://nile.tronscan.org/#/transaction/...
|
|
92
|
+
Deploying UnicityLock(asset, admin)...
|
|
93
|
+
lock = T... https://nile.tronscan.org/#/transaction/...
|
|
94
|
+
Minting 10000000 test-USDT to deployer...
|
|
95
|
+
✔ deploy complete.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Step 1 alternative — attach to the deployed vault and real Nile USDT
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
TRON_VAULT=TTKKLyhnRRQ7XV5vsRarV8xWWEvF9225mY \
|
|
102
|
+
TRON_USDT=TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf \
|
|
103
|
+
npm run e2e attach
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Skips deployment and records the live Nile vault (`deployments/nile/nile-usdt.json`)
|
|
107
|
+
and the faucet USDT as the target. Your `TRON_PRIVATE_KEY` account must already
|
|
108
|
+
hold Nile USDT (faucet: https://nileex.io/join/getJoinPage) and TRX for fees.
|
|
109
|
+
Everything after (`lock`, `mint`, `transfer`, `verify`) is unchanged, and the
|
|
110
|
+
resulting token is minted against the same vault the wallet manifest points at.
|
|
111
|
+
|
|
112
|
+
## Step 2 — Lock USDT, bound to a specific Unicity token + recipient
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm run e2e lock
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Generates the Unicity recipient key, derives the `tokenId` from a fresh salt,
|
|
119
|
+
and computes `recipientCommitment = SHA256(recipient predicate)`. Then on Tron
|
|
120
|
+
it `approve`s and calls `lock(amount, tokenId, recipientCommitment)`. The
|
|
121
|
+
`Lock` event commits the deposit to **exactly this token id and recipient** — so
|
|
122
|
+
it can fund one token, ownable only by that recipient.
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
Bridge intent:
|
|
126
|
+
Unicity tokenId = ...
|
|
127
|
+
recipientCommitment = ...
|
|
128
|
+
amount = 1000000 (6 decimals)
|
|
129
|
+
approve tx = https://nile.tronscan.org/#/transaction/...
|
|
130
|
+
lock tx = https://nile.tronscan.org/#/transaction/...
|
|
131
|
+
block = ..., logIndex = 0, nonce = 0
|
|
132
|
+
✔ lock complete.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Step 3 — Mint the bridged Unicity token (immediately, no finality wait)
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm run e2e mint
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Builds the mint reason — a self-contained CBOR proof (`tag 1330002`) referencing
|
|
142
|
+
the real Nile lock tx — and mints the token on Unicity testnet2. `Token.mint`
|
|
143
|
+
runs the bridge plugin's verifier at `MINT_CONFIRMATIONS` (default **0**), so it
|
|
144
|
+
confirms the lock **exists, succeeded, and binds this exact token id +
|
|
145
|
+
recipient** — without waiting for finality, because the minter is the locker and
|
|
146
|
+
trusts its own deposit. (Run this right after `lock`; you don't wait a minute.)
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
Submitting mint commitment to https://gateway.testnet2.unicity.network ...
|
|
150
|
+
Waiting for inclusion proof + checking the lock is in a block on Nile...
|
|
151
|
+
✔ mint complete. A real Unicity token now encapsulates the bridged USDT.
|
|
152
|
+
tokenId = ...
|
|
153
|
+
value = 1000000 (coinId ...)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
> Optional: `npm run e2e wait` is a read-only helper that just watches
|
|
157
|
+
> confirmations accrue on Nile. It's not on the critical path.
|
|
158
|
+
|
|
159
|
+
## Step 4 — Transfer the token to a second owner
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
npm run e2e transfer
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Generates a new owner, builds a `TransferTransaction`, certifies it on the
|
|
166
|
+
aggregator, and applies it. This is an ordinary Unicity off-chain transfer of
|
|
167
|
+
the bridged token.
|
|
168
|
+
|
|
169
|
+
## Step 5 — Receiver verifies the received token (enforces finality)
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
npm run e2e verify
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The receiver deserializes the token and calls `token.verify(...)`, which checks
|
|
176
|
+
ownership + history **and re-runs the bridge mint-reason check against the live
|
|
177
|
+
Tron node** at the strict `CONFIRMATIONS` threshold. If you ran `mint`
|
|
178
|
+
immediately, the lock may not be final yet — the receiver then reports
|
|
179
|
+
"awaiting source finality (X < K)" and **retries every few seconds until it is**,
|
|
180
|
+
rather than failing:
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
Receiver verifies from scratch (requires 20 confirmations on Tron)...
|
|
184
|
+
[attempt 1] Insufficient confirmations: 4 < 20 (awaiting source finality).
|
|
185
|
+
not final yet — retrying in 6s ...
|
|
186
|
+
[attempt 2] Insufficient confirmations: 11 < 20 (awaiting source finality).
|
|
187
|
+
not final yet — retrying in 6s ...
|
|
188
|
+
|
|
189
|
+
ownership + history (Unicity) : OK
|
|
190
|
+
mint reason (re-checked vs Nile): OK
|
|
191
|
+
✔ RECEIVED TOKEN VERIFIED.
|
|
192
|
+
The token is genuinely backed by 1000000 locked USDT on Tron Nile,
|
|
193
|
+
bound to this token id, and now owned by the receiver — no trusted bridge operator.
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Exit codes: `0` verified, `1` genuine rejection (the token does **not** back its
|
|
197
|
+
claimed lock — full trace printed), `2` timed out still awaiting finality (re-run
|
|
198
|
+
later, or lower `CONFIRMATIONS` as a receiver-side trust choice). A real
|
|
199
|
+
rejection (e.g. wrong amount, wrong recipient) is reported as fatal immediately —
|
|
200
|
+
only insufficient-confirmations is treated as transient and retried.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## One-shot
|
|
205
|
+
|
|
206
|
+
To run the whole flow end to end (with the finality wait built in):
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
TRON_PRIVATE_KEY=<hex> ./demo/run-demo.sh
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Inspect / reset
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
npm run e2e status # what's been done so far
|
|
216
|
+
cat demo/.demo-state.json # addresses, tx hashes, tokenId, serialized token
|
|
217
|
+
rm demo/.demo-state.json # start over
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## How this maps to the design
|
|
221
|
+
|
|
222
|
+
- **Mint reason format & verification rule:** [`../../docs/spec/MINT_REASON.md`](../../docs/spec/MINT_REASON.md)
|
|
223
|
+
- **Lock contract:** [`../../contracts/tron/contracts/UnicityLock.sol`](../../contracts/tron/contracts/UnicityLock.sol)
|
|
224
|
+
- **Verifier (the security):** [`src/TronUsdtMintJustificationVerifier.ts`](src/TronUsdtMintJustificationVerifier.ts)
|
|
225
|
+
- **Offline security demo (mock RPC, attack matrix):** `npm run cli demo`
|
|
226
|
+
|
|
227
|
+
## Bridge-BACK demo (return path, live testnet2)
|
|
228
|
+
|
|
229
|
+
`npm run e2e:back` runs the return path against the **live** Unicity aggregator
|
|
230
|
+
(only the Tron lock is mocked in-process): it mints a **bridge-lock-genesis**
|
|
231
|
+
token (real `TronUsdtLockJustification`, structurally verified at mint), burns it
|
|
232
|
+
to a canonical `BridgeBackReason` — terminal recipient predicate
|
|
233
|
+
`BurnPredicate(H(reasonBytes))` with `reasonBytes` in the burn's auxiliary data
|
|
234
|
+
(00 §4) — certifies the burn on the aggregator, then derives the `nullifier` +
|
|
235
|
+
return leaf from the **real** certified burn (state id + tx hash) exactly as the
|
|
236
|
+
prover will (00 §5/§7). It writes the burned-token blob + prover config +
|
|
237
|
+
witness-request envelope to `demo/.bridge-back-state.json`. Reads the repo-root
|
|
238
|
+
`.env` (`UNICITY_GATEWAY`, `UNICITY_API_KEY`, `UNICITY_TRUSTBASE`).
|
|
239
|
+
Source: [`demo/bridge-back-e2e.ts`](demo/bridge-back-e2e.ts).
|
|
240
|
+
|
|
241
|
+
**Cross-stack closure.** The Rust prover then decodes that exact live blob and
|
|
242
|
+
confirms it derives identical values (E3 `bridge_lock_obligation` lockDigest +
|
|
243
|
+
nonce, `configHash`, burn state id/tx hash, `burnTransitionId`, `nullifier`):
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
cd ../prover && cargo run -p bridge-return-host --example cross_check_live -- \
|
|
247
|
+
../packages/bridge-plugin/demo/.bridge-back-state.json
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
> Interop note: the token's value payload is the **wallet's format**
|
|
251
|
+
> (`SpherePaymentData`, CBOR tag 39050; `protocol/interop.md` §2.1), written and
|
|
252
|
+
> read by `encodeBridgePaymentData` / `decodeBridgePaymentData` in `src/value.ts`.
|
|
253
|
+
> The bespoke `encodeBridgedValue` there is CLI-only. The Rust prover's decoder
|
|
254
|
+
> follows in the SDK-v3 port (`docs/dev-plan/09-testnet-e2e.md`, Phase 2); until
|
|
255
|
+
> then `cross_check_live` does not accept tokens minted by this demo.
|
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# @unicitylabs/bridge-plugin
|
|
2
|
+
|
|
3
|
+
Unicity bridge plugin that validates **bridged USDT-on-Tron** tokens. It is an
|
|
4
|
+
`IMintJustificationVerifier` for the Unicity state-transition SDK: every
|
|
5
|
+
recipient of a bridged token re-checks the token's **mint reason** (a
|
|
6
|
+
self-contained Tron lock proof) against a Tron RPC node. There is no trusted
|
|
7
|
+
bridge operator.
|
|
8
|
+
|
|
9
|
+
See the design docs:
|
|
10
|
+
- [`../../docs/spec/MINT_REASON.md`](../../docs/spec/MINT_REASON.md) — proof format + verification rule
|
|
11
|
+
- [`../../docs/spec/PLUGIN_ARCHITECTURE.md`](../../docs/spec/PLUGIN_ARCHITECTURE.md) — how plugins plug in
|
|
12
|
+
- [`../../docs/spec/ZK_BACK3.md`](../../docs/spec/ZK_BACK3.md) — returning to Tron
|
|
13
|
+
|
|
14
|
+
## How security works
|
|
15
|
+
|
|
16
|
+
Minting on Unicity is permissionless (the minter key is derived from the
|
|
17
|
+
`tokenId`), so all bridge security comes from this verifier. The Tron
|
|
18
|
+
`UnicityLock` contract commits each deposit to the exact Unicity `tokenId` and to
|
|
19
|
+
`recipientCommitment = SHA256(recipient predicate)`. A deposit can therefore fund
|
|
20
|
+
exactly one token, owned only by the designated recipient:
|
|
21
|
+
|
|
22
|
+
| Attack | Rejected because |
|
|
23
|
+
|---|---|
|
|
24
|
+
| Mint without a real lock | RPC finds no matching Lock event |
|
|
25
|
+
| Replay a lock for a second token | event.unicityTokenId ≠ this token's id |
|
|
26
|
+
| Inflate value vs. locked amount | token value ≠ event amount |
|
|
27
|
+
| Steal/front-run a lock | event.recipientCommitment ≠ H(recipient) |
|
|
28
|
+
| Point at a rogue lock contract | trust-anchor (chain/contract/asset) mismatch |
|
|
29
|
+
| Use an unconfirmed lock | confirmations < threshold |
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { createTronUsdtBridgePlugin, tronMainnetUsdtConfig } from '@unicitylabs/bridge-plugin';
|
|
35
|
+
import { MintJustificationVerifierService } from '@unicitylabs/state-transition-sdk/lib/transaction/verification/MintJustificationVerifierService.js';
|
|
36
|
+
|
|
37
|
+
const plugin = createTronUsdtBridgePlugin(
|
|
38
|
+
tronMainnetUsdtConfig('TYourDeployedUnicityLockAddress'),
|
|
39
|
+
// { rpc, extractAmount } // optional: inject a TronRpc and a value extractor
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const service = new MintJustificationVerifierService();
|
|
43
|
+
service.register(plugin.verifier); // dispatched by CBOR tag 1330002
|
|
44
|
+
|
|
45
|
+
// plugin.tokenTypeHex / plugin.coinIdHex identify the bridged asset.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
In a Sphere wallet the plugin is registered through the SDK's generic token-plugin
|
|
49
|
+
seam, `Sphere.init({ plugins: [bridgeTokenPlugin(loaded)] })` (see
|
|
50
|
+
`src/wallet/token-plugin.ts`); the wallet SDK has no bridge-specific code. The
|
|
51
|
+
bridge-in mint and the bridge-out burn are composed over the wallet's generic
|
|
52
|
+
`mintCustom` / `burn` by `@unicitylabs/bridge-core` (`mintBridgedToken`,
|
|
53
|
+
`burnForReturn`, `recoverPendingBurns`). The token's declared value is checked
|
|
54
|
+
by `decodeBridgePaymentData` (the wallet's value format, `src/value.ts`).
|
|
55
|
+
|
|
56
|
+
## CLI
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm run cli demo # offline security demo (mock Tron RPC)
|
|
60
|
+
# or after build:
|
|
61
|
+
node lib/cli/main.js demo
|
|
62
|
+
|
|
63
|
+
# verify a real serialized CertifiedMintTransaction against a live node:
|
|
64
|
+
node lib/cli/main.js verify --token <hex> --lock <addr> --asset <addr> \
|
|
65
|
+
--chain mainnet --rpc https://api.trongrid.io [--api-key <key>]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The `demo` exits non-zero if the valid token is rejected or any attack is
|
|
69
|
+
accepted. Sample output:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
✔ [OK ] Valid bridged mint (lock finalized, bound to this token+recipient)
|
|
73
|
+
✔ [FAIL] Attack: inflate token value above locked amount
|
|
74
|
+
✔ [FAIL] Attack: tamper justification amount
|
|
75
|
+
✔ [FAIL] Attack: replay lock for a different token id
|
|
76
|
+
✔ [FAIL] Attack: steal lock by swapping recipient
|
|
77
|
+
✔ [FAIL] Attack: forged lock contract emits the event
|
|
78
|
+
✔ [FAIL] Attack: spend lock before finality (insufficient confirmations)
|
|
79
|
+
All checks behaved as expected: valid token accepted, every attack rejected.
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Develop
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npm install # from the repository root; links local workspaces
|
|
86
|
+
npm run typecheck
|
|
87
|
+
npm test # node:test via tsx
|
|
88
|
+
npm run build
|
|
89
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CBOR tag of the bridge lock justification, the same on every source chain:
|
|
3
|
+
* the chain id inside the justification tells the families apart, and the
|
|
4
|
+
* wallet's verifier for this tag dispatches on it.
|
|
5
|
+
*/
|
|
6
|
+
export declare const BRIDGE_LOCK_JUSTIFICATION_TAG = 1330002n;
|
|
7
|
+
/** Decoded contents of a lock justification (the token's mint reason). */
|
|
8
|
+
export interface BridgeLockJustificationData {
|
|
9
|
+
/** Source chain id (Tron mainnet 728126428, Nile 3448148188, Sepolia 11155111). */
|
|
10
|
+
readonly chainId: number;
|
|
11
|
+
/** 20-byte address of the canonical vault (lock) contract. */
|
|
12
|
+
readonly lockContract: Uint8Array;
|
|
13
|
+
/** 20-byte address of the bridged asset's token contract. */
|
|
14
|
+
readonly assetContract: Uint8Array;
|
|
15
|
+
/** 32-byte transaction hash of the lock() call. */
|
|
16
|
+
readonly txid: Uint8Array;
|
|
17
|
+
/** Index of the Lock event within that transaction's logs. */
|
|
18
|
+
readonly logIndex: number;
|
|
19
|
+
/** Locked amount in the asset's smallest unit. */
|
|
20
|
+
readonly amount: bigint;
|
|
21
|
+
/** Lock nonce assigned by the contract (echoed in the Lock event). */
|
|
22
|
+
readonly nonce: bigint;
|
|
23
|
+
}
|
|
24
|
+
/** Encodes/decodes the self-contained lock proof carried in a token's mint reason. */
|
|
25
|
+
export declare class BridgeLockJustification {
|
|
26
|
+
readonly data: BridgeLockJustificationData;
|
|
27
|
+
static readonly CBOR_TAG = 1330002n;
|
|
28
|
+
constructor(data: BridgeLockJustificationData);
|
|
29
|
+
toCBOR(): Uint8Array;
|
|
30
|
+
static fromCBOR(bytes: Uint8Array): BridgeLockJustification;
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { CborDeserializer } from '@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborDeserializer.js';
|
|
2
|
+
import { CborSerializer } from '@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborSerializer.js';
|
|
3
|
+
/**
|
|
4
|
+
* CBOR tag of the bridge lock justification, the same on every source chain:
|
|
5
|
+
* the chain id inside the justification tells the families apart, and the
|
|
6
|
+
* wallet's verifier for this tag dispatches on it.
|
|
7
|
+
*/
|
|
8
|
+
export const BRIDGE_LOCK_JUSTIFICATION_TAG = 1330002n;
|
|
9
|
+
const VERSION = 1n;
|
|
10
|
+
/** Encodes/decodes the self-contained lock proof carried in a token's mint reason. */
|
|
11
|
+
export class BridgeLockJustification {
|
|
12
|
+
data;
|
|
13
|
+
static CBOR_TAG = BRIDGE_LOCK_JUSTIFICATION_TAG;
|
|
14
|
+
constructor(data) {
|
|
15
|
+
this.data = data;
|
|
16
|
+
}
|
|
17
|
+
toCBOR() {
|
|
18
|
+
const d = this.data;
|
|
19
|
+
return CborSerializer.encodeTag(BridgeLockJustification.CBOR_TAG, CborSerializer.encodeArray(CborSerializer.encodeUnsignedInteger(VERSION), CborSerializer.encodeUnsignedInteger(d.chainId), CborSerializer.encodeByteString(d.lockContract), CborSerializer.encodeByteString(d.assetContract), CborSerializer.encodeByteString(d.txid), CborSerializer.encodeUnsignedInteger(d.logIndex), CborSerializer.encodeUnsignedInteger(d.amount), CborSerializer.encodeUnsignedInteger(d.nonce)));
|
|
20
|
+
}
|
|
21
|
+
static fromCBOR(bytes) {
|
|
22
|
+
const tag = CborDeserializer.decodeTag(bytes);
|
|
23
|
+
if (tag.tag !== BridgeLockJustification.CBOR_TAG) {
|
|
24
|
+
throw new Error(`Invalid CBOR tag for BridgeLockJustification: ${tag.tag}`);
|
|
25
|
+
}
|
|
26
|
+
const items = CborDeserializer.decodeArray(tag.data, 8);
|
|
27
|
+
const version = CborDeserializer.decodeUnsignedInteger(items[0]);
|
|
28
|
+
if (version !== VERSION) {
|
|
29
|
+
throw new Error(`Unsupported BridgeLockJustification version: ${version}`);
|
|
30
|
+
}
|
|
31
|
+
return new BridgeLockJustification({
|
|
32
|
+
chainId: Number(CborDeserializer.decodeUnsignedInteger(items[1])),
|
|
33
|
+
lockContract: CborDeserializer.decodeByteString(items[2]),
|
|
34
|
+
assetContract: CborDeserializer.decodeByteString(items[3]),
|
|
35
|
+
txid: CborDeserializer.decodeByteString(items[4]),
|
|
36
|
+
logIndex: Number(CborDeserializer.decodeUnsignedInteger(items[5])),
|
|
37
|
+
amount: CborDeserializer.decodeUnsignedInteger(items[6]),
|
|
38
|
+
nonce: CborDeserializer.decodeUnsignedInteger(items[7]),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CertifiedMintTransaction } from '@unicitylabs/state-transition-sdk/lib/transaction/CertifiedMintTransaction.js';
|
|
2
|
+
import type { Token } from '@unicitylabs/state-transition-sdk/lib/transaction/Token.js';
|
|
3
|
+
import type { IMintJustificationVerifier } from '@unicitylabs/state-transition-sdk/lib/transaction/verification/IMintJustificationVerifier.js';
|
|
4
|
+
import { VerificationResult } from '@unicitylabs/state-transition-sdk/lib/verification/VerificationResult.js';
|
|
5
|
+
import { VerificationStatus } from '@unicitylabs/state-transition-sdk/lib/verification/VerificationStatus.js';
|
|
6
|
+
import type { LockMintJustificationVerifier } from './LockMintJustificationVerifier.js';
|
|
7
|
+
export declare class BridgeMintJustificationVerifier implements IMintJustificationVerifier {
|
|
8
|
+
private readonly verifiers;
|
|
9
|
+
constructor(verifiers: readonly LockMintJustificationVerifier[]);
|
|
10
|
+
get tag(): bigint;
|
|
11
|
+
verify(transaction: CertifiedMintTransaction, nestedTokenCollector: (token: Token) => void): Promise<VerificationResult<VerificationStatus>>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { VerificationResult } from '@unicitylabs/state-transition-sdk/lib/verification/VerificationResult.js';
|
|
2
|
+
import { VerificationStatus } from '@unicitylabs/state-transition-sdk/lib/verification/VerificationStatus.js';
|
|
3
|
+
import { BridgeLockJustification, BRIDGE_LOCK_JUSTIFICATION_TAG } from './BridgeLockJustification.js';
|
|
4
|
+
import { toHex } from './hex.js';
|
|
5
|
+
const RULE = 'BridgeMintJustificationVerifier';
|
|
6
|
+
export class BridgeMintJustificationVerifier {
|
|
7
|
+
verifiers;
|
|
8
|
+
constructor(verifiers) {
|
|
9
|
+
this.verifiers = verifiers;
|
|
10
|
+
}
|
|
11
|
+
get tag() {
|
|
12
|
+
return BRIDGE_LOCK_JUSTIFICATION_TAG;
|
|
13
|
+
}
|
|
14
|
+
verify(transaction, nestedTokenCollector) {
|
|
15
|
+
const bytes = transaction.justification;
|
|
16
|
+
if (!bytes) {
|
|
17
|
+
return fail('Transaction has no justification.');
|
|
18
|
+
}
|
|
19
|
+
let j;
|
|
20
|
+
try {
|
|
21
|
+
j = BridgeLockJustification.fromCBOR(bytes);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
return fail(`Malformed justification: ${e.message}`);
|
|
25
|
+
}
|
|
26
|
+
const owner = this.verifiers.find((v) => v.accepts(j.data));
|
|
27
|
+
if (!owner) {
|
|
28
|
+
return fail(`No bridge verifies chain ${j.data.chainId} vault 0x${toHex(j.data.lockContract)}.`);
|
|
29
|
+
}
|
|
30
|
+
return owner.verify(transaction, nestedTokenCollector);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function fail(message) {
|
|
34
|
+
return Promise.resolve(new VerificationResult(RULE, VerificationStatus.FAIL, message));
|
|
35
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CertifiedMintTransaction } from '@unicitylabs/state-transition-sdk/lib/transaction/CertifiedMintTransaction.js';
|
|
2
|
+
import type { Token } from '@unicitylabs/state-transition-sdk/lib/transaction/Token.js';
|
|
3
|
+
import type { IMintJustificationVerifier } from '@unicitylabs/state-transition-sdk/lib/transaction/verification/IMintJustificationVerifier.js';
|
|
4
|
+
import { VerificationResult } from '@unicitylabs/state-transition-sdk/lib/verification/VerificationResult.js';
|
|
5
|
+
import { VerificationStatus } from '@unicitylabs/state-transition-sdk/lib/verification/VerificationStatus.js';
|
|
6
|
+
import type { ChainFamily } from '@unicitylabs/bridge-core';
|
|
7
|
+
import { type BridgeLockJustificationData } from './BridgeLockJustification.js';
|
|
8
|
+
import type { SourceChainRpc } from './source-chain.js';
|
|
9
|
+
import { type BridgedAmountExtractor } from './value.js';
|
|
10
|
+
/** Trust anchors + identifiers a verifier instance is bound to. */
|
|
11
|
+
export interface ResolvedBridgeConfig {
|
|
12
|
+
readonly family: ChainFamily;
|
|
13
|
+
readonly chainId: number;
|
|
14
|
+
/** 20-byte EVM-form address, lowercase hex. */
|
|
15
|
+
readonly lockContractHex: string;
|
|
16
|
+
/** 20-byte EVM-form address, lowercase hex. */
|
|
17
|
+
readonly assetContractHex: string;
|
|
18
|
+
readonly confirmations: number;
|
|
19
|
+
/** 32-byte Unicity TokenType for this asset. */
|
|
20
|
+
readonly tokenType: Uint8Array;
|
|
21
|
+
/** 32-byte Sphere coinId for this asset. */
|
|
22
|
+
readonly coinId: Uint8Array;
|
|
23
|
+
}
|
|
24
|
+
export interface LockVerifierDeps {
|
|
25
|
+
readonly rpc: SourceChainRpc;
|
|
26
|
+
/** Defaults to the simple CLI value envelope decoder. */
|
|
27
|
+
readonly extractAmount?: BridgedAmountExtractor;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Validates a bridged token by re-checking its mint reason against a node of
|
|
31
|
+
* the source chain: the lock exists, is final, has the right amount, and
|
|
32
|
+
* commits to exactly this token's id + recipient. See docs/spec/MINT_REASON.md.
|
|
33
|
+
*/
|
|
34
|
+
export declare class LockMintJustificationVerifier implements IMintJustificationVerifier {
|
|
35
|
+
private readonly config;
|
|
36
|
+
private readonly rpc;
|
|
37
|
+
private readonly extractAmount;
|
|
38
|
+
constructor(config: ResolvedBridgeConfig, deps: LockVerifierDeps);
|
|
39
|
+
get tag(): bigint;
|
|
40
|
+
accepts(j: BridgeLockJustificationData): boolean;
|
|
41
|
+
verify(transaction: CertifiedMintTransaction, _nestedTokenCollector: (token: Token) => void): Promise<VerificationResult<VerificationStatus>>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { VerificationResult } from '@unicitylabs/state-transition-sdk/lib/verification/VerificationResult.js';
|
|
2
|
+
import { VerificationStatus } from '@unicitylabs/state-transition-sdk/lib/verification/VerificationStatus.js';
|
|
3
|
+
import { bytesEqual, toHex } from './hex.js';
|
|
4
|
+
import { recipientCommitment } from './identifiers.js';
|
|
5
|
+
import { decodeLockEvent } from './lock-event.js';
|
|
6
|
+
import { BridgeLockJustification, BRIDGE_LOCK_JUSTIFICATION_TAG } from './BridgeLockJustification.js';
|
|
7
|
+
import { decodeBridgePaymentData } from './value.js';
|
|
8
|
+
const RULE = 'LockMintJustificationVerifier';
|
|
9
|
+
function fail(message) {
|
|
10
|
+
return new VerificationResult(RULE, VerificationStatus.FAIL, message);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Validates a bridged token by re-checking its mint reason against a node of
|
|
14
|
+
* the source chain: the lock exists, is final, has the right amount, and
|
|
15
|
+
* commits to exactly this token's id + recipient. See docs/spec/MINT_REASON.md.
|
|
16
|
+
*/
|
|
17
|
+
export class LockMintJustificationVerifier {
|
|
18
|
+
config;
|
|
19
|
+
rpc;
|
|
20
|
+
extractAmount;
|
|
21
|
+
constructor(config, deps) {
|
|
22
|
+
this.config = config;
|
|
23
|
+
this.rpc = deps.rpc;
|
|
24
|
+
this.extractAmount = deps.extractAmount ?? decodeBridgePaymentData;
|
|
25
|
+
}
|
|
26
|
+
get tag() {
|
|
27
|
+
return BRIDGE_LOCK_JUSTIFICATION_TAG;
|
|
28
|
+
}
|
|
29
|
+
accepts(j) {
|
|
30
|
+
return j.chainId === this.config.chainId && toHex(j.lockContract).toLowerCase() === this.config.lockContractHex;
|
|
31
|
+
}
|
|
32
|
+
async verify(transaction, _nestedTokenCollector) {
|
|
33
|
+
const bytes = transaction.justification;
|
|
34
|
+
if (!bytes) {
|
|
35
|
+
return fail('Transaction has no justification.');
|
|
36
|
+
}
|
|
37
|
+
let justification;
|
|
38
|
+
try {
|
|
39
|
+
justification = BridgeLockJustification.fromCBOR(bytes);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
return fail(`Malformed justification: ${e.message}`);
|
|
43
|
+
}
|
|
44
|
+
const j = justification.data;
|
|
45
|
+
// 1. Trust anchors: which chain/contract/asset is authoritative.
|
|
46
|
+
if (j.chainId !== this.config.chainId) {
|
|
47
|
+
return fail(`Chain id mismatch: proof ${j.chainId}, expected ${this.config.chainId}.`);
|
|
48
|
+
}
|
|
49
|
+
if (toHex(j.lockContract).toLowerCase() !== this.config.lockContractHex) {
|
|
50
|
+
return fail('Lock contract is not the canonical bridge contract.');
|
|
51
|
+
}
|
|
52
|
+
if (toHex(j.assetContract).toLowerCase() !== this.config.assetContractHex) {
|
|
53
|
+
return fail('Asset contract is not the canonical bridged asset.');
|
|
54
|
+
}
|
|
55
|
+
// 2. Token type must be this asset's type.
|
|
56
|
+
if (!bytesEqual(transaction.tokenType.bytes, this.config.tokenType)) {
|
|
57
|
+
return fail('Token type does not match this bridged asset.');
|
|
58
|
+
}
|
|
59
|
+
// 3-4. Fetch the lock tx and require success + finality.
|
|
60
|
+
const txInfo = await this.rpc.getTransactionInfo(toHex(j.txid));
|
|
61
|
+
if (!txInfo) {
|
|
62
|
+
return fail(`Lock transaction not found: ${toHex(j.txid)}.`);
|
|
63
|
+
}
|
|
64
|
+
if (!txInfo.success) {
|
|
65
|
+
return fail('Lock transaction did not succeed.');
|
|
66
|
+
}
|
|
67
|
+
const tip = await this.rpc.getNowBlockNumber();
|
|
68
|
+
const confirmations = tip - txInfo.blockNumber;
|
|
69
|
+
if (confirmations < BigInt(this.config.confirmations)) {
|
|
70
|
+
return fail(`Insufficient confirmations: ${confirmations} < ${this.config.confirmations} (awaiting source finality).`);
|
|
71
|
+
}
|
|
72
|
+
// 5. Locate the Lock event and confirm it came from the canonical contract.
|
|
73
|
+
const log = txInfo.logs[j.logIndex];
|
|
74
|
+
if (!log) {
|
|
75
|
+
return fail(`No log at index ${j.logIndex}.`);
|
|
76
|
+
}
|
|
77
|
+
if (log.address.toLowerCase() !== this.config.lockContractHex) {
|
|
78
|
+
return fail('Log was not emitted by the canonical lock contract.');
|
|
79
|
+
}
|
|
80
|
+
const event = decodeLockEvent(log);
|
|
81
|
+
if (!event) {
|
|
82
|
+
return fail(`Log at index ${j.logIndex} is not a Lock event.`);
|
|
83
|
+
}
|
|
84
|
+
// 6. Amount: event == justification == token's declared value.
|
|
85
|
+
if (event.amount !== j.amount) {
|
|
86
|
+
return fail(`Amount mismatch: event ${event.amount}, justification ${j.amount}.`);
|
|
87
|
+
}
|
|
88
|
+
const declared = this.extractAmount(transaction.data, this.config.coinId);
|
|
89
|
+
if (declared == null) {
|
|
90
|
+
return fail('Token declares no bridged-asset value.');
|
|
91
|
+
}
|
|
92
|
+
if (declared !== event.amount) {
|
|
93
|
+
return fail(`Token value ${declared} does not match locked amount ${event.amount}.`);
|
|
94
|
+
}
|
|
95
|
+
// 7. Binding: the lock commits to exactly this token id, recipient and nonce.
|
|
96
|
+
if (event.nonce !== j.nonce) {
|
|
97
|
+
return fail(`Nonce mismatch: event ${event.nonce}, justification ${j.nonce}.`);
|
|
98
|
+
}
|
|
99
|
+
if (!bytesEqual(event.unicityTokenId, transaction.tokenId.bytes)) {
|
|
100
|
+
return fail('Lock is bound to a different token id (replay/forgery).');
|
|
101
|
+
}
|
|
102
|
+
const expectedRecipient = recipientCommitment(transaction.recipient.toCBOR());
|
|
103
|
+
if (!bytesEqual(event.recipientCommitment, expectedRecipient)) {
|
|
104
|
+
return fail('Lock is bound to a different recipient (theft/front-run).');
|
|
105
|
+
}
|
|
106
|
+
return new VerificationResult(RULE, VerificationStatus.OK);
|
|
107
|
+
}
|
|
108
|
+
}
|
package/lib/address.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Tron mainnet address prefix byte (0x41). */
|
|
2
|
+
export declare const TRON_ADDRESS_PREFIX = 65;
|
|
3
|
+
/**
|
|
4
|
+
* Normalize any source-chain address form to the 20-byte EVM-style address as
|
|
5
|
+
* lowercase hex (no `0x`, no `41` prefix), the form event logs use for `address`
|
|
6
|
+
* on both families.
|
|
7
|
+
*
|
|
8
|
+
* Accepts:
|
|
9
|
+
* - Tron base58check `T...` addresses,
|
|
10
|
+
* - Tron `41`-prefixed 21-byte hex (TronWeb `toHex()` form),
|
|
11
|
+
* - bare 20-byte hex, optionally `0x`-prefixed (Ethereum, or either family's raw form).
|
|
12
|
+
*/
|
|
13
|
+
export declare function toEvmAddressHex(address: string): string;
|