# 서버 연동

Node 백엔드에서 로그인한 이용자를 확인하고, 선택 기능으로 범위가 제한된 컨트랙트 호출을 제안합니다.

[React 빠른 시작](/docs/quickstart/)으로 로그인과 네이티브 송금을 먼저 구현합니다. 이 문서는 별도 백엔드를 추가하는 선택 과정입니다.

## 서버 이용자 확인 (선택)

위의 네이티브 송금에는 앱의 별도 제안 서버가 필요하지 않습니다. 서버에서 로그인 상태를 확인하려면 아래 예제로 `server/pouchi.ts`를 만듭니다. `GET /api/pouchi/me`가 실제로 구현되어 있습니다. 서버에 `POUCHI_API_URL`, `POUCHI_TENANT_ID`, `POUCHI_APP_ORIGIN`을 설정하고 별도 터미널에서 실행합니다.

```sh
npm install --save-dev tsx @types/node
export POUCHI_API_URL=YOUR_POUCHI_API_URL
export POUCHI_TENANT_ID=YOUR_POUCHI_APP_ID
export POUCHI_APP_ORIGIN=http://localhost:5173
npx tsx server/pouchi.ts
```

`tsx`는 `.env.local`을 자동으로 읽지 않습니다. 이 터미널에 서버 환경 변수를 먼저 설정하세요. `POUCHI_APP_ORIGIN`은 `http://localhost:5173`처럼 실제 브라우저 출처와 같아야 합니다.

server/pouchi.ts

```ts
import { createServer } from "node:http";
import { createNcWaasServerClient } from "@shard-lab/nc-waas-react/server";
import type { TransactionProposalInput } from "@shard-lab/nc-waas-react";
import { decodeFunctionData, encodeFunctionData, isAddress, parseAbi } from "viem";

function required(name: string): string {
  const value = process.env[name]?.trim();
  if (!value) throw new Error(`Set ${name} on the server.`);
  return value;
}
function address(name: string): `0x${string}` {
  const value = required(name);
  if (!isAddress(value)) throw new Error(`${name} must be an EVM address.`);
  return value;
}
const tenantId = required("POUCHI_TENANT_ID");
const origin = required("POUCHI_APP_ORIGIN");
if (new URL(origin).origin !== origin) throw new Error("POUCHI_APP_ORIGIN must be an exact origin.");
const pouchi = createNcWaasServerClient({
  apiUrl: required("POUCHI_API_URL"), tenantId,
  // Optional for /me. Both are required for /propose (owner-intents:create).
  credentialId: process.env.POUCHI_CREDENTIAL_ID,
  clientSecret: process.env.POUCHI_CLIENT_SECRET
});
const transferAbi = parseAbi(["function transfer(address to, uint256 amount) returns (bool)"]);

export async function currentUser(authorization: string | undefined) {
  if (!authorization?.startsWith("Bearer ")) throw new Error("Sign in first.");
  const claims = await pouchi.verifyAuthToken(authorization.slice(7));
  if (claims.chainId !== 450815) throw new Error("Expected Maroo Testnet.");
  return { userId: claims.userId, walletAddress: claims.walletAddress, chainId: claims.chainId };
}

// Optional ERC-20 example. Native transfers use the SDK's direct path, not this hook.
export async function propose(body: unknown) {
  required("POUCHI_CREDENTIAL_ID");
  required("POUCHI_CLIENT_SECRET");
  const tokenContract = address("POUCHI_TOKEN_CONTRACT");
  const recipient = address("POUCHI_TEST_RECIPIENT");
  const rawAmount = required("POUCHI_TOKEN_AMOUNT");
  if (!/^[1-9][0-9]*$/.test(rawAmount)) throw new Error("POUCHI_TOKEN_AMOUNT must be positive base units.");
  const amount = BigInt(rawAmount);
  if (!body || typeof body !== "object") throw new Error("Invalid proposal.");
  const input = body as Partial<TransactionProposalInput>;
  if (typeof input.accessToken !== "string" || input.tenantId !== tenantId || input.chainId !== 450815
    || typeof input.to !== "string" || input.to.toLowerCase() !== tokenContract.toLowerCase()
    || input.value !== "0" || typeof input.data !== "string" || !/^0x[0-9a-fA-F]+$/.test(input.data)) {
    throw new Error("Proposal is outside this example's limits.");
  }
  const claims = await pouchi.verifyAuthToken(input.accessToken);
  if (claims.walletId !== input.walletId || claims.chainId !== 450815
    || typeof input.walletAddress !== "string"
    || claims.walletAddress.toLowerCase() !== input.walletAddress.toLowerCase()) throw new Error("Wallet mismatch.");
  const data = input.data as `0x${string}`;
  const call = decodeFunctionData({ abi: transferAbi, data });
  const expectedData = encodeFunctionData({ abi: transferAbi, functionName: "transfer", args: [recipient, amount] });
  if (call.functionName !== "transfer" || call.args[0].toLowerCase() !== recipient.toLowerCase()
    || call.args[1] !== amount || data.toLowerCase() !== expectedData.toLowerCase()) throw new Error("Transfer not allowed.");
  return pouchi.proposeTransaction({
    walletId: claims.walletId, to: tokenContract, value: "0", data: expectedData,
    title: "Test token transfer",
    requester: { appName: "My App", origin }
  });
}

const server = createServer(async (request, response) => {
  response.setHeader("content-type", "application/json");
  response.setHeader("cache-control", "no-store");
  if (request.url === "/api/pouchi/me" && request.method === "GET") {
    try { response.writeHead(200).end(JSON.stringify(await currentUser(request.headers.authorization))); }
    catch { response.writeHead(401).end(JSON.stringify({ error: "Sign in required." })); }
    return;
  }
  if (request.url !== "/api/pouchi/propose") { response.writeHead(404).end('{}'); return; }
  if (request.method !== "POST") { response.setHeader("allow", "POST"); response.writeHead(405).end('{}'); return; }
  if (request.headers["content-type"]?.split(";")[0] !== "application/json") {
    response.writeHead(415).end('{}'); return;
  }
  try {
    let body = "";
    for await (const chunk of request) {
      body += chunk.toString();
      if (Buffer.byteLength(body) > 16_384) { response.writeHead(413).end('{}'); return; }
    }
    response.writeHead(200).end(JSON.stringify(await propose(JSON.parse(body))));
  } catch {
    // Do not log request bodies, tokens, credentials or upstream error details.
    response.writeHead(400).end(JSON.stringify({ error: "Proposal rejected." }));
  }
});
server.listen(31082, "127.0.0.1");
```

