Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,14 @@ pnpm build
pnpm dev:web
```

Adapter packages (`adapters-qvac`, `adapters-wdk`, `adapters-p2p`) currently
contain typed ports and stubs marked `TODO(sdk-integration)` — see each
package's README for the exact SDK calls to wire in per the official docs.
`adapters-wdk` is wired to Tether WDK for Tron USDT (`prepare` quotes, `commit` signs and broadcasts). Adapter packages `adapters-qvac` and `adapters-p2p` still contain typed ports and stubs marked `TODO(sdk-integration)`. See each remaining package README for the SDK calls to wire in.

## Disclosed external services / third-party components

*(Required disclosure per hackathon rules — keep this list current.)*

- `@qvac/sdk` — Tether QVAC SDK, on-device inference (scam detection, TranslatePsy, VisionPsy).
- WDK — self-custodial wallet toolkit, local signing.
- `@tetherto/wdk-wallet-tron` `1.0.0-beta.13`: Tether WDK Tron wallet module. Self-custodial USDT TRC-20 transfers. Local signing. Tron RPC is used only to quote and broadcast, never for inference.
- Hyperswarm (Pears Stack) — P2P discovery/sync for the risk-address list, stretch goal.
- No cloud inference APIs, no analytics SDKs, no third-party trackers.
- Any additional library added during the hackathon must be listed here before submission.
Expand Down
86 changes: 76 additions & 10 deletions packages/adapters-wdk/README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,82 @@
# @custos/adapters-wdk

Implements `WalletPort` from `@custos/core` over WDK.
Implements `WalletPort` from `@custos/core` over Tether WDK.

**Non-negotiable design constraint:** keep `prepare` (build, unsigned) and
`commit` (sign + broadcast) as two separate calls. The friction/warning screen
in `apps/web` must be able to run — and let the user cancel — strictly
between them. If WDK's ergonomic API is a single `send(...)`, wrap it as two
steps here rather than exposing that shortcut through `WalletPort`.
## MVP network: Tron

Hackathon MVP sends USDT on **Tron (TRC-20)** only.

- Most USDT scam volume the product is built to intercept is on Tron.
- `apps/web` currently constructs destinations as `network: "tron"`.
- Ethereum intents are rejected by the adapter. They are not coerced onto Tron.

Default token contract: `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` (mainnet USDT). Pass `usdtTokenAddress` for Nile/Shasta test tokens.

## prepare / commit split

WDK's ergonomic TRC-20 API is a single `account.transfer(...)` that signs and broadcasts. This adapter wraps that shortcut so the friction screen can sit between the two `WalletPort` calls:

| `WalletPort` | WDK call | Signs? | Broadcasts? |
|---|---|---|---|
| `prepare` | `quoteTransfer` | no | no |
| `commit` | `transfer` | yes | yes |

`prepare` copies token, recipient, and base-unit amount into an internal pending map. `commit` spends that snapshot, not a later mutation of `SendIntent`. A prepared transfer can be abandoned by never calling `commit`. Committing twice throws.

Do not collapse this into one `send()` on `WalletPort`.

## Wiring the real SDK

Consult the WDK docs for the exact transaction-building and signing API for a
USDT transfer (network: Tron or Ethereum — pick one for the hackathon MVP,
document the choice here once made). Replace the `TODO(sdk-integration)` /
`not implemented` bodies in `WdkWalletAdapter.ts`.
`createTronWdkAccount` is the only function that imports `@tetherto/wdk-wallet-tron`. Seed and RPC URL are required arguments so this package never embeds a key or silently targets mainnet.

```ts
import { WdkWalletAdapter, createTronWdkAccount } from "@custos/adapters-wdk";

const { account, dispose } = await createTronWdkAccount({
seed: process.env.CUSTOS_WDK_SEED!,
provider: process.env.CUSTOS_TRON_RPC!, // e.g. https://api.shasta.trongrid.io
});

const wallet = new WdkWalletAdapter({
account,
// usdtTokenAddress: "<shasta-or-nile-usdt>",
});

const prepared = await wallet.prepare(intent);
// risk UI runs here; user may cancel
const { txHash } = await wallet.commit(prepared);

dispose();
```

Unit tests inject a fake `WdkTransferClient`. They do not load WDK, hold a seed, or talk to a node.

## Manual testnet send

This is the integration check for issue #5. Do not commit a seed.

1. Create a throwaway BIP-39 phrase. Fund it on [Shasta](https://www.trongrid.io/shasta) (or Nile) with TRX for fees and a test TRC-20 that you treat as USDT.
2. From a scratch Node script in this package (not checked in):

```ts
import { WdkWalletAdapter, createTronWdkAccount } from "@custos/adapters-wdk";

