
Thiết lập gói đăng ký và thanh toán định kỳ trên Solana
Mục lục
- Giới thiệu
- Tại sao các gói đăng ký onchain từng khó triển khai
- Giới thiệu Subscriptions Delegation Program mới
- Ba mô hình cấp quyền
- Ủy quyền cố định
- Ủy quyền định kỳ
- Gói đăng ký
- Bản triển khai tham chiếu
- Trường hợp sử dụng: Nhà phát triển có thể xây dựng gì
- Thanh toán định kỳ cho API và hạ tầng
- Chi tiêu có giới hạn cho tác nhân AI
- Trả lương và thanh toán cho nhà thầu onchain
- Thu hóa đơn bằng stablecoin
- Thanh toán vi mô cho nội dung và truyền thông
- Xây dựng luồng đăng ký trên Devnet với Helius
- Điều kiện tiên quyết
- Thiết lập dự án
- Tạo ví cho đơn vị bán hàng và khách hàng
- Tạo Helius client dùng chung
- Tạo mint thử nghiệm trên Devnet
- Khởi tạo Subscription Authority của khách hàng
- Tạo gói đăng ký của đơn vị bán hàng
- Cho khách hàng đăng ký
- Cho đơn vị bán hàng thu tiền
- Khách hàng hủy gói đăng ký
- Nghiên cứu tình huống thực tế: Gói đăng ký onchain của Helius
- Kết luận
- Tài nguyên khác
Giới thiệu
Thanh toán định kỳ là một phần nền tảng của thương mại trên internet. Các sản phẩm SaaS, nền tảng API, hệ thống trả lương và vô số doanh nghiệp khác đều phụ thuộc vào khả năng thu phí khách hàng theo một lịch trình có thể dự đoán.
Cho đến nay, việc triển khai trải nghiệm đó trên Solana đòi hỏi phải xây dựng rất nhiều hạ tầng tùy chỉnh. Solana Subscriptions Delegation Program mới (còn được gọi là Solana Subscriptions & Allowances Program) giải quyết vấn đề này bằng một primitive onchain mã nguồn mở, đã được kiểm toán dành cho thanh toán định kỳ và các gói đăng ký.
Giờ đây, người dùng có thể cấp quyền một lần cho các giao dịch chuyển token trong tương lai (tuân theo các ràng buộc onchain rõ ràng), sau đó một đơn vị bán hàng hoặc bên thu tiền đã được phê duyệt có thể khởi tạo thanh toán mà không cần người dùng ký. Chương trình hiện đã hoạt động trên cả mainnet và devnet, hỗ trợ các mint từ cả SPL Token Program ban đầu và Token-2022.
Thay vì mỗi ứng dụng phải tự thiết kế và bảo mật hệ thống ủy quyền riêng, giờ đây các nhà phát triển có thể tích hợp một chương trình được chuẩn hóa. Những luồng thanh toán trước đây cần nhiều tuần phát triển tùy chỉnh nay có thể được tích hợp chỉ trong vài ngày.
Với vai trò là đối tác ra mắt, Helius đã góp phần hoàn thiện chương trình và hiện dùng chương trình này để vận hành tính năng thanh toán gói API onchain. Nhờ đó, khách hàng Helius có thể cấp quyền thanh toán định kỳ bằng USDC trực tiếp từ ví Solana của họ.
Trong bài viết này, chúng ta sẽ tìm hiểu cách chương trình hoạt động và dùng chương trình để xây dựng các luồng thanh toán định kỳ có khả năng mở rộng. Nội dung sẽ đề cập đến kiến trúc Subscription Authority, các PDA đại diện cho từng quyền riêng lẻ và ba mô hình thanh toán được chương trình hỗ trợ:
- Hạn mức chi tiêu cố định
- Ủy quyền định kỳ
- Gói đăng ký do đơn vị bán hàng xác định
Tại sao các gói đăng ký onchain từng khó triển khai
Các chương trình token của Solana đã hỗ trợ chi tiêu được ủy quyền. Chủ sở hữu tài khoản token có thể dùng các lệnh Approve hoặc ApproveChecked để cấp quyền cho một địa chỉ khác chuyển hoặc đốt token thay mặt họ, tối đa đến hạn mức đã chỉ định.
Vấn đề là một tài khoản token chỉ lưu trữ một bên được ủy quyền hiện tại và một số tiền được ủy quyền. Việc phê duyệt một bên được ủy quyền mới sẽ thay thế bên trước đó cùng hạn mức của họ. Điều này đúng với các tài khoản được quản lý bởi cả Token Program ban đầu và Token-2022. Khi người dùng phê duyệt một dịch vụ thứ hai, dịch vụ này sẽ thay thế dịch vụ đầu tiên. Hạn mức gốc chỉ là một giá trị duy nhất; không có khái niệm tích hợp sẵn về kỳ thanh toán, đặt lại hạn mức hay trạng thái đăng ký.
Các ứng dụng có thể khắc phục bằng cách tạo một tài khoản token riêng cho mỗi quan hệ chi tiêu, nhưng cách này làm phân mảnh số dư của người dùng và khiến trải nghiệm ví cũng như ứng dụng phức tạp hơn nhiều. Một phương án khác là đội ngũ có thể xây dựng chương trình ký quỹ hoặc ủy quyền tùy chỉnh, nhưng điều đó lại tạo ra gánh nặng phát triển và bảo mật mà chương trình dùng chung được thiết kế để loại bỏ.
Điều còn thiếu là một cách biến vị trí bên được ủy quyền duy nhất của Token Program thành một cổng có thể lập trình cho nhiều quyền độc lập.
Giới thiệu Subscriptions Delegation Program mới
Subscriptions Delegation Program bổ sung lớp có thể lập trình còn thiếu đó mà không thay đổi bất kỳ chương trình token nền tảng nào của Solana. Với mỗi cặp (người dùng, token mint), chương trình dẫn xuất một Subscription Authority, tức địa chỉ dẫn xuất từ chương trình (PDA), làm bên được ủy quyền cho tài khoản token của người dùng đối với mint cụ thể đó. Địa chỉ này được khởi tạo một lần rồi được tái sử dụng cho mọi gói đăng ký hoặc quyền ủy quyền liên quan đến người dùng và mint đó.
Trong quá trình khởi tạo, người dùng ký một giao dịch phê duyệt Subscription Authority với hạn mức ~18,4 tỷ tỷ, hay u64::MAX. Điều này an toàn vì Subscription Authority là một PDA chỉ có thể ký thông qua Subscriptions Delegation Program. Nó không thể tự quyết định chuyển token.
Trước khi ký một CPI vào Token Program, Subscriptions Delegation Program phải tải một tài khoản cấp quyền hợp lệ và xác minh các ràng buộc của tài khoản đó. Tùy thuộc vào mô hình cấp quyền, các bước kiểm tra có thể bao gồm:
- Ví hoặc dịch vụ được phép khởi tạo lệnh trích tiền
- Mint và tài khoản token nguồn
- Tổng hạn mức còn lại
- Số tiền tối đa khả dụng trong kỳ thanh toán hiện tại
- Thời điểm bắt đầu và hết hạn của quyền
- Các gói đăng ký được người dùng chấp nhận
- Đơn vị bán hàng hoặc bên thu tiền được phê duyệt khởi tạo khoản thu
- Mọi giới hạn đích nhận được cấu hình trong gói
Chỉ sau khi các bước kiểm tra đó đạt yêu cầu, chương trình mới ký với tư cách Subscription Authority và thực thi giao dịch chuyển token. Nếu không có quyền đang hoạt động nào khớp với giao dịch chuyển được yêu cầu, giao dịch sẽ thất bại. Vì vậy, hạn mức u64::MAX thuộc về cổng do chương trình kiểm soát, chứ không thuộc về một đơn vị bán hàng riêng lẻ. Đơn vị bán hàng chỉ nhận được quyền được mô tả trong tài khoản ủy quyền cụ thể của mình.
Tài khoản token vẫn chỉ có đúng một bên được ủy quyền (tức Subscription Authority PDA), nhưng chương trình có thể đặt nhiều PDA cấp quyền độc lập phía sau đó. Việc tạo quyền mới không ghi đè bất kỳ quyền hiện có nào. Mỗi quyền có trạng thái, giới hạn, vòng đời và lộ trình thu hồi riêng.
Ba mô hình cấp quyền
Chương trình hỗ trợ ba mô hình riêng biệt: ủy quyền cố định, ủy quyền định kỳ và gói đăng ký.
Ủy quyền cố định
Ủy quyền cố định cho phép một ví hoặc dịch vụ trích tối đa một tổng số tiền đã xác định. Mỗi giao dịch chuyển sẽ làm giảm hạn mức còn lại và quyền ủy quyền có thể tùy chọn hết hạn tại một dấu thời gian Unix cụ thể.
Mô hình này hữu ích cho ngân sách có giới hạn của tác nhân, hạn mức một lần, quyền mua hàng có thời hạn và các trường hợp khác mà người dùng muốn xác định tổng mức rủi ro tối đa.
Ủy quyền định kỳ
Ủy quyền định kỳ quy định số tiền có thể được trích trong mỗi kỳ. Khi kỳ tiếp theo bắt đầu, số tiền đã trích trong kỳ trước sẽ được đặt lại.
Người dùng kiểm soát các điều khoản, bao gồm số tiền mỗi kỳ, độ dài kỳ, thời điểm bắt đầu và thời điểm hết hạn tổng thể. Điều này giúp ủy quyền định kỳ phù hợp với các mối quan hệ liên tục như trả lương, thanh toán cho nhà thầu, hạn mức định kỳ hoặc thỏa thuận thanh toán tùy chỉnh mà bên trả tiền xác định các giới hạn.
Gói đăng ký
Gói đăng ký đảo ngược luồng thiết lập. Thay vì mỗi người dùng tự xác định các điều khoản định kỳ, đơn vị bán hàng công bố một gói có thể tái sử dụng với số tiền, kỳ thanh toán, mint được chấp nhận, các bên thu tiền được phép và giới hạn đích nhận tùy chọn.
Người dùng xem xét và chấp nhận các điều khoản đó, tạo một Subscription Delegation PDA gắn với gói. Các điều khoản thanh toán đã chấp nhận được sao chép vào tài khoản đăng ký của người dùng, ngăn đơn vị bán hàng âm thầm thay đổi mức giá cốt lõi hoặc kỳ thanh toán đối với người đăng ký hiện tại. Chủ sở hữu gói hoặc bên trích tiền được phê duyệt sau đó có thể thu tối đa số tiền của gói trong mỗi kỳ thanh toán.
Sự khác biệt này rất quan trọng:
- Ủy quyền định kỳ là quyền do bên trả tiền xác định
- Gói đăng ký là các điều khoản do đơn vị bán hàng công bố mà bên trả tiền chủ động đồng ý
Cả ba mô hình đều sử dụng cùng một Subscription Authority và cuối cùng thực thi giao dịch chuyển thông qua cùng một kiến trúc ủy quyền nền tảng.
Bản triển khai tham chiếu
Subscriptions Delegation Program được Moonsong Labs thiết kế và xây dựng với sự hợp tác của Solana Foundation, đồng thời được Cantina kiểm toán. Mã nguồn, tài liệu và các client đều có trong kho lưu trữ subscriptions của Solana Foundation.
Chương trình onchain được viết bằng no_std Rust, sử dụng Pinocchio. Pinocchio cung cấp mô hình phát triển cấp thấp hơn với ít dependency. So với cách triển khai Anchor thông thường, mô hình này cho phép chương trình quản lý mức sử dụng tài nguyên tính toán và kích thước binary chặt chẽ hơn.
Kho lưu trữ cũng dùng Codama để tạo các client TypeScript và Rust đồng bộ trực tiếp từ giao diện chương trình. Đối với ứng dụng TypeScript, package chính là:
pnpm add @solana/subscriptionsNgoài ra còn có ứng dụng web demo chính thức, cung cấp một bản triển khai hoàn chỉnh từ đầu đến cuối có thể dễ dàng sử dụng trên devnet. Chương trình hỗ trợ cả SPL Token và Token-2022, đồng thời phát các sự kiện vòng đời và chuyển giao onchain mà ứng dụng và trình lập chỉ mục có thể giải mã bằng IDL đã công bố.
Trường hợp sử dụng: Nhà phát triển có thể xây dựng gì
Chương trình hữu ích trong mọi trường hợp mà người dùng có thể xác định giới hạn của khoản thanh toán trong tương lai trước khi biết chính xác thời điểm giao dịch chuyển diễn ra. Người dùng ký một lần để thiết lập quyền; sau đó đơn vị bán hàng, dịch vụ, người nhận hoặc tác nhân có thể khởi tạo giao dịch chuyển trong các giới hạn đó.
Vì mỗi thỏa thuận chi tiêu được đại diện bằng một PDA riêng, các trường hợp sử dụng này có thể cùng tồn tại phía sau một Subscription Authority. Người dùng có thể thanh toán gói API, cấp ngân sách hàng tuần cho tác nhân AI và cấp quyền trả phí duy trì cho nhà thầu từ cùng một tài khoản token USDC mà không có quyền nào can thiệp vào quyền khác.
Thanh toán định kỳ cho API và hạ tầng
Gói đăng ký là lựa chọn tự nhiên cho các sản phẩm SaaS, nhà cung cấp RPC, nền tảng dữ liệu và các dịch vụ hạ tầng khác. Nhà cung cấp có thể công bố một gói onchain riêng cho từng cấp sản phẩm, xác định token mint được chấp nhận, giá, kỳ thanh toán, bên thu tiền được phê duyệt và đích thanh toán được phép. Điều này tạo ra trải nghiệm đăng ký quen thuộc mà không cần đơn vị xử lý thẻ.
Chi tiêu có giới hạn cho tác nhân AI
Các tác nhân tự động cần khả năng thanh toán cho API, tài nguyên tính toán, dữ liệu, dịch vụ giao dịch và các tài nguyên khác mà không cần yêu cầu con người phê duyệt. Tuy nhiên, việc trao cho tác nhân quyền kiểm soát không giới hạn đối với một ví có tiền tạo ra rủi ro bảo mật rõ ràng.
Ủy quyền cố định cung cấp giải pháp thay thế an toàn hơn. Người dùng có thể cấp quyền cho tác nhân chi tiêu tối đa một lượng token cụ thể và đặt thời hạn cứng cho quyền đó. Người dùng cũng có thể thu hồi quyền trước khi hết hạn.
Ủy quyền định kỳ mở rộng cùng mô hình này. Tác nhân có thể nhận hạn mức hằng ngày cho các yêu cầu API hoặc ngân sách hoạt động hằng tuần, trong đó số tiền khả dụng được đặt lại vào đầu mỗi kỳ.
Trả lương và thanh toán cho nhà thầu onchain
Ủy quyền định kỳ có thể hỗ trợ trả lương theo cơ chế trích tiền, phí duy trì, tài trợ và thỏa thuận với nhà thầu. Bên trả tiền cấp quyền cho nhân viên hoặc nhà thầu thu tối đa một số tiền cụ thể trong mỗi kỳ trả lương. Quyền có thể quy định số tiền mỗi kỳ, độ dài kỳ, thời điểm bắt đầu và thời điểm hết hạn cuối cùng. Khi đến hạn thanh toán, người nhận hoặc dịch vụ trả lương gửi giao dịch chuyển.
Cơ chế này không giống giao dịch trả lương theo kiểu đẩy truyền thống. Bên trả tiền không tự động gửi tiền vào ngày trả lương. Thay vào đó, người nhận được trao quyền có phạm vi chặt chẽ để trích số tiền đã thỏa thuận trong mỗi kỳ. Kết quả là một thỏa thuận thanh toán minh bạch mà cả hai bên có thể kiểm tra onchain.
Các giao dịch chuyển và hoạt động ủy quyền có thể được theo dõi thông qua các sự kiện do chương trình phát ra, cho phép xây dựng bảng điều khiển trả lương và tích hợp kế toán.
Thu hóa đơn bằng stablecoin
Các cổng thanh toán và nền tảng thanh toán B2B có thể dùng chương trình để thay thế yêu cầu thanh toán lặp lại bằng quyền ủy quyền liên tục, có giới hạn. Khách hàng có thể cấp quyền cho cổng thu:
- Tối đa một tổng số tiền cố định cho đơn đặt hàng
- Tối đa một số tiền cụ thể trong mỗi kỳ hóa đơn hàng tuần hoặc hàng tháng
- Giá của một gói tiêu chuẩn của đơn vị bán hàng trong mỗi chu kỳ thanh toán
Cùng kiến trúc này có thể vận hành hóa đơn định kỳ, dịch vụ thanh toán cho đơn vị bán hàng, hạn mức sử dụng, chính sách chi tiêu doanh nghiệp và các quy trình khác mà bên trả tiền muốn tự động hóa nhưng không từ bỏ quyền kiểm soát không giới hạn.
Thanh toán vi mô cho nội dung và truyền thông
Ủy quyền định kỳ cũng có thể hỗ trợ mô hình thanh toán dựa trên mức sử dụng cho nhà xuất bản, nền tảng phát trực tuyến, nhà cung cấp nghiên cứu và các dịch vụ truyền thông khác.
Người dùng có thể cấp quyền cho một hạn mức chi tiêu hàng tháng được trừ dần mỗi khi họ truy cập nội dung trả phí. Ví dụ, mở một bài viết có thể tiêu thụ 0,10 USDC từ hạn mức 10 USDC mỗi tháng, trong khi báo cáo cao cấp hoặc luồng video có thể có mức giá cao hơn. Nền tảng gửi từng khoản thanh toán khi nội dung được truy cập.
Điều này cho phép mô hình “đọc bao nhiêu trả bấy nhiêu” mà không yêu cầu chữ ký ví cho mỗi bài viết hay buộc người dùng phải chọn gói đăng ký cố định theo kiểu tất cả hoặc không có gì. Nhà xuất bản có được cách kiếm tiền có khả năng mở rộng từ từng lượt truy cập, trong khi người dùng vẫn duy trì giới hạn chi tiêu có thể dự đoán và có thể thu hồi quyền bất kỳ lúc nào.
Xây dựng luồng đăng ký trên Devnet với Helius
Trong phần hướng dẫn này, chúng ta sẽ xây dựng vòng đời của một gói đăng ký với đơn vị bán hàng qua các bước sau:
- Khách hàng khởi tạo Subscription Authority cho tài khoản token của họ
- Đơn vị bán hàng công bố một gói đăng ký
- Khách hàng chấp nhận gói
- Đơn vị bán hàng thu một khoản thanh toán
- Khách hàng hủy gói đăng ký
Các ví dụ sử dụng @solana/subscriptions@0.4.0, client TypeScript mới nhất được phát hành tại thời điểm viết bài. Việc cố định phiên bản package giúp hướng dẫn ổn định ngay cả khi SDK thay đổi sau này.
Chúng ta sẽ mô hình hóa hai bên:
| Vai trò | Trách nhiệm |
| Khách hàng | Sở hữu token, khởi tạo Subscription Authority, đăng ký và hủy gói |
| Đơn vị bán hàng | Công bố gói và gửi các giao dịch thu tiền |
Đối với token, chúng ta sẽ tạo một mint devnet tùy chỉnh có sáu chữ số thập phân và phát hành 100 token thử nghiệm cho khách hàng. Cách này giúp tránh phụ thuộc vào faucet stablecoin riêng, trong khi vẫn giữ nguyên phép tính theo đơn vị cơ sở được các token sáu chữ số thập phân như USDC sử dụng.
Các keypair JSON cục bộ giúp dễ dàng chạy luồng từ dòng lệnh. Trong ứng dụng thực tế, giao dịch của khách hàng thường được ký qua ví trình duyệt hoặc ví di động, còn bên thu tiền của đơn vị bán hàng sẽ dùng trình ký backend được quản lý an toàn.
Điều kiện tiên quyết
Hướng dẫn này giả định bạn có:
- Một phiên bản Node.js gần đây
pnpm- Solana CLI
- Khóa API Helius trả phí
- SOL trên devnet cho cả hai ví thử nghiệm
Thiết lập dự án
Tạo một dự án mới với thư mục keys để lưu trữ keypair của khách hàng và đơn vị bán hàng:
mkdir helius-subscriptions-devnet
cd helius-subscriptions-devnet
pnpm init
mkdir -p src keysThêm "type": "module" vào package.json, rồi cài đặt các dependency:
pnpm add \
@solana/subscriptions@0.4.0 \
@solana/kit@6.10.0 \
@solana/kit-plugin-rpc@0.12.1 \
@solana/kit-plugin-signer@0.12.1 \
@solana-program/token@0.13.0 \
dotenv
pnpm add -D typescript tsx @types/nodeTạo tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}Tạo ví cho đơn vị bán hàng và khách hàng
Tạo một keypair cho mỗi bên:
solana-keygen new \
--no-bip39-passphrase \
--outfile keys/merchant.json
solana-keygen new \
--no-bip39-passphrase \
--outfile keys/customer.jsonCác keypair này chỉ dành cho hướng dẫn trên devnet. Không commit khóa production vào kho lưu trữ hoặc lưu trình ký production của đơn vị bán hàng dưới dạng tệp JSON không mã hóa.
Tạo .gitignore:
node_modules/
.env
keys/
state.jsonTạo .env và thêm khóa API Helius của bạn:
HELIUS_API_KEY=YOUR_HELIUS_API_KEY
MERCHANT_KEYPAIR=./keys/merchant.json
CUSTOMER_KEYPAIR=./keys/customer.jsonCả hai ví đều cần SOL để trả phí giao dịch và tạo các tài khoản PDA tương ứng.
export HELIUS_DEVNET_URL="https://devnet.helius-rpc.com/?api-key=YOUR_HELIUS_API_KEY"
solana airdrop 1 \
"$(solana-keygen pubkey keys/merchant.json)" \
--url "$HELIUS_DEVNET_URL"
solana airdrop 1 \
"$(solana-keygen pubkey keys/customer.json)" \
--url "$HELIUS_DEVNET_URL"Helius cũng cung cấp faucet devnet thông qua bảng điều khiển. Yêu cầu SOL trên Devnet qua faucet Helius hoặc Helius RPC cần một gói Helius trả phí.
Tạo Helius client dùng chung
Mỗi script đều cần cùng một kết nối Helius, plugin chương trình, giá trị cấu hình và địa chỉ. Chúng ta sẽ đưa tất cả vào src/config.ts:
import "dotenv/config";
import { readFileSync, writeFileSync } from "node:fs";
import {
address,
createClient,
type Address,
} from "@solana/kit";
import { solanaDevnetRpc } from "@solana/kit-plugin-rpc";
import { signerFromFile } from "@solana/kit-plugin-signer";
import {
associatedTokenProgram,
tokenProgram,
} from "@solana-program/token";
import { subscriptionsProgram } from "@solana/subscriptions";
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing ${name} in .env`);
}
return value;
}
const apiKey = requiredEnv("HELIUS_API_KEY");
export const HELIUS_RPC_URL =
`https://devnet.helius-rpc.com/?api-key=${encodeURIComponent(apiKey)}` as const;
export const HELIUS_WS_URL =
`wss://devnet.helius-rpc.com/?api-key=${encodeURIComponent(apiKey)}` as const;
export const MERCHANT_KEYPAIR =
process.env.MERCHANT_KEYPAIR ?? "./keys/merchant.json";
export const CUSTOMER_KEYPAIR =
process.env.CUSTOMER_KEYPAIR ?? "./keys/customer.json";
export const TOKEN_DECIMALS = 6;
// Five tokens when the mint has six decimals.
export const PLAN_AMOUNT = 5_000_000n;
// Keep the devnet period short so we can test multiple billing cycles.
export const PLAN_PERIOD_HOURS = 1n;
const STATE_FILE = "./state.json";
type StateJson = {
tokenMint: string;
planId: string;
};
export async function createAppClient(keypairPath: string) {
return await createClient()
.use(signerFromFile(keypairPath))
.use(
solanaDevnetRpc({
rpcUrl: HELIUS_RPC_URL,
rpcSubscriptionsUrl: HELIUS_WS_URL,
}),
)
.use(tokenProgram())
.use(associatedTokenProgram())
.use(subscriptionsProgram());
}
export function saveState(
tokenMint: Address,
planId: bigint,
): void {
writeFileSync(
STATE_FILE,
JSON.stringify(
{
tokenMint,
planId: planId.toString(),
},
null,
2,
),
);
}
export function loadState(): {
tokenMint: Address;
planId: bigint;
} {
const parsed = JSON.parse(
readFileSync(STATE_FILE, "utf8"),
) as StateJson;
if (!parsed.tokenMint || !parsed.planId) {
throw new Error(
"state.json is missing tokenMint or planId",
);
}
return {
tokenMint: address(parsed.tokenMint),
planId: BigInt(parsed.planId),
};
}
export function printSignature(
label: string,
signature: unknown,
): void {
const value = String(signature);
console.log(`${label}: ${value}`);
console.log(
`Orb: https://orb.helius.dev/tx/${value}?cluster=devnet`,
);
}
signerFromFile đặt keypair đã tải làm cả danh tính client và bên trả phí. Plugin Helius RPC xử lý việc lập kế hoạch, gửi và xác nhận giao dịch, trong khi các plugin token và subscriptions bổ sung trình hỗ trợ tài khoản và lệnh tương ứng. Mỗi script in ra một URL Orb (trình khám phá khối của Helius) cho giao dịch thu được.
Tạo mint thử nghiệm trên Devnet
Trước khi khởi tạo Subscription Authority, khách hàng phải có sẵn một tài khoản token cho mint của gói. Chúng ta sẽ tạo một mint thử nghiệm có sáu chữ số thập phân, phát hành 100 token cho khách hàng và tạo một tài khoản token đích trống cho đơn vị bán hàng.
Plugin token Solana Kit cung cấp các trình hỗ trợ để tạo mint, tài khoản token liên kết và mint token. Tạo src/00-bootstrap.ts:
import { generateKeyPairSigner } from "@solana/kit";
import {
findAssociatedTokenPda,
TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";
import {
createAppClient,
CUSTOMER_KEYPAIR,
MERCHANT_KEYPAIR,
printSignature,
saveState,
TOKEN_DECIMALS,
} from "./config.js";
const [merchantClient, customerClient] =
await Promise.all([
createAppClient(MERCHANT_KEYPAIR),
createAppClient(CUSTOMER_KEYPAIR),
]);
const mint = await generateKeyPairSigner();
const createMintResult =
await merchantClient.token.instructions
.createMint({
newMint: mint,
decimals: TOKEN_DECIMALS,
mintAuthority: merchantClient.identity.address,
freezeAuthority: null,
})
.sendTransaction();
const fundCustomerResult =
await merchantClient.token.instructions
.mintToATA({
mint: mint.address,
owner: customerClient.identity.address,
mintAuthority: merchantClient.identity,
amount: 100_000_000n,
decimals: TOKEN_DECIMALS,
})
.sendTransaction();
const createMerchantAtaResult =
await merchantClient.associatedToken.instructions
.createAssociatedTokenIdempotent({
mint: mint.address,
owner: merchantClient.identity.address,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
})
.sendTransaction();
const [customerAta] = await findAssociatedTokenPda({
mint: mint.address,
owner: customerClient.identity.address,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
const [merchantAta] = await findAssociatedTokenPda({
mint: mint.address,
owner: merchantClient.identity.address,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
// Plan PDAs are derived from the merchant and plan ID.
// Using the current timestamp gives each test run a fresh ID.
const planId = BigInt(Date.now());
saveState(mint.address, planId);
console.log("Mint:", mint.address);
console.log("Plan ID:", planId.toString());
console.log("Customer:", customerClient.identity.address);
console.log("Customer ATA:", customerAta);
console.log("Merchant:", merchantClient.identity.address);
console.log("Merchant ATA:", merchantAta);
printSignature(
"Create mint",
createMintResult.context.signature,
);
printSignature(
"Fund customer",
fundCustomerResult.context.signature,
);
printSignature(
"Create merchant ATA",
createMerchantAtaResult.context.signature,
);Chạy script. Script sẽ tạo state.json, chứa thông tin về mint đã tạo và một ID gói duy nhất. Khách hàng hiện sở hữu 100 token thử nghiệm, còn đơn vị bán hàng có một tài khoản token trống sẵn sàng nhận các khoản thanh toán đăng ký.
Khởi tạo Subscription Authority của khách hàng
Subscription Authority được tạo cho một cặp (customer, mint) cụ thể. Tài khoản token của khách hàng phải tồn tại trước khi khởi tạo.
Giao dịch khởi tạo tạo Subscription Authority PDA và phê duyệt PDA này làm bên được ủy quyền cho tài khoản token của khách hàng. Sau đó, cùng authority này có thể được tái sử dụng cho mọi ủy quyền cố định, ủy quyền định kỳ và Subscription Plan liên quan đến khách hàng và mint đó. Khách hàng ký giao dịch này.
Tạo src/01-init-authority.ts:
import {
findAssociatedTokenPda,
TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";
import {
fetchMaybeSubscriptionAuthority,
findSubscriptionAuthorityPda,
} from "@solana/subscriptions";
import {
createAppClient,
CUSTOMER_KEYPAIR,
loadState,
printSignature,
} from "./config.js";
const customerClient =
await createAppClient(CUSTOMER_KEYPAIR);
const { tokenMint } = loadState();
const [customerAta] = await findAssociatedTokenPda({
mint: tokenMint,
owner: customerClient.identity.address,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
const [subscriptionAuthorityPda] =
await findSubscriptionAuthorityPda({
user: customerClient.identity.address,
tokenMint,
});
const existing =
await fetchMaybeSubscriptionAuthority(
customerClient.rpc,
subscriptionAuthorityPda,
);
if (existing.exists) {
console.log(
"Subscription Authority already exists:",
subscriptionAuthorityPda,
);
process.exit(0);
}
const result =
await customerClient.subscriptions.instructions
.initSubscriptionAuthority({
tokenMint,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
userAta: customerAta,
})
.sendTransaction();
console.log(
"Subscription Authority:",
subscriptionAuthorityPda,
);
printSignature(
"Initialize authority",
result.context.signature,
);
Chạy script với tư cách khách hàng. Trước tiên, script kiểm tra xem PDA đã tồn tại hay chưa. Điều này giúp lệnh có thể chạy lại an toàn và tránh gửi giao dịch khởi tạo trùng lặp. Sau khi được xác nhận, tài khoản token của khách hàng có Subscription Authority làm bên được ủy quyền trong Token Program.
Tạo gói đăng ký của đơn vị bán hàng
Đơn vị bán hàng giờ đây công bố các điều khoản thanh toán mà khách hàng có thể chấp nhận. Plan PDA được dẫn xuất từ địa chỉ đơn vị bán hàng và ID gói.
Gói xác định mint thanh toán, số tiền tối đa mỗi kỳ, thời lượng kỳ, bên thu tiền được phê duyệt, đích nhận được phép và metadata offchain tùy chọn.
Tạo src/02-create-plan.ts:
import {
TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";
import {
fetchMaybePlan,
findPlanPda,
} from "@solana/subscriptions";
import {
createAppClient,
loadState,
MERCHANT_KEYPAIR,
PLAN_AMOUNT,
PLAN_PERIOD_HOURS,
printSignature,
} from "./config.js";
const merchantClient =
await createAppClient(MERCHANT_KEYPAIR);
const { tokenMint, planId } = loadState();
const [planPda] = await findPlanPda({
owner: merchantClient.identity.address,
planId,
});
const existing = await fetchMaybePlan(
merchantClient.rpc,
planPda,
);
if (existing.exists) {
console.log("Plan already exists:", planPda);
process.exit(0);
}
const result =
await merchantClient.subscriptions.instructions
.createPlan({
planId,
mint: tokenMint,
// Five tokens per billing period.
amount: PLAN_AMOUNT,
// One-hour periods for this devnet test.
periodHours: PLAN_PERIOD_HOURS,
// No scheduled plan-wide end.
endTs: 0n,
// The owner of the receiving token account
// must be included in this list.
destinations: [
merchantClient.identity.address,
],
// An empty pullers list means only the merchant
// can initiate collections.
pullers: [],
metadataUri:
"https://example.com/helius-devnet-plan.json",
tokenProgram: TOKEN_PROGRAM_ADDRESS,
})
.sendTransaction();
console.log("Plan PDA:", planPda);
printSignature(
"Create plan",
result.context.signature,
);
Chạy script với tư cách đơn vị bán hàng. Một vài trường đặc biệt quan trọng:
amount
Giá trị token được biểu thị bằng đơn vị cơ sở. Mint của chúng ta có sáu chữ số thập phân, vì vậy 5 token = 5.000.000 đơn vị cơ sở. Gói cho phép đơn vị bán hàng thu lũy kế tối đa năm token trong mỗi kỳ thanh toán. Đơn vị bán hàng có thể thu toàn bộ số tiền này trong một giao dịch hoặc chia thành nhiều giao dịch nhỏ hơn.
periodHours
Chúng ta dùng mức tối thiểu là một giờ. Nhờ đó, chúng ta có thể đăng ký, thu một khoản thanh toán, chờ một giờ và minh họa rằng hạn mức được đặt lại mà không phải chờ một tháng. Gói production sẽ dùng khoảng thời gian được xác định theo các điều khoản thanh toán thực tế của sản phẩm.
destinations
Danh sách đích nhận được phép chứa chủ sở hữu ví, không phải địa chỉ tài khoản token. Khi thu tiền, chương trình kiểm tra chủ sở hữu của tài khoản token nhận. Vì ví của đơn vị bán hàng nằm trong danh sách đích nhận, tài khoản token liên kết của ví là một bên nhận hợp lệ.
pullers
Đơn vị bán hàng luôn được phép thu tiền từ gói của chính mình. Có thể thêm các ví dịch vụ thanh toán khác vào pullers. Chúng ta để danh sách này trống để chỉ keypair của đơn vị bán hàng mới có thể thu tiền. Theo cấu hình này, chỉ đơn vị bán hàng hoặc ví có trong danh sách bên trích tiền của gói mới có thể gửi giao dịch thu tiền hợp lệ.
Cho khách hàng đăng ký
Giờ đây, khách hàng xem xét và chấp nhận các điều khoản hiện tại trong gói của đơn vị bán hàng. Subscription Delegation PDA thu được được dẫn xuất từ Plan PDA + địa chỉ khách hàng.
Plugin TypeScript truy xuất tài khoản gói hiện tại trong subscribe, vì vậy chúng ta không cần truyền thủ công số tiền, kỳ hoặc dấu thời gian tạo dự kiến. Các giá trị đó được đưa vào giao dịch dưới dạng điều khoản. Tạo src/03-subscribe.ts:
import {
fetchMaybeSubscriptionDelegation,
findPlanPda,
findSubscriptionDelegationPda,
} from "@solana/subscriptions";
import {
createAppClient,
CUSTOMER_KEYPAIR,
loadState,
MERCHANT_KEYPAIR,
printSignature,
} from "./config.js";
const [customerClient, merchantClient] =
await Promise.all([
createAppClient(CUSTOMER_KEYPAIR),
createAppClient(MERCHANT_KEYPAIR),
]);
const { tokenMint, planId } = loadState();
const [planPda] = await findPlanPda({
owner: merchantClient.identity.address,
planId,
});
const [subscriptionPda] =
await findSubscriptionDelegationPda({
planPda,
subscriber: customerClient.identity.address,
});
const existing =
await fetchMaybeSubscriptionDelegation(
customerClient.rpc,
subscriptionPda,
);
if (existing.exists) {
console.log(
"Customer is already subscribed:",
subscriptionPda,
);
process.exit(0);
}
const result =
await customerClient.subscriptions.instructions
.subscribe({
merchant: merchantClient.identity.address,
planId,
tokenMint,
})
.sendTransaction();
console.log("Subscription PDA:", subscriptionPda);
printSignature(
"Subscribe",
result.context.signature,
);
Chạy script với tư cách khách hàng. Khách hàng ký giao dịch này để chấp nhận một quyền chi tiêu mới. Tài khoản đăng ký lưu trữ các điều khoản đã chấp nhận.
Sau khi gói được tạo, planId, owner, mint, amount, periodHours, createdAt và destinations là bất biến, nghĩa là không thể thay đổi các điều khoản. Nếu sau đó đơn vị bán hàng cập nhật các trường có thể thay đổi trong gói, người đăng ký hiện tại vẫn giữ các điều khoản ban đầu mà họ đã chấp nhận, còn người đăng ký mới nhận phiên bản hiện tại của gói.
Khách hàng hiện có một gói đăng ký đang hoạt động, nhưng chưa có khoản thanh toán nào được thực hiện.
Cho đơn vị bán hàng thu tiền
Giờ đây, đơn vị bán hàng có thể thu tối đa hạn mức năm token của gói trong kỳ thanh toán hiện tại. Subscriptions Program không tự động thực thi giao dịch này khi bộ hẹn giờ hết hạn. Backend của đơn vị bán hàng, worker thanh toán, cron job hoặc bên trích tiền được phê duyệt vẫn cần gửi giao dịch thu tiền. Tạo src/04-collect.ts:
import {
findAssociatedTokenPda,
TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";
import {
findPlanPda,
findSubscriptionDelegationPda,
} from "@solana/subscriptions";
import {
createAppClient,
CUSTOMER_KEYPAIR,
loadState,
MERCHANT_KEYPAIR,
PLAN_AMOUNT,
printSignature,
} from "./config.js";
const [merchantClient, customerClient] =
await Promise.all([
createAppClient(MERCHANT_KEYPAIR),
createAppClient(CUSTOMER_KEYPAIR),
]);
const { tokenMint, planId } = loadState();
const [merchantAta] =
await findAssociatedTokenPda({
mint: tokenMint,
owner: merchantClient.identity.address,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
const [customerAta] =
await findAssociatedTokenPda({
mint: tokenMint,
owner: customerClient.identity.address,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
const [planPda] = await findPlanPda({
owner: merchantClient.identity.address,
planId,
});
const [subscriptionPda] =
await findSubscriptionDelegationPda({
planPda,
subscriber: customerClient.identity.address,
});
const beforeCustomer =
await merchantClient.rpc
.getTokenAccountBalance(customerAta)
.send();
const beforeMerchant =
await merchantClient.rpc
.getTokenAccountBalance(merchantAta)
.send();
const result =
await merchantClient.subscriptions.instructions
.transferSubscription({
caller: merchantClient.identity,
// The customer whose balance is being charged.
delegator: customerClient.identity.address,
tokenMint,
subscriptionPda,
planPda,
// Collect the full five-token period allowance.
amount: PLAN_AMOUNT,
receiverAta: merchantAta,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
})
.sendTransaction();
const afterCustomer =
await merchantClient.rpc
.getTokenAccountBalance(customerAta)
.send();
const afterMerchant =
await merchantClient.rpc
.getTokenAccountBalance(merchantAta)
.send();
console.log(
"Customer:",
beforeCustomer.value.uiAmountString,
"->",
afterCustomer.value.uiAmountString,
);
console.log(
"Merchant:",
beforeMerchant.value.uiAmountString,
"->",
afterMerchant.value.uiAmountString,
);
printSignature(
"Collect payment",
result.context.signature,
);
Chạy script với tư cách đơn vị bán hàng. Trong quá trình thực thi, chương trình xác minh rằng:
- Bên gọi là đơn vị bán hàng hoặc bên trích tiền được phê duyệt
- Gói đăng ký thuộc về khách hàng và gói tương ứng
- Gói đăng ký chưa hết hạn
- Số tiền được yêu cầu nằm trong hạn mức còn lại của kỳ hiện tại
- Ví của đơn vị bán hàng là đích nhận được phê duyệt
- Tài khoản token nhận thuộc về đích nhận được phê duyệt
- Mint và Token Program khớp với gói đã chấp nhận
Vì giao dịch này thu toàn bộ hạn mức năm token, việc chạy lại trong cùng kỳ một giờ sẽ thất bại. Khi kỳ tiếp theo bắt đầu, hạn mức theo kỳ được đặt lại và đơn vị bán hàng có thể chạy lại script thu tiền. Trong hệ thống thanh toán production, thành phần tương đương với script thường chỉ chạy khi hóa đơn đến hạn.
Khách hàng hủy gói đăng ký
Khách hàng có thể hủy gói đăng ký mà không cần đơn vị bán hàng hợp tác. Luồng hủy tiêu chuẩn không đóng tài khoản Subscription Delegation ngay lập tức. Thay vào đó, luồng này đánh dấu gói đăng ký sắp kết thúc và gán một expiresAtTs. Sau khi thời điểm hết hạn đó trôi qua, khách hàng có thể thu hồi gói đăng ký và đóng PDA. Tạo src/05-cancel.ts:
import {
fetchSubscriptionDelegation,
findPlanPda,
findSubscriptionDelegationPda,
} from "@solana/subscriptions";
import {
createAppClient,
CUSTOMER_KEYPAIR,
loadState,
MERCHANT_KEYPAIR,
printSignature,
} from "./config.js";
const [customerClient, merchantClient] =
await Promise.all([
createAppClient(CUSTOMER_KEYPAIR),
createAppClient(MERCHANT_KEYPAIR),
]);
const { planId } = loadState();
const [planPda] = await findPlanPda({
owner: merchantClient.identity.address,
planId,
});
const [subscriptionPda] =
await findSubscriptionDelegationPda({
planPda,
subscriber: customerClient.identity.address,
});
const result =
await customerClient.subscriptions.instructions
.cancelSubscription({
planPda,
subscriptionPda,
})
.sendTransaction();
const subscription =
await fetchSubscriptionDelegation(
customerClient.rpc,
subscriptionPda,
);
const expiresAt = new Date(
Number(subscription.data.expiresAtTs) * 1_000,
);
console.log("Subscription PDA:", subscriptionPda);
console.log(
"Cancellation effective at:",
expiresAt.toISOString(),
);
printSignature(
"Cancel subscription",
result.context.signature,
);
Chạy script với tư cách khách hàng. Đầu ra bao gồm dấu thời gian mà việc hủy có hiệu lực. Lệnh cancelSubscription tiêu chuẩn triển khai thời gian gia hạn cho đến cuối kỳ thanh toán đang hoạt động.
Về mặt vận hành, không nên xem việc hủy là lập tức đưa quyền trong kỳ hiện tại về 0. Mọi hạn mức còn khả dụng trong kỳ hiện tại vẫn có thể được thu cho đến expiresAtTs. Đơn vị bán hàng không thể bắt đầu kỳ thanh toán khác sau khi việc hủy có hiệu lực.
Điều này phù hợp với hành vi đăng ký phổ biến, trong đó việc hủy sẽ dừng lần gia hạn tiếp theo thay vì kết thúc hồi tố kỳ mà khách hàng đã bắt đầu.
Nghiên cứu tình huống thực tế: Gói đăng ký onchain của Helius
Helius là một trong những đối tác ra mắt đã góp phần định hình Subscriptions Delegation Program trước khi chương trình được phát hành trên mainnet. Chúng tôi dùng chương trình để hỗ trợ tự động gia hạn các gói API bằng USDC. Mục tiêu của chúng tôi là mang đến cho khách hàng thanh toán bằng tiền mã hóa sự tiện lợi của một gói SaaS thông thường, đồng thời giữ việc cấp quyền và quyết toán thanh toán hoàn toàn trên Solana.
Để bật thanh toán tự động, khách hàng thêm ví Solana từ phần Phương thức thanh toán trong bảng điều khiển thanh toán Helius.
Trong quá trình thiết lập, khách hàng ký phê duyệt một lần cho Solana Subscriptions Program chính thức và cấp quyền cho Helius thu các khoản thanh toán đăng ký bằng USDC từ ví đó.
Khi hóa đơn gia hạn đến hạn, hệ thống thanh toán của Helius gửi giao dịch thu tiền. Khách hàng không cần mở liên kết thanh toán, kết nối lại ví hoặc ký một giao dịch chuyển khác. Subscriptions Delegation Program cung cấp quyền onchain có thể tái sử dụng, trong khi Helius tiếp tục quản lý lịch hóa đơn, trạng thái tài khoản và quyền sử dụng sản phẩm.
Khách hàng có thể kết nối tối đa ba ví, nhưng chỉ ví được đánh dấu là phương thức thanh toán mặc định mới được dùng để gia hạn tự động. Helius không cố chia một khoản thu giữa nhiều ví hoặc chuyển sang một ví đã kết nối khác nếu ví mặc định không đủ để thanh toán hóa đơn.
Khách hàng có thể thay đổi ví mặc định từ bảng điều khiển. Họ cũng có thể xóa ví; thao tác này yêu cầu chữ ký và thu hồi quyền thanh toán tự động của ví đó. Việc xóa ví duy nhất đang kết nối sẽ đưa tài khoản trở lại hình thức liên kết thanh toán thủ công.
Tất nhiên, quyền onchain không đảm bảo ví sẽ có đủ USDC khi hóa đơn tiếp theo đến hạn. Trong trường hợp này:
- Helius không thu tiền từ ví cho lần gia hạn đó.
- Khách hàng nhận được liên kết thanh toán qua email và trong bảng điều khiển.
- Hóa đơn không được tự động thử lại với ví.
- Sau khi khách hàng nạp thêm tiền, các lần gia hạn sau có thể tiếp tục được thu tự động.
Cơ chế dự phòng này giúp trạng thái thanh toán đơn giản. Một lần thu tự động thất bại sẽ trở thành hóa đơn mở thông thường thay vì một chuỗi giao dịch thử lại onchain vô thời hạn.
Bản triển khai này cung cấp ví dụ thực tế về cách Subscriptions Delegation Program phù hợp với hệ thống thanh toán production. Chương trình không thay thế việc lập hóa đơn, quản lý tài khoản, thông báo hoặc thực thi quyền sử dụng. Nó thay thế phần trước đây yêu cầu khách hàng cấp quyền cho mỗi lần gia hạn hoặc yêu cầu đơn vị xử lý thanh toán tập trung lưu trữ và thực thi quyền đó.
Kết luận
Solana Subscriptions program giới thiệu một cách chuẩn hóa để xây dựng thanh toán định kỳ trực tiếp onchain. Bằng cách kết hợp subscription authority, gói của đơn vị bán hàng và giao dịch chuyển token được ủy quyền, nhà phát triển có thể triển khai thanh toán đăng ký mà không phụ thuộc vào hạ tầng thanh toán offchain hoặc logic thanh toán tùy chỉnh.
Nếu đang xây dựng thanh toán định kỳ trên Solana, Subscriptions program là nơi phù hợp để bắt đầu. Với SDK TypeScript cùng RPC và API của Helius, việc tích hợp thanh toán đăng ký onchain rất đơn giản, cho phép bạn tập trung vào ứng dụng thay vì cơ chế thanh toán nền tảng.
Tài nguyên khác
Bài viết liên quan
Đăng ký nhận tin từ Helius
Luôn cập nhật những thông tin mới nhất về phát triển Solana và nhận thông báo khi chúng tôi đăng bài