개발 중 `/api` 요청이 이 서버로 가도록 `vite.config.ts`에 프록시를 설정하고 Vite를 다시 실행합니다.

```ts title="vite.config.ts"
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { pouchiWasm } from "./pouchi-vite";
export default defineConfig({
  plugins: [react(), pouchiWasm()],
  server: { proxy: { "/api": "http://127.0.0.1:31082" } }
});
```

다음 컴포넌트로 `src/VerifyUser.tsx`를 만듭니다. `App.tsx`에 `import { VerifyUser } from "./VerifyUser";`를 추가하고 지갑 주소 아래에 `<VerifyUser />`를 넣으세요. 로그인 후 **Verify user on server**를 누르면 서버가 검증한 지갑 주소와 체인이 표시됩니다. 토큰은 화면에 표시하지 않습니다.

src/VerifyUser.tsx

```tsx
import { useState } from "react";
import { usePrivy } from "@shard-lab/nc-waas-react";

export function VerifyUser() {
  const { authenticated, getAccessToken } = usePrivy();
  const [pending, setPending] = useState(false);
  const [message, setMessage] = useState("");
  const verify = async () => {
    setPending(true);
    setMessage("");
    try {
      const token = await getAccessToken();
      if (!token) throw new Error("Sign in first.");
      const response = await fetch("/api/pouchi/me", {
        headers: { Authorization: `Bearer ${token}` }
      });
      if (!response.ok) throw new Error("Server verification failed.");
      const user = await response.json();
      setMessage(`Verified wallet: ${user.walletAddress} (chain ${user.chainId})`);
    } catch (cause) {
      setMessage(cause instanceof Error ? cause.message : "Verification failed.");
    } finally { setPending(false); }
  };
  return <section>
    <button disabled={!authenticated || pending} onClick={() => void verify()}>
      {pending ? "Verifying..." : "Verify user on server"}
    </button>
    <p role="status">{message}</p>
  </section>;
}
```