const { account, dispose } = await createTronWdkAccount({
seed: process.env.CUSTOS_WDK_SEED!,
provider: "https://api.shasta.trongrid.io",
});
const wallet = new WdkWalletAdapter({
account,
usdtTokenAddress: process.env.CUSTOS_TEST_USDT!,
});
const prepared = await wallet.prepare({
destination: { value: process.env.CUSTOS_TEST_DEST!, network: "tron" },
amountUsdt: 1,
});
console.log("quoted, not signed. abort here to prove cancel works, or commit:");
console.log(await wallet.commit(prepared));
dispose();
```

3. Confirm in a Tron explorer that no transaction exists after `prepare` alone, and that `commit` produces a hash.
4. `apps/web` is not wired to this adapter yet. That is the end-to-end send-flow issue.
9 changes: 6 additions & 3 deletions packages/adapters-wdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@custos/core": "workspace:*"
"@custos/core": "workspace:*",
"@tetherto/wdk-wallet-tron": "1.0.0-beta.13"
},
"devDependencies": {
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"vitest": "^2.1.4"
}
}
116 changes: 105 additions & 11 deletions packages/adapters-wdk/src/WdkWalletAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,114 @@
import type { WalletPort, PreparedTransfer, SendIntent } from "@custos/core";
import type { PreparedTransfer, SendIntent, WalletPort } from "@custos/core";

/** Mainnet USDT TRC-20. Override via `usdtTokenAddress` for testnets. */
export const TRON_USDT_MAINNET = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";

export const USDT_DECIMALS = 6;

const HANDLE_KIND = "custos.wdk.tron.usdt.v1" as const;

/** Tron base58check addresses are 34 chars starting with T, no 0/O/I/l. */
const TRON_ADDRESS_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;

export interface WdkTransferOptions {
readonly token: string;
readonly recipient: string;
readonly amount: bigint;
}

/**
* The slice of a WDK Tron account this adapter needs. Production wires
* `@tetherto/wdk-wallet-tron` through `createTronWdkAccount`. Tests pass a fake
* so CI never talks to a node or holds a seed.
*/
export interface WdkTransferClient {
quoteTransfer(options: WdkTransferOptions): Promise<{ fee: bigint }>;
transfer(options: WdkTransferOptions): Promise<{ hash: string; fee: bigint }>;
}

export interface WdkWalletAdapterOptions {
readonly account: WdkTransferClient;
readonly usdtTokenAddress?: string;
readonly newId?: () => string;
}

interface PendingUsdtTransfer {
readonly token: string;
readonly recipient: string;
readonly amount: bigint;
}

/**
* TODO(sdk-integration): wire the real WDK wallet. The critical property to
* preserve is the split between `prepare` (build, do not sign) and `commit`
* (sign + broadcast) — AnalyzeSendIntent's risk assessment must run in the
* gap between these two calls. See the WDK docs for the exact build/sign API
* and don't collapse this into a single "send" call, even if WDK offers one.
* WalletPort over WDK Tron. `prepare` only quotes (no sign, no broadcast).
* `commit` is the only call that invokes WDK `transfer()`, which signs and
* broadcasts. WDK's one-shot `transfer()` is wrapped, not exposed.
*
* MVP network is Tron. Ethereum intents are rejected rather than coerced.
*/
export class WdkWalletAdapter implements WalletPort {
private readonly account: WdkTransferClient;
private readonly usdtTokenAddress: string;
private readonly newId: () => string;
private readonly pending = new Map<string, PendingUsdtTransfer>();

constructor(options: WdkWalletAdapterOptions) {
this.account = options.account;
this.usdtTokenAddress = options.usdtTokenAddress ?? TRON_USDT_MAINNET;
this.newId = options.newId ?? (() => crypto.randomUUID());
}

async prepare(intent: SendIntent): Promise<PreparedTransfer> {
throw new Error(
`WdkWalletAdapter.prepare not implemented — wire WDK (amount=${intent.amountUsdt} USDT, dest=${intent.destination.value})`,
);
const recipient = intent.destination.value.trim();
if (intent.destination.network !== "tron") {
throw new Error(
`WdkWalletAdapter MVP only supports Tron USDT transfers (got network "${intent.destination.network}")`,
);
}
if (!TRON_ADDRESS_RE.test(recipient)) {
throw new Error(`Destination is not a valid Tron address: ${recipient}`);
}

const amount = usdtToBaseUnits(intent.amountUsdt);
const options: WdkTransferOptions = {
token: this.usdtTokenAddress,
recipient,
amount,
};

await this.account.quoteTransfer(options);

const id = this.newId();
this.pending.set(id, {
token: options.token,
recipient: options.recipient,
amount: options.amount,
});

return { id, intent, handle: { kind: HANDLE_KIND } };
}

async commit(transfer: PreparedTransfer): Promise<{ readonly txHash: string }> {
const handle = transfer.handle as { readonly kind?: unknown } | undefined;
const prepared = this.pending.get(transfer.id);
if (handle?.kind !== HANDLE_KIND || !prepared) {
throw new Error(
"Prepared transfer was not prepared by this adapter or was already committed",
);
}
this.pending.delete(transfer.id);

const result = await this.account.transfer(prepared);
return { txHash: result.hash };
}
}

async commit(_transfer: PreparedTransfer): Promise<{ readonly txHash: string }> {
throw new Error("WdkWalletAdapter.commit not implemented — wire WDK sign+broadcast");
function usdtToBaseUnits(amountUsdt: number): bigint {
if (!Number.isFinite(amountUsdt) || amountUsdt <= 0) {
throw new Error("USDT amount must be a positive finite number");
}
const scaled = Math.round(amountUsdt * 10 ** USDT_DECIMALS);
if (scaled <= 0) {
throw new Error("USDT amount must be a positive finite number");
}
return BigInt(scaled);
}
Loading
Loading