diff --git a/package-lock.json b/package-lock.json index a01e1e9..03d3b22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3903,7 +3903,7 @@ }, "packages/client": { "name": "@code-wallet/client", - "version": "1.1.1", + "version": "1.1.2", "license": "MIT", "dependencies": { "@code-wallet/library": "^1.1.1", @@ -3972,10 +3972,10 @@ }, "packages/library": { "name": "@code-wallet/library", - "version": "1.1.1", + "version": "1.2.1", "license": "MIT", "dependencies": { - "@code-wallet/rpc": "^1.1.0", + "@code-wallet/rpc": "^1.2.0", "@noble/curves": "^1.2.0", "@noble/hashes": "^1.3.0", "bs58": "^5.0.0", @@ -4037,7 +4037,7 @@ }, "packages/rpc": { "name": "@code-wallet/rpc", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "@bufbuild/connect": "^0.8.6", @@ -4155,7 +4155,7 @@ "@code-wallet/library": { "version": "file:packages/library", "requires": { - "@code-wallet/rpc": "^1.1.0", + "@code-wallet/rpc": "^1.2.0", "@noble/curves": "^1.2.0", "@noble/hashes": "^1.3.0", "@types/chai": "^4.3.5", diff --git a/packages/library/package.json b/packages/library/package.json index 430bc16..19a0d86 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@code-wallet/library", - "version": "1.1.1", + "version": "1.2.1", "license": "MIT", "repository": { "type": "git", @@ -22,7 +22,7 @@ "maintained node versions" ], "dependencies": { - "@code-wallet/rpc": "^1.1.0", + "@code-wallet/rpc": "^1.2.0", "@noble/curves": "^1.2.0", "@noble/hashes": "^1.3.0", "bs58": "^5.0.0", diff --git a/packages/library/src/elements/validate.ts b/packages/library/src/elements/validate.ts index 6b8db94..46aa56d 100644 --- a/packages/library/src/elements/validate.ts +++ b/packages/library/src/elements/validate.ts @@ -10,7 +10,6 @@ import { ErrLoginRequired, ErrLoginDomainRequired, ErrLoginVerifierRequired, - ErrNotImplemented, ErrInvalidValue, } from '../errors'; import { PublicKey } from '../keys'; @@ -113,7 +112,12 @@ function validateElementOptions(intent: ElementOptions) { switch (intent.mode) { case 'login': - throw ErrNotImplemented(); // TODO: implement login (soon) + validateLoginRequestOptions(intent); + + if (intent.signers) { + validateSigners(intent); + } + break; case 'payment': validatePaymentRequestOptions(intent); diff --git a/packages/library/src/intents/LoginRequestIntent.ts b/packages/library/src/intents/LoginRequestIntent.ts new file mode 100644 index 0000000..2675acf --- /dev/null +++ b/packages/library/src/intents/LoginRequestIntent.ts @@ -0,0 +1,168 @@ +import * as proto from '@code-wallet/rpc'; +import { IdempotencyKey } from '../idempotency'; +import { Keypair, PublicKey } from '../keys'; +import { CodePayload, CodeKind } from '../payload'; +import { Intent, SignedIntent } from '../intent'; +import { + ErrLoginDomainRequired, + ErrLoginRequired, + ErrLoginVerifierRequired, + ErrUnexpectedError +} from '../errors'; +import { generateRendezvousKeypair } from '../rendezvous'; +import { validateElementOptions } from '../elements/validate'; +import { ElementOptions } from '../elements/options'; + +/** + * Represents a login request and provides methods to construct, validate, and sign the request. + */ +class LoginRequestIntent implements Intent { + domain: string; + verifier: PublicKey; + signer?: Keypair; + + options: ElementOptions; + nonce: IdempotencyKey; + + rendezvousPayload: CodePayload; + rendezvousKeypair: Keypair; + + /** + * Constructs a new PaymentRequestIntent instance. + * + * @param opt - The payment request options. + */ + constructor(opt: ElementOptions) { + this.options = { + ...opt, + }; + + this.validate(); + + const { signers } = opt; + const { domain, verifier } = opt.login!; + + this.domain = domain; + this.verifier = PublicKey.fromBase58(verifier); + + if (signers) { + this.signer = signers.find((k) => k.getPublicKey().toBase58() === verifier) + } + + // Create an 11 byte buffer from idempotencyKey if provided, otherwise generate a random nonce + if (this.options.idempotencyKey) { + this.nonce = IdempotencyKey.fromSeed(this.options.idempotencyKey); + } else if (this.options.clientSecret) { + this.nonce = IdempotencyKey.fromClientSecret(this.options.clientSecret); + } else { + this.nonce = IdempotencyKey.generate(); + } + + // See payload encoding for CodeKind.RequestPayment + const kind = CodeKind.RequestLogin; + const nonce = this.nonce.value; + + // Create a rendezvous payload and derive a keypair from it + this.rendezvousPayload = new CodePayload({ kind, nonce, }); + this.rendezvousKeypair = generateRendezvousKeypair(this.rendezvousPayload); + } + + /** + * Validates the payment request options. + */ + validate() { + validateElementOptions(this.options); + + if (!this.options.login) { + throw ErrLoginRequired(); + } + + if (!this.options.login.domain) { + throw ErrLoginDomainRequired(); + } + + if (!this.options.login.verifier) { + throw ErrLoginVerifierRequired(); + } + } + + /** + * Converts the payment request intent to its protobuf representation. + * + * @returns The protobuf representation of the payment request intent. + */ + toProto() : proto.Message { + const msg = new proto.RequestToLogin({ + domain: { + value: this.domain, + }, + verifier: { + value: this.verifier.toBuffer(), + }, + rendezvousKey: { + value: this.rendezvousKeypair.getPublicKey().toBuffer(), + }, + }); + + return new proto.Message({ + kind: { + case: 'requestToLogin', + value: msg, + } + }); + } + + /** + * Signs the payment request intent. + * + * @returns A signed intent containing the message, intent, and signature. + */ + sign(): SignedIntent { + if (!this.signer) { + throw ErrLoginVerifierRequired(); + } + + const envelope = this.toProto(); + const msg = envelope.kind.value as proto.RequestToLogin; + if (!msg) { + throw ErrUnexpectedError(); + } + + msg.signature = new proto.Common.Signature({ + value: this.signer.sign(msg.toBinary()), + }); + + const sig = this.rendezvousKeypair.sign(envelope.toBinary()); + const intent = this.rendezvousKeypair.getPublicKey().toBase58(); + const message = msg.toBinary(); + const signature = sig; + + return { + message, + intent, + signature, + } + } + + /** + * Retrieves the client secret. + * + * @returns The client secret as a string. + */ + getClientSecret(): string { + return this.nonce.toString(); + } + + /** + * Retrieves the intent ID. + * + * @returns The intent ID as a Base58 encoded string. + */ + getIntentId(): string { + return this.rendezvousKeypair.getPublicKey().toBase58(); + } +} + +export { + LoginRequestIntent, +} \ No newline at end of file diff --git a/packages/library/src/intents/PaymentRequestIntent.ts b/packages/library/src/intents/PaymentRequestIntent.ts index 136841f..4af58b4 100644 --- a/packages/library/src/intents/PaymentRequestIntent.ts +++ b/packages/library/src/intents/PaymentRequestIntent.ts @@ -84,7 +84,7 @@ class PaymentRequestIntent implements Intent { const nonce = this.nonce.value; // Create a rendezvous payload and derive a keypair from it - this.rendezvousPayload = new CodePayload(kind, amount, nonce, this.options.currency); + this.rendezvousPayload = new CodePayload({kind, amount, nonce, currency: this.options.currency}); this.rendezvousKeypair = generateRendezvousKeypair(this.rendezvousPayload); } diff --git a/packages/library/src/intents/index.ts b/packages/library/src/intents/index.ts index 3e56d7b..2f17fd2 100644 --- a/packages/library/src/intents/index.ts +++ b/packages/library/src/intents/index.ts @@ -1,2 +1,3 @@ export * from './PaymentRequestIntent'; -export * from './PaymentRequestWithLoginIntent'; \ No newline at end of file +export * from './PaymentRequestWithLoginIntent'; +export * from './LoginRequestIntent'; \ No newline at end of file diff --git a/packages/library/src/payload.ts b/packages/library/src/payload.ts index eff41e6..5503471 100644 --- a/packages/library/src/payload.ts +++ b/packages/library/src/payload.ts @@ -1,5 +1,5 @@ import { CurrencyCode, currencyCodeToIndex, indexToCurrencyCode, isValidCurrency } from "./currency"; -import { ErrInvalidCurrency, ErrInvalidSize } from "./errors"; +import { ErrInvalidCurrency, ErrInvalidSize, ErrInvalidValue } from "./errors"; /* Scan Code Payload Format @@ -72,6 +72,14 @@ enum CodeKind { Cash = 0, GiftCard = 1, RequestPayment = 2, + RequestLogin = 3, +} + +interface CodePayloadOptions { + kind: CodeKind; + nonce: Uint8Array; + amount?: bigint; + currency?: CurrencyCode; } /** @@ -80,8 +88,8 @@ enum CodeKind { */ class CodePayload { kind: CodeKind; - amount: bigint; nonce: Uint8Array; + amount?: bigint; currency?: CurrencyCode; static readonly MAX_LENGTH: number = 20; @@ -89,21 +97,48 @@ class CodePayload { /** * Construct a new CodePayload. * - * @param kind - The type of the code. - * @param amount - The amount associated with the code. - * @param nonce - A randomly-generated nonce. - * @param currency - (Optional) Currency associated with the RequestPayment type. + * @param opt - The options for constructing the payload. */ - constructor(kind: CodeKind, amount: bigint, nonce: Uint8Array, currency?: CurrencyCode) { - this.kind = kind; - this.amount = amount; - this.nonce = nonce; + constructor(opt: CodePayloadOptions) { + this.kind = opt.kind; + this.amount = opt.amount; + this.nonce = opt.nonce; - // Validation for currency code - if (currency && !isValidCurrency(currency)) { + if (opt.currency && !isValidCurrency(opt.currency)) { throw ErrInvalidCurrency(); } - this.currency = currency as CurrencyCode | undefined; + this.currency = opt.currency as CurrencyCode | undefined; + } + + private isCash(): this is this & { amount: bigint } { + return this.kind === CodeKind.Cash && this.amount != null; + } + + private isGiftCard(): this is this & { amount: bigint } { + return this.kind === CodeKind.GiftCard && this.amount != null; + } + + private isRequestPayment(): this is this & { currency: string, amount: bigint } { + return this.kind === CodeKind.RequestPayment && this.currency != null && this.amount != null; + } + + /** + * Validates the payload, throwing an error if invalid. + */ + validate() { + if (this.kind === CodeKind.RequestPayment) { + if (!this.currency) { + throw ErrInvalidCurrency(); + } + } + + if (this.kind === CodeKind.Cash || + this.kind === CodeKind.GiftCard || + this.kind === CodeKind.RequestPayment) { + if (!this.amount) { + throw ErrInvalidValue(); + } + } } /** @@ -115,7 +150,9 @@ class CodePayload { const data = new Uint8Array(20); data[0] = this.kind; - if (this.kind === CodeKind.RequestPayment) { + this.validate(); + + if (this.isRequestPayment()) { // for Payment Request if (!this.currency) { throw ErrInvalidCurrency(); @@ -126,7 +163,9 @@ class CodePayload { for (let i = 0; i < 7; i++) { data[i + 2] = Number(this.amount >> BigInt(8 * i) & BigInt(0xFF)); } - } else { + } + + if (this.isCash() || this.isGiftCard()) { // for Cash and Gift Card for (let i = 0; i < 8; i++) { data[i + 1] = Number(this.amount >> BigInt(8 * i) & BigInt(0xFF)); @@ -149,24 +188,26 @@ class CodePayload { throw ErrInvalidSize(); } - const type = data[0] as CodeKind; - let amount: bigint; + const kind = data[0] as CodeKind; + let amount: bigint | undefined; let nonce: Uint8Array; let currency: CurrencyCode | undefined; - if (type === CodeKind.RequestPayment) { + if (kind === CodeKind.RequestPayment) { // for Payment Request const currencyIndex = data[1]; currency = indexToCurrencyCode(currencyIndex); amount = data.slice(2, 9).reduce((acc, val, i) => acc + (BigInt(val) << BigInt(8 * i)), BigInt(0)); - } else { + } + + if (kind === CodeKind.Cash || kind === CodeKind.GiftCard) { // for Cash and Gift Card amount = data.slice(1, 9).reduce((acc, val, i) => acc + (BigInt(val) << BigInt(8 * i)), BigInt(0)); } nonce = data.slice(9); - return new CodePayload(type, amount, nonce, currency); + return new CodePayload({ kind, amount, currency, nonce }); } } diff --git a/packages/library/test/intent_login_request.test.ts b/packages/library/test/intent_login_request.test.ts new file mode 100644 index 0000000..31484df --- /dev/null +++ b/packages/library/test/intent_login_request.test.ts @@ -0,0 +1,220 @@ +import * as proto from '@code-wallet/rpc'; +import { expect } from 'chai'; +import { + ErrLoginDomainRequired, + ErrLoginRequired, + ErrLoginVerifierRequired, + IntentType, + Keypair, + LoginRequestIntent, + PublicKey, +} from '../src'; + +describe('LoginRequestIntent', () => { + + const opt = { + mode: 'login' as IntentType, + } + + const domain = 'app.getcode.com'; + const verifier = Keypair.fromRawPrivateKey(new Uint8Array ([ + 31, 198, 32, 30, 134, 217, 253, 202, + 191, 201, 72, 101, 85, 57, 128, 211, + 204, 140, 82, 80, 37, 240, 241, 62, + 144, 107, 81, 63, 236, 197, 103, 45 + ])); + + const rendezvous = Keypair.fromRawPrivateKey(new Uint8Array([ + 21, 17, 247, 182, 187, 209, 72, 224, + 155, 234, 125, 157, 197, 64, 106, 229, + 230, 5, 176, 18, 30, 47, 210, 243, + 87, 206, 0, 3, 208, 130, 81, 174 + ])); + + describe('constructor', () => { + it('should initialize correctly', () => { + const intent = new LoginRequestIntent({ + ...opt, + login: { + domain, + verifier: verifier.getPublicKey().toBase58() + } + }); + + expect(intent.options.login!.domain).to.equal('app.getcode.com'); + expect(intent.options.login!.verifier).to.equal(verifier.getPublicKey().toBase58()); + }); + }); + + describe('validate', () => { + + it('should throw an error if login is missing', () => { + expect(() => new LoginRequestIntent({ + ...opt, + } as any)).to.throw(ErrLoginRequired().message); + }); + + it('should throw an error if login.domain is missing', () => { + expect(() => new LoginRequestIntent({ + ...opt, + login: { + verifier: verifier.getPublicKey().toBase58() + } + } as any)).to.throw(ErrLoginDomainRequired().message); + }); + + it('should throw an error if login.verifier is missing', () => { + expect(() => new LoginRequestIntent({ + ...opt, + login: { domain } + } as any)).to.throw(ErrLoginVerifierRequired().message); + }); + }); + + describe('toProto', () => { + it('should return correct protobuf json', () => { + const intent = new LoginRequestIntent({ + ...opt, + login: { + domain, + verifier: verifier.getPublicKey().toBase58() + } + }); + + intent.rendezvousKeypair = rendezvous; + const msg = intent.toProto(); + + expect(msg.toJson()).to.deep.equal({ + requestToLogin: { + domain: { + value: domain + }, + verifier: { + value: verifier.getPublicKey().toBuffer().toString('base64') + }, + rendezvousKey: { + value: rendezvous.getPublicKey().toBuffer().toString('base64') + } + } + }); + }); + + it('should return correct protobuf bytes', () => { + const intent = new LoginRequestIntent({ + ...opt, + login: { + domain, + verifier: verifier.getPublicKey().toBase58() + } + }); + + intent.rendezvousKeypair = rendezvous; + const protoMessage = intent.toProto(); + const actual = protoMessage.toBinary(); + + const expected = new Uint8Array([ + 0x52, 0x5b, 0x0a, 0x11, 0x0a, 0x0f, 0x61, 0x70, 0x70, 0x2e, + 0x67, 0x65, 0x74, 0x63, 0x6f, 0x64, 0x65, 0x2e, 0x63, 0x6f, + 0x6d, 0x22, 0x22, 0x0a, 0x20, 0x90, 0x5c, 0xc7, 0x96, 0xae, + 0x7a, 0x19, 0x98, 0x46, 0x18, 0x36, 0xcd, 0x9f, 0x59, 0x05, + 0x2f, 0x8a, 0x2a, 0x52, 0xcd, 0x53, 0x9b, 0x41, 0x7e, 0x57, + 0x7c, 0x11, 0x82, 0x83, 0xd2, 0xa0, 0x6c, 0x32, 0x22, 0x0a, + 0x20, 0x70, 0xa4, 0xae, 0xb6, 0x8f, 0x76, 0x36, 0x3f, 0x22, + 0x66, 0x81, 0xdf, 0x16, 0x62, 0xbf, 0xc0, 0xf6, 0x42, 0x36, + 0xe9, 0x7a, 0x7a, 0x64, 0x87, 0x46, 0x6a, 0x93, 0x99, 0x54, + 0x5e, 0x7a, 0xfb + ]); + + expect(actual.toString()).to.equal(expected.toString()); + }); + }); + + describe('sign', () => { + it('should expect a login signer', () => { + const intent = new LoginRequestIntent({ + ...opt, + login: { + domain, + verifier: verifier.getPublicKey().toBase58() + }, + }); + + intent.rendezvousKeypair = rendezvous; + + try { + intent.sign(); + } catch (e) { + expect(e.message).to.equal(ErrLoginVerifierRequired().message); + } + }); + + it('should return correct signature json', () => { + const intent = new LoginRequestIntent({ + ...opt, + login: { + domain, + verifier: verifier.getPublicKey().toBase58() + }, + signers: [verifier] + }); + + intent.rendezvousKeypair = rendezvous; + + const res = intent.sign(); + const msg = proto.RequestToLogin.fromBinary(res.message); + + expect(msg.toJson()).to.deep.equal({ + domain: { + value: domain + }, + verifier: { + value: verifier.getPublicKey().toBuffer().toString('base64') + }, + rendezvousKey: { + value: rendezvous.getPublicKey().toBuffer().toString('base64') + }, + signature: { + value: 'NmXm9rvjyt9XlrPYyLYqcIFyhyd1RG2P/yBdhs0dL2ducXpNZD/0lPpINq5rlkxb/HEGVe+hUli74pgmCljGBw==' + }, + }); + }); + + it('should return correct signature bytes', () => { + const intent = new LoginRequestIntent({ + ...opt, + login: { + domain, + verifier: verifier.getPublicKey().toBase58() + }, + signers: [verifier] + }); + + intent.rendezvousKeypair = rendezvous; + + const res = intent.sign(); + const msg = proto.RequestToLogin.fromBinary(res.message); + const actual = msg.toBinary(); + + const expected = new Uint8Array([ + 0x0a, 0x11, 0x0a, 0x0f, 0x61, 0x70, 0x70, 0x2e, 0x67, 0x65, + 0x74, 0x63, 0x6f, 0x64, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x22, + 0x22, 0x0a, 0x20, 0x90, 0x5c, 0xc7, 0x96, 0xae, 0x7a, 0x19, + 0x98, 0x46, 0x18, 0x36, 0xcd, 0x9f, 0x59, 0x05, 0x2f, 0x8a, + 0x2a, 0x52, 0xcd, 0x53, 0x9b, 0x41, 0x7e, 0x57, 0x7c, 0x11, + 0x82, 0x83, 0xd2, 0xa0, 0x6c, 0x2a, 0x42, 0x0a, 0x40, 0x36, + 0x65, 0xe6, 0xf6, 0xbb, 0xe3, 0xca, 0xdf, 0x57, 0x96, 0xb3, + 0xd8, 0xc8, 0xb6, 0x2a, 0x70, 0x81, 0x72, 0x87, 0x27, 0x75, + 0x44, 0x6d, 0x8f, 0xff, 0x20, 0x5d, 0x86, 0xcd, 0x1d, 0x2f, + 0x67, 0x6e, 0x71, 0x7a, 0x4d, 0x64, 0x3f, 0xf4, 0x94, 0xfa, + 0x48, 0x36, 0xae, 0x6b, 0x96, 0x4c, 0x5b, 0xfc, 0x71, 0x06, + 0x55, 0xef, 0xa1, 0x52, 0x58, 0xbb, 0xe2, 0x98, 0x26, 0x0a, + 0x58, 0xc6, 0x07, 0x32, 0x22, 0x0a, 0x20, 0x70, 0xa4, 0xae, + 0xb6, 0x8f, 0x76, 0x36, 0x3f, 0x22, 0x66, 0x81, 0xdf, 0x16, + 0x62, 0xbf, 0xc0, 0xf6, 0x42, 0x36, 0xe9, 0x7a, 0x7a, 0x64, + 0x87, 0x46, 0x6a, 0x93, 0x99, 0x54, 0x5e, 0x7a, 0xfb + ]); + + expect(actual.toString()).to.equal(expected.toString()); + }); + }); +}); \ No newline at end of file diff --git a/packages/library/test/payment_request.test.ts b/packages/library/test/intent_payment_request.test.ts similarity index 100% rename from packages/library/test/payment_request.test.ts rename to packages/library/test/intent_payment_request.test.ts diff --git a/packages/library/test/payment_request_with_login.test.ts b/packages/library/test/intent_payment_request_with_login.test.ts similarity index 100% rename from packages/library/test/payment_request_with_login.test.ts rename to packages/library/test/intent_payment_request_with_login.test.ts diff --git a/packages/library/test/payload.test.ts b/packages/library/test/payload.test.ts index 03e69d8..cd0a995 100644 --- a/packages/library/test/payload.test.ts +++ b/packages/library/test/payload.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { CodePayload, CodeKind, CurrencyCode, Kin } from '../src'; -import { ErrInvalidCurrency, ErrInvalidSize } from '../src/errors'; +import { ErrInvalidCurrency, ErrInvalidSize, ErrInvalidValue } from '../src/errors'; describe('CodePayload', () => { @@ -37,7 +37,7 @@ describe('CodePayload', () => { it('should create new payload from parameters', () => { const kind = CodeKind.Cash; const amount = BigInt(100); - const payload = new CodePayload(kind, amount, nonce); + const payload = new CodePayload({kind, amount, nonce}); expect(payload.kind).to.equal(kind); expect(payload.amount).to.equal(amount); @@ -52,7 +52,7 @@ describe('CodePayload', () => { it('should serialize and deserialize correctly for Cash and GiftCard', () => { const kind = CodeKind.Cash; const amount = BigInt(100); - const payload = new CodePayload(kind, amount, nonce); + const payload = new CodePayload({kind, amount, nonce}); const serialized = payload.toBinary(); const deserialized = CodePayload.fromData(serialized); @@ -67,7 +67,7 @@ describe('CodePayload', () => { const amount = BigInt(100); const currency = 'usd'; - const payload = new CodePayload(kind, amount, nonce, currency); + const payload = new CodePayload({kind, amount, nonce, currency}); const serialized = payload.toBinary(); const deserialized = CodePayload.fromData(serialized); @@ -83,12 +83,12 @@ describe('CodePayload', () => { const amount = BigInt(100); const currency = 'INVALID' as CurrencyCode; // Invalid currency - expect(() => new CodePayload(kind, amount, nonce, currency)).to.throw(ErrInvalidCurrency().message); + expect(() => new CodePayload({kind, amount, nonce, currency})).to.throw(ErrInvalidCurrency().message); }); it('should match sample data when encoding Kin (cash)', () => { const amount = kinAmount.toQuarks(); - const payload = new CodePayload(CodeKind.Cash, amount, nonce); + const payload = new CodePayload({kind: CodeKind.Cash, amount, nonce}); const encoded = payload.toBinary(); expect(encoded.toString()).to.eql(sampleKin.toString()); @@ -96,7 +96,7 @@ describe('CodePayload', () => { it('should match sample data when encoding Kin (request)', () => { const amount = BigInt(kinAmount.toDecimal() * 100); - const payload = new CodePayload(CodeKind.RequestPayment, amount, nonce, 'kin'); + const payload = new CodePayload({kind: CodeKind.RequestPayment, amount, nonce, currency: 'kin'}); const encoded = payload.toBinary(); expect(encoded.toString()).to.eql(sampleKinAsFiat.toString()); @@ -104,10 +104,69 @@ describe('CodePayload', () => { it('should match sample data when encoding Fiat', () => { const amount = BigInt(fiatAmount); - const payload = new CodePayload(CodeKind.RequestPayment, amount, nonce, 'usd'); + const payload = new CodePayload({kind: CodeKind.RequestPayment, amount, nonce, currency: 'usd'}); const encoded = payload.toBinary(); expect(encoded.toString()).to.eql(sampleFiat.toString()); }); + it('should throw ErrInvalidCurrency for RequestPayment without currency', () => { + const kind = CodeKind.RequestPayment; + const amount = BigInt(100); + + const payload = new CodePayload({ kind, amount, nonce }); + expect(() => payload.validate()).to.throw(ErrInvalidCurrency().message); + }); + + it('should throw ErrInvalidValue for RequestPayment without amount', () => { + const kind = CodeKind.RequestPayment; + const currency = 'usd'; + + const payload = new CodePayload({ kind, nonce, currency }); + expect(() => payload.validate()).to.throw(ErrInvalidValue().message); + }); + + it('should throw ErrInvalidValue for GiftCard without amount', () => { + const kind = CodeKind.GiftCard; + + const payload = new CodePayload({ kind, nonce }); + expect(() => payload.validate()).to.throw(ErrInvalidValue().message); + }); + + it('should throw ErrInvalidValue for Cash without amount', () => { + const kind = CodeKind.Cash; + + const payload = new CodePayload({ kind, nonce }); + expect(() => payload.validate()).to.throw(ErrInvalidValue().message); + }); + + it('should create new payload with RequestLogin kind', () => { + const kind = CodeKind.RequestLogin; + const payload = new CodePayload({ kind, nonce }); + + expect(payload.kind).to.equal(kind); + expect(payload.nonce).to.eql(nonce); + expect(payload.amount).to.be.undefined; + expect(payload.currency).to.be.undefined; + }); + + it('should serialize and deserialize correctly for RequestLogin', () => { + const kind = CodeKind.RequestLogin; + const payload = new CodePayload({ kind, nonce }); + + const serialized = payload.toBinary(); + const deserialized = CodePayload.fromData(serialized); + + expect(deserialized.kind).to.equal(kind); + expect(deserialized.nonce).to.eql(nonce); + expect(deserialized.amount).to.be.undefined; + expect(deserialized.currency).to.be.undefined; + }); + + it('should generate binary data of correct length for RequestLogin', () => { + const payload = new CodePayload({ kind: CodeKind.RequestLogin, nonce }); + const binaryData = payload.toBinary(); + + expect(binaryData.length).to.equal(CodePayload.MAX_LENGTH); + }); }); \ No newline at end of file diff --git a/packages/rpc/package.json b/packages/rpc/package.json index 84d2627..066e001 100644 --- a/packages/rpc/package.json +++ b/packages/rpc/package.json @@ -1,6 +1,6 @@ { "name": "@code-wallet/rpc", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/rpc/src/generated/messaging/v1/messaging_service_pb.ts b/packages/rpc/src/generated/messaging/v1/messaging_service_pb.ts index c884eba..791ed0e 100644 --- a/packages/rpc/src/generated/messaging/v1/messaging_service_pb.ts +++ b/packages/rpc/src/generated/messaging/v1/messaging_service_pb.ts @@ -916,20 +916,6 @@ export class RequestToLogin extends Message$1 { */ domain?: Domain; - /** - * Random nonce to include for signing in LoginAttempt - * - * @generated from field: code.common.v1.SolanaAccountId nonce = 2; - */ - nonce?: SolanaAccountId; - - /** - * Timestamp the request was created - * - * @generated from field: google.protobuf.Timestamp timestamp = 3; - */ - timestamp?: Timestamp; - /** * Owner account owned by the third party used in domain verification. * @@ -961,8 +947,6 @@ export class RequestToLogin extends Message$1 { static readonly typeName = "code.messaging.v1.RequestToLogin"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: "domain", kind: "message", T: Domain }, - { no: 2, name: "nonce", kind: "message", T: SolanaAccountId }, - { no: 3, name: "timestamp", kind: "message", T: Timestamp }, { no: 4, name: "verifier", kind: "message", T: SolanaAccountId }, { no: 5, name: "signature", kind: "message", T: Signature }, { no: 6, name: "rendezvous_key", kind: "message", T: RendezvousKey }, @@ -1008,20 +992,6 @@ export class LoginAttempt extends Message$1 { */ domain?: Domain; - /** - * Nonce value provided in the RequestToLogin message - * - * @generated from field: code.common.v1.SolanaAccountId nonce = 4; - */ - nonce?: SolanaAccountId; - - /** - * Timestamp the attempt was created - * - * @generated from field: google.protobuf.Timestamp timestamp = 5; - */ - timestamp?: Timestamp; - /** * Signature of this message using the user_id private key, which * authenticates the user. @@ -1047,8 +1017,6 @@ export class LoginAttempt extends Message$1 { static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: "user_id", kind: "message", T: SolanaAccountId }, { no: 3, name: "domain", kind: "message", T: Domain }, - { no: 4, name: "nonce", kind: "message", T: SolanaAccountId }, - { no: 5, name: "timestamp", kind: "message", T: Timestamp }, { no: 6, name: "signature", kind: "message", T: Signature }, { no: 7, name: "rendezvous_key", kind: "message", T: RendezvousKey }, ]); @@ -1071,9 +1039,13 @@ export class LoginAttempt extends Message$1 { } /** - * @generated from message code.messaging.v1.LoginRejected + * Login is rejected by the client + * + * This message type is only initiated by user clients + * + * @generated from message code.messaging.v1.ClientRejectedLogin */ -export class LoginRejected extends Message$1 { +export class ClientRejectedLogin extends Message$1 { /** * Timestamp the login was rejected * @@ -1081,31 +1053,31 @@ export class LoginRejected extends Message$1 { */ timestamp?: Timestamp; - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "code.messaging.v1.LoginRejected"; + static readonly typeName = "code.messaging.v1.ClientRejectedLogin"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 4, name: "timestamp", kind: "message", T: Timestamp }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): LoginRejected { - return new LoginRejected().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): ClientRejectedLogin { + return new ClientRejectedLogin().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): LoginRejected { - return new LoginRejected().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): ClientRejectedLogin { + return new ClientRejectedLogin().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): LoginRejected { - return new LoginRejected().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): ClientRejectedLogin { + return new ClientRejectedLogin().fromJsonString(jsonString, options); } - static equals(a: LoginRejected | PlainMessage | undefined, b: LoginRejected | PlainMessage | undefined): boolean { - return proto3.util.equals(LoginRejected, a, b); + static equals(a: ClientRejectedLogin | PlainMessage | undefined, b: ClientRejectedLogin | PlainMessage | undefined): boolean { + return proto3.util.equals(ClientRejectedLogin, a, b); } } @@ -1248,10 +1220,10 @@ export class Message extends Message$1 { case: "loginAttempt"; } | { /** - * @generated from field: code.messaging.v1.LoginRejected login_rejected = 12; + * @generated from field: code.messaging.v1.ClientRejectedLogin client_rejected_login = 12; */ - value: LoginRejected; - case: "loginRejected"; + value: ClientRejectedLogin; + case: "clientRejectedLogin"; } | { /** * @generated from field: code.messaging.v1.AirdropReceived airdrop_received = 4; @@ -1278,7 +1250,7 @@ export class Message extends Message$1 { { no: 9, name: "webhook_called", kind: "message", T: WebhookCalled, oneof: "kind" }, { no: 10, name: "request_to_login", kind: "message", T: RequestToLogin, oneof: "kind" }, { no: 11, name: "login_attempt", kind: "message", T: LoginAttempt, oneof: "kind" }, - { no: 12, name: "login_rejected", kind: "message", T: LoginRejected, oneof: "kind" }, + { no: 12, name: "client_rejected_login", kind: "message", T: ClientRejectedLogin, oneof: "kind" }, { no: 4, name: "airdrop_received", kind: "message", T: AirdropReceived, oneof: "kind" }, ]); diff --git a/packages/rpc/src/generated/micropayment/v1/micro_payment_service_pb.ts b/packages/rpc/src/generated/micropayment/v1/micro_payment_service_pb.ts index f5d036d..d56eef2 100644 --- a/packages/rpc/src/generated/micropayment/v1/micro_payment_service_pb.ts +++ b/packages/rpc/src/generated/micropayment/v1/micro_payment_service_pb.ts @@ -69,6 +69,17 @@ export class GetStatusResponse extends Message { */ intentSubmitted = false; + /** + * The user ID, if available, that submitted the payment. This will only be + * avalaible when the payment request included a verified identifier that the + * user can establish a relationship against. This ID is guaranteed to be a + * relationship account, which will be unique between the user's 12 words and + * the verified merchant identifier. + * + * @generated from field: code.common.v1.SolanaAccountId user_id = 4; + */ + userId?: SolanaAccountId; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -80,6 +91,7 @@ export class GetStatusResponse extends Message { { no: 1, name: "exists", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 2, name: "code_scanned", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 3, name: "intent_submitted", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 4, name: "user_id", kind: "message", T: SolanaAccountId }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): GetStatusResponse {