## 서버 승인 컨트랙트 호출 (고급)

`config.transactions.propose`는 일반 컨트랙트 호출에 적용됩니다. 네이티브 송금과 SDK가 지원 토큰 전송으로 인식한 ERC-20 송금은 이 훅을 거치지 않습니다. 이 훅만으로 모든 송금에 서버 승인 규칙을 적용할 수는 없습니다. 아래 예제에는 해당 지갑의 지원 토큰과 다른 테스트 ERC-20 컨트랙트가 필요합니다. 선택 기능인 `POST /api/pouchi/propose`는 ERC-20 컨트랙트, 받을 주소, 정확한 수량과 제안 생성 권한이 있는 서버용 Client Credential을 설정하기 전에는 꺼져 있습니다. 서버는 지정한 ERC-20 전송과 네이티브 금액 0만 허용하고, 지갑 소유자와 Maroo 체인을 확인한 뒤 사용자 승인을 요청합니다.

src/proposer.ts

```ts
import type { NcWaasConfig, TransactionProposer } from "@shard-lab/nc-waas-react";

const propose: TransactionProposer = async (input) => {
  const response = await fetch("/api/pouchi/propose", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(input)
  });
  if (!response.ok) throw new Error("The server rejected the proposal.");
  const result: unknown = await response.json();
  if (!result || typeof result !== "object" || !("signingRequestId" in result)
    || typeof result.signingRequestId !== "string") throw new Error("Invalid proposal response.");
  return { signingRequestId: result.signingRequestId };
};

export const transactionConfig: Pick<NcWaasConfig, "transactions"> = {
  transactions: { propose }
};
```

`./proposer`의 `transactionConfig`를 가져와 Provider의 기존 `config`에 합칩니다. 이 연결이 없으면 훅이 호출되지 않습니다.

```tsx
config={{
  loginMethods: ["email"],
  defaultChain: marooTestnet,
  transactions: transactionConfig.transactions
}}
```

특정 컨트랙트 호출을 위한 예제이며 범용 결제 서버가 아닙니다. 다른 컨트랙트에는 별도의 구매·권한 검증이 필요합니다. `clientSecret`은 서버에만 두고 `VITE_` 변수에 넣지 않습니다. JWT 검증만 할 때에는 Client Credential이 필요 없습니다.

서버에 `POUCHI_CREDENTIAL_ID`, `POUCHI_CLIENT_SECRET`, `POUCHI_TOKEN_CONTRACT`, `POUCHI_TEST_RECIPIENT`, `POUCHI_TOKEN_AMOUNT`를 설정합니다. 수량은 토큰 소수 자릿수를 반영한 양의 정수입니다. 토큰 잔액과 앱의 가스비 대납 예산도 필요합니다. 아래 호출은 설정한 컨트랙트·받을 주소·수량과 같아야 하며, 로그인한 컴포넌트의 이벤트 핸들러 안에서 실행합니다. 공개 주소와 수량을 먼저 유효한 값으로 준비하세요.

```ts
import { encodeFunctionData, parseAbi } from "viem";

// sendTransaction comes from useSendTransaction() in the component.
await sendTransaction({
  to: tokenContract,
  value: 0n,
  data: encodeFunctionData({
    abi: parseAbi(["function transfer(address to, uint256 amount) returns (bool)"]),
    functionName: "transfer",
    args: [testRecipient, tokenAmount]
  })
});
```

브라우저 Network 패널에서 `/api/pouchi/propose` 요청이 실제 발생하는지 확인하고, 승인 후 체인 영수증의 성공 여부를 확인합니다. 요청이 없다면 서버 경로를 검증한 것이 아닙니다. 이 고급 예제는 계약과 권한을 별도로 준비해야 하는 설정 예제이며, 실행 검증을 마친 예제가 아닙니다.
