Skip to content
Open
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
27 changes: 27 additions & 0 deletions crypto-payroll/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# --- Dfns API ---
# Authenticated as the SERVICE ACCOUNT that will auto-approve / reject payroll transfers.
# Do NOT use the treasury owner's personal credentials here.
DFNS_API_URL='https://api.dfns.io'
DFNS_ORG_ID='or-your-org-id'
DFNS_CRED_ID='your-service-account-cred-id'
DFNS_PRIVATE_KEY='-----BEGIN PRIVATE KEY-----\nYour Private Key Here\n-----END PRIVATE KEY-----'
DFNS_AUTH_TOKEN='your-service-account-auth-token'

# --- Wallet ---
# Wallet that holds payroll funds and sends USDC transfers.
# Tag this wallet with "payroll" in the Dfns dashboard so the policy targets it.
TREASURY_WALLET_ID='wa-your-treasury-wallet-id'

# --- Policy ---
# User ID listed as human approver for transfers above the auto-approve limit.
# Run `npm run users:list` to find this.
POLICY_USER_ID='us-your-user-id'

# --- Payroll config ---
# Transfers at or below this amount (in USDC) are auto-approved by the service account.
# Transfers above this amount wait for human review via `npm run approvals:list`.
AUTO_APPROVE_LIMIT_USDC=1000

# --- Chain ---
# Sepolia USDC contract address. Change this if you deploy on a different network.
USDC_CONTRACT=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238
3 changes: 3 additions & 0 deletions crypto-payroll/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
.env
206 changes: 206 additions & 0 deletions crypto-payroll/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# Crypto Payroll

Send USDC salaries to employees in bulk with policy-gated approvals — using a [Dfns](https://www.dfns.co) treasury wallet for key management and the Dfns Policy Engine to enforce spending controls.

Every payroll run reads a CSV of employees and amounts, initiates the USDC transfers, and routes them through a policy. A Dfns Service Account (the **checker**) auto-approves transfers within the configured limit. Transfers above the limit sit `Pending` until a human approver accepts or rejects them — giving finance teams a clear maker/checker workflow without building approval infrastructure from scratch.

> **Full tutorial:** [docs.dfns.co/solutions/automate-payments](https://docs.dfns.co/solutions/automate-payments)

## Why Dfns

| Concern | How Dfns covers it |
|---|---|
| **Key security** | The treasury private key is held in Dfns MPC/HSM. No key ever touches your payroll server. |
| **Spending controls** | A `Wallets:Sign` policy intercepts every transfer. The checker auto-approves small amounts; large transfers require human sign-off. |
| **Maker / checker** | The service account that runs payroll cannot approve its own transactions in production (`initiatorCanApprove: false`). |
| **Idempotency** | Each transfer carries an `externalId` derived from the payroll run date and employee address. Re-running the same payroll will not double-pay. |
| **Audit trail** | Every approval decision — who approved, when, with what reason — is recorded by Dfns and queryable via API. |

## Architecture

```
Finance team Dfns Policy Engine Service Account (checker)
| | |
|-- npm run payroll:run ------->| |
| (CSV: 5 employees · USDC) | |
| | |
| |-- approval Pending --------->|
| | (for each transfer) | listApprovals()
| | | amount <= 1 000 USDC?
| |<-- createApprovalDecision ---|
| | Approved / left Pending |
| | |
|<-- transfer broadcasts ------| |
| (small amounts auto-done) | |
| | |
Human approver | |
|-- npm run approvals:approve ->| |
| (for large amounts) | |
|<-- transfer broadcasts ------| |
```

### Two roles

| Role | Identity | Responsibility |
|---|---|---|
| **Maker** | Treasury wallet (`TREASURY_WALLET_ID`) | Holds USDC. Initiates transfers via `wallets.transferAsset`. |
| **Checker** | Service account (credentials in `.env`) | Runs `approvals:auto` to approve or leave transfers for human review. |

### Policy rules

The policy created by `npm run policy:create` triggers on every `Wallets:Sign` operation from the treasury wallet (filtered by the `payroll` tag). The checker enforces two rules:

1. **Contract check** — denies any transfer not targeting the configured USDC contract.
2. **Amount threshold** — auto-approves transfers ≤ `AUTO_APPROVE_LIMIT_USDC`; leaves larger ones `Pending` for human review.

## Tech stack

- **Transfers**: Dfns `wallets.transferAsset` with `kind: Erc20`
- **Policies**: Dfns Policy Engine — `Wallets:Sign` + `AlwaysTrigger` + approval groups
- **Scripts**: TypeScript via `tsx`, `viem` for amount parsing
- **Network**: Ethereum Sepolia (Chain ID `11155111`), USDC at `0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238`

## Quick start

### 1. Prerequisites

- Node.js v22+
- A [Dfns](https://www.dfns.co/) account with:
- A **service account** with `Wallets:TransferAsset` and `Policies:Read` / `Policies:Write` permissions
- A **treasury wallet** on Ethereum Sepolia funded with testnet USDC ([Circle faucet](https://faucet.circle.com/))
- A **user** to act as human approver on the policy

### 2. Install

```bash
cd crypto-payroll
npm install
```

### 3. Configure environment

```bash
cp .env.example .env
```

Fill in your `.env`:

| Variable | Description |
|---|---|
| `DFNS_API_URL` | Dfns API base URL (default: `https://api.dfns.io`) |
| `DFNS_ORG_ID` | Your Dfns organization ID |
| `DFNS_AUTH_TOKEN` | Service account auth token |
| `DFNS_CRED_ID` | Service account credential ID |
| `DFNS_PRIVATE_KEY` | Service account private key (PEM) |
| `TREASURY_WALLET_ID` | Wallet that holds payroll funds — tag it `payroll` in the Dfns dashboard |
| `POLICY_USER_ID` | User ID of the human approver — run `npm run users:list` to find it |
| `AUTO_APPROVE_LIMIT_USDC` | Transfers at or below this amount (in USDC) are auto-approved (default: `1000`) |
| `USDC_CONTRACT` | USDC contract address (default: Sepolia USDC) |

> **Whose credentials go in `.env`?** The service account — the checker. The treasury wallet is identified only by `TREASURY_WALLET_ID`. Tag it `payroll` in the Dfns dashboard so the policy filter picks it up.

### 4. Find your user ID

```bash
npm run users:list
```

Copy the `userId` of the intended human approver into `.env` as `POLICY_USER_ID`.

### 5. Create the policy

```bash
npm run policy:create
```

Creates a `Wallets:Sign` policy that intercepts all transfers from the tagged treasury wallet.

> **Tag the treasury wallet.** In the Dfns dashboard, add the tag `payroll` to `TREASURY_WALLET_ID` — the policy filter targets wallets with this tag.

### 6. Edit the payroll CSV

Open `data/employees.csv` and replace the sample entries with your employees:

```csv
name,address,amount_usdc
Alice Chen,0x...,500
Bob Martin,0x...,12000
```

### 7. Run payroll

```bash
npm run payroll:run
```

Sends a USDC transfer for each employee. The policy intercepts every transfer and puts it in `Pending`.

### 8. Process approvals

Let the service account auto-approve transfers within the limit:

```bash
npm run approvals:auto
```

Check what needs human review:

```bash
npm run approvals:list
```

Approve or reject large transfers manually:

```bash
npm run approvals:approve <approvalId>
npm run approvals:reject <approvalId>
```

Check final transfer statuses:

```bash
npm run status
```

## Scripts

| Script | Purpose |
|---|---|
| `npm run policy:create` | Create the payroll approval policy in Dfns |
| `npm run payroll:run` | Send USDC to every employee in `data/employees.csv` |
| `npm run approvals:list` | List pending transfers waiting for human review |
| `npm run approvals:auto` | Service-account checker: auto-approve small, leave large |
| `npm run approvals:approve <id>` | Manually approve a flagged transfer |
| `npm run approvals:reject <id>` | Manually reject a flagged transfer |
| `npm run status` | Show recent transfer statuses from the treasury wallet |
| `npm run users:list` | List org users and service accounts (find `POLICY_USER_ID`) |

## Project structure

```
crypto-payroll/
├── scripts/
│ ├── DfnsCommon.ts # shared Dfns client and env config
│ ├── Setup.ts # create the Wallets:Sign approval policy
│ ├── RunPayroll.ts # read CSV and initiate USDC transfers
│ ├── AutoReview.ts # service-account checker: approve or flag
│ ├── ListPending.ts # list transfers pending human review
│ ├── Approve.ts # manually approve a transfer
│ ├── Reject.ts # manually reject a transfer
│ ├── Status.ts # show recent transfer statuses
│ └── ListUsers.ts # list users to find POLICY_USER_ID
└── data/
└── employees.csv # payroll input: name, address, amount_usdc
```

## Adapting

- **Different token**: change `USDC_CONTRACT` in `.env` and the `kind` field in `RunPayroll.ts` (e.g. `Erc20` stays the same, just point at a different contract).
- **Different threshold**: update `AUTO_APPROVE_LIMIT_USDC` in `.env` — no code change needed.
- **Stricter approval**: set `initiatorCanApprove: false` in `Setup.ts` so the service account that runs payroll cannot approve its own transactions.
- **Multiple approvers**: add more user IDs to the `approvers.userId.in` array in `Setup.ts` and raise the `quorum`.
- **Scheduled runs**: invoke `npm run payroll:run && npm run approvals:auto` from a cron job or your CI/CD pipeline.

## License

MIT
6 changes: 6 additions & 0 deletions crypto-payroll/data/employees.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name,address,amount_usdc
Alice Chen,0x70997970C51812dc3A010C7d01b50e0d17dc79C8,500
Bob Martin,0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC,750
Carol Zhang,0x90F79bf6EB2c4f870365E785982E1f101E93b906,500
David Kim,0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65,12000
Eve Nakamura,0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc,1500
27 changes: 27 additions & 0 deletions crypto-payroll/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "crypto-payroll",
"version": "1.0.0",
"type": "module",
"scripts": {
"policy:create": "npx tsx scripts/Setup.ts",
"payroll:run": "npx tsx scripts/RunPayroll.ts",
"approvals:list": "npx tsx scripts/ListPending.ts",
"approvals:auto": "npx tsx scripts/AutoReview.ts",
"approvals:approve": "npx tsx scripts/Approve.ts",
"approvals:reject": "npx tsx scripts/Reject.ts",
"status": "npx tsx scripts/Status.ts",
"users:list": "npx tsx scripts/ListUsers.ts"
},
"license": "MIT",
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "~5.8.0"
},
"dependencies": {
"@dfns/sdk": "^0.8.21",
"@dfns/sdk-keysigner": "^0.8.21",
"dotenv": "^17.0.0",
"viem": "^2.45.0"
}
}
32 changes: 32 additions & 0 deletions crypto-payroll/scripts/Approve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { dfnsApi } from './DfnsCommon.js'

// Manually approves a pending payroll transfer that exceeded the auto-approve limit.
//
// Usage: npx tsx scripts/Approve.ts <approvalId>

async function main() {
const approvalId = process.argv[2]
if (!approvalId) {
console.error('Usage: npx tsx scripts/Approve.ts <approvalId>')
console.error('Run `npm run approvals:list` to find pending approval IDs.')
process.exit(1)
}

console.log(`Approving ${approvalId}...`)
try {
await dfnsApi.policies.createApprovalDecision({
approvalId,
body: { value: 'Approved', reason: 'Transfer verified and approved manually' },
})
console.log('Approved. The transfer will now broadcast to the network.')
} catch (error: any) {
if (error.context?.body) {
console.error('Failed:', JSON.stringify(error.context.body, null, 2))
} else {
console.error('Failed:', error)
}
process.exit(1)
}
}

main()
86 changes: 86 additions & 0 deletions crypto-payroll/scripts/AutoReview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { dfnsApi, TREASURY_WALLET_ID, USDC_CONTRACT, AUTO_APPROVE_LIMIT_USDC } from './DfnsCommon.js'
import { parseUnits } from 'viem'

// Service-account checker: runs over all Pending approvals for the treasury wallet.
// Transfers <= AUTO_APPROVE_LIMIT_USDC are approved automatically.
// Transfers > AUTO_APPROVE_LIMIT_USDC are left Pending for a human approver.
// Non-USDC transfers and transfers to unknown wallets are denied.
//
// Usage: npx tsx scripts/AutoReview.ts

const AUTO_APPROVE_LIMIT_BASE = parseUnits(String(AUTO_APPROVE_LIMIT_USDC), 6)

async function main() {
if (!TREASURY_WALLET_ID) {
throw new Error('TREASURY_WALLET_ID not set in .env')
}

const approvals = await dfnsApi.policies.listApprovals({ query: { status: 'Pending' } })

console.log(`--- Auto-reviewer — treasury ${TREASURY_WALLET_ID} ---`)
console.log(`Auto-approve limit: ${AUTO_APPROVE_LIMIT_USDC} USDC`)
console.log('')

if (approvals.items.length === 0) {
console.log('No pending approvals.')
return
}

for (const approval of approvals.items) {
const activity = approval.activity as any

const walletId =
activity.walletId ||
activity.transferRequest?.walletId ||
activity.transactionRequest?.walletId ||
activity.signRequest?.walletId

if (walletId !== TREASURY_WALLET_ID) continue

console.log(`\x1b[36mApproval ${approval.id}\x1b[0m`)

const transferReq = activity.transferRequest
if (!transferReq) {
console.log(' Not a transfer request — denying.')
await decide(approval.id, 'Denied', 'Only transfer operations are allowed from this wallet')
continue
}

const reqBody = transferReq.requestBody
const to: string = reqBody?.to || 'unknown'
const contract: string = (reqBody?.contract || '').toLowerCase()
const amount = BigInt(reqBody?.amount || '0')
const amountUsdc = Number(amount) / 1e6

console.log(` To: ${to}`)
console.log(` Amount: ${amountUsdc} USDC`)
console.log(` Contract: ${contract}`)

if (contract !== USDC_CONTRACT) {
await decide(approval.id, 'Denied', `Contract ${contract} is not the configured USDC contract`)
continue
}

if (amount <= AUTO_APPROVE_LIMIT_BASE) {
await decide(approval.id, 'Approved', `${amountUsdc} USDC is within the ${AUTO_APPROVE_LIMIT_USDC} USDC auto-approve limit`)
} else {
console.log(` \x1b[33m⚑ ${amountUsdc} USDC exceeds auto-approve limit — leaving for human review\x1b[0m`)
}
console.log('')
}
}

async function decide(approvalId: string, value: 'Approved' | 'Denied', reason: string) {
const color = value === 'Approved' ? '\x1b[32m' : '\x1b[31m'
console.log(` ${color}${value}\x1b[0m — ${reason}`)
try {
await dfnsApi.policies.createApprovalDecision({ approvalId, body: { value, reason } })
} catch (error: any) {
console.error(` Failed to post decision: ${error.message}`)
}
}

main().catch(err => {
console.error('Auto-review failed:', err)
process.exit(1)
})
Loading